feat(web): gastos adicionales de los documentos de ventas y compras como tabla de varias filas (antes solo el flete)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 12:46:56 -05:00
1 parent 3c1d02a263
commit 6c2cb30d63
10 files changed
+223 -162

No files matched your search

@@ -26,8 +26,8 @@ export function isDocumentReadOnly(doc: DocRecord | null | undefined): boolean {
export const FREIGHT_COLLECTION = 'DocumentAdditionalExpenses';
export interface DocTotals {
/** Flete (gastos adicionales) antes de impuestos. */
freight: number;
/** 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). */
@@ -51,7 +51,7 @@ export function computeTotals(
lines: readonly GridLine[],
rates: ReadonlyMap<string, number>,
discountPercent: number,
freight = 0,
expenseRows: readonly ExpenseRow[] = [],
): DocTotals {
const factor = 1 - discountPercent / 100;
let subtotal = 0;
@@ -61,54 +61,61 @@ export function computeTotals(
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),
freight: round2(freight),
total: round2(net + tax + freight),
expenses: round2(expenses),
total: round2(net + tax + expenses),
};
}
// --- flete ---------------------------------------------------------------------------------
// --- gastos adicionales -----------------------------------------------------------------------
/** Flete del documento tal como se edita: el código del gasto y el importe (`FreightExpense`, `FreightAmount`). */
export function freightOf(doc: DocRecord | undefined): { expense: string; amount: number; editable: boolean } {
const rows = (doc?.[FREIGHT_COLLECTION] as DocRecord[] | undefined) ?? [];
return {
expense: rows[0]?.['ExpenseCode'] === undefined || rows[0]?.['ExpenseCode'] === null ? '' : String(rows[0]['ExpenseCode']),
amount: round2(rows.reduce((sum, r) => sum + num(r['LineTotal']), 0)),
// con varios gastos adicionales (cargados en SAP) el flete solo se muestra: no se puede reducir a una fila
editable: rows.length <= 1,
};
/** 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;
});
}
const FREIGHT_KEYS = ['FreightExpense', 'FreightAmount'] as const;
const withoutFreight = (r: DocRecord): DocRecord => Object.fromEntries(Object.entries(r).filter(([k]) => !(FREIGHT_KEYS as readonly string[]).includes(k)));
/** 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)));
/**
* Convierte los campos del flete del formulario (`FreightExpense`, `FreightAmount`) en la colección
* `DocumentAdditionalExpenses` que espera SAP. `expense` es el código elegido o, si no eligió, el
* de flete por defecto. Al crear, sin importe no se envía nada; al modificar solo si cambió (con
* `replace`, porque SAP reemplaza la colección entera) y un importe 0 quita el flete.
*/
export function freightPayload(
body: DocRecord,
header: DocRecord,
expense: string,
mode: 'create' | 'update',
hadFreight = false,
): { body: DocRecord; replace: boolean } {
const touched = FREIGHT_KEYS.some((k) => k in body);
if (mode === 'update' && !touched) return { body, replace: false };
const amount = round2(num(header['FreightAmount']));
const code = isEmpty(header['FreightExpense']) ? expense : String(header['FreightExpense']);
const rest = withoutFreight(body);
if (amount > 0 && code !== '') return { body: { ...rest, [FREIGHT_COLLECTION]: [{ ExpenseCode: Number(code), LineTotal: amount }] }, replace: true };
if (mode === 'update' && hadFreight) return { body: { ...rest, [FREIGHT_COLLECTION]: [] }, replace: true };
return { body: rest, replace: false };
/** 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 -----------------------------------------------------------------------------