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) => Record } | { 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>; 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) => 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) => ({ 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; /** * 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> = { 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> = { ...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')));