feat(web): serie, indicador y proyecto en la cabecera; dimensiones, proyecto, cuenta, referencia base y precio con IVA en el detalle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 11:07:38 -05:00
1 parent 9f003ee4bf
commit f66ef108cf
22 files changed
+698 -100

No files matched your search

@@ -9,37 +9,42 @@ import {
today,
warehouseColumn,
} from '../document-layouts';
import type { GridColumn } from '../../../shared/grid/grid.types';
import { ANALYTIC_COLUMNS, lineFieldsOf } from '../line-columns';
/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */
export const purchaseRequestLayout: DocumentLayout = {
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false },
header: { left: [FIELDS.requester], right: [FIELDS.docNum, FIELDS.docDate, FIELDS.requiredDate] },
footer: commentsOnly,
columns: [
itemColumn,
descriptionColumn,
quantityColumn,
warehouseColumn,
{
key: 'LineVendor',
title: 'Proveedor',
width: '130px',
mono: true,
lookup: {
config: {
title: 'Proveedores',
resource: 'BusinessPartners',
baseFilter: `CardType eq 'cSupplier'`,
orderby: 'CardCode',
columns: [
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
{ key: 'CardName', title: 'Nombre' },
],
},
apply: (bp, line) => ({ ...line, LineVendor: bp['CardCode'] }),
const purchaseRequestColumns: readonly GridColumn[] = [
itemColumn,
descriptionColumn,
quantityColumn,
warehouseColumn,
{
key: 'LineVendor',
title: 'Proveedor',
width: '130px',
mono: true,
lookup: {
config: {
title: 'Proveedores',
resource: 'BusinessPartners',
baseFilter: `CardType eq 'cSupplier'`,
orderby: 'CardCode',
columns: [
{ key: 'CardCode', title: 'Código', width: '130px', mono: true },
{ key: 'CardName', title: 'Nombre' },
],
},
apply: (bp, line) => ({ ...line, LineVendor: bp['CardCode'] }),
},
],
},
...ANALYTIC_COLUMNS,
];
export const purchaseRequestLayout: DocumentLayout = {
lines: { key: 'DocumentLines', fields: [...lineFieldsOf(purchaseRequestColumns), 'UoMEntry'], base: false },
header: { left: [FIELDS.requester, FIELDS.indicator, FIELDS.project], right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.requiredDate] },
footer: commentsOnly,
columns: purchaseRequestColumns,
newHeader: () => ({ DocDate: today(), RequriedDate: today() }),
newLine: blankLine,
// El solicitante es un usuario de SAP (`ReqType` 12).
@@ -0,0 +1,37 @@
import type { VhLookup } from '../../shared/value-help/value-help.types';
/** Dimensión de costos (centro de costo, `CostingCode` 1 a 5): reglas de distribución activas de esa dimensión. */
export const dimensionLookup = (n: 1 | 2 | 3 | 4 | 5): VhLookup => ({
title: `Dimensión ${n}`,
resource: 'DistributionRules',
orderby: 'FactorCode',
baseFilter: `InWhichDimension eq ${n} and Active eq 'tYES'`,
columns: [
{ key: 'FactorCode', title: 'Código', width: '110px', mono: true },
{ key: 'FactorDescription', title: 'Descripción' },
],
});
/** Proyectos activos. */
export const PROJECT_LOOKUP: VhLookup = {
title: 'Proyectos',
resource: 'Projects',
orderby: 'Code',
baseFilter: `Active eq 'tYES'`,
columns: [
{ key: 'Code', title: 'Código', width: '130px', mono: true },
{ key: 'Name', title: 'Nombre' },
],
};
/** Cuentas contables activas. */
export const ACCOUNT_LOOKUP: VhLookup = {
title: 'Plan de cuentas',
resource: 'ChartOfAccounts',
orderby: 'Code',
baseFilter: `ActiveAccount eq 'tYES'`,
columns: [
{ key: 'Code', title: 'Código', width: '160px', mono: true },
{ key: 'Name', title: 'Nombre' },
],
};
@@ -0,0 +1,212 @@
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', 'BaseType', 'BaseEntry', 'BaseLine']) expect(keys, `${p} ${k}`).not.toContain(k);
}
});
});
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('');
});
});
@@ -62,6 +62,8 @@ describe('copyFromDocument', () => {
CardCode: 'C1',
NumAtCard: 'REF',
DiscountPercent: 5,
Project: '',
Indicator: '',
Comments: 'Basado en órdenes de venta 1315.',
});
});
@@ -2,24 +2,8 @@ import type { GridLine } from '../../shared/grid/grid.types';
import type { DocRecord } from './document-model';
import { newDocumentLine, recalcLine } from './line-columns';
/** Tipo de objeto de SAP de cada documento (`BaseType` de las líneas copiadas). */
export const OBJECT_TYPES: Readonly<Record<string, number>> = {
'/ventas/ofertas': 23,
'/ventas/ordenes': 17,
'/ventas/entrega': 15,
'/ventas/devoluciones': 16,
'/ventas/facturas': 13,
'/ventas/boletas': 13,
'/ventas/facturas-reserva': 13,
'/ventas/boletas-reserva': 13,
'/ventas/notas-credito': 14,
'/compras/ofertas': 540000006,
'/compras/ordenes': 22,
'/compras/recepcion': 20,
'/compras/devoluciones': 21,
'/compras/facturas': 18,
'/compras/notas-credito': 19,
};
export { OBJECT_TYPES } from './document-objects';
import { OBJECT_TYPES } from './document-objects';
/** De qué documento se puede copiar a cuáles (el flujo estándar de SAP B1). */
export const COPY_FLOWS: Readonly<Record<string, readonly string[]>> = {
@@ -83,6 +67,12 @@ export function copyFromDocument(source: DocRecord, sourcePath: string, sourceLa
Price: l['Price'] ?? null,
DiscountPercent: l['DiscountPercent'] ?? 0,
TaxCode: l['TaxCode'] ?? '',
CostingCode: l['CostingCode'] ?? '',
CostingCode2: l['CostingCode2'] ?? '',
CostingCode3: l['CostingCode3'] ?? '',
CostingCode4: l['CostingCode4'] ?? '',
CostingCode5: l['CostingCode5'] ?? '',
ProjectCode: l['ProjectCode'] ?? '',
BaseType: baseType,
BaseEntry: source['DocEntry'],
BaseLine: l['LineNum'],
@@ -94,6 +84,8 @@ export function copyFromDocument(source: DocRecord, sourcePath: string, sourceLa
CardCode: source['CardCode'] ?? '',
NumAtCard: source['NumAtCard'] ?? '',
DiscountPercent: source['DiscountPercent'] ?? 0,
Project: source['Project'] ?? '',
Indicator: source['Indicator'] ?? '',
Comments: `Basado en ${sourceLabel.toLowerCase()} ${String(source['DocNum'])}.`,
},
lines,
@@ -117,7 +117,7 @@ describe('folio y RUT en los formularios', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const body = ctrl.expectOne((r) => r.method === 'POST').request.body as Record<string, unknown>;
const body = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).request.body as Record<string, unknown>;
expect(body).not.toHaveProperty('FolioNumber');
expect(body).not.toHaveProperty('FolioPrefixString');
});
@@ -156,6 +156,20 @@
/>
</sbo-field>
}
@case ('series') {
<sbo-field [label]="f.label">
<sbo-select
width="170px"
ariaLabel="Serie de numeración"
placeholder="(por defecto)"
[options]="seriesOptions()"
[readonly]="readOnly() || !isNew()"
[value]="seriesValue()"
(valueChange)="setHeader(f.key, $event ?? '')"
/>
<sbo-input readonly ariaLabel="Número del documento" [value]="str(doc()?.['DocNum'])" />
</sbo-field>
}
@case ('freight') {
<sbo-field [label]="f.label">
<sbo-select
@@ -185,7 +185,7 @@ describe('DocumentFormPage', () => {
it('no envía nada si falta el cliente o las líneas', async () => {
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'POST');
ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
expect(el().querySelector('.status')?.textContent).toContain('Indique el cliente');
});
@@ -195,7 +195,7 @@ describe('DocumentFormPage', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST');
const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
expect(req.request.url).toBe('/api/sl/Orders');
expect(req.request.body).toEqual({
CardCode: 'C1',
@@ -215,7 +215,7 @@ describe('DocumentFormPage', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectOne((r) => r.method === 'POST').flush({ message: 'Código de cliente inválido' }, { status: 400, statusText: 'Bad Request' });
ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).flush({ message: 'Código de cliente inválido' }, { status: 400, statusText: 'Bad Request' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Código de cliente inválido');
expect(navigate).not.toHaveBeenCalled();
@@ -280,7 +280,7 @@ describe('DocumentFormPage', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST');
const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
expect(req.request.body.DocumentLines).toEqual([
{ ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 3, WarehouseCode: '01', Price: 100, DiscountPercent: 0, TaxCode: 'IVA', BaseType: 17, BaseEntry: 7, BaseLine: 0 },
]);
@@ -28,9 +28,10 @@ import {
updatePayload,
validateLayout,
} from './document-model';
import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, headerKeysOf, requiredHeaderOf } from './document-layouts';
import { type ComputedSource, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasFolio, hasFreight, hasSeries, headerKeysOf, requiredHeaderOf } from './document-layouts';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
import { OBJECT_TYPES } from './document-objects';
import { ITEMS_MULTI_ADD } from './line-columns';
const today = (): string => new Date().toISOString().slice(0, 10);
@@ -130,6 +131,35 @@ export class DocumentFormPage {
});
protected readonly freightEditable = computed(() => freightOf(this.doc()).editable);
/** Series de numeración de este tipo de documento (acción `SeriesService_GetDocumentSeries` de SAP). */
private readonly hasSeries = hasSeries(this.layout);
private readonly objectType = OBJECT_TYPES[this.screen.path];
private readonly seriesResource = httpResource<SlListResponse<DocRecord>>(() =>
this.hasSeries && this.objectType
? {
url: `${API_URL}/sl/SeriesService_GetDocumentSeries`,
method: 'POST',
body: { DocumentTypeParams: { Document: String(this.objectType) } },
}
: undefined,
);
protected readonly seriesValue = computed<number | null>(() => Number(this.header()['Series']) || null);
/** Series elegibles: del subtipo de la pantalla y sin bloquear; la del documento abierto se incluye siempre. */
protected readonly seriesOptions = computed(() => {
const subType = this.layout.seriesSubType ?? '--';
const rows = this.seriesResource.hasValue() ? this.seriesResource.value().value : [];
const options = rows
.filter((r) => r['DocumentSubType'] === subType && r['Locked'] !== 'tYES')
.map((r) => ({ value: Number(r['Series']), label: text(r['Name']) || String(r['Series']) }));
const current = Number(this.doc()?.['Series']);
if (current && !options.some((o) => o.value === current)) {
const known = rows.find((r) => Number(r['Series']) === current);
options.unshift({ value: current, label: text(known?.['Name']) || `Serie ${current}` });
}
return options;
});
protected readonly doc = computed(() => (this.docResource.hasValue() ? this.docResource.value() : undefined));
protected readonly loading = computed(() => this.docResource.isLoading());
protected readonly loadError = computed(() => {
@@ -278,7 +308,8 @@ export class DocumentFormPage {
protected readonly columns = this.layout.columns;
private readonly itemColumn = this.layout.columns.find((c) => c.key === 'ItemCode') ?? this.layout.columns[0];
protected readonly newLine = () => this.layout.newLine(this.header());
protected readonly recalc = this.layout.recalc;
/** Recalcula la línea con la tasa de cada impuesto (precio con y sin impuesto, total). */
protected readonly recalc = this.layout.recalc ? (line: GridLine, key: string) => this.layout.recalc!(line, key, this.rates()) : undefined;
protected readonly multi: GridMultiAdd = {
...ITEMS_MULTI_ADD,
lookup: this.itemColumn.lookup!.config,
@@ -81,7 +81,7 @@ describe('flete en el formulario', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST');
const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
const body = req.request.body as Record<string, unknown>;
expect(body['DocumentAdditionalExpenses']).toEqual([{ ExpenseCode: 2, LineTotal: 1500 }]);
expect(body).not.toHaveProperty('FreightAmount');
@@ -98,7 +98,7 @@ describe('flete en el formulario', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const body = ctrl.expectOne((r) => r.method === 'POST').request.body as Record<string, unknown>;
const body = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).request.body as Record<string, unknown>;
expect(body).not.toHaveProperty('DocumentAdditionalExpenses');
});
@@ -134,7 +134,7 @@ describe('DocumentFormPage de inventario', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST');
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,
@@ -153,7 +153,7 @@ describe('DocumentFormPage de inventario', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'POST');
ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
expect(el().querySelector('.status')?.textContent).toContain('almacén origen');
});
@@ -206,9 +206,16 @@ describe('columnas de línea por documento', () => {
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',
'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode',
'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode',
'BaseType', 'BaseEntry', 'BaseLine', 'LineTotal',
]);
expect(l.lines.fields).toEqual([...MARKETING_LINES.fields, 'UoMEntry']);
// 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', () => {
@@ -233,7 +240,7 @@ describe('columnas de línea por documento', () => {
const custom = marketingLayout({ ...base, lineColumns: [LINE_COLUMNS.item] });
const other = marketingLayout(base);
expect(custom.columns).toHaveLength(1);
expect(other.columns).toHaveLength(8);
expect(other.columns).toHaveLength(19);
});
});
@@ -2,7 +2,9 @@ import type { GridColumn, GridLine } from '../../shared/grid/grid.types';
import type { ListColumn } from '../../shared/list/list.types';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import type { DocRecord, LinesSpec } from './document-model';
import { DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
import { PROJECT_LOOKUP } from './dimension-lookups';
import { SERIES_SUBTYPE } from './document-objects';
import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
/** Valores calculados o del socio que una fila de solo lectura puede mostrar. */
export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'tax' | 'total';
@@ -37,6 +39,8 @@ export type HeaderField =
| { type: 'partner'; key: string; label: string; required?: boolean; missing?: string }
/** Prefijo y número del folio tributario en una misma fila (`FolioPrefixString` y `FolioNumber`). */
| { type: 'folio'; label: string; prefixKey: string; numberKey: string }
/** Serie de numeración y número del documento en una misma fila (`Series` y `DocNum`). */
| { type: 'series'; key: string; label: string }
/** Flete: código del gasto adicional y su importe (`DocumentAdditionalExpenses`). */
| { type: 'freight'; label: string }
| { type: 'computed'; label: string; source: ComputedSource };
@@ -67,8 +71,10 @@ export interface DocumentLayout {
newHeader: () => DocRecord;
/** Línea nueva; las transferencias toman los almacenes de la cabecera. */
newLine: (header: DocRecord) => GridLine;
/** Recalcula una línea tras un cambio (total = cantidad × precio…). */
recalc?: (line: GridLine, changedKey: string) => GridLine;
/** Recalcula una línea tras un cambio (total = cantidad × precio…); recibe la tasa de cada código de impuesto. */
recalc?: (line: GridLine, changedKey: string, rates?: ReadonlyMap<string, number>) => GridLine;
/** Subtipo con que SAP etiqueta las series de este documento (`--` = ninguno, `IB` = boleta…). */
seriesSubType?: string;
/** Columnas de las listas de valores de este documento; las que falten usan las del base. */
lookups?: LookupColumns;
/** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */
@@ -85,6 +91,10 @@ export const today = (): string => new Date().toISOString().slice(0, 10);
/** Catálogo de campos: cada pantalla elige cuáles lleva. */
export const FIELDS = {
docNum: { type: 'computed', label: 'Número', source: 'docNum' },
/** Número del documento con su serie. */
docNumber: { type: 'series', key: 'Series', label: 'Número' },
indicator: { type: 'text', key: 'Indicator', label: 'Indicador' },
project: { type: 'lookup', key: 'Project', label: 'Proyecto', lookup: PROJECT_LOOKUP },
docDate: { type: 'date', key: 'DocDate', label: 'Fecha de contabilización', required: true, missing: 'la fecha de contabilización' },
docDueDate: { type: 'date', key: 'DocDueDate', label: 'Fecha de vencimiento' },
requiredDate: { type: 'date', key: 'RequriedDate', label: 'Fecha requerida', required: true, missing: 'la fecha requerida' },
@@ -201,9 +211,11 @@ export function marketingLayout(opts: {
{ type: 'computed', label: 'Nombre', source: 'partnerName' },
{ type: 'computed', label: 'RUT', source: 'partnerTaxId' },
FIELDS.numAtCard,
FIELDS.indicator,
FIELDS.project,
...(opts.left ?? []),
],
right: [FIELDS.docNum, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])],
right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.docDueDate, ...(opts.right ?? [])],
},
footer: {
left: [FIELDS.comments],
@@ -219,6 +231,7 @@ export function marketingLayout(opts: {
newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }),
newLine: newDocumentLine,
recalc: recalcLine,
seriesSubType: SERIES_SUBTYPE[String(opts.fixed?.['DocumentSubType'] ?? 'bod_None')] ?? '--',
...(opts.fixed ? { beforeCreate: (h: DocRecord) => ({ ...h, ...opts.fixed }) } : {}),
requiredLine: [],
};
@@ -264,6 +277,8 @@ export function isRequired(f: HeaderField): f is HeaderField & { key: string; re
return 'required' in f && f.required === true && 'key' in f;
}
export const hasSeries = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'series');
export const hasFreight = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'freight');
export const hasFolio = (layout: DocumentLayout): boolean => allFields(layout).some((f) => f.type === 'folio');
@@ -142,6 +142,11 @@ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKE
if (f === 'UoMEntry' && !(Number(l[f]) > 0)) continue; // -1 = unidad manual: SAP no admite enviarla
out[f] = l[f];
}
// Precio con y sin impuesto: solo se envía el que el usuario escribió (el otro lo calcula SAP).
if ('PriceAfterVAT' in out || spec.fields.includes('PriceAfterVAT')) {
if (l['_priceBasis'] === 'gross') delete out['Price'];
else delete out['PriceAfterVAT'];
}
if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f];
return out;
});
@@ -0,0 +1,56 @@
import { MENU } from '@basic-erp/sap-types';
/**
* Tipo de objeto de SAP de cada documento: es el `BaseType` de las líneas copiadas y el documento
* del que se piden las series de numeración. Facturas, boletas y sus reservas son el objeto 13.
*/
export const OBJECT_TYPES: Readonly<Record<string, number>> = {
'/ventas/ofertas': 23,
'/ventas/ordenes': 17,
'/ventas/entrega': 15,
'/ventas/devoluciones': 16,
'/ventas/facturas': 13,
'/ventas/boletas': 13,
'/ventas/facturas-reserva': 13,
'/ventas/boletas-reserva': 13,
'/ventas/notas-credito': 14,
'/compras/solicitudes': 1470000113,
'/compras/ofertas': 540000006,
'/compras/ordenes': 22,
'/compras/recepcion': 20,
'/compras/devoluciones': 21,
'/compras/facturas': 18,
'/compras/notas-credito': 19,
'/inventario/entradas': 59,
'/inventario/salidas': 60,
'/inventario/transferencias': 67,
'/inventario/solicitudes-traslado': 1250000001,
};
/** Primera pantalla de cada tipo de objeto (las facturas, boletas y reservas comparten el 13). */
const PATH_OF_OBJECT: ReadonlyMap<number, string> = new Map(
Object.entries(OBJECT_TYPES)
.reverse()
.map(([path, type]) => [type, path]),
);
/** Nombre del documento de un tipo de objeto (`17` → «Órdenes de venta»), o el número si no se conoce. */
export function objectLabel(type: unknown): string {
const code = Number(type);
const path = PATH_OF_OBJECT.get(code);
return (path && MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label) || (code > 0 ? String(code) : '');
}
/**
* Código de subtipo con que SAP etiqueta las series de numeración, según el `DocumentSubType` del
* documento. Solo `bod_None` (`--`) está verificado contra SBODemoCL; los demás son los del enum de SAP
* y se confirman al tener series de ese subtipo.
*/
export const SERIES_SUBTYPE: Readonly<Record<string, string>> = {
bod_None: '--',
bod_InvoiceExempt: 'IX',
bod_DebitMemo: 'DN',
bod_Bill: 'IB',
bod_ExemptBill: 'EB',
bod_ExportInvoice: 'EI',
};
@@ -10,13 +10,16 @@ import {
today,
warehouseColumn,
} from '../document-layouts';
import type { GridColumn } from '../../../shared/grid/grid.types';
import { ANALYTIC_COLUMNS, lineFieldsOf } from '../line-columns';
/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
const goodsColumns: readonly GridColumn[] = [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn, ...ANALYTIC_COLUMNS];
export const goodsLayout: DocumentLayout = {
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false },
header: { left: [FIELDS.docDate], right: [FIELDS.docNum] },
lines: { key: 'DocumentLines', fields: [...lineFieldsOf(goodsColumns), 'UoMEntry'], base: false },
header: { left: [FIELDS.docDate, FIELDS.indicator, FIELDS.project], right: [FIELDS.docNumber] },
footer: commentsOnly,
columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn],
columns: goodsColumns,
newHeader: () => ({ DocDate: today() }),
newLine: () => ({ ...blankLine(), Price: null }),
immutable: true,
@@ -24,11 +27,8 @@ export const goodsLayout: DocumentLayout = {
};
/** Transferencias de stock y solicitudes de traslado: almacén origen y destino en la cabecera y en cada línea. */
const transfer = (immutable: boolean): DocumentLayout => ({
lines: { key: 'StockTransferLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'FromWarehouseCode', 'WarehouseCode'], base: false },
header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse], right: [FIELDS.docNum, FIELDS.docDate] },
footer: commentsOnly,
columns: [
const transfer = (immutable: boolean): DocumentLayout => {
const columns: readonly GridColumn[] = [
itemColumn,
descriptionColumn,
quantityColumn,
@@ -40,19 +40,26 @@ const transfer = (immutable: boolean): DocumentLayout => ({
lookup: { ...warehouseColumn.lookup!, apply: (wh, line) => ({ ...line, FromWarehouseCode: wh['WarehouseCode'] }) },
},
{ ...warehouseColumn, title: 'Almacén destino', required: true },
],
newHeader: () => ({ DocDate: today() }),
newLine: (header) => ({
...blankLine(),
FromWarehouseCode: header['FromWarehouse'] ?? '',
WarehouseCode: header['ToWarehouse'] ?? '',
}),
immutable,
requiredLine: [
{ key: 'FromWarehouseCode', label: 'el almacén origen' },
{ key: 'WarehouseCode', label: 'el almacén destino' },
],
});
...ANALYTIC_COLUMNS,
];
return {
lines: { key: 'StockTransferLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: false },
header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse, FIELDS.indicator, FIELDS.project], right: [FIELDS.docNumber, FIELDS.docDate] },
footer: commentsOnly,
columns,
newHeader: () => ({ DocDate: today() }),
newLine: (header) => ({
...blankLine(),
FromWarehouseCode: header['FromWarehouse'] ?? '',
WarehouseCode: header['ToWarehouse'] ?? '',
}),
immutable,
requiredLine: [
{ key: 'FromWarehouseCode', label: 'el almacén origen' },
{ key: 'WarehouseCode', label: 'el almacén destino' },
],
};
};
export const transferLayout: DocumentLayout = transfer(true);
export const transferRequestLayout: DocumentLayout = transfer(false);
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
import { ANALYTIC_COLUMNS, DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, lineFieldsOf, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
describe('recalcLine', () => {
it('total = cantidad × precio, redondeado a 2 decimales', () => {
@@ -24,7 +24,11 @@ describe('columnas estándar de línea', () => {
const keys = DOCUMENT_LINE_COLUMNS.map((c) => c.key);
it('son las de una línea de documento de SAP (con TaxCode)', () => {
expect(keys).toEqual(['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode', 'LineTotal']);
expect(keys).toEqual([
'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode',
'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode',
'BaseType', 'BaseEntry', 'BaseLine', 'LineTotal',
]);
});
it('el total es de solo lectura y artículo y cantidad son obligatorios', () => {
@@ -34,7 +38,8 @@ describe('columnas estándar de línea', () => {
it('artículo, almacén e impuesto tienen lista de valores sobre recursos registrados', () => {
const resources = DOCUMENT_LINE_COLUMNS.filter((c) => c.lookup).map((c) => c.lookup!.config.resource);
expect(resources).toEqual(['Items', 'Warehouses', 'SalesTaxCodes']);
expect(resources.slice(0, 2)).toEqual(['Items', 'Warehouses']);
expect(resources).toContain('SalesTaxCodes');
});
it('elegir un artículo rellena código y descripción sin perder el resto', () => {
@@ -79,3 +84,67 @@ describe('uomEntryOf', () => {
expect(uomEntryOf({ InventoryUoMEntry: 1 })).toBeUndefined();
});
});
describe('dimensiones, proyecto, cuenta y referencia base', () => {
const col = (key: string) => DOCUMENT_LINE_COLUMNS.find((c) => c.key === key)!;
it('cada dimensión de costos abre su ventana de selección, de su propia dimensión', () => {
for (const n of [1, 2, 3, 4, 5]) {
const c = col(n === 1 ? 'CostingCode' : `CostingCode${n}`);
expect(c.lookup!.config.resource).toBe('DistributionRules');
expect(c.lookup!.config.baseFilter).toContain(`InWhichDimension eq ${n}`);
expect(c.lookup!.apply({ FactorCode: 'CC9' }, {})).toEqual({ [c.key]: 'CC9' });
}
});
it('el proyecto y la cuenta también se eligen de su ventana', () => {
expect(col('ProjectCode').lookup!.config.resource).toBe('Projects');
expect(col('ProjectCode').lookup!.apply({ Code: 'PE001' }, {})).toEqual({ ProjectCode: 'PE001' });
expect(col('AccountCode').lookup!.config.resource).toBe('ChartOfAccounts');
});
it('las columnas analíticas de inventario son las cinco dimensiones y el proyecto', () => {
expect(ANALYTIC_COLUMNS.map((c) => c.key)).toEqual(['CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode']);
});
it('BaseType, BaseEntry y BaseLine son de solo lectura y solo se muestran si la línea viene copiada', () => {
const copied = { BaseType: 17, BaseEntry: 315, BaseLine: 2 };
const plain = { BaseType: -1, BaseEntry: null, BaseLine: null };
for (const k of ['BaseType', 'BaseEntry', 'BaseLine']) expect(col(k).readonly).toBe(true);
expect(col('BaseType').format!(copied)).toBe('Órdenes de venta');
expect(col('BaseEntry').format!(copied)).toBe('315');
expect(col('BaseLine').format!(copied)).toBe('2');
for (const k of ['BaseType', 'BaseEntry', 'BaseLine']) expect(col(k).format!(plain)).toBe('');
});
it('las columnas de solo lectura (referencia base y total) no se envían a SAP', () => {
expect(lineFieldsOf(DOCUMENT_LINE_COLUMNS)).not.toEqual(expect.arrayContaining(['BaseType', 'BaseEntry', 'BaseLine', 'LineTotal']));
});
});
describe('precio con y sin impuesto', () => {
const rates = new Map([['IVA', 19], ['EXE', 0]]);
const base = { Quantity: 2, DiscountPercent: 0, TaxCode: 'IVA' };
it('al escribir el precio calcula el precio con IVA', () => {
const l = recalcLine({ ...base, Price: 100 }, 'Price', rates);
expect(l).toMatchObject({ PriceAfterVAT: 119, LineTotal: 200, _priceBasis: 'net' });
});
it('al escribir el precio con IVA calcula el precio neto y el total', () => {
const l = recalcLine({ ...base, PriceAfterVAT: 119 }, 'PriceAfterVAT', rates);
expect(l).toMatchObject({ Price: 100, LineTotal: 200, _priceBasis: 'gross' });
});
it('al cambiar el impuesto mantiene lo último que escribió el usuario', () => {
const net = recalcLine({ ...base, Price: 100, _priceBasis: 'net' }, 'TaxCode', rates);
expect(net['PriceAfterVAT']).toBe(119);
const gross = recalcLine({ ...base, PriceAfterVAT: 119, Price: 100, _priceBasis: 'gross', TaxCode: 'EXE' }, 'TaxCode', rates);
expect(gross).toMatchObject({ Price: 119, LineTotal: 238 });
});
it('vaciar un precio vacía el otro; sin tasas conocidas no toca los precios', () => {
expect(recalcLine({ ...base, Price: null }, 'Price', rates)['PriceAfterVAT']).toBeNull();
expect(recalcLine({ ...base, Price: 50, PriceAfterVAT: 80 }, 'Quantity')).toMatchObject({ Price: 50, PriceAfterVAT: 80, LineTotal: 100 });
});
});
@@ -1,4 +1,6 @@
import type { GridColumn, GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { ACCOUNT_LOOKUP, PROJECT_LOOKUP, dimensionLookup } from './dimension-lookups';
import { objectLabel } from './document-objects';
const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100;
const num = (v: unknown): number => {
@@ -6,10 +8,32 @@ const num = (v: unknown): number => {
return Number.isFinite(n) ? n : 0;
};
/** Total de la línea antes de impuestos: cantidad × precio × (1 − descuento %). SAP lo recalcula al guardar. */
export function recalcLine(line: GridLine): GridLine {
const total = num(line['Quantity']) * num(line['Price']) * (1 - num(line['DiscountPercent']) / 100);
return { ...line, LineTotal: round2(total) };
const round6 = (n: number): number => Math.round((n + Number.EPSILON) * 1e6) / 1e6;
const filled = (v: unknown): boolean => v !== null && v !== undefined && v !== '';
/**
* Recalcula una línea tras un cambio: precio con impuesto y total (cantidad × precio × (1 − descuento %), antes
* de impuestos; SAP lo recalcula al guardar). Con `rates` (tasa % por código de impuesto) mantiene al día el
* precio con y sin impuesto: lo último que escribió el usuario manda (`_priceBasis`: `net` = `Price`, `gross` =
* `PriceAfterVAT`) y es lo único que se envía a SAP, para no mandar dos precios que no cuadren por redondeo.
*/
export function recalcLine(line: GridLine, changedKey = '', rates?: ReadonlyMap<string, number>): GridLine {
const next: GridLine = { ...line };
if (rates) {
const factor = 1 + (rates.get(String(next['TaxCode'] ?? '')) ?? 0) / 100;
if (changedKey === 'PriceAfterVAT') {
next['_priceBasis'] = 'gross';
next['Price'] = filled(next['PriceAfterVAT']) ? round6(num(next['PriceAfterVAT']) / factor) : null;
} else if (changedKey === 'Price') {
next['_priceBasis'] = 'net';
next['PriceAfterVAT'] = filled(next['Price']) ? round6(num(next['Price']) * factor) : null;
} else if (changedKey === 'TaxCode') {
if (next['_priceBasis'] === 'gross' && filled(next['PriceAfterVAT'])) next['Price'] = round6(num(next['PriceAfterVAT']) / factor);
else if (filled(next['Price'])) next['PriceAfterVAT'] = round6(num(next['Price']) * factor);
}
}
const total = num(next['Quantity']) * num(next['Price']) * (1 - num(next['DiscountPercent']) / 100);
return { ...next, LineTotal: round2(total) };
}
/**
@@ -36,11 +60,27 @@ export const newDocumentLine = (): GridLine => ({
Quantity: 1,
WarehouseCode: '',
Price: null,
PriceAfterVAT: null,
DiscountPercent: 0,
TaxCode: '',
LineTotal: 0,
});
/** La línea viene copiada de otro documento (SAP devuelve `BaseType: -1` y `BaseEntry: null` en las que no). */
const hasBase = (l: GridLine): boolean => num(l['BaseType']) > 0 && filled(l['BaseEntry']);
/** Columna de dimensión de costos `n` (`CostingCode`, `CostingCode2`…): se elige de las reglas de distribución de esa dimensión. */
function dimension(n: 1 | 2 | 3 | 4 | 5): GridColumn {
const key = n === 1 ? 'CostingCode' : `CostingCode${n}`;
return {
key,
title: `Dim. ${n}`,
width: '90px',
mono: true,
lookup: { config: dimensionLookup(n), apply: (rule, line) => ({ ...line, [key]: rule['FactorCode'] }) },
};
}
/**
* Columnas estándar de una línea de documento de SAP B1 (artículo, descripción, cantidad, almacén,
* precio, % descuento, código de impuesto y total). El impuesto es `TaxCode` (maestro `SalesTaxCodes`);
@@ -90,6 +130,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
},
},
{ key: 'Price', title: 'Precio', type: 'number', decimals: 2, width: '100px' },
{ key: 'PriceAfterVAT', title: 'Precio con IVA', type: 'number', decimals: 2, width: '110px' },
{ key: 'DiscountPercent', title: '% Dto.', type: 'number', decimals: 2, width: '70px' },
{
key: 'TaxCode',
@@ -111,6 +152,29 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
apply: (tax, line) => ({ ...line, TaxCode: tax['Code'] }),
},
},
dimension(1),
dimension(2),
dimension(3),
dimension(4),
dimension(5),
{
key: 'ProjectCode',
title: 'Proyecto',
width: '110px',
mono: true,
lookup: { config: PROJECT_LOOKUP, apply: (p, line) => ({ ...line, ProjectCode: p['Code'] }) },
},
{
key: 'AccountCode',
title: 'Cuenta',
width: '140px',
mono: true,
lookup: { config: ACCOUNT_LOOKUP, apply: (a, line) => ({ ...line, AccountCode: a['Code'] }) },
},
// Referencia al documento de origen de una línea copiada: solo se muestra (la fija el copiado).
{ key: 'BaseType', title: 'Doc. base', width: '150px', readonly: true, format: (l) => (hasBase(l) ? objectLabel(l['BaseType']) : '') },
{ key: 'BaseEntry', title: 'Nº base', width: '80px', mono: true, readonly: true, format: (l) => (hasBase(l) ? String(l['BaseEntry']) : '') },
{ key: 'BaseLine', title: 'Línea base', width: '90px', mono: true, readonly: true, format: (l) => (hasBase(l) ? String(l['BaseLine'] ?? '') : '') },
{ key: 'LineTotal', title: 'Total', type: 'number', decimals: 2, width: '110px', readonly: true },
];
@@ -126,11 +190,32 @@ export const LINE_COLUMNS = {
quantity: columnOf('Quantity'),
warehouse: columnOf('WarehouseCode'),
price: columnOf('Price'),
priceAfterVat: columnOf('PriceAfterVAT'),
discount: columnOf('DiscountPercent'),
tax: columnOf('TaxCode'),
costingCode: columnOf('CostingCode'),
costingCode2: columnOf('CostingCode2'),
costingCode3: columnOf('CostingCode3'),
costingCode4: columnOf('CostingCode4'),
costingCode5: columnOf('CostingCode5'),
project: columnOf('ProjectCode'),
account: columnOf('AccountCode'),
baseType: columnOf('BaseType'),
baseEntry: columnOf('BaseEntry'),
baseLine: columnOf('BaseLine'),
total: columnOf('LineTotal'),
} as const;
/** Columnas de dimensión y proyecto: las únicas de contabilidad analítica que también llevan inventario. */
export const ANALYTIC_COLUMNS: readonly GridColumn[] = [
LINE_COLUMNS.costingCode,
LINE_COLUMNS.costingCode2,
LINE_COLUMNS.costingCode3,
LINE_COLUMNS.costingCode4,
LINE_COLUMNS.costingCode5,
LINE_COLUMNS.project,
];
/** Campos de línea que se envían a SAP: los de las columnas editables (el total y lo calculado lo resuelve SAP). */
export const lineFieldsOf = (columns: readonly GridColumn[]): string[] =>
columns.filter((c) => !c.format && c.readonly !== true).map((c) => c.key);
@@ -188,7 +188,7 @@ describe('MasterFormPage', () => {
it('no envía nada sin código o sin nombre', async () => {
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectNone((r) => r.method === 'POST');
ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
expect(el().querySelector('.status')?.textContent).toContain('Indique el código');
page().setValue('CardCode', 'C300');
(el().querySelector('.actions .btn.main') as HTMLElement).click();
@@ -203,7 +203,7 @@ describe('MasterFormPage', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST');
const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
expect(req.request.url).toBe('/api/sl/BusinessPartners');
expect(req.request.body).toEqual({
CardCode: 'C300',
@@ -227,7 +227,7 @@ describe('MasterFormPage', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
expect((ctrl.expectOne((r) => r.method === 'POST').request.body as Record<string, unknown>)['CardType']).toBe('cSupplier');
expect((ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).request.body as Record<string, unknown>)['CardType']).toBe('cSupplier');
});
it('muestra el error de SAP si rechaza el registro', async () => {
@@ -236,7 +236,7 @@ describe('MasterFormPage', () => {
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
ctrl.expectOne((r) => r.method === 'POST').flush({ message: 'Código duplicado' }, { status: 400, statusText: 'Bad Request' });
ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')).flush({ message: 'Código duplicado' }, { status: 400, statusText: 'Bad Request' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Código duplicado');
expect(navigate).not.toHaveBeenCalled();
@@ -3,7 +3,19 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common
import { Component, signal } from '@angular/core';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, newDocumentLine, recalcLine } from '../../features/documents/line-columns';
import { ITEMS_MULTI_ADD, LINE_COLUMNS, newDocumentLine, recalcLine } from '../../features/documents/line-columns';
/** Las ocho columnas básicas de una línea (las posiciones que usan estos tests). */
const BASIC_COLUMNS = [
LINE_COLUMNS.item,
LINE_COLUMNS.description,
LINE_COLUMNS.quantity,
LINE_COLUMNS.warehouse,
LINE_COLUMNS.price,
LINE_COLUMNS.discount,
LINE_COLUMNS.tax,
LINE_COLUMNS.total,
];
import type { GridLine } from './grid.types';
import { LinesGrid } from './lines-grid';
@@ -21,7 +33,7 @@ import { LinesGrid } from './lines-grid';
`,
})
class Host {
columns = DOCUMENT_LINE_COLUMNS;
columns = BASIC_COLUMNS;
lines = signal<readonly GridLine[]>([]);
readonly = signal(false);
newLine = newDocumentLine;