feat(web): formularios de inventario y solicitudes de compra (entradas, salidas, transferencias, solicitudes de traslado)

Configuración declarativa en document-layouts.ts; las líneas de las transferencias van en
StockTransferLines y las de compra/inventario llevan UoMEntry si el artículo tiene grupo de unidades.
Probado contra SBODemoCL (solicitud de compra 2, solicitud de traslado 472, entrada 155).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-14 14:37:01 -05:00
1 parent 45c0ca536e
commit 155d18fce4
13 files changed
+550 -46

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ export class SlLookupApi {
const keyField = lookup.valueKey ?? lookup.columns[0]?.key ?? '';
const filter = combineFilters(lookup.baseFilter, `${keyField} eq '${escapeODataString(code.trim())}'`);
const params = new HttpParams()
.set('$select', [...new Set([keyField, ...lookup.columns.map((c) => c.key)])].join(','))
.set('$select', [...new Set([keyField, ...lookup.columns.map((c) => c.key), ...(lookup.extraFields ?? [])])].join(','))
.set('$filter', filter ?? '')
.set('$top', 1);
const res = await firstValueFrom(
@@ -81,11 +81,46 @@
<sbo-text-field label="Total del documento" readonly [value]="fmt(totals().total)" />
</div>
</div>
} @else if (layout; as lay) {
<div class="sbo-hdr">
<div class="sbo-col">
@for (f of lay.left; track f.key) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
<div class="sbo-col">
<sbo-text-field label="Número" readonly [value]="str(doc()?.['DocNum'])" />
@for (f of lay.right; track f.key) {
<ng-container *ngTemplateOutlet="field; context: { $implicit: f }" />
}
</div>
</div>
<sbo-lines-grid
[columns]="columns"
[lines]="lines()"
(linesChange)="setLines($event)"
(notice)="notify($event)"
[readonly]="readOnly()"
[newLine]="newLine"
[multiAdd]="multi"
/>
<div class="sbo-hdr" style="margin-top: 14px">
<div class="sbo-col">
<sbo-textarea-field
label="Comentarios"
[readonly]="readOnly()"
[value]="str(header()['Comments'])"
(valueChange)="setHeader('Comments', $event)"
/>
</div>
</div>
} @else {
<p>El formulario de este documento aún no está disponible. <a routerLink="..">Volver a la lista</a></p>
}
@if (partner && !readOnly()) {
@if ((partner || layout) && !readOnly()) {
<button sboActions class="btn main" type="button" [disabled]="saving() || loading()" (click)="save()">
{{ isNew() ? 'Crear' : 'Actualizar' }}
</button>
@@ -115,3 +150,36 @@
(closed)="copyListFor.set(null)"
/>
}
<ng-template #field let-f>
@switch (f.type) {
@case ('date') {
<sbo-date-field
[label]="f.label"
[required]="!!f.required"
[readonly]="readOnly()"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)"
/>
}
@case ('warehouse') {
<sbo-lookup-field
[label]="f.label"
[required]="!!f.required"
[readonly]="readOnly()"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)"
[lookup]="warehouseLookup"
resolveOnLeave
/>
}
@default {
<sbo-text-field
[label]="f.label"
[readonly]="readOnly()"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)"
/>
}
}
</ng-template>
@@ -1,4 +1,5 @@
import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { NgTemplateOutlet } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -8,7 +9,7 @@ import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { DateField, LookupField, NumberField, TextField, TextareaField } from '../../shared/form';
import type { GridLine } from '../../shared/grid/grid.types';
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import { Icon, MenuButton, type MenuItem, SboWindow, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
@@ -18,13 +19,16 @@ import {
type DocRecord,
type NavTarget,
computeTotals,
MARKETING_LINES,
createPayload,
isBlankLine,
isDocumentReadOnly,
taxRateMap,
updatePayload,
validateDocument,
validateLayout,
} from './document-model';
import { WAREHOUSE_LOOKUP, headerKeysOf } from './document-layouts';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
@@ -33,15 +37,13 @@ import { ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './
const HEADER_KEYS = ['CardCode', 'NumAtCard', 'DocDate', 'DocDueDate', 'DiscountPercent', 'Comments'] as const;
const today = (): string => new Date().toISOString().slice(0, 10);
const DATE_KEYS = new Set(['DocDate', 'DocDueDate', 'RequriedDate']);
/** SAP devuelve las fechas como `2026-01-05T00:00:00Z`; el campo de fecha y el guardado usan `2026-01-05`. */
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
const pickHeader = (doc: DocRecord | undefined): DocRecord => {
if (!doc) return { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
const pickHeader = (doc: DocRecord | undefined, keys: readonly string[], blank: DocRecord): DocRecord => {
if (!doc) return blank;
return Object.fromEntries(
HEADER_KEYS.map((k) => [
k,
k === 'DocDate' || k === 'DocDueDate' ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : '')),
]),
keys.map((k) => [k, DATE_KEYS.has(k) ? dateOnly(doc[k]) : (doc[k] ?? (k === 'DiscountPercent' ? 0 : ''))]),
);
};
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
@@ -54,7 +56,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp],
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboWindow, TextField, TextareaField, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -71,6 +73,19 @@ export class DocumentFormPage {
protected readonly title = this.route.snapshot.data['title'] as string;
protected readonly screen = this.route.snapshot.data['screen'] as DocumentScreen;
protected readonly partner = this.screen.partner;
/** Formulario de inventario o de solicitud de compra (sin socio ni totales). */
protected readonly layout = this.screen.layout;
private readonly lineSpec = this.layout?.lines ?? MARKETING_LINES;
private readonly headerKeys: readonly string[] = this.layout ? headerKeysOf(this.layout) : HEADER_KEYS;
private readonly blankHeader: DocRecord = this.layout
? this.layout.newHeader()
: { DocDate: today(), DocDueDate: today(), DiscountPercent: 0 };
private pick(doc: DocRecord | undefined): DocRecord {
return pickHeader(doc, this.headerKeys, this.blankHeader);
}
private docLines(doc: DocRecord | undefined): GridLine[] {
return (doc?.[this.lineSpec.key] as GridLine[] | undefined) ?? [];
}
protected readonly docEntry = toSignal(
this.route.paramMap.pipe(map((p) => (p.has('docEntry') ? Number(p.get('docEntry')) : null))),
{ initialValue: this.route.snapshot.paramMap.has('docEntry') ? Number(this.route.snapshot.paramMap.get('docEntry')) : null },
@@ -97,15 +112,13 @@ export class DocumentFormPage {
});
/** Valores editables; se reinician al cargar o recargar el documento. */
protected readonly header = linkedSignal<DocRecord>(() => pickHeader(this.doc()));
protected readonly lines = linkedSignal<readonly GridLine[]>(() =>
((this.doc()?.['DocumentLines'] as GridLine[] | undefined) ?? []).map((l) => ({ ...l })),
);
protected readonly header = linkedSignal<DocRecord>(() => this.pick(this.doc()));
protected readonly lines = linkedSignal<readonly GridLine[]>(() => this.docLines(this.doc()).map((l) => ({ ...l })));
protected readonly partnerName = linkedSignal(() => text(this.doc()?.['CardName']));
// --- estado --------------------------------------------------------------------------------
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()));
protected readonly readOnly = computed(() => isDocumentReadOnly(this.doc()) || (!!this.layout?.immutable && !this.isNew()));
protected readonly saving = signal(false);
private readonly message = signal<StatusMessage | null>(
((): StatusMessage | null => {
@@ -120,8 +133,13 @@ export class DocumentFormPage {
if (this.loadError()) return { kind: 'err', text: this.loadError() };
const message = this.message();
if (message) return message;
if (this.readOnly()) return { kind: 'info', text: 'Documento cerrado o cancelado: solo lectura.' };
if (this.isNew()) return { kind: 'info', text: `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
if (this.readOnly()) {
const posted = this.layout?.immutable && !isDocumentReadOnly(this.doc());
return { kind: 'info', text: posted ? 'Documento contabilizado: solo lectura.' : 'Documento cerrado o cancelado: solo lectura.' };
}
if (this.isNew()) {
return { kind: 'info', text: this.layout ? 'Complete la cabecera y las líneas.' : `Indique el ${this.partner?.label.toLowerCase() ?? 'socio'} y las líneas.` };
}
return { kind: 'info', text: this.dirty() ? 'Hay cambios sin guardar.' : '' };
});
protected readonly badge = computed<WindowBadge | undefined>(() => {
@@ -137,8 +155,7 @@ export class DocumentFormPage {
protected readonly dirty = computed(() => {
const d = this.doc();
if (!d) return text(this.header()['CardCode']) !== '' || this.lines().some((l) => !isBlankLine(l));
const original = pickHeader(d);
const { body } = updatePayload(original, this.header(), (d['DocumentLines'] as GridLine[]) ?? [], this.lines());
const { body } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
return Object.keys(body).length > 0;
});
@@ -229,10 +246,13 @@ export class DocumentFormPage {
// --- configuración de la vista --------------------------------------------------------------
protected readonly columns = lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
protected readonly newLine = newDocumentLine;
protected readonly recalc = recalcLine;
protected readonly multi = ITEMS_MULTI_ADD;
protected readonly columns = this.layout?.columns ?? lineColumnsFor(this.partner?.taxResource ?? 'SalesTaxCodes');
protected readonly newLine = this.layout ? () => this.layout!.newLine(this.header()) : newDocumentLine;
protected readonly recalc = this.layout ? undefined : recalcLine;
protected readonly multi: GridMultiAdd = this.layout
? { ...ITEMS_MULTI_ADD, toLine: (item) => this.layout!.columns[0].lookup!.apply(item, this.layout!.newLine(this.header())) }
: ITEMS_MULTI_ADD;
protected readonly warehouseLookup = WAREHOUSE_LOOKUP;
protected readonly partnerLookup: VhLookup = {
title: this.partner?.type === 'cSupplier' ? 'Proveedores' : 'Clientes',
resource: 'BusinessPartners',
@@ -293,7 +313,14 @@ export class DocumentFormPage {
protected async save(): Promise<void> {
if (this.saving() || this.readOnly()) return;
const errors = validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
const errors = this.layout
? validateLayout(
this.header(),
this.lines(),
[...this.layout.left, ...this.layout.right].filter((f) => f.required),
this.layout.requiredLine,
)
: validateDocument(this.header(), this.lines(), this.partner?.label ?? 'Socio de negocio');
if (errors.length) {
this.message.set({ kind: 'err', text: errors[0] });
return;
@@ -303,19 +330,15 @@ export class DocumentFormPage {
try {
const d = this.doc();
if (!d) {
const created = await this.api.create(this.screen.resource, createPayload(this.header(), this.lines()));
const header = this.layout?.beforeCreate?.(this.header()) ?? this.header();
const created = await this.api.create(this.screen.resource, createPayload(header, this.lines(), this.lineSpec));
await this.router.navigate(['..', created['DocEntry']], {
relativeTo: this.route,
state: { created: text(created['DocNum']) },
});
return;
}
const { body, replaceLines } = updatePayload(
pickHeader(d),
this.header(),
(d['DocumentLines'] as GridLine[]) ?? [],
this.lines(),
);
const { body, replaceLines } = updatePayload(this.pick(d), this.header(), this.docLines(d), this.lines(), this.lineSpec);
if (!Object.keys(body).length) {
this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' });
return;
@@ -0,0 +1,183 @@
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 { LAYOUTS, headerKeysOf } from './document-layouts';
import { createPayload, updatePayload, validateLayout } from './document-model';
import { DocumentFormPage } from './document-form.page';
import { DOCUMENT_SCREENS } from './document-screens';
describe('modelo de los formularios sin socio', () => {
const transfer = LAYOUTS.transferRequest;
it('cada pantalla de inventario y la solicitud de compra tienen su formulario', () => {
const withLayout = DOCUMENT_SCREENS.filter((s) => s.layout).map((s) => s.resource);
expect(withLayout.sort()).toEqual(
['InventoryGenEntries', 'InventoryGenExits', 'InventoryTransferRequests', 'PurchaseRequests', 'StockTransfers'].sort(),
);
expect(DOCUMENT_SCREENS.filter((s) => s.layout && s.partner)).toHaveLength(0);
});
it('la cabecera tiene como máximo dos columnas y no usa campos de usuario', () => {
for (const l of Object.values(LAYOUTS)) {
for (const k of headerKeysOf(l)) expect(k.startsWith('U_')).toBe(false);
for (const c of l.columns) expect(c.key.startsWith('U_')).toBe(false);
}
});
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 = LAYOUTS.purchaseRequest;
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({});
});
});
@@ -0,0 +1,151 @@
import type { GridColumn, GridLine } from '../../shared/grid/grid.types';
import type { VhLookup } from '../../shared/value-help/value-help.types';
import type { DocRecord, LinesSpec, RequiredField } from './document-model';
import { DOCUMENT_LINE_COLUMNS, uomEntryOf } from './line-columns';
export interface HeaderField {
key: string;
label: string;
/** `warehouse` abre la lista de almacenes. */
type: 'text' | 'date' | 'warehouse';
required?: boolean;
}
/**
* Formulario de los documentos sin socio de negocio ni totales (inventario y solicitudes de
* compra): qué campos de cabecera y de línea tienen, y cómo se guardan.
*/
export interface DocumentLayout {
lines: LinesSpec;
/** Dos columnas como máximo (regla del proyecto base); el número y los comentarios van aparte. */
left: readonly HeaderField[];
right: readonly HeaderField[];
columns: readonly GridColumn[];
/** Cabecera de un documento nuevo (siempre lleva `DocDate`). */
newHeader: () => DocRecord;
/** Línea nueva; las transferencias toman los almacenes de la cabecera. */
newLine: (header: DocRecord) => GridLine;
/** Una vez contabilizado ya no se modifica (SAP no permite cambiar entradas, salidas ni transferencias). */
immutable?: boolean;
/** Ajustes a la cabecera antes de crear. */
beforeCreate?: (header: DocRecord) => DocRecord;
requiredLine: readonly RequiredField[];
}
const today = (): string => new Date().toISOString().slice(0, 10);
const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */
const itemColumn: GridColumn = {
...baseItemColumn,
lookup: {
...baseItemColumn.lookup!,
apply: (item, line) => ({ ...baseItemColumn.lookup!.apply(item, line), UoMEntry: uomEntryOf(item) ?? line['UoMEntry'] }),
},
};
/** Lista de almacenes de la cabecera (la misma de las líneas). */
export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config;
const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Quantity: 1, WarehouseCode: '' });
/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
const goods: DocumentLayout = {
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'Price'], base: false },
left: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
right: [],
columns: [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn],
newHeader: () => ({ DocDate: today() }),
newLine: () => ({ ...blankLine(), Price: null }),
immutable: true,
requiredLine: [{ key: 'WarehouseCode', label: 'el almacén' }],
};
/** 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 },
left: [
{ key: 'FromWarehouse', label: 'Almacén origen', type: 'warehouse', required: true },
{ key: 'ToWarehouse', label: 'Almacén destino', type: 'warehouse', required: true },
],
right: [{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true }],
columns: [
itemColumn,
descriptionColumn,
quantityColumn,
{
...warehouseColumn,
key: 'FromWarehouseCode',
title: 'Almacén origen',
required: true,
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' },
],
});
/** Solicitud de compra: solicitante (usuario de SAP), fecha requerida y, por línea, proveedor sugerido. */
const purchaseRequest: DocumentLayout = {
lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'UoMEntry', 'WarehouseCode', 'LineVendor'], base: false },
left: [{ key: 'Requester', label: 'Solicitante (usuario)', type: 'text' }],
right: [
{ key: 'DocDate', label: 'Fecha de contabilización', type: 'date', required: true },
{ key: 'RequriedDate', label: 'Fecha requerida', type: 'date', required: true },
],
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'] }),
},
},
],
newHeader: () => ({ DocDate: today(), RequriedDate: today() }),
newLine: blankLine,
// El solicitante es un usuario de SAP (`ReqType` 12).
beforeCreate: (h) => (h['Requester'] ? { ...h, ReqType: 12 } : h),
requiredLine: [],
};
export const LAYOUTS = {
goods,
transfer: transfer(true),
transferRequest: transfer(false),
purchaseRequest,
} as const;
/** Campos de cabecera que se editan (y se envían): los de las columnas del formulario y los comentarios. */
export const headerKeysOf = (layout: DocumentLayout): readonly string[] => [
...layout.left.map((f) => f.key),
...layout.right.map((f) => f.key),
'Comments',
];
@@ -68,6 +68,16 @@ export function computeTotals(
/** Campos de línea que se envían a SAP; el total de línea y lo calculado lo resuelve SAP. */
const LINE_FIELDS = ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'] as const;
/** Colección de líneas de un documento y los campos de cada línea que se envían a SAP. */
export interface LinesSpec {
/** `DocumentLines`, o `StockTransferLines` en las transferencias de stock. */
key: string;
fields: readonly string[];
/** Los documentos de ventas y compras referencian el origen de una línea copiada (`BaseType`…). */
base: boolean;
}
export const MARKETING_LINES: LinesSpec = { key: 'DocumentLines', fields: LINE_FIELDS, base: true };
/** Referencia al documento origen de una línea copiada; SAP devuelve `BaseType: -1` y `BaseEntry: null` en las que no tienen. */
const BASE_FIELDS = ['BaseType', 'BaseEntry', 'BaseLine'] as const;
const hasBase = (l: GridLine): boolean => num(l['BaseType']) > 0 && !isEmpty(l['BaseEntry']);
@@ -76,13 +86,13 @@ const hasBase = (l: GridLine): boolean => num(l['BaseType']) > 0 && !isEmpty(l['
export const isBlankLine = (line: GridLine): boolean => isEmpty(line['ItemCode']);
/** Líneas listas para SAP: sin filas en blanco ni campos vacíos. */
export function linesPayload(lines: readonly GridLine[]): DocRecord[] {
export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord[] {
return lines
.filter((l) => !isBlankLine(l))
.map((l) => {
const out: DocRecord = {};
for (const f of LINE_FIELDS) if (!isEmpty(l[f])) out[f] = l[f];
if (hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f];
for (const f of spec.fields) if (!isEmpty(l[f])) out[f] = l[f];
if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f];
return out;
});
}
@@ -100,9 +110,33 @@ export function validateDocument(header: DocRecord, lines: readonly GridLine[],
return errors;
}
/** Un campo obligatorio y su nombre para el aviso. */
export interface RequiredField {
key: string;
label: string;
}
/** Validación de los documentos sin socio de negocio (inventario, solicitudes de compra). */
export function validateLayout(
header: DocRecord,
lines: readonly GridLine[],
requiredHeader: readonly RequiredField[],
requiredLine: readonly RequiredField[],
): string[] {
const errors: string[] = [];
for (const f of requiredHeader) if (isEmpty(header[f.key])) errors.push(`Indique ${f.label.toLowerCase()}.`);
const real = lines.filter((l) => !isBlankLine(l));
if (!real.length) errors.push('Agregue al menos una línea con artículo.');
real.forEach((l, i) => {
if (num(l['Quantity']) <= 0) errors.push(`Línea ${i + 1}: la cantidad debe ser mayor que cero.`);
for (const f of requiredLine) if (isEmpty(l[f.key])) errors.push(`Línea ${i + 1}: indique ${f.label.toLowerCase()}.`);
});
return errors;
}
/** Cuerpo para crear: cabecera sin campos vacíos + líneas. */
export function createPayload(header: DocRecord, lines: readonly GridLine[]): DocRecord {
return { ...compact(header), DocumentLines: linesPayload(lines) };
export function createPayload(header: DocRecord, lines: readonly GridLine[], spec: LinesSpec = MARKETING_LINES): DocRecord {
return { ...compact(header), [spec.key]: linesPayload(lines, spec) };
}
/**
@@ -115,6 +149,7 @@ export function updatePayload(
header: DocRecord,
originalLines: readonly GridLine[],
lines: readonly GridLine[],
spec: LinesSpec = MARKETING_LINES,
): { body: DocRecord; replaceLines: boolean } {
const body: DocRecord = {};
for (const [key, value] of Object.entries(header)) {
@@ -122,9 +157,9 @@ export function updatePayload(
if (isEmpty(value) && isEmpty(before)) continue;
if (value !== before) body[key] = isEmpty(value) ? null : value;
}
const next = linesPayload(lines);
const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines));
if (replaceLines) body['DocumentLines'] = next;
const next = linesPayload(lines, spec);
const replaceLines = JSON.stringify(next) !== JSON.stringify(linesPayload(originalLines, spec));
if (replaceLines) body[spec.key] = next;
return { body, replaceLines };
}
@@ -2,6 +2,7 @@ import { MENU } from '@basic-erp/sap-types';
import { formatNumber } from '../../core/format';
import type { ListFilter } from '../../core/sl/list-filters';
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
import { type DocumentLayout, LAYOUTS } from './document-layouts';
/** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */
export interface DocumentScreen {
@@ -23,6 +24,8 @@ export interface DocumentScreen {
/** Maestro de impuestos (el Service Layer no tiene uno propio de compras: en Chile ambos usan `SalesTaxCodes`). */
taxResource: string;
};
/** Formulario de los documentos sin socio ni totales (inventario y solicitudes de compra). */
layout?: DocumentLayout;
}
export const menuLabel = (path: string): string =>
@@ -110,8 +113,15 @@ const marketing = (path: string, resource: string, createLabel: string, partner:
filters: [statusFilter, currencyFilter, dateFilter],
});
const inventory = (path: string, resource: string, createLabel: string, withStatus: boolean): DocumentScreen => ({
const inventory = (
path: string,
resource: string,
createLabel: string,
withStatus: boolean,
layout: DocumentLayout,
): DocumentScreen => ({
path,
layout,
resource,
createLabel,
orderby: 'DocEntry desc',
@@ -142,6 +152,7 @@ export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
// Compras
{
path: '/compras/solicitudes',
layout: LAYOUTS.purchaseRequest,
resource: 'PurchaseRequests',
createLabel: 'Nueva solicitud',
orderby: 'DocEntry desc',
@@ -162,8 +173,8 @@ export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [
marketing('/compras/devoluciones', 'PurchaseReturns', 'Nueva devolución', 'Proveedor'),
marketing('/compras/notas-credito', 'PurchaseCreditNotes', 'Nueva nota de crédito', 'Proveedor'),
// Inventario
inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true),
inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true),
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false),
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true),
inventory('/inventario/entradas', 'InventoryGenEntries', 'Nueva entrada', true, LAYOUTS.goods),
inventory('/inventario/salidas', 'InventoryGenExits', 'Nueva salida', true, LAYOUTS.goods),
inventory('/inventario/transferencias', 'StockTransfers', 'Nueva transferencia', false, LAYOUTS.transfer),
inventory('/inventario/solicitudes-traslado', 'InventoryTransferRequests', 'Nueva solicitud', true, LAYOUTS.transferRequest),
];
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine } from './line-columns';
import { DOCUMENT_LINE_COLUMNS, ITEMS_MULTI_ADD, lineColumnsFor, newDocumentLine, recalcLine, uomEntryOf } from './line-columns';
describe('recalcLine', () => {
it('total = cantidad × precio, redondeado a 2 decimales', () => {
@@ -68,3 +68,14 @@ describe('lineColumnsFor', () => {
expect(DOCUMENT_LINE_COLUMNS.find((c) => c.key === 'TaxCode')?.lookup?.config.resource).toBe('SalesTaxCodes');
});
});
describe('uomEntryOf', () => {
it('usa la unidad de compra por defecto y, si no hay, la de inventario', () => {
expect(uomEntryOf({ UoMGroupEntry: 1, DefaultPurchasingUoMEntry: 3, InventoryUoMEntry: 1 })).toBe(3);
expect(uomEntryOf({ UoMGroupEntry: 1, DefaultPurchasingUoMEntry: null, InventoryUoMEntry: 1 })).toBe(1);
});
it('un artículo de unidad manual (sin grupo) no lleva unidad: SAP no la admite', () => {
expect(uomEntryOf({ UoMGroupEntry: -1, InventoryUoMEntry: -1 })).toBeUndefined();
expect(uomEntryOf({ InventoryUoMEntry: 1 })).toBeUndefined();
});
});
@@ -16,6 +16,20 @@ export function recalcLine(line: GridLine): GridLine {
* Línea nueva con los valores por defecto de un documento. El precio queda vacío (no 0): si el
* usuario no lo escribe no se envía y SAP aplica la lista de precios del socio de negocio.
*/
/**
* Unidad de medida de un artículo con grupo de unidades, para las líneas de compra e inventario: la de
* compra por defecto o, si no tiene, la de inventario. Sin ella SAP rechaza el documento ("specify a
* UoM code"); y a un artículo de unidad manual (sin grupo) SAP no admite ponérsela.
*/
export function uomEntryOf(item: Record<string, unknown>): number | undefined {
if (!(Number(item['UoMGroupEntry']) > 0)) return undefined;
for (const key of ['DefaultPurchasingUoMEntry', 'InventoryUoMEntry']) {
const entry = Number(item[key]);
if (entry > 0) return entry;
}
return undefined;
}
export const newDocumentLine = (): GridLine => ({
ItemCode: '',
ItemDescription: '',
@@ -44,6 +58,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
title: 'Artículos',
resource: 'Items',
orderby: 'ItemCode',
extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'InventoryUoMEntry'],
columns: [
{ key: 'ItemCode', title: 'Código', width: '150px', mono: true },
{ key: 'ItemName', title: 'Descripción' },
@@ -92,6 +92,7 @@
[baseFilter]="cfg.baseFilter"
[orderby]="cfg.orderby"
[rowKey]="cfg.valueKey"
[extraFields]="cfg.extraFields ?? []"
[selection]="lookupMultiple() ? 'multiple' : 'single'"
(selected)="lookupPicked($event)"
(closed)="lookupClosed()"
@@ -106,6 +107,7 @@
[columns]="m.lookup.columns"
[baseFilter]="m.lookup.baseFilter"
[orderby]="m.lookup.orderby"
[extraFields]="m.lookup.extraFields ?? []"
selection="multiple"
(selected)="multiPicked($event)"
(closed)="multiOpen.set(false)"
@@ -28,6 +28,8 @@ export class ValueHelp {
/** Filtro fijo (ej. `CardType eq 'cCustomer'`) que se combina con la búsqueda. */
readonly baseFilter = input<string>();
readonly orderby = input<string>();
/** Campos que se piden aunque no sean columna (ver `VhLookup.extraFields`). */
readonly extraFields = input<readonly string[]>([]);
/** Campo único de cada fila; por defecto, el de la primera columna. */
readonly rowKey = input<string>();
readonly pageSize = input(20);
@@ -41,6 +43,7 @@ export class ValueHelp {
resource: () => this.resource(),
columns: () => this.columns(),
rowKey: () => this.rowKey(),
extraFields: () => this.extraFields(),
baseFilter: () => this.baseFilter(),
orderby: () => this.orderby(),
pageSize: () => this.pageSize(),
@@ -13,4 +13,6 @@ export interface VhLookup {
orderby?: string;
/** Campo de la fila elegida que se escribe en el campo; por defecto, la primera columna. */
valueKey?: string;
/** Campos que se piden al Service Layer sin mostrarlos como columna (los usa quien recibe la fila elegida). */
extraFields?: readonly string[];
}