import { describe, expect, it } from 'vitest'; import { newDocumentLine } from './line-columns'; import { computeTotals, createPayload, isBlankLine, isDocumentReadOnly, navigationQuery, taxRateMap, updatePayload, validateLayout, freightOf, freightPayload, } 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, freight: 0, 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, freight: 0, 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, freight: 0, total: 0 }); }); }); describe('validateLayout', () => { const header = { CardCode: 'C1', DocDate: '2026-01-05' }; const required = [ { key: 'CardCode', label: 'el cliente' }, { key: 'DocDate', label: 'la fecha de contabilización' }, ]; it('documento correcto no tiene errores', () => { expect(validateLayout(header, [line()], required, [])).toEqual([]); }); it('exige los campos obligatorios y al menos una línea con artículo', () => { expect(validateLayout({}, [line({ ItemCode: '' })], required, [])).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(validateLayout(header, [line({ ItemCode: '' }), line({ Quantity: 0 })], required, [])).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('un precio sin escribir no se envía: SAP aplica la lista de precios', () => { const body = createPayload({ CardCode: 'C1' }, [{ ...newDocumentLine(), ItemCode: 'A1' }]); expect(body['DocumentLines']).toEqual([{ ItemCode: 'A1', Quantity: 1, DiscountPercent: 0 }]); }); it('envía la referencia al origen solo en las líneas copiadas', () => { const body = createPayload({ CardCode: 'C1' }, [ line({ BaseType: 17, BaseEntry: 315, BaseLine: 0 }), line({ BaseType: -1, BaseEntry: null, BaseLine: null }), ]); const [copied, own] = body['DocumentLines'] as Record[]; expect(copied).toMatchObject({ BaseType: 17, BaseEntry: 315, BaseLine: 0 }); expect(own).not.toHaveProperty('BaseType'); }); 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' }); }); }); describe('flete (gastos adicionales)', () => { const doc = (rows: Record[]) => ({ DocumentAdditionalExpenses: rows }); it('suma el flete al total del documento', () => { const rates = taxRateMap([{ Code: 'IVA', Rate: 19 }]); const t = computeTotals([line({ LineTotal: 100, TaxCode: 'IVA' })], rates, 0, 25); expect(t).toMatchObject({ subtotal: 100, tax: 19, freight: 25, total: 144 }); }); it('lee el flete de SAP: código, importe y si se puede editar', () => { expect(freightOf(doc([{ ExpenseCode: 2, LineTotal: 1500 }]))).toEqual({ expense: '2', amount: 1500, editable: true }); expect(freightOf(undefined)).toEqual({ expense: '', amount: 0, editable: true }); expect(freightOf(doc([{ ExpenseCode: 1, LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 5 }])).editable).toBe(false); }); it('al crear arma DocumentAdditionalExpenses con el gasto por defecto si no se eligió', () => { const out = freightPayload({ CardCode: 'C1', FreightAmount: 1500, FreightExpense: '' }, { FreightAmount: 1500, FreightExpense: '' }, '2', 'create'); expect(out.body).toEqual({ CardCode: 'C1', DocumentAdditionalExpenses: [{ ExpenseCode: 2, LineTotal: 1500 }] }); expect(out.replace).toBe(true); }); it('al crear sin importe no envía flete', () => { const out = freightPayload({ CardCode: 'C1', FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'create'); expect(out.body).toEqual({ CardCode: 'C1' }); expect(out.replace).toBe(false); }); it('al modificar solo envía el flete si cambió, y con importe 0 lo quita', () => { expect(freightPayload({ Comments: 'x' }, { FreightAmount: 10 }, '2', 'update', true)).toEqual({ body: { Comments: 'x' }, replace: false }); const changed = freightPayload({ FreightAmount: 300 }, { FreightAmount: 300, FreightExpense: '3' }, '2', 'update', true); expect(changed).toEqual({ body: { DocumentAdditionalExpenses: [{ ExpenseCode: 3, LineTotal: 300 }] }, replace: true }); const cleared = freightPayload({ FreightAmount: 0 }, { FreightAmount: 0, FreightExpense: '3' }, '2', 'update', true); expect(cleared).toEqual({ body: { DocumentAdditionalExpenses: [] }, replace: true }); expect(freightPayload({ FreightAmount: 0 }, { FreightAmount: 0 }, '2', 'update', false).body).toEqual({}); }); });