2026-10-14 14:37:01 -05:00
|
|
|
|
import type { GridColumn, 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-14 15:16:22 -05:00
|
|
|
|
import { DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
|
2026-10-14 14:37:01 -05:00
|
|
|
|
|
2026-10-14 15:10:40 -05:00
|
|
|
|
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
|
|
|
|
|
|
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'tax' | 'total';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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 }
|
|
|
|
|
|
| { type: 'date'; key: string; label: string; required?: boolean; missing?: string }
|
|
|
|
|
|
| { type: 'number'; key: string; label: string; decimals?: number }
|
|
|
|
|
|
| { type: 'textarea'; key: string; label: string }
|
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). */
|
|
|
|
|
|
| { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string }
|
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-14 16:09:06 -05:00
|
|
|
|
/** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */
|
|
|
|
|
|
| { type: 'freight'; label: string }
|
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-14 15:10:40 -05:00
|
|
|
|
/** Recalcula una línea tras un cambio (total = cantidad × precio…). */
|
|
|
|
|
|
recalc?: (line: GridLine, changedKey: string) => GridLine;
|
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-14 14:37:01 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-14 15:12:01 -05:00
|
|
|
|
export const today = (): string => new Date().toISOString().slice(0, 10);
|
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. */
|
|
|
|
|
|
export const FIELDS = {
|
|
|
|
|
|
docNum: { type: 'computed', label: 'Número', source: 'docNum' },
|
|
|
|
|
|
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-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!,
|
|
|
|
|
|
apply: (picked, line) => ({ ...item.lookup!.apply(picked, line), UoMEntry: uomEntryOf(picked, side) }),
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) -------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 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í). */
|
|
|
|
|
|
freight?: 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-14 15:16:22 -05:00
|
|
|
|
header: opts.header ?? {
|
2026-10-14 15:10:40 -05:00
|
|
|
|
left: [
|
|
|
|
|
|
{ type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` },
|
|
|
|
|
|
{ type: 'computed', label: 'Nombre', source: 'partnerName' },
|
|
|
|
|
|
{ type: 'computed', label: 'RUT', source: 'partnerTaxId' },
|
|
|
|
|
|
FIELDS.numAtCard,
|
|
|
|
|
|
...(opts.left ?? []),
|
|
|
|
|
|
],
|
|
|
|
|
|
right: [FIELDS.docNum, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])],
|
|
|
|
|
|
},
|
|
|
|
|
|
footer: {
|
|
|
|
|
|
left: [FIELDS.comments],
|
|
|
|
|
|
right: [
|
|
|
|
|
|
{ type: 'computed', label: 'Total antes del descuento', source: 'subtotal' },
|
|
|
|
|
|
{ type: 'number', key: 'DiscountPercent', label: 'Descuento %', decimals: 2 },
|
2026-10-14 16:09:06 -05:00
|
|
|
|
...(opts.freight === false ? [] : [{ type: 'freight', label: 'Flete' } as const]),
|
2026-10-14 15:10:40 -05:00
|
|
|
|
{ type: 'computed', label: 'Impuesto', source: 'tax' },
|
|
|
|
|
|
{ type: 'computed', label: 'Total del documento', source: 'total' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
2026-10-14 15:16:22 -05:00
|
|
|
|
columns,
|
2026-10-14 15:10:40 -05:00
|
|
|
|
newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
|
|
|
|
|
|
newLine: newDocumentLine,
|
|
|
|
|
|
recalc: recalcLine,
|
2026-10-15 17:13:33 -05:00
|
|
|
|
...(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,
|
|
|
|
|
|
...layout.footer.left,
|
|
|
|
|
|
...layout.footer.right,
|
2026-10-14 14:37:01 -05:00
|
|
|
|
];
|
2026-10-14 15:10:40 -05:00
|
|
|
|
|
|
|
|
|
|
/** Campos de cabecera que se editan (y se envían a SAP). */
|
|
|
|
|
|
export const headerKeysOf = (layout: DocumentLayout): readonly string[] =>
|
|
|
|
|
|
allFields(layout).flatMap((f) => {
|
|
|
|
|
|
switch (f.type) {
|
|
|
|
|
|
case 'computed':
|
|
|
|
|
|
return [];
|
2026-10-14 16:09:06 -05:00
|
|
|
|
case 'freight':
|
|
|
|
|
|
return ['FreightExpense', 'FreightAmount'];
|
2026-10-14 15:10:40 -05:00
|
|
|
|
case 'folio':
|
|
|
|
|
|
return [f.prefixKey, f.numberKey];
|
|
|
|
|
|
default:
|
|
|
|
|
|
return [f.key];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/** Campos obligatorios de la cabecera, con el texto para el aviso ("Indique el cliente."). */
|
|
|
|
|
|
export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label: string }[] =>
|
|
|
|
|
|
allFields(layout).flatMap((f) =>
|
2026-10-14 15:28:03 -05:00
|
|
|
|
(f.type === 'date' || f.type === 'warehouse' || f.type === 'partner' || f.type === 'select') && f.required
|
2026-10-14 15:10:40 -05:00
|
|
|
|
? [{ key: f.key, label: f.missing ?? f.label }]
|
|
|
|
|
|
: [],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-10-14 16:09:06 -05:00
|
|
|
|
export const hasFreight = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'freight');
|
|
|
|
|
|
|
2026-10-14 15:10:40 -05:00
|
|
|
|
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');
|