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

318 lines
15 KiB
TypeScript
Raw Normal View History

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<DocumentFormPage>;
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<string, unknown>) {
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<string, unknown>) => {
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<string, unknown>;
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<string, unknown>;
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<HTMLSelectElement>('select[aria-label="Serie de numeración"]')!;
expect(select.disabled).toBe(true);
expect(select.selectedOptions[0]?.textContent?.trim()).toBe('Bloqueada');
const values = [...el().querySelectorAll<HTMLInputElement>('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 un campo con lista de valores sobre FactoringIndicators, con código y nombre', () => {
const f = DOCUMENT_SCREENS.find((s) => s.path === '/ventas/facturas')!.layout.header.left.find((x) => x.type === 'lookup' && x.key === 'Indicator');
expect(f && f.type === 'lookup' && f.lookup.resource).toBe('FactoringIndicators');
expect(f && f.type === 'lookup' && f.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-lookup-field')].find((f) => f.querySelector('label')?.textContent?.trim() === 'Indicador')!;
expect(row, 'campo Indicador con lista').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']));
});
});