feat(web): lógica pura del documento (totales, payload, solo lectura, navegación)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 21:58:01 -05:00
1 parent c66ad96e4a
commit 51982fd3ab
2 files changed
+284

No files matched your search

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import {
computeTotals,
createPayload,
isBlankLine,
isDocumentReadOnly,
navigationQuery,
taxRateMap,
updatePayload,
validateDocument,
} from './document-model';
const line = (over: Record<string, unknown> = {}) => ({
ItemCode: 'A1',
Quantity: 2,
Price: 100,
DiscountPercent: 0,
TaxCode: 'IVA',
LineTotal: 200,
...over,
});
describe('isDocumentReadOnly', () => {
it('cerrado o cancelado es solo lectura', () => {
expect(isDocumentReadOnly({ DocumentStatus: 'bost_Close' })).toBe(true);
expect(isDocumentReadOnly({ DocumentStatus: 'bost_Open', Cancelled: 'tYES' })).toBe(true);
expect(isDocumentReadOnly({ DocumentStatus: 'bost_Open', Cancelled: 'tNO' })).toBe(false);
expect(isDocumentReadOnly(null)).toBe(false);
});
});
describe('computeTotals', () => {
const rates = taxRateMap([{ Code: 'IVA', Rate: 19 }, { Code: 'EXE', Rate: 0 }]);
it('suma líneas e impuesto por tasa', () => {
const t = computeTotals([line(), line({ TaxCode: 'EXE', LineTotal: 100 })], rates, 0);
expect(t).toEqual({ subtotal: 300, discount: 0, net: 300, tax: 38, total: 338 });
});
it('aplica el descuento de cabecera antes del impuesto', () => {
const t = computeTotals([line()], rates, 10);
expect(t).toEqual({ subtotal: 200, discount: 20, net: 180, tax: 34.2, total: 214.2 });
});
it('un código de impuesto desconocido no suma impuesto', () => {
expect(computeTotals([line({ TaxCode: '??' })], rates, 0).tax).toBe(0);
});
it('sin líneas, todo en cero', () => {
expect(computeTotals([], rates, 0)).toEqual({ subtotal: 0, discount: 0, net: 0, tax: 0, total: 0 });
});
});
describe('validateDocument', () => {
const header = { CardCode: 'C1', DocDate: '2026-01-05' };
it('documento correcto no tiene errores', () => {
expect(validateDocument(header, [line()], 'Cliente')).toEqual([]);
});
it('exige socio, fecha y al menos una línea con artículo', () => {
expect(validateDocument({}, [line({ ItemCode: '' })], 'Cliente')).toEqual([
'Indique el cliente.',
'Indique la fecha de contabilización.',
'Agregue al menos una línea con artículo.',
]);
});
it('exige cantidad mayor que cero (numerando solo las líneas reales)', () => {
expect(validateDocument(header, [line({ ItemCode: '' }), line({ Quantity: 0 })], 'Cliente')).toEqual([
'Línea 1: la cantidad debe ser mayor que cero.',
]);
});
});
describe('createPayload', () => {
it('quita campos vacíos y líneas en blanco, y no envía el total de línea', () => {
const body = createPayload({ CardCode: 'C1', Comments: '', NumAtCard: ' ' }, [
line({ ItemDescription: '', WarehouseCode: '01' }),
line({ ItemCode: '' }),
]);
expect(body).toEqual({
CardCode: 'C1',
DocumentLines: [{ ItemCode: 'A1', Quantity: 2, Price: 100, DiscountPercent: 0, TaxCode: 'IVA', WarehouseCode: '01' }],
});
});
it('isBlankLine', () => {
expect(isBlankLine({ ItemCode: '' })).toBe(true);
expect(isBlankLine({ ItemCode: 'A1' })).toBe(false);
});
});
describe('updatePayload', () => {
const original = { CardCode: 'C1', Comments: 'hola', NumAtCard: '' };
const lines = [line()];
it('sin cambios no envía nada', () => {
expect(updatePayload(original, { ...original }, lines, [line()])).toEqual({ body: {}, replaceLines: false });
});
it('envía solo la cabecera que cambió, y null al vaciar', () => {
const r = updatePayload(original, { CardCode: 'C1', Comments: '', NumAtCard: 'X' }, lines, lines);
expect(r).toEqual({ body: { Comments: null, NumAtCard: 'X' }, replaceLines: false });
});
it('si cambian las líneas envía todas para reemplazar la colección', () => {
const r = updatePayload(original, original, lines, [line(), line({ ItemCode: 'B2' })]);
expect(r.replaceLines).toBe(true);
expect((r.body['DocumentLines'] as unknown[]).length).toBe(2);
});
it('una fila en blanco añadida no cuenta como cambio', () => {
expect(updatePayload(original, original, lines, [line(), line({ ItemCode: '' })]).replaceLines).toBe(false);
});
});
describe('navigationQuery', () => {
it('primero y último ignoran el documento actual', () => {
expect(navigationQuery('first', 5)).toEqual({ $orderby: 'DocEntry asc' });
expect(navigationQuery('last', 5)).toEqual({ $orderby: 'DocEntry desc' });
});
it('anterior y siguiente parten del documento actual', () => {
expect(navigationQuery('prev', 5)).toEqual({ $filter: 'DocEntry lt 5', $orderby: 'DocEntry desc' });
expect(navigationQuery('next', 5)).toEqual({ $filter: 'DocEntry gt 5', $orderby: 'DocEntry asc' });
});
it('desde un documento nuevo, anterior = último y siguiente = primero', () => {
expect(navigationQuery('prev', null)).toEqual({ $orderby: 'DocEntry desc' });
expect(navigationQuery('next', null)).toEqual({ $orderby: 'DocEntry asc' });
});
});
@@ -0,0 +1,151 @@
import type { GridLine } from '../../shared/grid/grid.types';
/** 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 ------------------------------------------------------------------------------
export interface DocTotals {
/** 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,
): 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;
}
const net = subtotal * factor;
return {
subtotal: round2(subtotal),
discount: round2(subtotal - net),
net: round2(net),
tax: round2(tax),
total: round2(net + tax),
};
}
// --- 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;
/** Una línea vacía (la fila en blanco que deja la grilla) no se envía. */
export const isBlankLine = (line: GridLine): boolean => isEmpty(line['ItemCode']);
/** Líneas listas para SAP: sin filas en blanco ni campos vacíos. */
export function linesPayload(lines: readonly GridLine[]): DocRecord[] {
return lines
.filter((l) => !isBlankLine(l))
.map((l) => {
const out: DocRecord = {};
for (const f of LINE_FIELDS) if (!isEmpty(l[f])) out[f] = l[f];
return out;
});
}
/** Errores que se pueden detectar sin preguntarle a SAP. Lista vacía = se puede guardar. */
export function validateDocument(header: DocRecord, lines: readonly GridLine[], partnerLabel: string): string[] {
const errors: string[] = [];
if (isEmpty(header['CardCode'])) errors.push(`Indique el ${partnerLabel.toLowerCase()}.`);
if (isEmpty(header['DocDate'])) errors.push('Indique la fecha de contabilización.');
const real = lines.filter((l) => !isBlankLine(l));
if (!real.length) errors.push('Agregue al menos una línea con artículo.');
real.forEach((l, i) => {
if (num(l['Quantity']) <= 0) errors.push(`Línea ${i + 1}: la cantidad debe ser mayor que cero.`);
});
return errors;
}
/** Cuerpo para crear: cabecera sin campos vacíos + líneas. */
export function createPayload(header: DocRecord, lines: readonly GridLine[]): DocRecord {
return { ...compact(header), DocumentLines: linesPayload(lines) };
}
/**
* 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[],
): { 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);
const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines));
if (replaceLines) body['DocumentLines'] = 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`. */
export function navigationQuery(target: NavTarget, docEntry: number | null): { $filter?: string; $orderby: string } {
switch (target) {
case 'first':
return { $orderby: 'DocEntry asc' };
case 'last':
return { $orderby: 'DocEntry desc' };
case 'prev':
return docEntry === null
? { $orderby: 'DocEntry desc' }
: { $filter: `DocEntry lt ${docEntry}`, $orderby: 'DocEntry desc' };
case 'next':
return docEntry === null
? { $orderby: 'DocEntry asc' }
: { $filter: `DocEntry gt ${docEntry}`, $orderby: 'DocEntry asc' };
}
}