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

278 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 { 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<DocumentFormPage>;
let ctrl: HttpTestingController;
let navigate: ReturnType<typeof vi.spyOn>;
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<string, unknown>) {
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<string, unknown>, line: Record<string, unknown> = {}) =>
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);
});
});