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

257 lines
11 KiB
TypeScript
Raw Normal View History

import type { GridLine } from '../../shared/grid/grid.types';
import { hasBaseRef } from './document-objects';
import { DETAIL_KEYS, detailRowsPayload } from './line-details';
/** Documento tal como lo devuelve / recibe el Service Layer. */
export type DocRecord = Record<string, unknown>;
const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100;
const num = (v: unknown): number => {
const n = Number(v ?? 0);
return Number.isFinite(n) ? n : 0;
};
const isEmpty = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
// --- estado -------------------------------------------------------------------------------
/** Cerrado o cancelado: el documento solo se puede consultar. */
export function isDocumentReadOnly(doc: DocRecord | null | undefined): boolean {
if (!doc) return false;
return doc['Cancelled'] === 'tYES' || doc['DocumentStatus'] === 'bost_Close';
}
// --- totales ------------------------------------------------------------------------------
/** Gasto adicional del documento (el flete): colección `DocumentAdditionalExpenses` de SAP. */
export const FREIGHT_COLLECTION = 'DocumentAdditionalExpenses';
export interface DocTotals {
/** Gastos adicionales (flete, seguro…) antes de impuestos. */
expenses: number;
/** Suma de los totales de línea (ya con el descuento de cada línea). */
subtotal: number;
/** Descuento del documento (% de cabecera aplicado al subtotal). */
discount: number;
/** Total antes de impuestos. */
net: number;
tax: number;
total: number;
}
/** `Code → tasa %` a partir del maestro `SalesTaxCodes`. */
export function taxRateMap(rows: readonly DocRecord[]): ReadonlyMap<string, number> {
return new Map(rows.map((r) => [String(r['Code']), num(r['Rate'])]));
}
/**
* Totales informativos del documento. SAP los recalcula al guardar (y es quien manda), pero el
* usuario los ve mientras edita. Impuesto = total de línea con el descuento de cabecera × tasa.
*/
export function computeTotals(
lines: readonly GridLine[],
rates: ReadonlyMap<string, number>,
discountPercent: number,
expenseRows: readonly ExpenseRow[] = [],
): DocTotals {
const factor = 1 - discountPercent / 100;
let subtotal = 0;
let tax = 0;
for (const line of lines) {
const lineTotal = num(line['LineTotal']);
subtotal += lineTotal;
tax += lineTotal * factor * (rates.get(String(line['TaxCode'] ?? '')) ?? 0) / 100;
}
let expenses = 0;
for (const e of expenseRows) {
const amount = num(e['LineTotal']);
expenses += amount;
tax += amount * (rates.get(String(e['TaxCode'] ?? '')) ?? 0) / 100;
}
const net = subtotal * factor;
return {
subtotal: round2(subtotal),
discount: round2(subtotal - net),
net: round2(net),
tax: round2(tax),
expenses: round2(expenses),
total: round2(net + tax + expenses),
};
}
// --- gastos adicionales -----------------------------------------------------------------------
/** Gastos adicionales del documento (flete, seguro…): colección `DocumentAdditionalExpenses` de SAP, una fila por gasto. */
export const EXPENSES_COLLECTION = 'DocumentAdditionalExpenses';
/** Una fila de gastos adicionales en edición (código del gasto, importe, impuesto y comentarios). */
export type ExpenseRow = Record<string, unknown>;
/** Las filas de gastos adicionales de un documento de SAP, para editarlas. */
export const expenseRowsOf = (doc: DocRecord | undefined): ExpenseRow[] => ((doc?.[EXPENSES_COLLECTION] as DocRecord[] | undefined) ?? []).map((r) => ({ ...r }));
/** Una fila sin gasto ni importe (la que se añadió y no se llenó) no se envía. */
const isBlankExpense = (r: ExpenseRow): boolean => isEmpty(r['ExpenseCode']) && num(r['LineTotal']) === 0;
/** Filas listas para SAP: sin filas en blanco ni campos vacíos. */
export function expensesPayload(rows: readonly ExpenseRow[]): DocRecord[] {
return rows
.filter((r) => !isBlankExpense(r))
.map((r) => {
const out: DocRecord = { ExpenseCode: Number(r['ExpenseCode']), LineTotal: round2(num(r['LineTotal'])) };
if (!isEmpty(r['TaxCode'])) out['TaxCode'] = r['TaxCode'];
if (!isEmpty(r['Remarks'])) out['Remarks'] = r['Remarks'];
return out;
});
}
/** Las filas cambiaron respecto de las del documento (lo que ya no se envía no cuenta). */
export const expensesChanged = (original: DocRecord | undefined, rows: readonly ExpenseRow[]): boolean =>
JSON.stringify(expensesPayload(rows)) !== JSON.stringify(expensesPayload(expenseRowsOf(original)));
/** Errores que se pueden detectar sin preguntarle a SAP. */
export function validateExpenses(rows: readonly ExpenseRow[]): string[] {
const errors: string[] = [];
rows.filter((r) => !isBlankExpense(r)).forEach((r, i) => {
if (isEmpty(r['ExpenseCode']) || !Number.isFinite(Number(r['ExpenseCode']))) errors.push(`Gasto adicional ${i + 1}: indique el gasto.`);
if (num(r['LineTotal']) <= 0) errors.push(`Gasto adicional ${i + 1}: el importe debe ser mayor que cero.`);
});
return errors;
}
// --- guardado -----------------------------------------------------------------------------
/** Campos de línea que se envían a SAP; el total de línea y lo calculado lo resuelve SAP. */
const LINE_FIELDS = ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'] as const;
/** Colección de líneas de un documento y los campos de cada línea que se envían a SAP. */
export interface LinesSpec {
/** `DocumentLines`, o `StockTransferLines` en las transferencias de stock. */
key: string;
fields: readonly string[];
/** Los documentos de ventas y compras referencian el origen de una línea copiada (`BaseType`…). */
base: boolean;
/** Líneas de un documento de servicio: cuenta contable, descripción, impuesto y total, sin artículo ni cantidad. */
service?: boolean;
}
export const MARKETING_LINES: LinesSpec = { key: 'DocumentLines', fields: LINE_FIELDS, base: true };
/** Referencia al documento origen de una línea copiada; SAP devuelve `BaseType: -1` y `BaseEntry: null` en las que no tienen. */
const BASE_FIELDS = ['BaseType', 'BaseEntry', 'BaseLine'] as const;
const hasBase = (l: GridLine): boolean => hasBaseRef(l['BaseType'], l['BaseEntry']);
/**
* Una línea vacía (la fila en blanco que deja la grilla) no se envía. En un documento de artículos es la que no
* tiene artículo; en uno de servicio, la que no tiene cuenta, descripción ni total.
*/
export const isBlankLine = (line: GridLine, service = false): boolean =>
service ? isEmpty(line['AccountCode']) && isEmpty(line['ItemDescription']) && num(line['LineTotal']) === 0 : isEmpty(line['ItemCode']);
/** Líneas listas para SAP: sin filas en blanco ni campos vacíos. */
export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord[] {
return lines
.filter((l) => !isBlankLine(l, spec.service))
.map((l) => {
const out: DocRecord = {};
for (const f of spec.fields) {
if (DETAIL_KEYS.includes(f)) {
const rows = detailRowsPayload(f, l[f]);
if (rows.length) out[f] = rows;
continue;
}
if (isEmpty(l[f])) continue;
if (f === 'UoMEntry' && !(Number(l[f]) > 0)) continue; // -1 = unidad manual: SAP no admite enviarla
out[f] = l[f];
}
// Precio con y sin impuesto: solo se envía el que el usuario escribió (el otro lo calcula SAP).
if ('PriceAfterVAT' in out || spec.fields.includes('PriceAfterVAT')) {
if (l['_priceBasis'] === 'gross') delete out['Price'];
else delete out['PriceAfterVAT'];
}
if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f];
return out;
});
}
/** Un campo obligatorio y su nombre para el aviso. */
export interface RequiredField {
key: string;
label: string;
}
/** Validación de los documentos sin socio de negocio (inventario, solicitudes de compra). */
export function validateLayout(
header: DocRecord,
lines: readonly GridLine[],
requiredHeader: readonly RequiredField[],
requiredLine: readonly RequiredField[],
service = false,
): string[] {
const errors: string[] = [];
for (const f of requiredHeader) if (isEmpty(header[f.key])) errors.push(`Indique ${f.label.toLowerCase()}.`);
const real = lines.filter((l) => !isBlankLine(l, service));
if (!real.length) errors.push(service ? 'Agregue al menos una línea con cuenta contable.' : 'Agregue al menos una línea con artículo.');
real.forEach((l, i) => {
if (service) {
if (isEmpty(l['AccountCode'])) errors.push(`Línea ${i + 1}: indique la cuenta contable.`);
if (num(l['LineTotal']) <= 0) errors.push(`Línea ${i + 1}: el total debe ser mayor que cero.`);
return;
}
if (num(l['Quantity']) <= 0) errors.push(`Línea ${i + 1}: la cantidad debe ser mayor que cero.`);
for (const f of requiredLine) if (isEmpty(l[f.key])) errors.push(`Línea ${i + 1}: indique ${f.label.toLowerCase()}.`);
});
return errors;
}
/** Cuerpo para crear: cabecera sin campos vacíos + líneas. */
export function createPayload(header: DocRecord, lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord {
return { ...compact(header), [spec.key]: linesPayload(lines, spec) };
}
/**
* Cuerpo para modificar: solo los campos de cabecera que cambiaron (los vaciados se envían como
* `null` para que SAP los limpie) y, si cambiaron, todas las líneas (se envían con
* `B1S-ReplaceCollectionsOnPatch: true`, que reemplaza la colección entera).
*/
export function updatePayload(
original: DocRecord,
header: DocRecord,
originalLines: readonly GridLine[],
lines: readonly GridLine[],
spec: LinesSpec = MARKETING_LINES,
): { body: DocRecord; replaceLines: boolean } {
const body: DocRecord = {};
for (const [key, value] of Object.entries(header)) {
const before = original[key];
if (isEmpty(value) && isEmpty(before)) continue;
if (value !== before) body[key] = isEmpty(value) ? null : value;
}
const next = linesPayload(lines, spec);
const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines, spec));
if (replaceLines) body[spec.key] = next;
return { body, replaceLines };
}
/** Quita los campos vacíos (SAP rechaza `""` en campos con lista de valores válidos). */
export function compact(record: DocRecord): DocRecord {
return Object.fromEntries(Object.entries(record).filter(([, v]) => !isEmpty(v)));
}
// --- navegación ---------------------------------------------------------------------------
export type NavTarget = 'first' | 'prev' | 'next' | 'last';
/** Consulta de un solo documento para ir al primero / anterior / siguiente / último por `DocEntry` (u otra clave numérica). */
export function navigationQuery(target: NavTarget, docEntry: number | null, key = 'DocEntry'): { $filter?: string; $orderby: string } {
const asc = `${key} asc`;
const desc = `${key} desc`;
switch (target) {
case 'first':
return { $orderby: asc };
case 'last':
return { $orderby: desc };
case 'prev':
return docEntry === null ? { $orderby: desc } : { $filter: `${key} lt ${docEntry}`, $orderby: desc };
case 'next':
return docEntry === null ? { $orderby: asc } : { $filter: `${key} gt ${docEntry}`, $orderby: asc };
}
}