import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { type ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { purchaseRequestLayout } from './compras/compras.layouts'; import { hasFolio, headerKeysOf, marketingLayout } from './document-layouts'; import { LINE_COLUMNS, lineFieldsOf } from './line-columns'; import { transferRequestLayout } from './inventario/inventario.layouts'; import { MARKETING_LINES, createPayload, updatePayload, validateLayout } from './document-model'; import { DocumentFormPage } from './document-form.page'; import { BASE_SCREENS, DOCUMENT_SCREENS } from './document-screens'; describe('modelo de los formularios sin socio', () => { const transfer = transferRequestLayout; it('cada pantalla tiene su formulario, declarado por ella', () => { expect(DOCUMENT_SCREENS.every((s) => s.layout)).toBe(true); const withPartner = DOCUMENT_SCREENS.filter((s) => s.partner).map((s) => s.resource); const without = DOCUMENT_SCREENS.filter((s) => !s.partner).map((s) => s.resource); expect(withPartner).toHaveLength(15); expect(without.sort()).toEqual( ['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(), ); }); it('cada formulario declara solo sus campos: la orden de venta no lleva folio y la factura sí', () => { const by = (r: string) => DOCUMENT_SCREENS.find((s) => s.resource === r)!.layout; expect(hasFolio(by('Orders'))).toBe(false); expect(hasFolio(by('PurchaseQuotations'))).toBe(false); for (const r of ['Invoices', 'PurchaseInvoices', 'CreditNotes', 'PurchaseCreditNotes', 'DeliveryNotes', 'PurchaseDeliveryNotes']) { expect(hasFolio(by(r))).toBe(true); } expect(headerKeysOf(by('Invoices'))).toEqual(expect.arrayContaining(['FolioPrefixString', 'FolioNumber'])); expect(headerKeysOf(by('Orders'))).not.toContain('FolioNumber'); }); it('el proyecto base no usa campos de usuario (los de cada cliente van en client-extensions.ts)', () => { for (const s of BASE_SCREENS) { for (const k of headerKeysOf(s.layout)) expect(k.startsWith('U_')).toBe(false); for (const c of s.layout.columns) expect(c.key.startsWith('U_')).toBe(false); expect(Object.keys(s.layout.header)).toEqual(['left', 'right']); expect(Object.keys(s.layout.footer)).toEqual(['left', 'right']); } }); it('las transferencias guardan las líneas en StockTransferLines con sus dos almacenes', () => { const lines = [{ ItemCode: 'A', ItemDescription: 'x', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02', Price: 5, LineTotal: 10 }]; expect(createPayload({ DocDate: '2026-10-14', FromWarehouse: '01', ToWarehouse: '02', Comments: '' }, lines, transfer.lines)).toEqual({ DocDate: '2026-10-14', FromWarehouse: '01', ToWarehouse: '02', StockTransferLines: [{ ItemCode: 'A', ItemDescription: 'x', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02' }], }); }); it('modificar una solicitud de traslado envía las líneas con su colección', () => { const before = [{ ItemCode: 'A', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02' }]; const after = [{ ItemCode: 'A', Quantity: 3, FromWarehouseCode: '01', WarehouseCode: '02' }]; const { body, replaceLines } = updatePayload({ DocDate: 'd' }, { DocDate: 'd' }, before, after, transfer.lines); expect(replaceLines).toBe(true); expect(body).toEqual({ StockTransferLines: [{ ItemCode: 'A', Quantity: 3, FromWarehouseCode: '01', WarehouseCode: '02' }] }); }); it('validateLayout pide los campos obligatorios de cabecera y de línea', () => { const req = [{ key: 'FromWarehouse', label: 'Almacén origen' }]; expect(validateLayout({ FromWarehouse: '' }, [], req, [])).toEqual(['Indique almacén origen.', 'Agregue al menos una línea con artículo.']); const bad = [{ ItemCode: 'A', Quantity: 0, WarehouseCode: '' }]; expect(validateLayout({ FromWarehouse: '01' }, bad, req, [{ key: 'WarehouseCode', label: 'El almacén' }])).toEqual([ 'Línea 1: la cantidad debe ser mayor que cero.', 'Línea 1: indique el almacén.', ]); }); it('la solicitud de compra marca al solicitante como usuario (ReqType 12) solo si lo indica', () => { const l = purchaseRequestLayout; expect(l.beforeCreate!({ Requester: 'manager' })).toEqual({ Requester: 'manager', ReqType: 12 }); expect(l.beforeCreate!({ Requester: '' })).toEqual({ Requester: '' }); }); it('una línea nueva de transferencia toma los almacenes de la cabecera', () => { expect(transfer.newLine({ FromWarehouse: '01', ToWarehouse: '02' })).toMatchObject({ FromWarehouseCode: '01', WarehouseCode: '02', Quantity: 1 }); }); }); describe('DocumentFormPage de inventario', () => { let fixture: ComponentFixture; let ctrl: HttpTestingController; let navigate: ReturnType; const el = () => fixture.nativeElement as HTMLElement; // eslint-disable-next-line @typescript-eslint/no-explicit-any const page = () => fixture.componentInstance as any; const tick = async () => { fixture.detectChanges(); await new Promise((r) => setTimeout(r)); fixture.detectChanges(); }; async function open(resource: string, docEntry: string | null, doc?: Record) { const screen = DOCUMENT_SCREENS.find((s) => s.resource === resource)!; const params = docEntry ? { docEntry } : {}; const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap(params) }, paramMap: of(convertToParamMap(params)), }; await TestBed.configureTestingModule({ imports: [DocumentFormPage], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], }).compileComponents(); ctrl = TestBed.inject(HttpTestingController); navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); fixture = TestBed.createComponent(DocumentFormPage); await tick(); if (docEntry) ctrl.expectOne(`/api/sl/${resource}/${docEntry}`).flush(doc ?? {}); await tick(); } beforeEach(() => TestBed.resetTestingModule()); it('no pide el maestro de impuestos (no hay totales) y muestra la cabecera de la transferencia', async () => { await open('InventoryTransferRequests', null); ctrl.expectNone((r) => r.url.includes('SalesTaxCodes')); const labels = [...el().querySelectorAll('label')].map((l) => l.textContent?.trim()); expect(labels).toEqual(expect.arrayContaining(['Almacén origen', 'Almacén destino'])); expect(el().querySelector('.status')?.textContent).toContain('Complete la cabecera'); }); it('crea una solicitud de traslado con las líneas en StockTransferLines', async () => { await open('InventoryTransferRequests', null); page().setHeader('FromWarehouse', '01'); page().setHeader('ToWarehouse', '02'); page().setLines([{ ItemCode: 'A1', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02' }]); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(req.request.url).toBe('/api/sl/InventoryTransferRequests'); expect(req.request.body).toEqual({ DocDate: page().header().DocDate, FromWarehouse: '01', ToWarehouse: '02', StockTransferLines: [{ ItemCode: 'A1', Quantity: 2, FromWarehouseCode: '01', WarehouseCode: '02' }], }); req.flush({ DocEntry: 7, DocNum: 7 }); await tick(); expect(navigate).toHaveBeenCalledWith(['..', 7], expect.objectContaining({ state: { created: '7' } })); }); it('no envía una transferencia sin almacenes', async () => { await open('InventoryTransferRequests', null); page().setLines([{ ItemCode: 'A1', Quantity: 2, FromWarehouseCode: '', WarehouseCode: '' }]); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService')); expect(el().querySelector('.status')?.textContent).toContain('almacén origen'); }); it('una entrada de mercancía contabilizada se abre en solo lectura', async () => { await open('InventoryGenEntries', '154', { DocEntry: 154, DocNum: 154, DocDate: '2026-09-30T00:00:00Z', Comments: 'Stock', DocumentStatus: 'bost_Open', Cancelled: 'tNO', DocumentLines: [{ LineNum: 0, ItemCode: 'A', ItemDescription: 'Pieza', Quantity: 10, WarehouseCode: '01', Price: 5 }], }); expect(page().readOnly()).toBe(true); expect([...el().querySelectorAll('.actions .btn')].map((b) => b.textContent?.trim())).not.toContain('Actualizar'); expect(el().querySelector('.status')?.textContent).toContain('contabilizado'); expect(el().querySelectorAll('tbody tr')).toHaveLength(1); }); it('una solicitud de traslado abierta se puede modificar y envía solo el cambio', async () => { await open('InventoryTransferRequests', '471', { 'odata.etag': 'W/"1"', DocEntry: 471, DocNum: 471, DocDate: '2026-09-30T00:00:00Z', FromWarehouse: '01', ToWarehouse: '02', Comments: '', DocumentStatus: 'bost_Open', Cancelled: 'tNO', StockTransferLines: [{ LineNum: 0, ItemCode: 'A', ItemDescription: 'Pieza', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02' }], }); expect(page().readOnly()).toBe(false); page().setHeader('Comments', 'urgente'); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); const req = ctrl.expectOne((r) => r.method === 'PATCH'); expect(req.request.body).toEqual({ Comments: 'urgente' }); expect(req.request.headers.get('If-Match')).toBe('W/"1"'); req.flush(null); await tick(); ctrl.expectOne('/api/sl/InventoryTransferRequests/471').flush({}); }); }); describe('columnas de línea por documento', () => { const base = { partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes', uom: 'sales' } as const; it('sin elegir, el documento lleva todas las columnas y envía los mismos campos de siempre', () => { const l = marketingLayout(base); expect(l.columns.map((c) => c.key)).toEqual([ 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', 'LineTotal', 'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode', 'BaseType', 'BaseEntry', 'BaseLine', ]); // lo que se envía por línea: las columnas editables (sin el total ni las de solo lectura) y la unidad de medida expect(l.lines.fields).toEqual([ 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', 'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode', 'UoMEntry', ]); expect(l.lines.fields).toEqual(expect.arrayContaining([...MARKETING_LINES.fields])); }); it('un documento elige sus columnas y el orden; los campos enviados salen de ellas', () => { const { item, description, quantity, warehouse } = LINE_COLUMNS; const l = marketingLayout({ ...base, lineColumns: [item, description, quantity, warehouse] }); expect(l.columns.map((c) => c.key)).toEqual(['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode']); expect(l.lines.fields).toEqual(['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'UoMEntry']); const body = createPayload({ CardCode: 'C1' }, [{ ItemCode: 'A', Quantity: 1, WarehouseCode: '01', Price: 9, TaxCode: 'IVA' }], l.lines); expect(body['DocumentLines']).toEqual([{ ItemCode: 'A', Quantity: 1, WarehouseCode: '01' }]); }); it('el total y los campos calculados no se envían', () => { expect(lineFieldsOf([LINE_COLUMNS.price, LINE_COLUMNS.total])).toEqual(['Price']); }); it('un documento puede traer su cabecera completa en lugar de la base', () => { const l = marketingLayout({ ...base, header: { left: [], right: [] } }); expect(headerKeysOf(l)).toEqual(expect.arrayContaining(['Comments', 'DiscountPercent', 'Rounding', 'RoundingDiffAmount', 'ShipToCode', 'PaymentGroupCode', 'Project'])); }); it('los demás documentos no cambian cuando uno elige columnas', () => { const custom = marketingLayout({ ...base, lineColumns: [LINE_COLUMNS.item] }); const other = marketingLayout(base); expect(custom.columns).toHaveLength(1); expect(other.columns).toHaveLength(19); }); }); describe('unidad de medida en ventas y compras', () => { const item = { ItemCode: 'SU01', ItemName: 'Sutura', UoMGroupEntry: 1, InventoryUoMEntry: 1, DefaultPurchasingUoMEntry: null, DefaultSalesUoMEntry: 2 }; const pick = (uom: 'sales' | 'purchase', picked: Record, line: Record = {}) => marketingLayout({ partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes', uom }) .columns.find((c) => c.key === 'ItemCode')! .lookup!.apply(picked, line); it('al elegir un artículo la línea toma su unidad: la de venta en ventas, la de compra en compras', () => { expect(pick('sales', item)).toMatchObject({ ItemCode: 'SU01', UoMEntry: 2 }); expect(pick('purchase', item)).toMatchObject({ ItemCode: 'SU01', UoMEntry: 1 }); // sin unidad de compra: la de inventario }); it('un artículo de unidad manual no lleva unidad, y al cambiar de artículo no arrastra la anterior', () => { const manual = { ItemCode: 'M1', UoMGroupEntry: -1, InventoryUoMEntry: -1 }; expect(pick('purchase', manual, { ItemCode: 'SU01', UoMEntry: 1 })['UoMEntry']).toBeUndefined(); }); it('la unidad se envía a SAP solo si es válida (la manual, -1, no)', () => { const l = marketingLayout({ partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes', uom: 'purchase' }); const body = createPayload({}, [{ ItemCode: 'A', Quantity: 1, UoMEntry: 3 }, { ItemCode: 'B', Quantity: 1, UoMEntry: -1 }], l.lines); expect(body['DocumentLines']).toEqual([ { ItemCode: 'A', Quantity: 1, UoMEntry: 3 }, { ItemCode: 'B', Quantity: 1 }, ]); }); it('cada pantalla toma la unidad de su lado', () => { const ap = (path: string) => DOCUMENT_SCREENS.find((s) => s.path === path)!.layout.columns.find((c) => c.key === 'ItemCode')!.lookup!.apply(item, {}); expect(ap('/ventas/ordenes')['UoMEntry']).toBe(2); expect(ap('/compras/ordenes')['UoMEntry']).toBe(1); }); });