2026-10-14 14:37:01 -05:00
|
|
|
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';
|
2026-10-14 15:12:01 -05:00
|
|
|
import { purchaseRequestLayout } from './compras/compras.layouts';
|
2026-10-14 15:16:22 -05:00
|
|
|
import { hasFolio, headerKeysOf, marketingLayout } from './document-layouts';
|
|
|
|
|
import { LINE_COLUMNS, lineFieldsOf } from './line-columns';
|
2026-10-14 15:12:01 -05:00
|
|
|
import { transferRequestLayout } from './inventario/inventario.layouts';
|
2026-10-14 15:16:22 -05:00
|
|
|
import { MARKETING_LINES, createPayload, updatePayload, validateLayout } from './document-model';
|
2026-10-14 14:37:01 -05:00
|
|
|
import { DocumentFormPage } from './document-form.page';
|
2026-10-14 15:28:03 -05:00
|
|
|
import { BASE_SCREENS, DOCUMENT_SCREENS } from './document-screens';
|
2026-10-14 14:37:01 -05:00
|
|
|
|
|
|
|
|
describe('modelo de los formularios sin socio', () => {
|
2026-10-14 15:12:01 -05:00
|
|
|
const transfer = transferRequestLayout;
|
2026-10-14 14:37:01 -05:00
|
|
|
|
2026-10-14 15:10:40 -05:00
|
|
|
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(12);
|
|
|
|
|
expect(without.sort()).toEqual(
|
2026-10-14 14:37:01 -05:00
|
|
|
['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-14 15:10:40 -05:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-14 15:28:03 -05:00
|
|
|
it('el proyecto base no usa campos de usuario (los de cada cliente van en client-extensions.ts)', () => {
|
|
|
|
|
for (const s of BASE_SCREENS) {
|
2026-10-14 15:10:40 -05:00
|
|
|
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']);
|
2026-10-14 14:37:01 -05:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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', () => {
|
2026-10-14 15:12:01 -05:00
|
|
|
const l = purchaseRequestLayout;
|
2026-10-14 14:37:01 -05:00
|
|
|
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');
|
|
|
|
|
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');
|
|
|
|
|
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({});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-14 15:16:22 -05:00
|
|
|
|
|
|
|
|
describe('columnas de línea por documento', () => {
|
2026-10-14 16:05:25 -05:00
|
|
|
const base = { partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes', uom: 'sales' } as const;
|
2026-10-14 15:16:22 -05:00
|
|
|
|
|
|
|
|
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', 'DiscountPercent', 'TaxCode', 'LineTotal',
|
|
|
|
|
]);
|
2026-10-14 16:05:25 -05:00
|
|
|
expect(l.lines.fields).toEqual([...MARKETING_LINES.fields, 'UoMEntry']);
|
2026-10-14 15:16:22 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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']);
|
2026-10-14 16:05:25 -05:00
|
|
|
expect(l.lines.fields).toEqual(['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'UoMEntry']);
|
2026-10-14 15:16:22 -05:00
|
|
|
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: [] } });
|
2026-10-14 16:09:06 -05:00
|
|
|
expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent', 'FreightExpense', 'FreightAmount']);
|
2026-10-14 15:16:22 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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(8);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-10-14 16:05:25 -05:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|