feat(web): serie, indicador y proyecto en la cabecera; dimensiones, proyecto, cuenta, referencia base y precio con IVA en el detalle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 11:07:38 -05:00
1 parent 9f003ee4bf
commit f66ef108cf
22 files changed
+698 -100

No files matched your search

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
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', () => {
@@ -24,7 +24,11 @@ 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', 'DiscountPercent', 'TaxCode', 'LineTotal']);
expect(keys).toEqual([
'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode',
'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode',
'BaseType', 'BaseEntry', 'BaseLine', 'LineTotal',
]);
});
it('el total es de solo lectura y artículo y cantidad son obligatorios', () => {
@@ -34,7 +38,8 @@ describe('columnas estándar de línea', () => {
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).toEqual(['Items', 'Warehouses', 'SalesTaxCodes']);
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', () => {
@@ -79,3 +84,67 @@ describe('uomEntryOf', () => {
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 });
});
});