652 lines
32 KiB
TypeScript
652 lines
32 KiB
TypeScript
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 };
|
||
|
||
/**
|
||
* Un campo de usuario por su definición en SAP: la tabla (`OBTN`, `OWTQ`…) y el alias sin el `U_` (`SIS_ESTLOT`). Sus valores
|
||
* válidos o su tabla de usuario enlazada se leen de SAP al abrir la pantalla; no se escriben aquí.
|
||
*/
|
||
export const udfField = (table: string, alias: string, label: string, extra: { required?: boolean; missing?: string; readonly?: boolean } = {}): HeaderField => ({
|
||
type: 'udf',
|
||
key: `U_${alias}`,
|
||
label,
|
||
table,
|
||
alias,
|
||
...extra,
|
||
});
|
||
|
||
/** 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 }
|
||
/**
|
||
* Campo de usuario (`U_*`) que se pide como lo definió SAP: lista desplegable si tiene valores válidos, ventana de
|
||
* selección si está enlazado a una tabla de usuario y texto en otro caso (`udfField`).
|
||
*/
|
||
| { type: 'udf'; key: string; label: string; table: string; alias: string; required?: boolean; missing?: string; readonly?: boolean }
|
||
/** 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')));
|
||
|
||
/**
|
||
* Nombre(s) del campo de SAP de una fila del formulario, para «Campos» y la ayuda del cursor: el de un campo sencillo, o
|
||
* todos los de una fila con varios controles (`FolioPrefixString · FolioNumber`).
|
||
*/
|
||
export function fieldDbName(f: HeaderField): string | undefined {
|
||
switch (f.type) {
|
||
case 'folio':
|
||
return `${f.prefixKey} · ${f.numberKey}`;
|
||
case 'group':
|
||
return f.cells.map((c) => c.key).join(' · ');
|
||
case 'series':
|
||
return 'Series · DocNum';
|
||
case 'currency':
|
||
return 'DocCurrency · DocRate';
|
||
case 'rounding':
|
||
return 'Rounding · RoundingDiffAmount';
|
||
case 'expenses':
|
||
return 'DocumentAdditionalExpenses';
|
||
case 'computed':
|
||
return COMPUTED_DB_NAMES[f.source];
|
||
default:
|
||
return 'key' in f ? f.key : undefined;
|
||
}
|
||
}
|
||
|
||
const COMPUTED_DB_NAMES: Readonly<Record<ComputedSource, string>> = {
|
||
docNum: 'DocNum',
|
||
partnerName: 'CardName',
|
||
partnerTaxId: 'FederalTaxID',
|
||
subtotal: 'Σ líneas',
|
||
expenses: 'Σ DocumentAdditionalExpenses',
|
||
tax: 'VatSum',
|
||
total: 'DocTotal',
|
||
downPayment: 'DownPaymentAmount',
|
||
applied: 'PaidToDate',
|
||
balance: 'DocTotal − PaidToDate',
|
||
};
|
||
|