699 lines
35 KiB
TypeScript
699 lines
35 KiB
TypeScript
import { HttpErrorResponse, httpResource } from '@angular/common/http';
|
|
import { NgTemplateOutlet } from '@angular/common';
|
|
import { ChangeDetectionStrategy, Component, computed, effect, inject, linkedSignal, signal, untracked } from '@angular/core';
|
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
import type { SlListResponse } from '@basic-erp/sap-types';
|
|
import { map } from 'rxjs';
|
|
import { API_URL, apiErrorMessage } from '../../core/api';
|
|
import { formatNumber, todayIso } from '../../core/format';
|
|
import { CompanySettingsService } from '../../core/company/company-settings';
|
|
import { SlDocumentApi } from '../../core/sl/sl-document';
|
|
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField } from '../../shared/form';
|
|
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
|
|
import { LinesGrid } from '../../shared/grid/lines-grid';
|
|
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
|
import { Icon, MenuButton, type MenuItem, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
|
|
import { ValueHelp } from '../../shared/value-help/value-help';
|
|
import type { ListRow } from '../../shared/list/list.types';
|
|
import {
|
|
type DocRecord,
|
|
type NavTarget,
|
|
computeTotals,
|
|
EXPENSES_COLLECTION,
|
|
type ExpenseRow,
|
|
createPayload,
|
|
expenseRowsOf,
|
|
expensesChanged,
|
|
expensesPayload,
|
|
isBlankLine,
|
|
isDocumentReadOnly,
|
|
taxRateMap,
|
|
updatePayload,
|
|
validateExpenses,
|
|
validateLayout,
|
|
} from './document-model';
|
|
import { SERVICE_DOC_TYPE } from './document-layouts';
|
|
import { type ComputedSource, type GroupCell, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasCurrency, hasExpenses, hasFolio, hasPartnerData, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
|
|
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
|
|
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
|
|
import { OBJECT_TYPES } from './document-objects';
|
|
import { detailHint, validateLineDetails } from './line-details';
|
|
import { ITEMS_MULTI_ADD, LINE_COLUMNS } from './line-columns';
|
|
import { RowsTable } from '../../shared/grid/rows-table';
|
|
import type { DetailColumn } from '../../shared/grid/grid.types';
|
|
|
|
const today = (): string => todayIso();
|
|
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate', 'TaxDate', 'DueDate']);
|
|
/** SAP devuelve las fechas como `2026-01-05T00:00:00Z`; el campo de fecha y el guardado usan `2026-01-05`. */
|
|
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
|
|
const pickHeader = (doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord => {
|
|
if (!doc) return blank;
|
|
return Object.fromEntries(
|
|
keys.map((k) => [
|
|
k,
|
|
DATE_KEYS.has(k) ? dateOnly(doc[k]) : k === 'FolioNumber' ? text(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : '')),
|
|
]),
|
|
);
|
|
};
|
|
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
|
|
/** El folio se edita como texto; SAP lo espera como número. */
|
|
const folioAsNumber = (h: DocRecord): DocRecord => {
|
|
const folio = h['FolioNumber'];
|
|
if (typeof folio !== 'string') return h;
|
|
return { ...h, FolioNumber: folio.trim() === '' ? null : Number(folio) };
|
|
};
|
|
|
|
/**
|
|
* Formulario de un documento de ventas o compras: `nuevo` (vacío) o `:docEntry` (ver / editar).
|
|
* Los cerrados y cancelados quedan en solo lectura. Al modificar se envía el `odata.etag` leído
|
|
* (`If-Match`): si otro usuario cambió el documento, SAP responde 412 y se avisa sin pisarlo.
|
|
*/
|
|
@Component({
|
|
selector: 'sbo-document-form-page',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
|
|
templateUrl: './document-form.page.html',
|
|
styles: `
|
|
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
|
|
p { margin: 0; color: var(--quiet); }
|
|
a { color: var(--focus); }
|
|
.totals { display: grid; gap: 2px; }
|
|
.exp h3 { margin: 0 0 4px; font-size: 13px; font-weight: 600; }
|
|
.exp .addrow { margin: 6px 0 0; }
|
|
.ask { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 4px 0 6px; padding: 4px 8px; background: var(--warn-bg); border: 1px solid var(--line); border-radius: var(--radius); color: var(--ink); }
|
|
`,
|
|
})
|
|
export class DocumentFormPage {
|
|
private readonly route = inject(ActivatedRoute);
|
|
private readonly router = inject(Router);
|
|
private readonly api = inject(SlDocumentApi);
|
|
/** La empresa usa redondeo (`OADM.RoundMthd`): solo entonces se ofrece la fila «Redondeo». */
|
|
private readonly settings = inject(CompanySettingsService);
|
|
protected readonly roundingEnabled = this.settings.roundingEnabled;
|
|
|
|
protected readonly title = this.route.snapshot.data['title'] as string;
|
|
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
|
|
protected readonly partner = this.screen.partner;
|
|
/** Qué campos y columnas lleva este documento (lo declara su pantalla); el formulario solo lo dibuja. */
|
|
protected readonly layout = this.screen.layout;
|
|
private readonly lineSpec = this.layout.lines;
|
|
/** Si el documento es de servicio, las columnas, los campos que se guardan y las reglas de las líneas son otras. */
|
|
protected readonly serviceMode = computed(() => !!this.layout.service && this.header()['DocType'] === SERVICE_DOC_TYPE);
|
|
private readonly spec = computed(() => (this.serviceMode() ? this.layout.service!.lines : this.lineSpec));
|
|
private readonly headerKeys: readonly string[] = headerKeysOf(this.layout);
|
|
private readonly hasFolio = hasFolio(this.layout);
|
|
private readonly blankHeader: DocRecord = this.layout.newHeader();
|
|
private readonly hasExpenses = hasExpenses(this.layout);
|
|
private readonly hasCurrency = hasCurrency(this.layout);
|
|
private pick(doc: DocRecord | undefined): DocRecord {
|
|
return pickHeader(doc, this.headerKeys, this.blankHeader);
|
|
}
|
|
private docLines(doc: DocRecord | undefined): GridLine[] {
|
|
return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
|
|
}
|
|
protected readonly docEntry = toSignal(
|
|
this.route.paramMap.pipe(map((p) => (p.has('docEntry') ? Number(p.get('docEntry')) : null))),
|
|
{ initialValue: this.route.snapshot.paramMap.has('docEntry') ? Number(this.route.snapshot.paramMap.get('docEntry')) : null },
|
|
);
|
|
protected readonly isNew = computed(() => this.docEntry() === null);
|
|
|
|
// --- datos ---------------------------------------------------------------------------------
|
|
|
|
private readonly docResource = httpResource<DocRecord>(() => {
|
|
const id = this.docEntry();
|
|
return id === null ? undefined : `${API_URL}/sl/${this.screen.resource}/${id}`;
|
|
});
|
|
private readonly taxResource = httpResource<SlListResponse<DocRecord>>(() =>
|
|
this.partner
|
|
? { url: `${API_URL}/sl/${this.partner.taxResource}`, params: { $select: 'Code,Rate', $top: 500 } }
|
|
: undefined,
|
|
);
|
|
|
|
/** Gastos adicionales de SAP (flete, seguro…): sus nombres se muestran junto al código de cada fila. */
|
|
private readonly expensesResource = httpResource<SlListResponse<DocRecord>>(() =>
|
|
this.hasExpenses ? { url: `${API_URL}/sl/AdditionalExpenses`, params: { $select: 'ExpensCode,Name', $top: 200 } } : undefined,
|
|
);
|
|
private readonly expenseNames = computed(
|
|
() => new Map((this.expensesResource.hasValue() ? this.expensesResource.value().value : []).map((e) => [String(e['ExpensCode']), text(e['Name'])])),
|
|
);
|
|
|
|
// --- moneda y tipo de cambio ---------------------------------------------------------------
|
|
|
|
private readonly currenciesResource = httpResource<SlListResponse<DocRecord>>(() =>
|
|
this.hasCurrency ? { url: `${API_URL}/sl/Currencies`, params: { $select: 'Code,Name', $top: 100 } } : undefined,
|
|
);
|
|
/** Moneda local primero («Moneda local») y después las demás. */
|
|
protected readonly currencyOptions = computed(() => {
|
|
const local = this.settings.localCurrency();
|
|
const others = (this.currenciesResource.hasValue() ? this.currenciesResource.value().value : [])
|
|
.filter((c) => text(c['Code']) !== local)
|
|
.map((c) => ({ value: text(c['Code']), label: `${text(c['Code'])} · ${text(c['Name'])}` }));
|
|
return [{ value: local, label: 'Moneda local' }, ...others];
|
|
});
|
|
protected readonly currencyValue = computed(() => text(this.header()['DocCurrency']) || this.settings.localCurrency());
|
|
/** El documento está en la moneda local (o la empresa aún no informa cuál es): no lleva tipo de cambio. */
|
|
protected readonly isLocalCurrency = computed(() => !text(this.header()['DocCurrency']) || this.currencyValue() === this.settings.localCurrency());
|
|
|
|
protected onCurrency(code: string): void {
|
|
this.header.update((h) => ({ ...h, DocCurrency: code, DocRate: '' }));
|
|
this.message.set(null);
|
|
if (code !== this.settings.localCurrency()) void this.fetchRate(code);
|
|
}
|
|
|
|
/** El tipo de cambio de la moneda en la fecha del documento; se puede corregir a mano. */
|
|
private async fetchRate(currency: string): Promise<void> {
|
|
try {
|
|
const rate = await this.api.currencyRate(currency, text(this.header()['DocDate']) || today());
|
|
if (text(this.header()['DocCurrency']) === currency) this.header.update((h) => ({ ...h, DocRate: rate }));
|
|
} catch (e) {
|
|
this.message.set({ kind: 'warn', text: `No se pudo obtener el tipo de cambio de ${currency}: ${apiErrorMessage(e)}` });
|
|
}
|
|
}
|
|
|
|
/** Filas de gastos adicionales; se reinician al cargar o recargar el documento. */
|
|
protected readonly expenses = linkedSignal<ExpenseRow[]>(() => expenseRowsOf(this.doc()));
|
|
protected readonly expenseColumns: readonly DetailColumn[] = [
|
|
{
|
|
key: 'ExpenseCode',
|
|
title: 'Gasto',
|
|
type: 'lookup',
|
|
required: true,
|
|
width: '90px',
|
|
lookup: {
|
|
title: 'Gastos adicionales',
|
|
resource: 'AdditionalExpenses',
|
|
orderby: 'ExpensCode',
|
|
valueKey: 'ExpensCode',
|
|
valueType: 'number',
|
|
columns: [
|
|
{ key: 'ExpensCode', title: 'Código', width: '90px', mono: true },
|
|
{ key: 'Name', title: 'Nombre' },
|
|
],
|
|
},
|
|
},
|
|
{ key: '_Name', title: 'Nombre', type: 'text', width: '170px', computed: (row) => this.expenseNames().get(String(row['ExpenseCode'] ?? '')) ?? '' },
|
|
{ key: 'LineTotal', title: 'Importe', type: 'number', decimals: 2, required: true, width: '110px' },
|
|
{
|
|
key: 'TaxCode',
|
|
title: 'Impuesto',
|
|
type: 'lookup',
|
|
width: '100px',
|
|
lookup: {
|
|
...(this.layout.columns.find((c) => c.key === 'TaxCode')?.lookup?.config ?? LINE_COLUMNS.tax.lookup!.config),
|
|
},
|
|
},
|
|
{ key: 'Remarks', title: 'Comentarios', type: 'text', width: '180px' },
|
|
];
|
|
|
|
protected setExpenses(rows: ExpenseRow[]): void {
|
|
this.expenses.set(rows);
|
|
this.message.set(null);
|
|
}
|
|
|
|
protected addExpense(): void {
|
|
this.setExpenses([...this.expenses(), {}]);
|
|
}
|
|
|
|
/** Series de numeración de este tipo de documento (acción `SeriesService_GetDocumentSeries` de SAP). */
|
|
private readonly hasSeries = hasSeries(this.layout);
|
|
private readonly objectType = OBJECT_TYPES[this.screen.path];
|
|
private readonly seriesResource = httpResource<SlListResponse<DocRecord>>(() =>
|
|
this.hasSeries && this.objectType
|
|
? {
|
|
url: `${API_URL}/sl/SeriesService_GetDocumentSeries`,
|
|
method: 'POST',
|
|
body: { DocumentTypeParams: { Document: String(this.objectType) } },
|
|
}
|
|
: undefined,
|
|
);
|
|
protected readonly seriesValue = computed<number | null>(() => Number(this.header()['Series']) || null);
|
|
|
|
/** Series elegibles: del subtipo de la pantalla y sin bloquear; la del documento abierto se incluye siempre. */
|
|
protected readonly seriesOptions = computed(() => {
|
|
const subType = this.layout.seriesSubType ?? '--';
|
|
const rows = this.seriesResource.hasValue() ? this.seriesResource.value().value : [];
|
|
const options = rows
|
|
.filter((r) => r['DocumentSubType'] === subType && r['Locked'] !== 'tYES')
|
|
.map((r) => ({ value: Number(r['Series']), label: text(r['Name']) || String(r['Series']) }));
|
|
const current = Number(this.doc()?.['Series']);
|
|
if (current && !options.some((o) => o.value === current)) {
|
|
const known = rows.find((r) => Number(r['Series']) === current);
|
|
options.unshift({ value: current, label: text(known?.['Name']) || `Serie ${current}` });
|
|
}
|
|
return options;
|
|
});
|
|
|
|
protected readonly doc = computed(() => (this.docResource.hasValue() ? this.docResource.value() : undefined));
|
|
protected readonly loading = computed(() => this.docResource.isLoading());
|
|
protected readonly loadError = computed(() => {
|
|
const err = this.docResource.error();
|
|
return err ? (err instanceof HttpErrorResponse && err.status === 404 ? 'El documento no existe.' : apiErrorMessage(err)) : '';
|
|
});
|
|
|
|
/** Valores editables; se reinician al cargar o recargar el documento. */
|
|
protected readonly header = linkedSignal<DocRecord>(() => this.pick(this.doc()));
|
|
protected readonly lines = linkedSignal<readonly GridLine[]>(() => this.docLines(this.doc()).map((l) => ({ ...l })));
|
|
protected readonly partnerName = linkedSignal(() => text(this.doc()?.['CardName']));
|
|
/** RUT del socio: lo completa SAP desde el maestro; aquí solo se muestra. */
|
|
protected readonly partnerTaxId = linkedSignal(() => text(this.doc()?.['FederalTaxID']));
|
|
|
|
// --- estado --------------------------------------------------------------------------------
|
|
|
|
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout.immutable && !this.isNew()));
|
|
protected readonly saving = signal(false);
|
|
private readonly message = signal<StatusMessage | null>(
|
|
((): StatusMessage | null => {
|
|
const created = (this.router.currentNavigation()?.extras.state as { created?: string } | undefined)?.created;
|
|
return created ? { kind: 'ok', text: `Documento ${created} creado.` } : null;
|
|
})(),
|
|
);
|
|
protected readonly conflict = signal(false);
|
|
|
|
protected readonly status = computed<StatusMessage>(() => {
|
|
if (this.loading()) return { kind: 'info', text: 'Cargando…' };
|
|
if (this.loadError()) return { kind: 'err', text: this.loadError() };
|
|
const message = this.message();
|
|
if (message) return message;
|
|
if (this.readOnly()) {
|
|
const posted = this.layout.immutable && !isDocumentReadOnly(this.doc());
|
|
return { kind: 'info', text: posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' };
|
|
}
|
|
if (this.isNew()) {
|
|
return { kind: 'info', text: this.partner ? `Indique el ${this.partner.label.toLowerCase()} y las líneas.` : 'Complete la cabecera y las líneas.' };
|
|
}
|
|
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
|
|
});
|
|
protected readonly badge = computed<WindowBadge | undefined>(() => {
|
|
if (this.isNew()) return { label: 'Nueva', tone: 'info' };
|
|
const d = this.doc();
|
|
return d ? documentStatus(d) : undefined;
|
|
});
|
|
protected readonly windowTitle = computed(() => {
|
|
const num = this.doc()?.['DocNum'];
|
|
return num === undefined ? this.title : `${this.title} · ${text(num)}`;
|
|
});
|
|
|
|
protected readonly dirty = computed(() => {
|
|
const d = this.doc();
|
|
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l, this.serviceMode())) || expensesPayload(this.expenses()).length > 0;
|
|
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
|
|
return Object.keys(body).length > 0 || (this.hasExpenses && expensesChanged(d, this.expenses()));
|
|
});
|
|
|
|
// --- totales -------------------------------------------------------------------------------
|
|
|
|
private readonly rates = computed(() =>
|
|
taxRateMap(this.taxResource.hasValue() ? this.taxResource.value().value : []),
|
|
);
|
|
protected readonly totals = computed(() =>
|
|
computeTotals(this.lines(), this.rates(), Number(this.header()['DiscountPercent'] ?? 0), this.expenses(), this.header()['Rounding'] === 'tYES' ? Number(this.header()['RoundingDiffAmount'] ?? 0) : 0),
|
|
);
|
|
protected readonly fmt = (n: number): string => formatNumber(n, 2);
|
|
|
|
// --- copiar de / a ----------------------------------------------------------------------------
|
|
|
|
private readonly copyOrigin = (this.router.currentNavigation()?.extras.state as { copyFrom?: CopyOrigin } | undefined)?.copyFrom;
|
|
protected readonly copyListFor = signal<string | null>(null);
|
|
|
|
/** "Copiar a": solo desde un documento guardado, abierto y sin cambios pendientes. */
|
|
protected readonly copyToItems = computed<MenuItem[]>(() => {
|
|
const blocked = this.isNew() || this.readOnly() || this.dirty();
|
|
return copyTargets(this.screen.path).map((p) => ({ id: p, label: menuLabel(p), disabled: blocked }));
|
|
});
|
|
protected readonly copyFromItems = computed<MenuItem[]>(() =>
|
|
copySources(this.screen.path).map((p) => ({ id: p, label: menuLabel(p), disabled: !this.isNew() })),
|
|
);
|
|
protected readonly copyListTitle = computed(() => menuLabel(this.copyListFor() ?? ''));
|
|
protected readonly copyListScreen = computed(() => DOCUMENT_SCREENS.find((s) => s.path === this.copyListFor()));
|
|
/** Documentos abiertos del origen elegido, del mismo socio si ya se indicó. */
|
|
protected readonly copyListFilter = computed(() => {
|
|
const card = text(this.header()['CardCode']);
|
|
return [`DocumentStatus eq 'bost_Open'`, `Cancelled eq 'tNO'`, ...(card ? [`CardCode eq '${card.replace(/'/g, "''")}'`] : [])].join(' and ');
|
|
});
|
|
protected readonly copyListColumns = this.partner
|
|
? ([
|
|
docNum,
|
|
{ key: 'CardCode', title: this.partner.label, width: '120px', mono: true },
|
|
{ key: 'CardName', title: 'Nombre' },
|
|
{ key: 'DocDate', title: 'Fecha', type: 'date', align: 'center', width: '105px' },
|
|
] as const)
|
|
: ([
|
|
docNum,
|
|
{ key: 'FromWarehouse', title: 'Almacén origen', width: '130px', mono: true },
|
|
{ key: 'ToWarehouse', title: 'Almacén destino', width: '130px', mono: true },
|
|
{ key: 'DocDate', title: 'Fecha', type: 'date', align: 'center', width: '105px' },
|
|
] as const);
|
|
|
|
protected copyTo(path: string): void {
|
|
const docEntry = this.docEntry();
|
|
if (docEntry === null) return;
|
|
const origin: CopyOrigin = { path: this.screen.path, docEntry };
|
|
void this.router.navigateByUrl(`${path}/nuevo`, { state: { copyFrom: origin } });
|
|
}
|
|
|
|
protected copyFrom(path: string): void {
|
|
this.copyListFor.set(path);
|
|
}
|
|
|
|
protected async copyPicked(rows: ListRow[]): Promise<void> {
|
|
const path = this.copyListFor();
|
|
this.copyListFor.set(null);
|
|
const docEntry = rows[0]?.['DocEntry'];
|
|
if (path && typeof docEntry === 'number') await this.loadCopy({ path, docEntry });
|
|
}
|
|
|
|
/** Trae el documento origen y vuelca sus datos y líneas abiertas en el formulario. */
|
|
private async loadCopy(origin: CopyOrigin): Promise<void> {
|
|
const source = DOCUMENT_SCREENS.find((s) => s.path === origin.path);
|
|
if (!source) return;
|
|
try {
|
|
const doc = await this.api.get(source.resource, origin.docEntry);
|
|
const copied = copyFromDocument(doc, origin.path, menuLabel(origin.path), source.layout.lines.key);
|
|
const card = text(this.header()['CardCode']);
|
|
if (card && card !== text(copied.header['CardCode'])) {
|
|
this.message.set({ kind: 'warn', text: `El documento es de ${text(copied.header['CardCode'])}; este es de ${card}.` });
|
|
return;
|
|
}
|
|
if (!copied.lines.length) {
|
|
this.message.set({ kind: 'warn', text: 'El documento no tiene líneas abiertas para copiar.' });
|
|
return;
|
|
}
|
|
// el tipo (artículo / servicio) del origen: si este documento ya tiene líneas de otro tipo no se mezclan
|
|
const copiedType = text(copied.header['DocType']);
|
|
if (this.layout.service && copiedType && copiedType !== text(this.header()['DocType'])) {
|
|
if (this.lines().some((l) => !isBlankLine(l, this.serviceMode()))) {
|
|
this.message.set({ kind: 'warn', text: `El documento es de ${copiedType === SERVICE_DOC_TYPE ? 'servicio' : 'artículos'} y este tiene líneas de otro tipo.` });
|
|
return;
|
|
}
|
|
this.header.update((h) => ({ ...h, DocType: copiedType }));
|
|
}
|
|
if (!card) {
|
|
this.header.update((h) => ({ ...h, ...copied.header }));
|
|
this.partnerName.set(text(doc['CardName']));
|
|
this.partnerTaxId.set(text(doc['FederalTaxID']));
|
|
this.partnerKey.set(text(copied.header['CardCode']));
|
|
}
|
|
this.lines.update((ls) => [...ls.filter((l) => !isBlankLine(l, this.serviceMode())), ...copied.lines]);
|
|
this.message.set({
|
|
kind: 'ok',
|
|
text: `Copiado de ${menuLabel(origin.path).toLowerCase()} ${text(doc['DocNum'])}${copied.skipped ? ` (${copied.skipped} línea(s) cerradas no se copian)` : ''}. Revise y pulse Crear.`,
|
|
});
|
|
} catch (e) {
|
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
|
}
|
|
}
|
|
|
|
// --- configuración de la vista --------------------------------------------------------------
|
|
|
|
protected readonly columns = computed(() => (this.serviceMode() ? this.layout.service!.columns : this.layout.columns));
|
|
private readonly itemColumn = this.layout.columns.find((c) => c.key === 'ItemCode') ?? this.layout.columns[0];
|
|
protected readonly newLine = () => (this.serviceMode() ? this.layout.service!.newLine() : this.layout.newLine(this.header()));
|
|
/** Recalcula la línea con la tasa de cada impuesto (precio con y sin impuesto, total). */
|
|
protected readonly detailHint = detailHint;
|
|
protected readonly recalc = computed(() =>
|
|
!this.serviceMode() && this.layout.recalc ? (line: GridLine, key: string) => this.layout.recalc!(line, key, this.rates()) : undefined,
|
|
);
|
|
protected readonly multi: GridMultiAdd = {
|
|
...ITEMS_MULTI_ADD,
|
|
lookup: this.itemColumn.lookup!.config,
|
|
toLine: (item) => this.itemColumn.lookup!.apply(item, this.layout.newLine(this.header())),
|
|
};
|
|
protected readonly warehouseLookup: VhLookup = {
|
|
...WAREHOUSE_LOOKUP,
|
|
columns: this.layout.lookups?.warehouse ?? WAREHOUSE_LOOKUP.columns,
|
|
};
|
|
protected readonly partnerLookup: VhLookup = {
|
|
title: !this.partner ? 'Socios de negocio' : this.partner.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
|
|
resource: 'BusinessPartners',
|
|
// un documento con socio de un solo tipo lo filtra; los demás (transferencias de stock) admiten cualquiera
|
|
...(this.partner ? { baseFilter: `CardType eq '${this.partner.type}'` } : {}),
|
|
orderby: 'CardCode',
|
|
columns: this.layout.lookups?.partner ?? PARTNER_COLUMNS,
|
|
extraFields: ['FederalTaxID', 'Currency'],
|
|
};
|
|
|
|
protected str = text;
|
|
|
|
private readonly lockedKeys = new Set(this.layout.lockedKeys ?? []);
|
|
/** El campo se ve pero no se edita (una extensión de cliente lo dejó de solo lectura). */
|
|
protected locked(f: HeaderField): boolean {
|
|
// la moneda de un documento ya creado no se cambia
|
|
if (f.type === 'currency' && !this.isNew()) return true;
|
|
// el tipo (artículo / servicio) no se cambia en un documento ya creado
|
|
if (f.type === 'select' && f.key === 'DocType' && !this.isNew()) return true;
|
|
return this.lockedKeys.size > 0 && (keysOfFields([f]).some((k) => this.lockedKeys.has(k)) || (f.type === 'computed' && this.lockedKeys.has(f.source)));
|
|
}
|
|
|
|
/** Valor de una fila de solo lectura (número del documento, datos del socio, totales). */
|
|
protected computedValue(source: ComputedSource): string {
|
|
switch (source) {
|
|
case 'docNum':
|
|
return text(this.doc()?.['DocNum']);
|
|
case 'partnerName':
|
|
return this.partnerName();
|
|
case 'partnerTaxId':
|
|
return this.partnerTaxId();
|
|
case 'subtotal':
|
|
return this.fmt(this.totals().subtotal);
|
|
case 'expenses':
|
|
return this.fmt(this.totals().expenses);
|
|
case 'tax':
|
|
return this.fmt(this.totals().tax);
|
|
case 'downPayment':
|
|
return this.fmt(Number(this.doc()?.['DownPaymentAmount'] ?? 0));
|
|
case 'applied':
|
|
return this.fmt(Number(this.doc()?.['PaidToDate'] ?? 0));
|
|
case 'balance': {
|
|
const d = this.doc();
|
|
return this.fmt(d ? Number(d['DocTotal'] ?? 0) - Number(d['PaidToDate'] ?? 0) : 0);
|
|
}
|
|
case 'total':
|
|
return this.fmt(this.totals().total);
|
|
}
|
|
}
|
|
|
|
constructor() {
|
|
if (this.copyOrigin) void this.loadCopy(this.copyOrigin);
|
|
// al cargarse los datos del socio de un documento nuevo, sus direcciones por defecto
|
|
effect(() => {
|
|
const data = this.partnerData();
|
|
if (!data || !this.isNew()) return;
|
|
untracked(() => {
|
|
// lo que el socio trae por defecto (como en SAP): direcciones, condiciones y forma de pago, cuenta asociada
|
|
const defaults: Record<string, unknown> = {
|
|
ShipToCode: text(data['ShipToDefault']),
|
|
PayToCode: text(data['BilltoDefault']),
|
|
PaymentGroupCode: data['PayTermsGrpCode'] ?? '',
|
|
PaymentMethod: text(data['PeymentMethodCode']),
|
|
ControlAccount: text(data['DebitorAccount']),
|
|
};
|
|
this.header.update((h) => {
|
|
const next = { ...h };
|
|
for (const [key, value] of Object.entries(defaults)) if (this.headerKeys.includes(key) && text(next[key]) === '' && text(value) !== '') next[key] = value;
|
|
return next;
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
protected setHeader(key: string, value: unknown): void {
|
|
if (key === 'DocType' && this.layout.service) return this.onDocType(String(value));
|
|
this.header.update((h) => ({ ...h, [key]: value }));
|
|
this.message.set(null);
|
|
// otra fecha del documento, otro tipo de cambio
|
|
if (key === 'DocDate' && this.isNew() && !this.isLocalCurrency()) void this.fetchRate(this.currencyValue());
|
|
}
|
|
|
|
/** Tipo al que se quiere cambiar y que espera confirmación porque hay líneas escritas que se descartarían. */
|
|
protected readonly pendingType = signal<string | null>(null);
|
|
|
|
// --- datos del socio: personas de contacto y direcciones ---------------------------------------
|
|
|
|
private readonly hasPartnerData = hasPartnerData(this.layout);
|
|
/** Código del socio cuyos datos se cargan: el del documento abierto o el que se acaba de elegir. */
|
|
protected readonly partnerKey = linkedSignal(() => text(this.doc()?.['CardCode']));
|
|
private readonly partnerDetail = httpResource<DocRecord>(() => {
|
|
const key = this.partnerKey();
|
|
return key && this.hasPartnerData
|
|
? { url: `${API_URL}/sl/BusinessPartners/${encodeURIComponent(key)}`, params: { $select: 'CardCode,BPAddresses,ContactEmployees,ShipToDefault,BilltoDefault,PayTermsGrpCode,PeymentMethodCode,DebitorAccount' } }
|
|
: undefined;
|
|
});
|
|
private readonly partnerData = computed(() => (this.partnerDetail.hasValue() ? this.partnerDetail.value() : undefined));
|
|
private readonly addresses = computed(() => (this.partnerData()?.['BPAddresses'] as DocRecord[] | undefined) ?? []);
|
|
|
|
protected readonly contactOptions = computed(() =>
|
|
((this.partnerData()?.['ContactEmployees'] as DocRecord[] | undefined) ?? []).map((c) => ({ value: Number(c['InternalCode']), label: text(c['Name']) })),
|
|
);
|
|
/** Direcciones del socio de ese lado (envío o facturación), por su nombre. */
|
|
protected readonly addressOptions = (side: 'ship' | 'bill') => {
|
|
const type = side === 'ship' ? 'bo_ShipTo' : 'bo_BillTo';
|
|
return this.addresses().filter((a) => a['AddressType'] === type).map((a) => ({ value: text(a['AddressName']), label: text(a['AddressName']) }));
|
|
};
|
|
protected readonly shipOptions = computed(() => this.addressOptions('ship'));
|
|
protected readonly billOptions = computed(() => this.addressOptions('bill'));
|
|
|
|
/** El texto de la dirección elegida: el que SAP guardó en el documento o, al elegir otra, el armado con los datos de la dirección del socio. */
|
|
protected addressText(f: { key: string; side: 'ship' | 'bill' }): string {
|
|
const code = text(this.header()[f.key]);
|
|
const found = this.addresses().find((a) => text(a['AddressName']) === code && a['AddressType'] === (f.side === 'ship' ? 'bo_ShipTo' : 'bo_BillTo'));
|
|
if (found) return [found['Street'], found['Block'], found['ZipCode'], found['City'], found['County'], found['State']].map(text).filter((p) => p !== '').join(', ');
|
|
const saved = this.doc()?.[f.side === 'ship' ? 'Address' : 'Address2'];
|
|
return code === text(this.doc()?.[f.key]) ? text(saved).replace(/\r/g, '\n') : '';
|
|
}
|
|
|
|
/** Un código numérico de SAP (lista de precios, empleado) que se escribe o elige como texto se guarda como número. */
|
|
protected typedLookup(f: { lookup: { valueType?: string } }, value: string): string | number {
|
|
return f.lookup.valueType === 'number' && /^-?\d+$/.test(value.trim()) ? Number(value) : value;
|
|
}
|
|
|
|
/** La celda de un grupo de campos (el indicador) cuya lista de valores está abierta. */
|
|
protected readonly groupCell = signal<GroupCell | null>(null);
|
|
|
|
protected groupPicked(rows: ListRow[]): void {
|
|
const cell = this.groupCell();
|
|
const row = rows[0];
|
|
if (cell?.kind === 'lookup' && row) {
|
|
this.setHeader(cell.key, row[cell.lookup.valueKey ?? cell.lookup.columns[0]?.key ?? ''] ?? '');
|
|
}
|
|
this.groupCell.set(null);
|
|
}
|
|
|
|
/** Cambiar entre artículo y servicio cambia las columnas de línea: las líneas escritas no sirven y se pide permiso para descartarlas. */
|
|
private onDocType(type: string): void {
|
|
if (type === text(this.header()['DocType'])) return;
|
|
if (this.lines().some((l) => !isBlankLine(l, this.serviceMode()))) {
|
|
this.pendingType.set(type);
|
|
return;
|
|
}
|
|
this.applyDocType(type);
|
|
}
|
|
|
|
protected applyDocType(type: string): void {
|
|
this.pendingType.set(null);
|
|
this.header.update((h) => ({ ...h, DocType: type }));
|
|
this.lines.set([]);
|
|
this.message.set(null);
|
|
}
|
|
|
|
protected readonly pendingTypeLabel = computed(() => (this.pendingType() === SERVICE_DOC_TYPE ? 'Servicio' : 'Artículo'));
|
|
protected readonly lineCount = computed(() => this.lines().filter((l) => !isBlankLine(l, this.serviceMode())).length);
|
|
|
|
/** Al escribir otro código el nombre anterior ya no vale; se completa al salir del campo o al elegir de la lista. */
|
|
protected onPartnerText(code: string): void {
|
|
if (code === text(this.header()['CardCode'])) return;
|
|
this.setHeader('CardCode', code);
|
|
this.partnerName.set('');
|
|
this.partnerTaxId.set('');
|
|
this.partnerKey.set('');
|
|
// la persona de contacto y las direcciones eran del socio anterior
|
|
this.header.update((h) => ({ ...h, ContactPerson: '', ShipToCode: '', PayToCode: '' }));
|
|
}
|
|
|
|
protected notify(textMessage: string): void {
|
|
this.message.set({ kind: 'warn', text: textMessage });
|
|
}
|
|
|
|
protected onPartner(row: Record<string, unknown>): void {
|
|
this.partnerKey.set(text(row['CardCode']));
|
|
this.partnerName.set(text(row['CardName']));
|
|
this.partnerTaxId.set(text(row['FederalTaxID']));
|
|
// el documento toma la moneda del socio (si es una sola y no es la local), como en SAP
|
|
const currency = text(row['Currency']);
|
|
if (this.isNew() && this.hasCurrency && currency && currency !== '##' && currency !== this.currencyValue() && this.isLocalCurrency()) this.onCurrency(currency);
|
|
// elegido el socio, el documento nuevo arranca con una línea lista para escribir
|
|
if (this.isNew() && !this.readOnly() && this.lines().length === 0) this.lines.set([this.newLine()]);
|
|
}
|
|
|
|
protected setLines(lines: readonly GridLine[]): void {
|
|
this.lines.set(lines);
|
|
this.message.set(null);
|
|
}
|
|
|
|
// --- acciones ------------------------------------------------------------------------------
|
|
|
|
protected async go(target: NavTarget): Promise<void> {
|
|
try {
|
|
const entry = await this.api.neighbour(this.screen.resource, target, this.docEntry(), this.screen.baseFilter);
|
|
if (entry === null) {
|
|
this.message.set({ kind: 'warn', text: 'No hay más documentos en esa dirección.' });
|
|
return;
|
|
}
|
|
this.message.set(null);
|
|
await this.router.navigate(['..', entry], { relativeTo: this.route });
|
|
} catch (e) {
|
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
|
}
|
|
}
|
|
|
|
protected async save(): Promise<void> {
|
|
if (this.saving() || this.readOnly()) return;
|
|
const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine, this.serviceMode());
|
|
if (this.layout.details?.length && !this.serviceMode()) errors.push(...validateLineDetails(this.lines()));
|
|
if (this.hasFolio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
|
|
if (this.hasExpenses) errors.push(...validateExpenses(this.expenses()));
|
|
if (errors.length) {
|
|
this.message.set({ kind: 'err', text: errors[0] });
|
|
return;
|
|
}
|
|
this.saving.set(true);
|
|
this.conflict.set(false);
|
|
try {
|
|
const d = this.doc();
|
|
if (!d) {
|
|
const header = folioAsNumber(this.layout.beforeCreate?.(this.header()) ?? this.header());
|
|
// en la moneda local no se envía moneda ni tipo de cambio: SAP usa los suyos
|
|
if (this.isLocalCurrency()) {
|
|
delete header['DocCurrency'];
|
|
delete header['DocRate'];
|
|
}
|
|
// sin redondeo marcado no se envía nada: SAP calcula el suyo
|
|
if (header['Rounding'] !== 'tYES') {
|
|
delete header['Rounding'];
|
|
delete header['RoundingDiffAmount'];
|
|
}
|
|
const body = createPayload(header, this.lines(), this.spec());
|
|
if (this.hasExpenses && expensesPayload(this.expenses()).length) body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
|
|
const created = await this.api.create(this.screen.resource, body);
|
|
await this.router.navigate(['..', created['DocEntry']], {
|
|
relativeTo: this.route,
|
|
state: { created: text(created['DocNum']) },
|
|
});
|
|
return;
|
|
}
|
|
const update = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.spec());
|
|
const body = folioAsNumber(update.body);
|
|
let replaceLines = update.replaceLines;
|
|
if (this.hasExpenses && expensesChanged(d, this.expenses())) {
|
|
body[EXPENSES_COLLECTION] = expensesPayload(this.expenses());
|
|
replaceLines = true; // SAP reemplaza la colección entera
|
|
}
|
|
if (!Object.keys(body).length) {
|
|
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
|
|
return;
|
|
}
|
|
await this.api.update(this.screen.resource, this.docEntry()!, body, text(d['odata.etag']) || undefined, replaceLines);
|
|
this.message.set({ kind: 'ok', text: 'Documento actualizado.' });
|
|
this.docResource.reload();
|
|
} catch (e) {
|
|
if (e instanceof HttpErrorResponse && e.status === 412) {
|
|
this.conflict.set(true);
|
|
this.message.set({
|
|
kind: 'warn',
|
|
text: 'Otro usuario modificó este documento. Recárguelo para ver sus cambios; los suyos no se guardaron.',
|
|
});
|
|
} else {
|
|
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
|
|
}
|
|
} finally {
|
|
this.saving.set(false);
|
|
}
|
|
}
|
|
|
|
protected reload(): void {
|
|
this.conflict.set(false);
|
|
this.message.set(null);
|
|
this.docResource.reload();
|
|
}
|
|
}
|