Files
basic-erp/apps/web/src/app/features/documents/document-layouts.ts
T

595 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { todayIso } from '../../core/format';
import type { GridColumn, GridDetail, GridLine } from '../../shared/grid/grid.types';
import type { ListColumn } from '../../shared/list/list.types';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import type { DocRecord, LinesSpec } from './document-model';
import { ACCOUNT_LOOKUP, INDICATOR_LOOKUP, PROJECT_LOOKUP } from './dimension-lookups';
import { SERIES_SUBTYPE } from './document-objects';
import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, newServiceLine, recalcLine, serviceLineColumns, uomEntryOf } from './line-columns';
/** Una celda de un grupo de campos: un texto (con ancho y largo máximo) o un código que se elige de una lista. */
export type GroupCell =
| { kind: 'text'; key: string; label: string; width?: string; maxlength?: number }
| { kind: 'lookup'; key: string; label: string; lookup: VhLookup; width?: string };
/** Fila con varios campos juntos, para la cabecera de un documento. */
export const fieldGroup = (label: string, cells: readonly GroupCell[]): HeaderField => ({ type: 'group', label, cells });
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'expenses' | 'tax' | 'total' | 'downPayment' | 'applied' | 'balance';
/**
* Un campo de la cabecera (o del pie) del formulario. La pantalla de cada documento declara su lista
* de campos y el formulario base los dibuja: no hay HTML por documento.
*/
export type HeaderField =
| {
type: 'text';
key: string;
label: string;
readonly?: boolean;
/** El código de un maestro no se cambia una vez creado. */
immutable?: boolean;
required?: boolean;
missing?: string;
}
| { type: 'date'; key: string; label: string; required?: boolean; missing?: string }
| { type: 'number'; key: string; label: string; decimals?: number; readonly?: boolean }
/** Casilla: en SAP un campo `tYES` / `tNO`. */
| { type: 'checkbox'; key: string; label: string; readonly?: boolean; immutable?: boolean }
/** Campo de código con su ventana de selección (grupo, moneda, lista de precios…). */
| { type: 'lookup'; key: string; label: string; lookup: VhLookup; required?: boolean; missing?: string; immutable?: boolean; onPick?: (row: Record<string, unknown>) => Record<string, unknown> }
| { type: 'textarea'; key: string; label: string }
/** Lista de valores válidos (típico de un campo de usuario con valores válidos en SAP). */
| { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string; immutable?: boolean }
/** Abre la lista de almacenes. */
| { type: 'warehouse'; key: string; label: string; required?: boolean; missing?: string }
/** Código del socio de negocio (cliente o proveedor) con su lista; solo editable al crear. */
| { type: 'partner'; key: string; label: string; required?: boolean; missing?: string }
/** Prefijo y número del folio tributario en una misma fila (`FolioPrefixString` y `FolioNumber`). */
| { type: 'folio'; label: string; prefixKey: string; numberKey: string }
/**
* Varios campos juntos en una sola fila, en el orden dado (el indicador, el prefijo y el número del folio). Una empresa
* que los sustituye por campos de usuario arma su propio grupo con `fieldGroup(...)` y oculta el del base.
*/
| { type: 'group'; label: string; cells: readonly GroupCell[] }
/** Serie de numeración y número del documento en una misma fila (`Series` y `DocNum`). */
| { type: 'series'; key: string; label: string }
/** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */
/** Tabla de gastos adicionales del documento (flete, seguro…): una fila por gasto. */
| { type: 'expenses'; label: string }
/** Redondeo del documento (`Rounding` y `RoundingDiffAmount`): solo se ofrece si la empresa usa redondeo (`OADM.RoundMthd`). */
| { type: 'rounding'; label: string }
/** Moneda del documento (`DocCurrency`) y su tipo de cambio (`DocRate`): el de la fecha del documento si no es la local. */
| { type: 'currency'; label: string }
/** Persona de contacto del socio (`ContactPerson`): se elige de las del socio de negocio. */
| { type: 'contact'; key: string; label: string }
/**
* Dirección de envío (`ShipToCode`) o de facturación (`PayToCode`) del socio: se elige de sus direcciones y debajo se
* muestra su texto. `side` dice cuáles direcciones de la lista de direcciones del socio se ofrecen.
*/
| { type: 'address'; key: string; label: string; side: 'ship' | 'bill' }
| { type: 'computed'; label: string; source: ComputedSource };
/** Las listas de valores (ventanas modales) de un formulario. */
export type LookupName = 'partner' | 'item' | 'warehouse' | 'tax';
/** Columnas que muestra cada lista de valores; la primera es el código que se escribe en el campo. */
export type LookupColumns = Partial<Record<LookupName, readonly ListColumn[]>>;
export interface FieldColumns {
/** Dos columnas como máximo (regla del proyecto base). */
left: readonly HeaderField[];
right: readonly HeaderField[];
}
/**
* Formulario de un documento: qué campos de cabecera y de pie, qué columnas de línea y cómo se
* guarda. El formulario base lo dibuja sin saber de qué documento se trata.
*/
export interface DocumentLayout {
lines: LinesSpec;
header: FieldColumns;
/** Debajo de las líneas: comentarios y, donde los hay, descuento y totales. */
footer: FieldColumns;
columns: readonly GridColumn[];
/** Cabecera de un documento nuevo (siempre lleva `DocDate`). */
newHeader: () => DocRecord;
/** Línea nueva; las transferencias toman los almacenes de la cabecera. */
newLine: (header: DocRecord) => GridLine;
/** Recalcula una línea tras un cambio (total = cantidad × precio…); recibe la tasa de cada código de impuesto. */
recalc?: (line: GridLine, changedKey: string, rates?: ReadonlyMap<string, number>) => GridLine;
/** Detalles de línea (lotes, series) que este documento permite indicar. */
details?: readonly GridDetail[];
/** Campos de cabecera que se ven pero no se editan (un cliente puede querer verlos sin cambiarlos). */
lockedKeys?: readonly string[];
/** Subtipo con que SAP etiqueta las series de este documento (`--` = ninguno, `IB` = boleta…). */
seriesSubType?: string;
/** Columnas de las listas de valores de este documento; las que falten usan las del base. */
lookups?: LookupColumns;
/** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */
immutable?: boolean;
/** Ajustes a la cabecera antes de crear. */
beforeCreate?: (header: DocRecord) => DocRecord;
requiredLine: readonly { key: string; label: string }[];
/**
* Cómo cambia el documento cuando es de tipo **Servicio** (`DocType = dDocument_Service`): otras columnas de línea
* y otros campos que se guardan. Solo lo tienen los documentos de ventas y compras, que además llevan en la
* cabecera la lista «Tipo» (`FIELDS.docType`).
*/
service?: { columns: readonly GridColumn[]; lines: LinesSpec; newLine: () => GridLine };
/** Pestañas «Logística» y «Finanzas» de los documentos de ventas y compras (la primera, «Contenido», son las líneas). */
tabs?: { logistics: FieldColumns; finance: FieldColumns };
}
/** Un documento de servicio (no de artículos): `DocType` de SAP. */
export const SERVICE_DOC_TYPE = 'dDocument_Service';
export const ITEMS_DOC_TYPE = 'dDocument_Items';
export const today = (): string => todayIso();
// --- catálogo de campos ---------------------------------------------------------------------
/** Catálogo de campos: cada pantalla elige cuáles lleva. */
/** Lista de precios de un documento (`PriceList`): las números de SAP y, en negativo, los costos (-2 último precio de compra). */
export const PRICE_LIST_LOOKUP: VhLookup = {
title: 'Listas de precios',
resource: 'PriceLists',
orderby: 'PriceListNo',
valueType: 'number',
columns: [
{ key: 'PriceListNo', title: 'Código', width: '90px', mono: true },
{ key: 'PriceListName', title: 'Nombre' },
],
};
/** Empleado de ventas o compras del documento (`SalesPersonCode`). */
export const SALES_PERSON_LOOKUP: VhLookup = {
title: 'Empleados de ventas',
resource: 'SalesPersons',
orderby: 'SalesEmployeeCode',
valueType: 'number',
baseFilter: `Active eq 'tYES'`,
columns: [
{ key: 'SalesEmployeeCode', title: 'Código', width: '90px', mono: true },
{ key: 'SalesEmployeeName', title: 'Nombre' },
],
};
/** Formas de envío (`TransportationCode`). */
export const SHIPPING_TYPE_LOOKUP: VhLookup = {
title: 'Formas de envío',
resource: 'ShippingTypes',
orderby: 'Code',
valueType: 'number',
columns: [
{ key: 'Code', title: 'Código', width: '90px', mono: true },
{ key: 'Name', title: 'Nombre' },
],
};
/** Condiciones de pago (`PaymentGroupCode`). */
export const PAYMENT_TERMS_LOOKUP: VhLookup = {
title: 'Condiciones de pago',
resource: 'PaymentTermsTypes',
orderby: 'GroupNumber',
valueType: 'number',
columns: [
{ key: 'GroupNumber', title: 'Código', width: '90px', mono: true },
{ key: 'PaymentTermsGroupName', title: 'Nombre' },
],
};
/** Formas de pago (`PaymentMethod`): las de cobro en ventas y las de pago en compras. */
export const paymentMethodLookup = (type: 'boptIncoming' | 'boptOutgoing'): VhLookup => ({
title: 'Formas de pago',
resource: 'WizardPaymentMethods',
orderby: 'PaymentMethodCode',
baseFilter: `Type eq '${type}' and Active eq 'tYES'`,
columns: [
{ key: 'PaymentMethodCode', title: 'Código', width: '170px', mono: true },
{ key: 'Description', title: 'Descripción' },
],
});
/** Cuentas asociadas (las de control de los socios): la cuenta de control de un documento sale de ellas. */
export const CONTROL_ACCOUNT_LOOKUP: VhLookup = {
...ACCOUNT_LOOKUP,
title: 'Cuentas asociadas',
baseFilter: `ActiveAccount eq 'tYES' and LockManualTransaction eq 'tYES'`,
};
/** Usuarios de SAP (solicitante de una solicitud de compra): el servidor solo entrega estos campos. */
export const USER_LOOKUP: VhLookup = {
title: 'Usuarios',
resource: 'Users',
orderby: 'UserCode',
valueKey: 'UserCode',
extraFields: ['eMail', 'Department', 'Branch'],
columns: [
{ key: 'UserCode', title: 'Código', width: '140px', mono: true },
{ key: 'UserName', title: 'Nombre' },
],
};
export const DEPARTMENT_LOOKUP: VhLookup = {
title: 'Departamentos',
resource: 'Departments',
orderby: 'Code',
valueType: 'number',
columns: [
{ key: 'Code', title: 'Código', width: '90px', mono: true },
{ key: 'Name', title: 'Nombre' },
],
};
export const BRANCH_LOOKUP: VhLookup = {
title: 'Sucursales',
resource: 'Branches',
orderby: 'Code',
valueType: 'number',
columns: [
{ key: 'Code', title: 'Código', width: '90px', mono: true },
{ key: 'Name', title: 'Nombre' },
],
};
export const FIELDS = {
/** Solicitante de una solicitud de compra: al elegir el usuario se completan su nombre, correo, departamento y sucursal. */
requesterUser: {
type: 'lookup',
key: 'Requester',
label: 'Solicitante (usuario)',
lookup: USER_LOOKUP,
required: true,
missing: 'el solicitante',
onPick: (u: Record<string, unknown>) => ({
RequesterName: u['UserName'] ?? '',
RequesterEmail: u['eMail'] ?? '',
RequesterDepartment: u['Department'] ?? '',
RequesterBranch: u['Branch'] ?? '',
}),
},
requesterName: { type: 'text', key: 'RequesterName', label: 'Nombre de solicitante', readonly: true },
requesterBranch: { type: 'lookup', key: 'RequesterBranch', label: 'Sucursal', lookup: BRANCH_LOOKUP },
requesterDepartment: { type: 'lookup', key: 'RequesterDepartment', label: 'Departamento', lookup: DEPARTMENT_LOOKUP },
requesterNotify: { type: 'checkbox', key: 'SendNotification', label: 'Enviar correo electrónico si se agregó pedido o pedido de compra' },
requesterEmail: { type: 'text', key: 'RequesterEmail', label: 'Dirección de correo electrónico' },
shippingType: { type: 'lookup', key: 'TransportationCode', label: 'Forma de envío', lookup: SHIPPING_TYPE_LOOKUP },
trackingNumber: { type: 'text', key: 'TrackingNumber', label: 'Número de seguimiento' },
blockDunning: { type: 'checkbox', key: 'BlockDunning', label: 'Bloquear reclamaciones' },
paymentTerms: { type: 'lookup', key: 'PaymentGroupCode', label: 'Condiciones de pago', lookup: PAYMENT_TERMS_LOOKUP },
controlAccount: { type: 'lookup', key: 'ControlAccount', label: 'Cuenta asociada', lookup: CONTROL_ACCOUNT_LOOKUP },
paymentBlock: { type: 'checkbox', key: 'PaymentBlock', label: 'Bloqueo de pago' },
centralBankIndicator: { type: 'text', key: 'CentralBankIndicator', label: 'Indicador banco central' },
installments: { type: 'number', key: 'NumberOfInstallments', label: 'Plazos', decimals: 0 },
/** Desde qué fecha se calcula el vencimiento (`StartFrom`) y los meses y días que se suman. */
startFrom: {
type: 'select',
key: 'StartFrom',
label: 'Volver a calcular desde',
options: [
{ value: 'pdt_None', label: 'Ninguno' },
{ value: 'pdt_MonthEnd', label: 'Fin de mes' },
{ value: 'pdt_HalfMonth', label: 'Mitad de mes' },
{ value: 'pdt_MonthStart', label: 'Inicio de mes' },
],
},
extraMonth: { type: 'number', key: 'ExtraMonth', label: 'Meses +', decimals: 0 },
extraDays: { type: 'number', key: 'ExtraDays', label: 'Días +', decimals: 0 },
/** Persona de contacto del socio. */
contact: { type: 'contact', key: 'ContactPerson', label: 'Persona de contacto' },
/** Dirección de destino (envío) del socio. */
shipTo: { type: 'address', key: 'ShipToCode', label: 'Destino', side: 'ship' },
/** Dirección de facturación del socio. */
payTo: { type: 'address', key: 'PayToCode', label: 'Dirección de facturación', side: 'bill' },
priceList: { type: 'lookup', key: 'PriceList', label: 'Lista de precios', lookup: PRICE_LIST_LOOKUP },
salesPerson: { type: 'lookup', key: 'SalesPersonCode', label: 'Empleado del departamento', lookup: SALES_PERSON_LOOKUP },
/** Tipo del documento: de artículos o de servicio (las líneas cambian). */
docType: {
type: 'select',
key: 'DocType',
label: 'Tipo',
options: [
{ value: ITEMS_DOC_TYPE, label: 'Artículo' },
{ value: SERVICE_DOC_TYPE, label: 'Servicio' },
],
},
docNum: { type: 'computed', label: 'Número', source: 'docNum' },
/** Número del documento con su serie. */
docNumber: { type: 'series', key: 'Series', label: 'Número' },
/** Fecha del documento (`TaxDate`), distinta de la de contabilización. */
taxDate: { type: 'date', key: 'TaxDate', label: 'Fecha del documento' },
dueDate: { type: 'date', key: 'DueDate', label: 'Fecha de vencimiento' },
reference2: { type: 'text', key: 'Reference2', label: 'Referencia 2' },
/** Comentarios del asiento contable que genera el movimiento (`JournalMemo`). */
journalMemo: { type: 'text', key: 'JournalMemo', label: 'Comentarios del asiento' },
indicator: { type: 'lookup', key: 'Indicator', label: 'Indicador', lookup: INDICATOR_LOOKUP },
project: { type: 'lookup', key: 'Project', label: 'Proyecto', lookup: PROJECT_LOOKUP },
docDate: { type: 'date', key: 'DocDate', label: 'Fecha de contabilización', required: true, missing: 'la fecha de contabilización' },
docDueDate: { type: 'date', key: 'DocDueDate', label: 'Fecha de vencimiento' },
requiredDate: { type: 'date', key: 'RequriedDate', label: 'Fecha requerida', required: true, missing: 'la fecha requerida' },
comments: { type: 'textarea', key: 'Comments', label: 'Comentarios' },
numAtCard: { type: 'text', key: 'NumAtCard', label: 'Nº referencia' },
folio: { type: 'folio', label: 'Folio', prefixKey: 'FolioPrefixString', numberKey: 'FolioNumber' },
fromWarehouse: { type: 'warehouse', key: 'FromWarehouse', label: 'Almacén origen', required: true, missing: 'el almacén origen' },
toWarehouse: { type: 'warehouse', key: 'ToWarehouse', label: 'Almacén destino', required: true, missing: 'el almacén destino' },
requester: { type: 'text', key: 'Requester', label: 'Solicitante (usuario)' },
} as const satisfies Record<string, HeaderField>;
/**
* Indicador y folio tributario juntos y en ese orden (en el Service Layer `Indicator`, `FolioPrefixString` y
* `FolioNumber`). El número se edita como texto y se envía como número.
*/
export const IDENTITY_GROUP: HeaderField = fieldGroup('Indicador / Folio', [
{ kind: 'lookup', key: 'Indicator', label: 'Indicador', lookup: INDICATOR_LOOKUP, width: '80px' },
{ kind: 'text', key: 'FolioPrefixString', label: 'Prefijo del folio', width: '90px', maxlength: 10 },
{ kind: 'text', key: 'FolioNumber', label: 'Número de folio' },
]);
export const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] };
// --- columnas de línea compartidas ----------------------------------------------------------
const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
export { descriptionColumn, priceColumn, quantityColumn, warehouseColumn };
/**
* Columna de artículo que además completa la unidad de medida de la línea (`UoMEntry`): sin ella SAP
* rechaza el documento si el artículo no tiene unidad por defecto. Al cambiar de artículo se reemplaza
* (un artículo de unidad manual no lleva).
*/
export function itemColumnWithUom(item: GridColumn, side: 'purchase' | 'sales'): GridColumn {
return {
...item,
lookup: {
...item.lookup!,
apply: (picked, line) => ({
...item.lookup!.apply(picked, line),
UoMEntry: uomEntryOf(picked, side),
// Lotes y series: si el artículo se gestiona por ellos la línea los pide; al cambiar de artículo se descartan.
_manageBatches: picked['ManageBatchNumbers'] === 'tYES',
_manageSerials: picked['ManageSerialNumbers'] === 'tYES',
...(picked['ItemCode'] === line['ItemCode'] ? {} : { BatchNumbers: undefined, SerialNumbers: undefined }),
}),
},
};
}
export const itemColumn: GridColumn = itemColumnWithUom(baseItemColumn, 'purchase');
/** Lista de almacenes de la cabecera (la misma de las líneas). */
export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config;
/** Columnas del base de la lista de clientes y proveedores. */
export const PARTNER_COLUMNS: readonly ListColumn[] = [
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
{ key: 'CardName', title: 'Nombre' },
];
/** Columna de línea que abre cada lista de valores. */
const LOOKUP_OF_COLUMN: Readonly<Record<string, LookupName>> = {
ItemCode: 'item',
WarehouseCode: 'warehouse',
FromWarehouseCode: 'warehouse',
TaxCode: 'tax',
};
const defaultLookupColumns = (layout: DocumentLayout, name: LookupName): readonly ListColumn[] => {
if (layout.lookups?.[name]) return layout.lookups[name]!;
if (name === 'partner') return PARTNER_COLUMNS;
return layout.columns.find((c) => LOOKUP_OF_COLUMN[c.key] === name)?.lookup?.config.columns ?? [];
};
/**
* Cambia las columnas de las listas de valores de un documento. `replace` las sustituye por las
* indicadas (el base lo usa); `add` las suma a las que ya tiene (lo usan las extensiones de cliente).
* El recurso, el filtro y el orden de cada lista no cambian.
*/
export function withLookupColumns(layout: DocumentLayout, changes: LookupColumns, mode: 'replace' | 'add'): DocumentLayout {
const next: Partial<Record<LookupName, readonly ListColumn[]>> = { ...layout.lookups };
for (const name of Object.keys(changes) as LookupName[]) {
const given = changes[name] ?? [];
next[name] = mode === 'add' ? [...defaultLookupColumns(layout, name), ...given] : given;
}
const columns = layout.columns.map((c) => {
const name = LOOKUP_OF_COLUMN[c.key];
const cols = name ? next[name] : undefined;
return cols && c.lookup ? { ...c, lookup: { ...c.lookup, config: { ...c.lookup.config, columns: cols } } } : c;
});
return { ...layout, columns, lookups: next };
}
export const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' });
// --- documentos con socio de negocio (ventas y compras) -------------------------------------
/**
* Cabecera de una solicitud de compra (como en SAP): el solicitante con su sucursal, departamento y correo a la
* izquierda; número, las tres fechas («Válido hasta» en lugar del vencimiento) y la fecha necesaria a la derecha.
*/
const REQUEST_HEADER: FieldColumns = {
left: [FIELDS.docType, FIELDS.requesterUser, FIELDS.requesterName, FIELDS.requesterBranch, FIELDS.requesterDepartment, FIELDS.requesterNotify, FIELDS.requesterEmail, FIELDS.indicator, FIELDS.project],
right: [FIELDS.docNumber, FIELDS.docDate, { ...FIELDS.docDueDate, label: 'Válido hasta' }, FIELDS.taxDate, { type: 'date', key: 'RequriedDate', label: 'Fecha necesaria' }],
};
/**
* Ventas y compras: socio, referencia, fechas; líneas con precio, descuento e impuesto; pie con
* comentarios, descuento y totales. Es el punto de partida; cada pantalla ajusta lo suyo:
* - `left` y `right` agregan campos a la cabecera base (por ejemplo `FIELDS.folio` en las facturas);
* - `header` la reemplaza por completo;
* - `lineColumns` reemplaza las columnas de línea (elegidas de `LINE_COLUMNS`); lo que se envía a SAP
* por línea se deduce de ellas.
*/
export function marketingLayout(opts: {
partnerLabel: string;
taxResource: string;
left?: readonly HeaderField[];
right?: readonly HeaderField[];
header?: FieldColumns;
lineColumns?: readonly GridColumn[];
/** Columnas de las listas de valores (cliente, artículo, almacén, impuesto) de este documento. */
lookups?: LookupColumns;
/** Unidad de medida que se toma del artículo: la de venta o la de compra. */
uom: 'sales' | 'purchase';
/** Fila del flete en el pie (por defecto sí). */
expenses?: boolean;
/** Solicitud de compra: en lugar del socio de negocio lleva al solicitante (usuario, sucursal, departamento y correo), «Válido hasta» y la fecha necesaria; sin moneda, gastos adicionales ni redondeo. */
requester?: boolean;
/** Indicador y folio juntos en una fila de la derecha (facturas y notas de crédito) en lugar del indicador suelto a la izquierda y el folio. */
identity?: boolean;
/** Facturas y notas de crédito: el pie suma el anticipo total, el importe aplicado y el saldo pendiente. */
billing?: boolean;
/** Valores que se envían siempre al crear (ej. `DocumentSubType: 'bod_Bill'` en las boletas). */
fixed?: DocRecord;
}): DocumentLayout {
const columns = lineColumnsFor(opts.taxResource, opts.lineColumns).map((c) =>
c.key === 'ItemCode' && c.lookup ? itemColumnWithUom(c, opts.uom) : c,
);
const layout: DocumentLayout = {
lines: { key: 'DocumentLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: true },
header: opts.header ?? (opts.requester ? REQUEST_HEADER : {
left: [
FIELDS.docType,
{ type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` },
{ type: 'computed', label: 'Nombre', source: 'partnerName' },
FIELDS.contact,
{ type: 'computed', label: 'RUT', source: 'partnerTaxId' },
FIELDS.numAtCard,
{ type: 'currency', label: 'Moneda / Tipo de cambio' },
...(opts.identity ? [] : [FIELDS.indicator]),
...(opts.left ?? []),
],
// las tres fechas juntas, una debajo de la otra: contabilización, vencimiento y del documento
right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.docDueDate, FIELDS.taxDate, ...(opts.identity ? [IDENTITY_GROUP] : []), ...(opts.right ?? [])],
}),
footer: opts.requester
? {
left: [{ type: 'expenses', label: 'Gastos adicionales' }, FIELDS.comments],
right: [
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
{ type: 'computed', label: 'Gastos adicionales', source: 'expenses' },
{ type: 'computed', label: 'Impuesto', source: 'tax' },
{ type: 'computed', label: 'Total del documento', source: 'total' },
],
}
: {
left: [...(opts.expenses === false ? [] : [{ type: 'expenses', label: 'Gastos adicionales' } as const]), FIELDS.comments],
right: [
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
// facturas y notas de crédito: lo que ya se anticipó, se aplicó y falta por pagar (como en SAP)
...(opts.billing ? [{ type: 'computed', label: 'Anticipo total', source: 'downPayment' } as const] : []),
{ type: 'computed', label: 'Gastos adicionales', source: 'expenses' },
{ type: 'rounding', label: 'Redondeo' },
{ type: 'computed', label: 'Impuesto', source: 'tax' },
{ type: 'computed', label: 'Total del documento', source: 'total' },
...(opts.billing
? [{ type: 'computed', label: 'Importe aplicado', source: 'applied' } as const, { type: 'computed', label: 'Saldo pendiente', source: 'balance' } as const]
: []),
],
},
columns,
newHeader: () => (opts.requester ? { DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: today(), TaxDate: today(), RequriedDate: today(), SendNotification: 'tNO' } : { DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: today(), TaxDate: today(), DiscountPercent: 0, Rounding: 'tNO', RoundingDiffAmount: 0 }),
newLine: newDocumentLine,
recalc: recalcLine,
service: {
columns: serviceLineColumns(opts.taxResource),
lines: { key: 'DocumentLines', fields: lineFieldsOf(serviceLineColumns(opts.taxResource)), base: true, service: true },
newLine: newServiceLine,
},
// pestañas como en SAP: Logística (destino, facturación, envío) y Finanzas (pago, cuenta asociada, proyecto)
...(opts.requester ? {} : { tabs: {
logistics: {
left: [FIELDS.shipTo, { ...FIELDS.payTo, label: opts.uom === 'sales' ? 'Destinatario de factura' : 'Dirección de facturación' }, FIELDS.shippingType],
right: [FIELDS.trackingNumber, FIELDS.blockDunning],
},
finance: {
left: [
{ ...FIELDS.journalMemo, label: 'Entrada en el diario' },
FIELDS.controlAccount,
FIELDS.paymentBlock,
FIELDS.paymentTerms,
{ type: 'lookup', key: 'PaymentMethod', label: 'Forma de pago', lookup: paymentMethodLookup(opts.uom === 'sales' ? 'boptIncoming' : 'boptOutgoing') },
FIELDS.centralBankIndicator,
FIELDS.installments,
FIELDS.startFrom,
FIELDS.extraMonth,
FIELDS.extraDays,
],
right: [FIELDS.project],
},
} }),
seriesSubType: SERIES_SUBTYPE[String(opts.fixed?.['DocumentSubType'] ?? 'bod_None')] ?? '--',
...(opts.requester
? {
// el solicitante es un usuario de SAP (`ReqType` 12); su nombre lo completa SAP
beforeCreate: (h: DocRecord) => {
const { RequesterName: _name, ...rest } = h;
return rest['Requester'] ? { ...rest, ReqType: 12 } : rest;
},
}
: opts.fixed
? { beforeCreate: (h: DocRecord) => ({ ...h, ...opts.fixed }) }
: {}),
requiredLine: [],
};
return opts.lookups ? withLookupColumns(layout, opts.lookups, 'replace') : layout;
}
// --- consultas sobre un layout --------------------------------------------------------------
const allFields = (layout: DocumentLayout): HeaderField[] => [
...layout.header.left,
...layout.header.right,
...(layout.tabs ? [...layout.tabs.logistics.left, ...layout.tabs.logistics.right, ...layout.tabs.finance.left, ...layout.tabs.finance.right] : []),
...layout.footer.left,
...layout.footer.right,
];
/** Campos de SAP que se editan (y se envían) de una lista de campos. */
export const keysOfFields = (fields: readonly HeaderField[]): string[] =>
fields.flatMap((f) => {
switch (f.type) {
case 'computed':
return [];
case 'folio':
return [f.prefixKey, f.numberKey];
case 'group':
return f.cells.map((c) => c.key);
case 'expenses':
return [];
case 'rounding':
return ['Rounding', 'RoundingDiffAmount'];
case 'currency':
return ['DocCurrency', 'DocRate'];
default:
return [f.key];
}
});
/** Campos obligatorios de una lista de campos, con el texto para el aviso ("Indique el cliente."). */
export const requiredOfFields = (fields: readonly HeaderField[]): { key: string; label: string }[] =>
fields.flatMap((f) => (isRequired(f) ? [{ key: f.key, label: f.missing ?? f.label }] : []));
/** Campos de cabecera que se editan (y se envían a SAP). */
export const headerKeysOf = (layout: DocumentLayout): readonly string[] => keysOfFields(allFields(layout));
/** Campos obligatorios de la cabecera, con el texto para el aviso ("Indique el cliente."). */
export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label: string }[] => requiredOfFields(allFields(layout));
/** Campos con clave y marca de obligatorio. */
export function isRequired(f: HeaderField): f is HeaderField & { key: string; required: true; missing?: string } {
return 'required' in f && f.required === true && 'key' in f;
}
export const hasSeries = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'series');
export const hasExpenses = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'expenses');
export const hasPartnerData = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'contact' || f.type === 'address');
export const hasCurrency = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'currency');
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio' || (f.type === 'group' && f.cells.some((c) => c.key === 'FolioNumber')));