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

217 lines
9.4 KiB
TypeScript
Raw Normal View History

import { describe, expect, it } from 'vitest';
import { newDocumentLine } from './line-columns';
import {
computeTotals,
createPayload,
isBlankLine,
isDocumentReadOnly,
navigationQuery,
taxRateMap,
updatePayload,
validateLayout,
expenseRowsOf,
expensesChanged,
expensesPayload,
validateExpenses,
} 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, expenses: 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, expenses: 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, expenses: 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<string, unknown>[];
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('gastos adicionales', () => {
const doc = (rows: Record<string, unknown>[]) => ({ DocumentAdditionalExpenses: rows });
it('suman al total del documento, con el impuesto de cada gasto', () => {
const rates = taxRateMap([{ Code: 'IVA', Rate: 19 }]);
const t = computeTotals([line({ LineTotal: 100, TaxCode: 'IVA' })], rates, 0, [{ ExpenseCode: 1, LineTotal: 25 }, { ExpenseCode: 2, LineTotal: 100, TaxCode: 'IVA' }]);
expect(t).toMatchObject({ subtotal: 100, expenses: 125, tax: 38, total: 263 });
});
it('lee las filas de SAP para editarlas', () => {
expect(expenseRowsOf(doc([{ ExpenseCode: 2, LineTotal: 1500 }, { ExpenseCode: 1, LineTotal: 10 }]))).toHaveLength(2);
expect(expenseRowsOf(undefined)).toEqual([]);
});
it('arma la colección con una fila por gasto, sin filas en blanco ni campos vacíos', () => {
expect(expensesPayload([{ ExpenseCode: '2', LineTotal: 1500.456, TaxCode: '', Remarks: 'Maritimo' }, { ExpenseCode: 1, LineTotal: 10, TaxCode: 'IGV' }, {}, { ExpenseCode: '', LineTotal: 0 }])).toEqual([
{ ExpenseCode: 2, LineTotal: 1500.46, Remarks: 'Maritimo' },
{ ExpenseCode: 1, LineTotal: 10, TaxCode: 'IGV' },
]);
});
it('detecta si cambiaron respecto del documento', () => {
const original = doc([{ ExpenseCode: 2, LineTotal: 25, LineNum: 0, Status: 'bost_Open' }]);
expect(expensesChanged(original, expenseRowsOf(original))).toBe(false);
expect(expensesChanged(original, [{ ExpenseCode: 2, LineTotal: 40 }])).toBe(true);
expect(expensesChanged(original, [])).toBe(true); // quitar el gasto también es un cambio
expect(expensesChanged(undefined, [{}])).toBe(false);
});
it('pide el gasto y un importe mayor que cero', () => {
expect(validateExpenses([{ ExpenseCode: 2, LineTotal: 10 }, {}])).toEqual([]);
expect(validateExpenses([{ LineTotal: 10 }, { ExpenseCode: 2, LineTotal: 0 }])).toEqual(['Gasto adicional 1: indique el gasto.', 'Gasto adicional 2: el importe debe ser mayor que cero.']);
});
});
describe('documentos de servicio', () => {
const service = { key: 'DocumentLines', fields: ['AccountCode', 'ItemDescription', 'TaxCode', 'LineTotal'], base: true, service: true };
it('una línea de servicio vacía no tiene cuenta, descripción ni total', () => {
expect(isBlankLine({ AccountCode: '', ItemDescription: '', LineTotal: 0 }, true)).toBe(true);
expect(isBlankLine({ AccountCode: '_SYS1', LineTotal: 0 }, true)).toBe(false);
expect(isBlankLine({ ItemDescription: 'Alquiler', LineTotal: 0 }, true)).toBe(false);
expect(isBlankLine({ ItemCode: '', LineTotal: 0 }, false)).toBe(true);
});
it('exige al menos una línea con cuenta y que cada una tenga cuenta y total', () => {
expect(validateLayout({}, [], [], [], true)).toEqual(['Agregue al menos una línea con cuenta contable.']);
expect(validateLayout({}, [{ ItemDescription: 'x', LineTotal: 0 }], [], [], true)).toEqual([
'Línea 1: indique la cuenta contable.',
'Línea 1: el total debe ser mayor que cero.',
]);
expect(validateLayout({}, [{ AccountCode: '_SYS1', LineTotal: 50 }], [], [], true)).toEqual([]);
});
it('envía solo los campos de servicio, sin cantidad ni artículo', () => {
const body = createPayload({ DocType: 'dDocument_Service', CardCode: 'P1' }, [{ AccountCode: '_SYS1', ItemDescription: 'Alquiler', TaxCode: 'IGV', LineTotal: 100, Quantity: 1, ItemCode: '' }, { AccountCode: '', LineTotal: 0 }], service);
expect(body['DocumentLines']).toEqual([{ AccountCode: '_SYS1', ItemDescription: 'Alquiler', TaxCode: 'IGV', LineTotal: 100 }]);
});
});