diff --git a/apps/web/src/app/features/documents/document-model.spec.ts b/apps/web/src/app/features/documents/document-model.spec.ts new file mode 100644 index 0000000..8dffe30 --- /dev/null +++ b/apps/web/src/app/features/documents/document-model.spec.ts @@ -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 = {}) => ({ + 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' }); + }); +}); diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts new file mode 100644 index 0000000..aab1202 --- /dev/null +++ b/apps/web/src/app/features/documents/document-model.ts @@ -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; + +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 { + 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, + 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' }; + } +}