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

334 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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 { 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';
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'expenses' | '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;
/** 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 }
| { 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 }
/** 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 }
| { 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 };
}
/** 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 => new Date().toISOString().slice(0, 10);
// --- catálogo de campos ---------------------------------------------------------------------
/** Catálogo de campos: cada pantalla elige cuáles lleva. */
export const FIELDS = {
/** 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>;
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) -------------------------------------
/**
* 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;
/** 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 ?? {
left: [
FIELDS.docType,
{ 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,
FIELDS.indicator,
FIELDS.project,
...(opts.left ?? []),
],
right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])],
},
footer: {
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 },
{ 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' },
],
},
columns,
newHeader: () => ({ DocType: ITEMS_DOC_TYPE, DocDate: today(), DocDueDate: 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,
},
seriesSubType: SERIES_SUBTYPE[String(opts.fixed?.['DocumentSubType'] ?? 'bod_None')] ?? '--',
...(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.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 'expenses':
return [];
case 'rounding':
return ['Rounding', 'RoundingDiffAmount'];
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 hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');