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 { copyFromDocument } from './document-copy'; import { DocumentFormPage } from './document-form.page'; import { createPayload, linesPayload } from './document-model'; import { DOCUMENT_SCREENS } from './document-screens'; import { SERIES_SUBTYPE, objectLabel } from './document-objects'; import { headerKeysOf } from './document-layouts'; const SERIES = { value: [ { Document: '17', DocumentSubType: '--', Name: 'Primario', Series: 10, Locked: 'tNO' }, { Document: '17', DocumentSubType: '--', Name: 'Web', Series: 11, Locked: 'tNO' }, { Document: '17', DocumentSubType: '--', Name: 'Bloqueada', Series: 12, Locked: 'tYES' }, { Document: '17', DocumentSubType: 'IB', Name: 'Boletas', Series: 20, Locked: 'tNO' }, ], }; describe('series, indicador y proyecto', () => { let fixture: ComponentFixture; let ctrl: HttpTestingController; 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(); }; const isSeries = (r: { url: string }) => r.url.includes('SeriesService_GetDocumentSeries'); async function open(path: string, doc?: Record) { const screen = DOCUMENT_SCREENS.find((s) => s.path === path)!; const params = doc ? { docEntry: '5' } : {}; 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); vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); fixture = TestBed.createComponent(DocumentFormPage); await tick(); return screen; } const answerCatalogs = async (resource: string, doc?: Record) => { ctrl.match((r) => r.url === '/api/sl/SalesTaxCodes').forEach((r) => r.flush({ value: [{ Code: 'IVA', Rate: 19 }] })); ctrl.match((r) => r.url === '/api/sl/AdditionalExpenses').forEach((r) => r.flush({ value: [] })); ctrl.match(isSeries).forEach((r) => r.flush(SERIES)); if (doc) ctrl.expectOne(`/api/sl/${resource}/5`).flush(doc); await tick(); }; beforeEach(() => TestBed.resetTestingModule()); it('pide las series del tipo de documento de la pantalla (órdenes de venta = 17)', async () => { await open('/ventas/ordenes'); const req = ctrl.expectOne(isSeries); expect(req.request.method).toBe('POST'); expect(req.request.body).toEqual({ DocumentTypeParams: { Document: '17' } }); await answerCatalogs('Orders'); }); it('ofrece solo las series del subtipo de la pantalla y sin bloquear', async () => { await open('/ventas/ordenes'); await answerCatalogs('Orders'); expect(page().seriesOptions().map((o: { label: string }) => o.label)).toEqual(['Primario', 'Web']); }); it('una pantalla de boletas usa las series de su subtipo', async () => { await open('/ventas/boletas'); await answerCatalogs('Invoices'); expect(page().seriesOptions().map((o: { label: string }) => o.label)).toEqual(['Boletas']); }); it('un documento nuevo envía la serie, el indicador y el proyecto elegidos', async () => { await open('/ventas/ordenes'); await answerCatalogs('Orders'); page().setHeader('CardCode', 'C1'); page().setHeader('Series', 11); page().setHeader('Indicator', '01'); page().setHeader('Project', 'PE001'); page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 50, LineTotal: 50 }]); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); const body = ctrl.expectOne((r) => r.method === 'POST' && !isSeries(r)).request.body as Record; expect(body).toMatchObject({ Series: 11, Indicator: '01', Project: 'PE001' }); }); it('sin elegir serie no la envía (SAP usa la por defecto), y sin indicador ni proyecto tampoco', async () => { await open('/ventas/ordenes'); await answerCatalogs('Orders'); page().setHeader('CardCode', 'C1'); page().setLines([{ ItemCode: 'A1', Quantity: 1, Price: 50, LineTotal: 50 }]); await tick(); (el().querySelector('.actions .btn.main') as HTMLElement).click(); await tick(); const body = ctrl.expectOne((r) => r.method === 'POST' && !isSeries(r)).request.body as Record; for (const k of ['Series', 'Indicator', 'Project']) expect(body).not.toHaveProperty(k); }); it('en un documento guardado la serie no se cambia y se muestra aunque esté bloqueada', async () => { await open('/ventas/ordenes', { DocEntry: 5 }); await answerCatalogs('Orders', { 'odata.etag': 'W/"1"', DocEntry: 5, DocNum: 316, Series: 12, CardCode: 'C1', CardName: 'ACME', Indicator: '02', Project: 'SUD001', DocDate: '2026-09-11T00:00:00Z', DocDueDate: '2026-09-20T00:00:00Z', DocumentStatus: 'bost_Open', Cancelled: 'tNO', DocumentLines: [], }); const select = el().querySelector('select[aria-label="Serie de numeración"]')!; expect(select.disabled).toBe(true); expect(select.selectedOptions[0]?.textContent?.trim()).toBe('Bloqueada'); const values = [...el().querySelectorAll('input')].map((i) => i.value); expect(values).toEqual(expect.arrayContaining(['316', '02', 'SUD001'])); expect(page().dirty()).toBe(false); }); it('todos los formularios de documento llevan serie, indicador y proyecto', () => { for (const s of DOCUMENT_SCREENS) { expect(headerKeysOf(s.layout), s.path).toEqual(expect.arrayContaining(['Series', 'Indicator', 'Project'])); } }); it('el subtipo de las series de las boletas y facturas de SAP', () => { expect(SERIES_SUBTYPE['bod_None']).toBe('--'); expect(SERIES_SUBTYPE['bod_Bill']).toBe('IB'); }); }); describe('qué precio se envía a SAP', () => { const lines = DOCUMENT_SCREENS.find((s) => s.path === '/ventas/ordenes')!.layout.lines; it('si el usuario escribió el precio, se envía solo el precio', () => { expect(linesPayload([{ ItemCode: 'A', Price: 100, PriceAfterVAT: 119, _priceBasis: 'net' }], lines)).toEqual([{ ItemCode: 'A', Price: 100 }]); }); it('si escribió el precio con IVA, se envía solo ese', () => { expect(linesPayload([{ ItemCode: 'A', Price: 100, PriceAfterVAT: 119, _priceBasis: 'gross' }], lines)).toEqual([{ ItemCode: 'A', PriceAfterVAT: 119 }]); }); it('una línea cargada de SAP (sin marca) envía el precio, igual que antes', () => { expect(linesPayload([{ ItemCode: 'A', Price: 100, PriceAfterVAT: 119 }], lines)).toEqual([{ ItemCode: 'A', Price: 100 }]); }); it('las dimensiones y el proyecto de la línea se envían; la referencia base solo si viene copiada', () => { const body = createPayload({}, [{ ItemCode: 'A', Quantity: 1, CostingCode: 'CC1', CostingCode3: 'CC3', ProjectCode: 'PE001', BaseType: -1, BaseEntry: null }], lines); expect(body['DocumentLines']).toEqual([{ ItemCode: 'A', Quantity: 1, CostingCode: 'CC1', CostingCode3: 'CC3', ProjectCode: 'PE001' }]); }); }); describe('inventario y solicitudes de compra', () => { const by = (path: string) => DOCUMENT_SCREENS.find((s) => s.path === path)!.layout; const ANALYTIC = ['CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode']; it('llevan las cinco dimensiones y el proyecto en el detalle, y se envían', () => { for (const p of ['/inventario/entradas', '/inventario/salidas', '/inventario/transferencias', '/inventario/solicitudes-traslado', '/compras/solicitudes']) { const l = by(p); expect(l.columns.map((c) => c.key), p).toEqual(expect.arrayContaining(ANALYTIC)); expect(l.lines.fields, p).toEqual(expect.arrayContaining(ANALYTIC)); } }); it('no llevan precio con IVA, cuenta ni referencia base', () => { for (const p of ['/inventario/entradas', '/inventario/transferencias', '/compras/solicitudes']) { const keys = by(p).columns.map((c) => c.key); for (const k of ['PriceAfterVAT', 'AccountCode']) expect(keys, `${p} ${k}`).not.toContain(k); } // solo las transferencias muestran el origen: pueden hacerse desde una solicitud de traslado for (const p of ['/inventario/entradas', '/inventario/salidas', '/compras/solicitudes']) { expect(by(p).columns.map((c) => c.key), p).not.toContain('BaseEntry'); } expect(by('/inventario/transferencias').columns.map((c) => c.key)).toEqual(expect.arrayContaining(['BaseType', 'BaseEntry', 'BaseLine'])); }); }); describe('copiar conserva dimensiones y proyecto', () => { it('las líneas y la cabecera copiadas llevan las dimensiones, el proyecto y el indicador del origen', () => { const copied = copyFromDocument( { DocEntry: 315, DocNum: 315, CardCode: 'C1', Project: 'PE001', Indicator: '01', DocumentLines: [{ LineNum: 0, ItemCode: 'A', Quantity: 2, RemainingOpenQuantity: 2, CostingCode: 'CC1', CostingCode2: 'CC2', ProjectCode: 'SUD001' }], }, '/ventas/ordenes', 'Órdenes de venta', ); expect(copied.header).toMatchObject({ Project: 'PE001', Indicator: '01' }); expect(copied.lines[0]).toMatchObject({ CostingCode: 'CC1', CostingCode2: 'CC2', ProjectCode: 'SUD001', BaseType: 17, BaseEntry: 315, BaseLine: 0 }); }); it('el tipo base de una línea copiada se muestra con el nombre del documento', () => { expect(objectLabel(17)).toBe('Órdenes de venta'); expect(objectLabel(1470000113)).toBe('Solicitudes de compra'); expect(objectLabel(-1)).toBe(''); }); }); describe('el proyecto de la cabecera se elige de su ventana modal', () => { it('el campo tiene su botón «…» y abre la lista de proyectos', async () => { TestBed.resetTestingModule(); const screen = DOCUMENT_SCREENS.find((s) => s.path === '/ventas/ordenes')!; const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({}) }, paramMap: of(convertToParamMap({})), }; await TestBed.configureTestingModule({ imports: [DocumentFormPage], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], }).compileComponents(); const ctrl = TestBed.inject(HttpTestingController); const fixture = TestBed.createComponent(DocumentFormPage); fixture.detectChanges(); await new Promise((r) => setTimeout(r)); fixture.detectChanges(); const el = fixture.nativeElement as HTMLElement; const row = [...el.querySelectorAll('sbo-lookup-field')].find((f) => f.querySelector('label')?.textContent?.trim() === 'Proyecto')!; expect(row, 'campo Proyecto con lista').toBeDefined(); (row.querySelector('button.sbo-btn') as HTMLElement).click(); fixture.detectChanges(); await new Promise((r) => setTimeout(r)); const req = ctrl.expectOne((r) => r.url === '/api/sl/Projects'); expect(req.request.params.get('$filter')).toContain(`Active eq 'tYES'`); req.flush({ value: [{ Code: 'PE001', Name: 'Proyecto uno' }], 'odata.count': '1' }); fixture.detectChanges(); await new Promise((r) => setTimeout(r)); fixture.detectChanges(); (el.querySelector('.dlg tbody tr') as HTMLElement).click(); fixture.detectChanges(); expect(fixture.componentInstance['header']()['Project']).toBe('PE001'); }); }); describe('el indicador de la cabecera se elige de su ventana modal', () => { it('es una celda con lista de valores sobre FactoringIndicators, con código y nombre, junto al folio', () => { const group = DOCUMENT_SCREENS.find((s) => s.path === '/ventas/facturas')!.layout.header.right.find((x) => x.type === 'group'); const cells = group && group.type === 'group' ? group.cells : []; expect(cells.map((c) => c.key)).toEqual(['Indicator', 'FolioPrefixString', 'FolioNumber']); const first = cells[0]; expect(first.kind === 'lookup' && first.lookup.resource).toBe('FactoringIndicators'); expect(first.kind === 'lookup' && first.lookup.columns.map((c) => c.key)).toEqual(['IndicatorCode', 'IndicatorName']); }); it('abre su ventana de selección y escribe el código elegido', async () => { TestBed.resetTestingModule(); const screen = DOCUMENT_SCREENS.find((s) => s.path === '/ventas/facturas')!; const route = { snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({}) }, paramMap: of(convertToParamMap({})), }; await TestBed.configureTestingModule({ imports: [DocumentFormPage], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], }).compileComponents(); const ctrl = TestBed.inject(HttpTestingController); const fixture = TestBed.createComponent(DocumentFormPage); const tick = async () => { fixture.detectChanges(); await new Promise((r) => setTimeout(r)); fixture.detectChanges(); }; await tick(); const el = fixture.nativeElement as HTMLElement; const row = [...el.querySelectorAll('sbo-field')].find((f) => f.querySelector('label')?.textContent?.trim() === 'Indicador / Folio')!; expect(row, 'fila Indicador / Folio').toBeDefined(); (row.querySelector('button.sbo-btn') as HTMLElement).click(); await tick(); ctrl.expectOne((r) => r.url === '/api/sl/FactoringIndicators').flush({ value: [{ IndicatorCode: '33', IndicatorName: 'Factura' }, { IndicatorCode: '39', IndicatorName: 'Boleta' }], 'odata.count': '2' }); await tick(); (el.querySelectorAll('.dlg tbody tr')[1] as HTMLElement).click(); await tick(); expect(fixture.componentInstance['header']()['Indicator']).toBe('39'); }); }); describe('cabecera de los movimientos de inventario', () => { const by = (path: string) => DOCUMENT_SCREENS.find((s) => s.path === path)!.layout; it('las entradas y salidas llevan fecha del documento, referencia 2 y comentarios del asiento', () => { for (const p of ['/inventario/entradas', '/inventario/salidas']) { expect(headerKeysOf(by(p)), p).toEqual(expect.arrayContaining(['DocDate', 'TaxDate', 'Reference2', 'JournalMemo', 'Series', 'Indicator', 'Project'])); expect(headerKeysOf(by(p)), p).not.toContain('DueDate'); } }); it('las transferencias y solicitudes de traslado suman la fecha de vencimiento', () => { for (const p of ['/inventario/transferencias', '/inventario/solicitudes-traslado']) { expect(headerKeysOf(by(p)), p).toEqual(expect.arrayContaining(['FromWarehouse', 'ToWarehouse', 'TaxDate', 'DueDate', 'Reference2', 'JournalMemo'])); } }); it('Reference1 no se edita: lo asigna SAP', () => { for (const s of DOCUMENT_SCREENS) expect(headerKeysOf(s.layout), s.path).not.toContain('Reference1'); }); it('las fechas se leen y se envían como AAAA-MM-DD', () => { // el formulario normaliza `2026-10-16T00:00:00Z` (TaxDate / DueDate) igual que las demás fechas const keys = headerKeysOf(by('/inventario/transferencias')); expect(keys).toEqual(expect.arrayContaining(['TaxDate', 'DueDate'])); }); });