2026-10-19 14:40:14 -05:00
import { todayIso } from '../../core/format' ;
2026-10-16 12:25:01 -05:00
import type { GridColumn , GridDetail , GridLine } from '../../shared/grid/grid.types' ;
2026-10-14 15:39:53 -05:00
import type { ListColumn } from '../../shared/list/list.types' ;
2026-10-14 14:37:01 -05:00
import type { VhLookup } from '../../shared/value-help/value-help.types' ;
2026-10-14 15:10:40 -05:00
import type { DocRecord , LinesSpec } from './document-model' ;
2026-10-19 14:50:33 -05:00
import { ACCOUNT_LOOKUP , INDICATOR_LOOKUP , PROJECT_LOOKUP } from './dimension-lookups' ;
2026-10-16 11:07:38 -05:00
import { SERIES_SUBTYPE } from './document-objects' ;
2026-10-19 12:19:18 -05:00
import { ANALYTIC_COLUMNS , DOCUMENT_LINE_COLUMNS , lineColumnsFor , lineFieldsOf , newDocumentLine , newServiceLine , recalcLine , serviceLineColumns , uomEntryOf } from './line-columns' ;
2026-10-14 14:37:01 -05:00
2026-10-19 14:35:54 -05:00
/** 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 };
2026-10-20 15:46:00 -05:00
/**
* 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 ,
});
2026-10-19 14:35:54 -05:00
/** Fila con varios campos juntos, para la cabecera de un documento. */
export const fieldGroup = ( label : string , cells : readonly GroupCell []) : HeaderField => ({ type : 'group' , label , cells });
2026-10-14 15:10:40 -05:00
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
2026-10-19 13:03:32 -05:00
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'expenses' | 'tax' | 'total' | 'downPayment' | 'applied' | 'balance' ;
2026-10-14 15:10:40 -05:00
/**
* 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 =
2026-10-16 10:24:39 -05:00
| {
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 ;
}
2026-10-14 15:10:40 -05:00
| { type : 'date' ; key : string ; label : string ; required? : boolean ; missing? : string }
2026-10-16 10:24:39 -05:00
| { type : 'number' ; key : string ; label : string ; decimals? : number ; readonly ?: boolean }
/** Casilla: en SAP un campo `tYES` / `tNO`. */
2026-10-16 14:36:24 -05:00
| { type : 'checkbox' ; key : string ; label : string ; readonly ?: boolean ; immutable? : boolean }
2026-10-16 10:24:39 -05:00
/** Campo de código con su ventana de selección (grupo, moneda, lista de precios…). */
2026-10-19 14:53:59 -05:00
| { type : 'lookup' ; key : string ; label : string ; lookup : VhLookup ; required? : boolean ; missing? : string ; immutable? : boolean ; onPick ?: ( row : Record < string , unknown >) => Record < string , unknown > }
2026-10-14 15:10:40 -05:00
| { type : 'textarea' ; key : string ; label : string }
2026-10-20 15:46:00 -05:00
/**
* 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 }
2026-10-14 15:28:03 -05:00
/** Lista de valores válidos (típico de un campo de usuario con valores válidos en SAP). */
2026-10-16 14:36:24 -05:00
| { type : 'select' ; key : string ; label : string ; options : readonly { value : string | number ; label : string }[]; required? : boolean ; missing? : string ; immutable? : boolean }
2026-10-14 15:10:40 -05:00
/** 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 }
2026-10-19 14:35:54 -05:00
/**
* 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 [] }
2026-10-16 11:07:38 -05:00
/** Serie de numeración y número del documento en una misma fila (`Series` y `DocNum`). */
| { type : 'series' ; key : string ; label : string }
2026-10-14 16:09:06 -05:00
/** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */
2026-10-19 12:46:56 -05:00
/** Tabla de gastos adicionales del documento (flete, seguro…): una fila por gasto. */
| { type : 'expenses' ; label : string }
2026-10-19 12:53:23 -05:00
/** Redondeo del documento (`Rounding` y `RoundingDiffAmount`): solo se ofrece si la empresa usa redondeo (`OADM.RoundMthd`). */
| { type : 'rounding' ; label : string }
2026-10-19 14:40:14 -05:00
/** 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 }
2026-10-19 14:47:23 -05:00
/** 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' }
2026-10-14 15:10:40 -05:00
| { type : 'computed' ; label : string ; source : ComputedSource };
2026-10-14 15:39:53 -05:00
/** 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 [] >>;
2026-10-14 15:10:40 -05:00
export interface FieldColumns {
/** Dos columnas como máximo (regla del proyecto base). */
left : readonly HeaderField [];
right : readonly HeaderField [];
2026-10-14 14:37:01 -05:00
}
/**
2026-10-14 15:10:40 -05:00
* 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.
2026-10-14 14:37:01 -05:00
*/
export interface DocumentLayout {
lines : LinesSpec ;
2026-10-14 15:10:40 -05:00
header : FieldColumns ;
/** Debajo de las líneas: comentarios y, donde los hay, descuento y totales. */
footer : FieldColumns ;
2026-10-14 14:37:01 -05:00
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 ;
2026-10-16 11:07:38 -05:00
/** 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 ;
2026-10-16 12:25:01 -05:00
/** Detalles de línea (lotes, series) que este documento permite indicar. */
details? : readonly GridDetail [];
2026-10-16 11:14:52 -05:00
/** Campos de cabecera que se ven pero no se editan (un cliente puede querer verlos sin cambiarlos). */
lockedKeys? : readonly string [];
2026-10-16 11:07:38 -05:00
/** Subtipo con que SAP etiqueta las series de este documento (`--` = ninguno, `IB` = boleta…). */
seriesSubType? : string ;
2026-10-14 15:39:53 -05:00
/** Columnas de las listas de valores de este documento; las que falten usan las del base. */
lookups? : LookupColumns ;
2026-10-14 14:37:01 -05:00
/** 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 ;
2026-10-14 15:10:40 -05:00
requiredLine : readonly { key : string ; label : string }[];
2026-10-19 12:19:18 -05:00
/**
* 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 };
2026-10-19 14:50:33 -05:00
/** 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 };
2026-10-14 14:37:01 -05:00
}
2026-10-19 12:19:18 -05:00
/** 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' ;
2026-10-19 14:40:14 -05:00
export const today = () : string => todayIso ();
2026-10-14 14:37:01 -05:00
2026-10-14 15:10:40 -05:00
// --- catálogo de campos ---------------------------------------------------------------------
/** Catálogo de campos: cada pantalla elige cuáles lleva. */
2026-10-19 14:47:23 -05:00
/** 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' },
],
};
2026-10-19 14:50:33 -05:00
/** 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'` ,
};
2026-10-19 14:53:59 -05:00
/** 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' },
],
};
2026-10-14 15:10:40 -05:00
export const FIELDS = {
2026-10-19 14:53:59 -05:00
/** 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' },
2026-10-19 14:50:33 -05:00
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 },
2026-10-19 14:47:23 -05:00
/** 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 },
2026-10-19 12:19:18 -05:00
/** 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' },
],
},
2026-10-14 15:10:40 -05:00
docNum : { type : 'computed' , label : 'Número' , source : 'docNum' },
2026-10-16 11:07:38 -05:00
/** Número del documento con su serie. */
docNumber : { type : 'series' , key : 'Series' , label : 'Número' },
2026-10-16 12:18:09 -05:00
/** 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' },
2026-10-16 11:57:42 -05:00
indicator : { type : 'lookup' , key : 'Indicator' , label : 'Indicador' , lookup : INDICATOR_LOOKUP },
2026-10-16 11:07:38 -05:00
project : { type : 'lookup' , key : 'Project' , label : 'Proyecto' , lookup : PROJECT_LOOKUP },
2026-10-14 15:10:40 -05:00
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 >;
2026-10-19 14:35:54 -05:00
/**
* 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' },
]);
2026-10-14 15:12:01 -05:00
export const commentsOnly : FieldColumns = { left : [ FIELDS . comments ], right : [] };
2026-10-14 15:10:40 -05:00
// --- columnas de línea compartidas ----------------------------------------------------------
2026-10-14 14:37:01 -05:00
const [ baseItemColumn , descriptionColumn , quantityColumn , warehouseColumn , priceColumn ] = DOCUMENT_LINE_COLUMNS ;
2026-10-14 15:12:01 -05:00
export { descriptionColumn , priceColumn , quantityColumn , warehouseColumn };
2026-10-14 14:37:01 -05:00
2026-10-14 16:05:25 -05:00
/**
* 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 ! ,
2026-10-16 12:25:01 -05:00
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 }),
}),
2026-10-14 16:05:25 -05:00
},
};
}
export const itemColumn : GridColumn = itemColumnWithUom ( baseItemColumn , 'purchase' );
2026-10-14 14:37:01 -05:00
/** Lista de almacenes de la cabecera (la misma de las líneas). */
export const WAREHOUSE_LOOKUP : VhLookup = warehouseColumn . lookup ! . config ;
2026-10-14 15:39:53 -05:00
/** 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 };
}
2026-10-14 15:12:01 -05:00
export const blankLine = () : GridLine => ({ ItemCode : '' , ItemDescription : '' , Quantity : 1 , WarehouseCode : '' });
2026-10-14 14:37:01 -05:00
2026-10-14 15:10:40 -05:00
// --- documentos con socio de negocio (ventas y compras) -------------------------------------
2026-10-19 14:53:59 -05:00
/**
* 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' }],
};
2026-10-14 15:10:40 -05:00
/**
* Ventas y compras: socio, referencia, fechas; líneas con precio, descuento e impuesto; pie con
2026-10-14 15:16:22 -05:00
* 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.
2026-10-14 15:10:40 -05:00
*/
export function marketingLayout ( opts : {
partnerLabel : string ;
taxResource : string ;
left? : readonly HeaderField [];
right? : readonly HeaderField [];
2026-10-14 15:16:22 -05:00
header? : FieldColumns ;
lineColumns? : readonly GridColumn [];
2026-10-14 15:39:53 -05:00
/** Columnas de las listas de valores (cliente, artículo, almacén, impuesto) de este documento. */
lookups? : LookupColumns ;
2026-10-14 16:05:25 -05:00
/** Unidad de medida que se toma del artículo: la de venta o la de compra. */
uom : 'sales' | 'purchase' ;
2026-10-14 16:09:06 -05:00
/** Fila del flete en el pie (por defecto sí). */
2026-10-19 12:46:56 -05:00
expenses? : boolean ;
2026-10-19 14:53:59 -05:00
/** 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 ;
2026-10-19 14:35:54 -05:00
/** 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 ;
2026-10-19 13:03:32 -05:00
/** Facturas y notas de crédito: el pie suma el anticipo total, el importe aplicado y el saldo pendiente. */
billing? : boolean ;
2026-10-15 17:13:33 -05:00
/** Valores que se envían siempre al crear (ej. `DocumentSubType: 'bod_Bill'` en las boletas). */
fixed? : DocRecord ;
2026-10-14 15:10:40 -05:00
}) : DocumentLayout {
2026-10-14 16:05:25 -05:00
const columns = lineColumnsFor ( opts . taxResource , opts . lineColumns ). map (( c ) =>
c . key === 'ItemCode' && c . lookup ? itemColumnWithUom ( c , opts . uom ) : c ,
);
2026-10-14 15:39:53 -05:00
const layout : DocumentLayout = {
2026-10-14 16:05:25 -05:00
lines : { key : 'DocumentLines' , fields : [... lineFieldsOf ( columns ), 'UoMEntry' ], base : true },
2026-10-19 14:53:59 -05:00
header : opts.header ?? ( opts . requester ? REQUEST_HEADER : {
2026-10-14 15:10:40 -05:00
left : [
2026-10-19 12:19:18 -05:00
FIELDS . docType ,
2026-10-14 15:10:40 -05:00
{ type : 'partner' , key : 'CardCode' , label : opts.partnerLabel , required : true , missing : `el ${ opts . partnerLabel . toLowerCase () } ` },
{ type : 'computed' , label : 'Nombre' , source : 'partnerName' },
2026-10-19 14:50:33 -05:00
FIELDS . contact ,
2026-10-14 15:10:40 -05:00
{ type : 'computed' , label : 'RUT' , source : 'partnerTaxId' },
FIELDS . numAtCard ,
2026-10-19 14:40:14 -05:00
{ type : 'currency' , label : 'Moneda / Tipo de cambio' },
2026-10-19 14:35:54 -05:00
...( opts . identity ? [] : [ FIELDS . indicator ]),
2026-10-14 15:10:40 -05:00
...( opts . left ?? []),
],
2026-10-19 14:35:54 -05:00
// 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 ?? [])],
2026-10-19 14:53:59 -05:00
}),
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' },
],
}
: {
2026-10-19 12:46:56 -05:00
left : [...( opts . expenses === false ? [] : [{ type : 'expenses' , label : 'Gastos adicionales' } as const ]), FIELDS . comments ],
2026-10-14 15:10:40 -05:00
right : [
{ type : 'computed' , label : 'Total antes del descuento' , source : 'subtotal' },
{ type : 'number' , key : 'DiscountPercent' , label : 'Descuento %' , decimals : 2 },
2026-10-19 13:03:32 -05:00
// 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 ] : []),
2026-10-19 12:46:56 -05:00
{ type : 'computed' , label : 'Gastos adicionales' , source : 'expenses' },
2026-10-19 12:53:23 -05:00
{ type : 'rounding' , label : 'Redondeo' },
2026-10-19 12:59:14 -05:00
{ type : 'computed' , label : 'Impuesto' , source : 'tax' },
2026-10-14 15:10:40 -05:00
{ type : 'computed' , label : 'Total del documento' , source : 'total' },
2026-10-19 13:03:32 -05:00
...( opts . billing
? [{ type : 'computed' , label : 'Importe aplicado' , source : 'applied' } as const , { type : 'computed' , label : 'Saldo pendiente' , source : 'balance' } as const ]
: []),
2026-10-14 15:10:40 -05:00
],
},
2026-10-14 15:16:22 -05:00
columns ,
2026-10-19 14:53:59 -05:00
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 }),
2026-10-14 15:10:40 -05:00
newLine : newDocumentLine ,
recalc : recalcLine ,
2026-10-19 12:19:18 -05:00
service : {
columns : serviceLineColumns ( opts . taxResource ),
lines : { key : 'DocumentLines' , fields : lineFieldsOf ( serviceLineColumns ( opts . taxResource )), base : true , service : true },
newLine : newServiceLine ,
},
2026-10-19 14:50:33 -05:00
// pestañas como en SAP: Logística (destino, facturación, envío) y Finanzas (pago, cuenta asociada, proyecto)
2026-10-19 14:53:59 -05:00
...( opts . requester ? {} : { tabs : {
2026-10-19 14:50:33 -05:00
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 ],
},
2026-10-19 14:53:59 -05:00
} }),
2026-10-16 11:07:38 -05:00
seriesSubType : SERIES_SUBTYPE [ String ( opts . fixed ? .[ 'DocumentSubType' ] ?? 'bod_None' )] ?? '--' ,
2026-10-19 14:53:59 -05:00
...( 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 }) }
: {}),
2026-10-14 15:10:40 -05:00
requiredLine : [],
};
2026-10-14 15:39:53 -05:00
return opts . lookups ? withLookupColumns ( layout , opts . lookups , 'replace' ) : layout ;
2026-10-14 15:10:40 -05:00
}
// --- consultas sobre un layout --------------------------------------------------------------
const allFields = ( layout : DocumentLayout ) : HeaderField [] => [
... layout . header . left ,
... layout . header . right ,
2026-10-19 14:50:33 -05:00
...( layout . tabs ? [... layout . tabs . logistics . left , ... layout . tabs . logistics . right , ... layout . tabs . finance . left , ... layout . tabs . finance . right ] : []),
2026-10-14 15:10:40 -05:00
... layout . footer . left ,
... layout . footer . right ,
2026-10-14 14:37:01 -05:00
];
2026-10-14 15:10:40 -05:00
2026-10-16 10:24:39 -05:00
/** 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 ) => {
2026-10-14 15:10:40 -05:00
switch ( f . type ) {
case 'computed' :
return [];
case 'folio' :
return [ f . prefixKey , f . numberKey ];
2026-10-19 14:35:54 -05:00
case 'group' :
return f . cells . map (( c ) => c . key );
2026-10-19 12:46:56 -05:00
case 'expenses' :
return [];
2026-10-19 12:53:23 -05:00
case 'rounding' :
return [ 'Rounding' , 'RoundingDiffAmount' ];
2026-10-19 14:40:14 -05:00
case 'currency' :
return [ 'DocCurrency' , 'DocRate' ];
2026-10-14 15:10:40 -05:00
default :
return [ f . key ];
}
});
2026-10-16 10:24:39 -05:00
/** 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 ));
2026-10-14 15:10:40 -05:00
/** Campos obligatorios de la cabecera, con el texto para el aviso ("Indique el cliente."). */
2026-10-16 10:24:39 -05:00
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 ;
}
2026-10-14 15:10:40 -05:00
2026-10-16 11:07:38 -05:00
export const hasSeries = ( layout : DocumentLayout ) : boolean => allFields ( layout ). some (( f ) => f . type === 'series' );
2026-10-19 12:46:56 -05:00
export const hasExpenses = ( layout : DocumentLayout ) : boolean => allFields ( layout ). some (( f ) => f . type === 'expenses' );
2026-10-14 16:09:06 -05:00
2026-10-19 14:47:23 -05:00
export const hasPartnerData = ( layout : DocumentLayout ) : boolean => allFields ( layout ). some (( f ) => f . type === 'contact' || f . type === 'address' );
2026-10-19 14:40:14 -05:00
export const hasCurrency = ( layout : DocumentLayout ) : boolean => allFields ( layout ). some (( f ) => f . type === 'currency' );
2026-10-19 14:35:54 -05:00
export const hasFolio = ( layout : DocumentLayout ) : boolean => allFields ( layout ). some (( f ) => f . type === 'folio' || ( f . type === 'group' && f . cells . some (( c ) => c . key === 'FolioNumber' )));
2026-10-20 09:09:45 -05:00
/**
* 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' ,
};