import { describe, expect, it } from 'vitest'; import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, lineFieldsOf, newDocumentLine, recalcLine, uomEntryOf } from './line-columns'; describe('recalcLine', () => { it('total = cantidad × precio, redondeado a 2 decimales', () => { expect(recalcLine({ Quantity: 5, Price: 10000 })['LineTotal']).toBe(50000); expect(recalcLine({ Quantity: 3, Price: 0.1 })['LineTotal']).toBe(0.3); expect(recalcLine({ Quantity: 1.5, Price: 19.99 })['LineTotal']).toBe(29.99); }); it('aplica el % de descuento', () => { expect(recalcLine({ Quantity: 2, Price: 100, DiscountPercent: 10 })['LineTotal']).toBe(180); expect(recalcLine({ Quantity: 2, Price: 100, DiscountPercent: 100 })['LineTotal']).toBe(0); }); it('valores vacíos o no numéricos cuentan como 0 y se conserva el resto de la línea', () => { const line = recalcLine({ ItemCode: 'A', Quantity: null, Price: 'abc' }); expect(line['LineTotal']).toBe(0); expect(line['ItemCode']).toBe('A'); }); }); describe('columnas estándar de línea', () => { const keys = DOCUMENT_LINE_COLUMNS.map((c) => c.key); it('son las de una línea de documento de SAP (con TaxCode)', () => { expect(keys).toEqual([ 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', 'LineTotal', 'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode', 'BaseType', 'BaseEntry', 'BaseLine', ]); }); it('el total es de solo lectura y artículo y cantidad son obligatorios', () => { expect(DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'LineTotal')?.readonly).toBe(true); expect(DOCUMENT_LINE_COLUMNS.filter((c) => c.required).map((c) => c.key)).toEqual(['ItemCode', 'Quantity']); }); it('artículo, almacén e impuesto tienen lista de valores sobre recursos registrados', () => { const resources = DOCUMENT_LINE_COLUMNS.filter((c) => c.lookup).map((c) => c.lookup!.config.resource); expect(resources.slice(0, 2)).toEqual(['Items', 'Warehouses']); expect(resources).toContain('SalesTaxCodes'); }); it('elegir un artículo rellena código y descripción sin perder el resto', () => { const apply = DOCUMENT_LINE_COLUMNS[0].lookup!.apply; expect(apply({ ItemCode: 'I1', ItemName: 'Disco' }, { Quantity: 3 })).toMatchObject({ ItemCode: 'I1', ItemDescription: 'Disco', Quantity: 3 }); }); it('elegir almacén o impuesto solo cambia ese campo', () => { const wh = DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'WarehouseCode')!.lookup!.apply; const tax = DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'TaxCode')!.lookup!.apply; expect(wh({ WarehouseCode: '01', WarehouseName: 'Principal' }, { ItemCode: 'A' })).toEqual({ ItemCode: 'A', WarehouseCode: '01' }); expect(tax({ Code: 'IVA', Rate: 19 }, { ItemCode: 'A' })).toEqual({ ItemCode: 'A', TaxCode: 'IVA' }); }); }); describe('línea nueva y "catálogo completo"', () => { it('la línea nueva trae cantidad 1 y total 0', () => { expect(newDocumentLine()).toMatchObject({ Quantity: 1, Price: null, LineTotal: 0, ItemCode: '' }); }); it('cada artículo elegido se convierte en una línea nueva', () => { expect(ITEMS_MULTI_ADD.toLine({ ItemCode: 'I9', ItemName: 'Cable' })).toMatchObject({ ItemCode: 'I9', ItemDescription: 'Cable', Quantity: 1 }); }); }); describe('lineColumnsFor', () => { it('ventas conserva las columnas; compras cambia el maestro de impuestos', () => { expect(lineColumnsFor('SalesTaxCodes')).toBe(DOCUMENT_LINE_COLUMNS); const tax = lineColumnsFor('PurchaseTaxCodes').find((c) => c.key === 'TaxCode'); expect(tax?.lookup?.config.resource).toBe('PurchaseTaxCodes'); expect(DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'TaxCode')?.lookup?.config.resource).toBe('SalesTaxCodes'); }); }); describe('uomEntryOf', () => { it('usa la unidad de compra por defecto y, si no hay, la de inventario', () => { expect(uomEntryOf({ UoMGroupEntry: 1, DefaultPurchasingUoMEntry: 3, InventoryUoMEntry: 1 })).toBe(3); expect(uomEntryOf({ UoMGroupEntry: 1, DefaultPurchasingUoMEntry: null, InventoryUoMEntry: 1 })).toBe(1); }); it('un artículo de unidad manual (sin grupo) no lleva unidad: SAP no la admite', () => { expect(uomEntryOf({ UoMGroupEntry: -1, InventoryUoMEntry: -1 })).toBeUndefined(); expect(uomEntryOf({ InventoryUoMEntry: 1 })).toBeUndefined(); }); }); describe('dimensiones, proyecto, cuenta y referencia base', () => { const col = (key: string) => DOCUMENT_LINE_COLUMNS.find((c) => c.key === key)!; it('cada dimensión de costos abre su ventana de selección, de su propia dimensión', () => { for (const n of [1, 2, 3, 4, 5]) { const c = col(n === 1 ? 'CostingCode' : `CostingCode${n}`); expect(c.lookup!.config.resource).toBe('DistributionRules'); expect(c.lookup!.config.baseFilter).toContain(`InWhichDimension eq ${n}`); expect(c.lookup!.apply({ FactorCode: 'CC9' }, {})).toEqual({ [c.key]: 'CC9' }); } }); it('el proyecto y la cuenta también se eligen de su ventana', () => { expect(col('ProjectCode').lookup!.config.resource).toBe('Projects'); expect(col('ProjectCode').lookup!.apply({ Code: 'PE001' }, {})).toEqual({ ProjectCode: 'PE001' }); expect(col('AccountCode').lookup!.config.resource).toBe('ChartOfAccounts'); }); it('las columnas analíticas de inventario son las cinco dimensiones y el proyecto', () => { expect(ANALYTIC_COLUMNS.map((c) => c.key)).toEqual(['CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode']); }); it('BaseType, BaseEntry y BaseLine son de solo lectura y solo se muestran si la línea viene copiada', () => { const copied = { BaseType: 17, BaseEntry: 315, BaseLine: 2 }; const plain = { BaseType: -1, BaseEntry: null, BaseLine: null }; for (const k of ['BaseType', 'BaseEntry', 'BaseLine']) expect(col(k).readonly).toBe(true); expect(col('BaseType').format!(copied)).toBe('Órdenes de venta'); expect(col('BaseEntry').format!(copied)).toBe('315'); expect(col('BaseLine').format!(copied)).toBe('2'); for (const k of ['BaseType', 'BaseEntry', 'BaseLine']) expect(col(k).format!(plain)).toBe(''); }); it('las columnas de solo lectura (referencia base y total) no se envían a SAP', () => { expect(lineFieldsOf(DOCUMENT_LINE_COLUMNS)).not.toEqual(expect.arrayContaining(['BaseType', 'BaseEntry', 'BaseLine', 'LineTotal'])); }); }); describe('precio con y sin impuesto', () => { const rates = new Map([['IVA', 19], ['EXE', 0]]); const base = { Quantity: 2, DiscountPercent: 0, TaxCode: 'IVA' }; it('al escribir el precio calcula el precio con IVA', () => { const l = recalcLine({ ...base, Price: 100 }, 'Price', rates); expect(l).toMatchObject({ PriceAfterVAT: 119, LineTotal: 200, _priceBasis: 'net' }); }); it('al escribir el precio con IVA calcula el precio neto y el total', () => { const l = recalcLine({ ...base, PriceAfterVAT: 119 }, 'PriceAfterVAT', rates); expect(l).toMatchObject({ Price: 100, LineTotal: 200, _priceBasis: 'gross' }); }); it('al cambiar el impuesto mantiene lo último que escribió el usuario', () => { const net = recalcLine({ ...base, Price: 100, _priceBasis: 'net' }, 'TaxCode', rates); expect(net['PriceAfterVAT']).toBe(119); const gross = recalcLine({ ...base, PriceAfterVAT: 119, Price: 100, _priceBasis: 'gross', TaxCode: 'EXE' }, 'TaxCode', rates); expect(gross).toMatchObject({ Price: 119, LineTotal: 238 }); }); it('vaciar un precio vacía el otro; sin tasas conocidas no toca los precios', () => { expect(recalcLine({ ...base, Price: null }, 'Price', rates)['PriceAfterVAT']).toBeNull(); expect(recalcLine({ ...base, Price: 50, PriceAfterVAT: 80 }, 'Quantity')).toMatchObject({ Price: 50, PriceAfterVAT: 80, LineTotal: 100 }); }); });