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

263 lines
12 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 { 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;
/** Redondeo del documento, si está marcado. */
rounding: 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[] = [],
rounding = 0,
): 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),
rounding: round2(rounding),
total: round2(net + tax + expenses + rounding),
};
}
// --- 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
// las fechas de línea (`RequiredDate`) vuelven de SAP con hora (`2026-10-01T00:00:00Z`) y se envían solo con el día
out[f] = typeof l[f] === 'string' && /Date$/.test(f) && /^\d{4}-\d{2}-\d{2}T/.test(l[f] as string) ? (l[f] as string).slice(0, 10) : 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 };
}
}