diff --git a/CLAUDE.md b/CLAUDE.md index 32f855c..831773f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`). Pendiente, en orden: -1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Falta: botón PDF, fila del flete, toolbar de ancho completo, formularios de inventario y solicitudes de compra. +1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: los artículos por **lote o serie** no se pueden contabilizar (SAP pide `BatchNumbers`/`SerialNumbers`); las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras (`marketing`) aún no lo envían, y los de compra fallan con artículos sin unidad de compra por defecto. Falta: botón PDF, fila del flete y toolbar de ancho completo. 2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible". 3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`). 4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs. diff --git a/apps/web/src/app/core/sl/sl-lookup.ts b/apps/web/src/app/core/sl/sl-lookup.ts index d226714..ceed50e 100644 --- a/apps/web/src/app/core/sl/sl-lookup.ts +++ b/apps/web/src/app/core/sl/sl-lookup.ts @@ -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( diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html index c895754..d8e2334 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -81,11 +81,46 @@ + } @else if (layout; as lay) { +
+
+ @for (f of lay.left; track f.key) { + + } +
+
+ + @for (f of lay.right; track f.key) { + + } +
+
+ + + +
+
+ +
+
} @else {

El formulario de este documento aún no está disponible. Volver a la lista

} - @if (partner && !readOnly()) { + @if ((partner || layout) && !readOnly()) { @@ -115,3 +150,36 @@ (closed)="copyListFor.set(null)" /> } + + + @switch (f.type) { + @case ('date') { + + } + @case ('warehouse') { + + } + @default { + + } + } + diff --git a/apps/web/src/app/features/documents/document-form.page.ts b/apps/web/src/app/features/documents/document-form.page.ts index b771d51..f8385b4 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -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(() => pickHeader(this.doc())); - protected readonly lines = linkedSignal(() => - ((this.doc()?.['DocumentLines'] as GridLine[] | undefined) ?? []).map((l) => ({ ...l })), - ); + protected readonly header = linkedSignal(() => this.pick(this.doc())); + protected readonly lines = linkedSignal(() => 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 => { @@ -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(() => { @@ -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 { 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; diff --git a/apps/web/src/app/features/documents/document-layouts.spec.ts b/apps/web/src/app/features/documents/document-layouts.spec.ts new file mode 100644 index 0000000..495e64a --- /dev/null +++ b/apps/web/src/app/features/documents/document-layouts.spec.ts @@ -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; + let ctrl: HttpTestingController; + let navigate: ReturnType; + 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) { + 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({}); + }); +}); diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts new file mode 100644 index 0000000..47c5dae --- /dev/null +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -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', +]; diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index 5f039cf..ba9ef85 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -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 }; } diff --git a/apps/web/src/app/features/documents/document-screens.ts b/apps/web/src/app/features/documents/document-screens.ts index facb8b7..442a41c 100644 --- a/apps/web/src/app/features/documents/document-screens.ts +++ b/apps/web/src/app/features/documents/document-screens.ts @@ -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), ]; diff --git a/apps/web/src/app/features/documents/line-columns.spec.ts b/apps/web/src/app/features/documents/line-columns.spec.ts index 9fa1336..5240c34 100644 --- a/apps/web/src/app/features/documents/line-columns.spec.ts +++ b/apps/web/src/app/features/documents/line-columns.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts index 5d8ff82..7b1739b 100644 --- a/apps/web/src/app/features/documents/line-columns.ts +++ b/apps/web/src/app/features/documents/line-columns.ts @@ -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): 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' }, diff --git a/apps/web/src/app/shared/grid/lines-grid.html b/apps/web/src/app/shared/grid/lines-grid.html index 7a6c140..4385260 100644 --- a/apps/web/src/app/shared/grid/lines-grid.html +++ b/apps/web/src/app/shared/grid/lines-grid.html @@ -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)" diff --git a/apps/web/src/app/shared/value-help/value-help.ts b/apps/web/src/app/shared/value-help/value-help.ts index cdf3a26..08e615a 100644 --- a/apps/web/src/app/shared/value-help/value-help.ts +++ b/apps/web/src/app/shared/value-help/value-help.ts @@ -28,6 +28,8 @@ export class ValueHelp { /** Filtro fijo (ej. `CardType eq 'cCustomer'`) que se combina con la búsqueda. */ readonly baseFilter = input(); readonly orderby = input(); + /** Campos que se piden aunque no sean columna (ver `VhLookup.extraFields`). */ + readonly extraFields = input([]); /** Campo único de cada fila; por defecto, el de la primera columna. */ readonly rowKey = input(); 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(), diff --git a/apps/web/src/app/shared/value-help/value-help.types.ts b/apps/web/src/app/shared/value-help/value-help.types.ts index b093a10..b54596a 100644 --- a/apps/web/src/app/shared/value-help/value-help.types.ts +++ b/apps/web/src/app/shared/value-help/value-help.types.ts @@ -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[]; }