From a5592fac7647e3c8033fc5ce4e4e591ed767f784 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 16 Oct 2026 12:20:26 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20copiar=20de=20/=20a=20entre=20movi?= =?UTF-8?q?mientos=20de=20inventario=20(solicitud=20de=20traslado=20?= =?UTF-8?q?=E2=86=92=20transferencia=20de=20stock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 + .../documents/document-analytics.spec.ts | 7 +- .../documents/document-copy-stock.spec.ts | 176 ++++++++++++++++++ .../app/features/documents/document-copy.ts | 53 +++++- .../documents/document-form.page.html | 4 +- .../features/documents/document-form.page.ts | 21 ++- .../app/features/documents/document-model.ts | 3 +- .../features/documents/document-objects.ts | 22 ++- .../inventario/inventario.layouts.ts | 8 +- .../app/features/documents/line-columns.ts | 4 +- 10 files changed, 282 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/app/features/documents/document-copy-stock.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6ee751c..8f47e39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,8 @@ Pendiente, en orden: **Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. **Campos de contabilidad analítica y numeración (estándar):** cabecera de todos los documentos: `Series` (la fila «Número» lleva la lista de series del documento, pedida con la acción `SeriesService_GetDocumentSeries` del Service Layer —registrada en el proxy como acción de solo consulta—, filtrada por el subtipo de la pantalla; no se puede cambiar una vez creado; sin elegir, SAP usa la por defecto), `Indicator` (ventana de selección sobre `FactoringIndicators` —tabla OIDC; en tu SAP: 33 Factura, 39 Boleta; el recurso `Indicators` no existe en este Service Layer) y `Project` (lista de proyectos). Detalle de ventas y compras: `PriceAfterVAT` (el precio con IVA; solo se envía a SAP el que el usuario escribió, `Price` o `PriceAfterVAT`, nunca los dos), `CostingCode` a `CostingCode5` y `ProjectCode` (cada uno con su **ventana modal** de selección: reglas de distribución por dimensión y proyectos), `AccountCode` (plan de cuentas) y `BaseType`/`BaseEntry`/`BaseLine` (solo lectura: el documento de origen de una línea copiada). Inventario y solicitudes de compra llevan en el detalle solo las cinco dimensiones y el proyecto. `OBJECT_TYPES` (`document-objects.ts`) da el tipo de objeto de SAP de cada pantalla. El subtipo de las series de boleta (`IB`) y el resto de `SERIES_SUBTYPE` no está verificado contra SBODemoCL (solo hay series `--`). +**Movimientos de inventario:** cabecera con fecha de contabilización, fecha del documento (`TaxDate`), vencimiento (`DueDate`, solo traslados), `Reference2` y comentarios del asiento (`JournalMemo`); `Reference1` lo asigna SAP. Copiar de/a: la transferencia de stock se crea desde una solicitud de traslado (`COPY_FLOWS`); en las líneas de transferencia `BaseType` es un **enumerado de texto** de SAP (`InventoryTransferRequest`, `WarehouseTransfers`…; `STOCK_BASE_TYPES`, `hasBaseRef`), no el número de objeto de ventas y compras. + Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (``). Falta: botón PDF. En ventas el flete falla en SBODemoCL ("Define the freight revenue account…"): el gasto adicional «Flete» solo tiene cuenta de gasto, no de ingresos (configuración de SAP, no del código); en compras funciona (orden de compra 2885). 2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). Pendiente en maestros: colecciones (direcciones, personas de contacto, precios por lista, almacenes por artículo), lotes/series, y cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy. 3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`). diff --git a/apps/web/src/app/features/documents/document-analytics.spec.ts b/apps/web/src/app/features/documents/document-analytics.spec.ts index ee4dbb1..3139d6e 100644 --- a/apps/web/src/app/features/documents/document-analytics.spec.ts +++ b/apps/web/src/app/features/documents/document-analytics.spec.ts @@ -181,8 +181,13 @@ describe('inventario y solicitudes de compra', () => { 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); + for (const k of ['PriceAfterVAT', 'AccountCode']) expect(keys, `${p} ${k}`).not.toContain(k); } + // solo las transferencias muestran el origen: pueden hacerse desde una solicitud de traslado + for (const p of ['/inventario/entradas', '/inventario/salidas', '/compras/solicitudes']) { + expect(by(p).columns.map((c) => c.key), p).not.toContain('BaseEntry'); + } + expect(by('/inventario/transferencias').columns.map((c) => c.key)).toEqual(expect.arrayContaining(['BaseType', 'BaseEntry', 'BaseLine'])); }); }); diff --git a/apps/web/src/app/features/documents/document-copy-stock.spec.ts b/apps/web/src/app/features/documents/document-copy-stock.spec.ts new file mode 100644 index 0000000..2166229 --- /dev/null +++ b/apps/web/src/app/features/documents/document-copy-stock.spec.ts @@ -0,0 +1,176 @@ +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 { COPY_FLOWS, copyFromDocument, copySources, copyTargets, copyTransferFromDocument } from './document-copy'; +import { DocumentFormPage } from './document-form.page'; +import { createPayload } from './document-model'; +import { STOCK_BASE_TYPES, hasBaseRef, objectLabel } from './document-objects'; +import { DOCUMENT_SCREENS } from './document-screens'; + +const request = (over: Record = {}) => ({ + 'odata.etag': 'W/"1"', + DocEntry: 472, + DocNum: 472, + FromWarehouse: '01', + ToWarehouse: '02', + Project: 'PE001', + Indicator: null, + DocDate: '2026-10-15T00:00:00Z', + DocumentStatus: 'bost_Open', + Cancelled: 'tNO', + StockTransferLines: [ + { LineNum: 0, ItemCode: 'SU01', ItemDescription: 'Sutura', Quantity: 5, RemainingOpenQuantity: 3, LineStatus: 'bost_Open', FromWarehouseCode: '01', WarehouseCode: '02', UoMEntry: 1, CostingCode: 'CC1', BaseType: 'Default' }, + { LineNum: 1, ItemCode: 'A2', ItemDescription: 'Cerrada', Quantity: 2, RemainingOpenQuantity: 0, LineStatus: 'bost_Close', FromWarehouseCode: '01', WarehouseCode: '02' }, + ], + ...over, +}); + +describe('copiar entre movimientos de inventario', () => { + it('la transferencia se hace desde una solicitud de traslado (y solo ese flujo)', () => { + expect(copyTargets('/inventario/solicitudes-traslado')).toEqual(['/inventario/transferencias']); + expect(copySources('/inventario/transferencias')).toEqual(['/inventario/solicitudes-traslado']); + expect(copySources('/inventario/solicitudes-traslado')).toEqual([]); + for (const p of ['/inventario/entradas', '/inventario/salidas']) { + expect(COPY_FLOWS[p]).toBeUndefined(); + expect(copySources(p)).toEqual([]); + } + }); + + it('copia los almacenes y las líneas abiertas con la cantidad pendiente y la referencia al origen', () => { + const copied = copyFromDocument(request(), '/inventario/solicitudes-traslado', 'Solicitudes de traslado', 'StockTransferLines'); + expect(copied.header).toMatchObject({ FromWarehouse: '01', ToWarehouse: '02', Project: 'PE001', Comments: 'Basado en solicitudes de traslado 472.' }); + expect(copied.skipped).toBe(1); + expect(copied.lines).toHaveLength(1); + expect(copied.lines[0]).toMatchObject({ + ItemCode: 'SU01', + Quantity: 3, + FromWarehouseCode: '01', + WarehouseCode: '02', + CostingCode: 'CC1', + UoMEntry: 1, + BaseType: 'InventoryTransferRequest', + BaseEntry: 472, + BaseLine: 0, + }); + }); + + it('en las líneas de transferencia el BaseType es el enumerado de texto de SAP', () => { + expect(STOCK_BASE_TYPES[1250000001]).toBe('InventoryTransferRequest'); + expect(copyTransferFromDocument(request(), '/inventario/solicitudes-traslado', 'x').lines[0]['BaseType']).toBe('InventoryTransferRequest'); + }); + + it('reconoce una referencia al origen por número o por enumerado, y descarta «Default»', () => { + expect(hasBaseRef(17, 315)).toBe(true); + expect(hasBaseRef('InventoryTransferRequest', 472)).toBe(true); + expect(hasBaseRef('Default', null)).toBe(false); + expect(hasBaseRef('Default', 5)).toBe(false); + expect(hasBaseRef(-1, null)).toBe(false); + expect(hasBaseRef(17, null)).toBe(false); + expect(objectLabel('InventoryTransferRequest')).toBe('Solicitudes de traslado'); + }); + + it('una transferencia creada desde la solicitud envía la referencia; una normal no', () => { + const spec = DOCUMENT_SCREENS.find((s) => s.path === '/inventario/transferencias')!.layout.lines; + const copied = copyTransferFromDocument(request(), '/inventario/solicitudes-traslado', 'x'); + const body = createPayload({}, copied.lines, spec); + expect(body['StockTransferLines']).toEqual([ + expect.objectContaining({ ItemCode: 'SU01', Quantity: 3, BaseType: 'InventoryTransferRequest', BaseEntry: 472, BaseLine: 0 }), + ]); + const plain = createPayload({}, [{ ItemCode: 'A', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02', BaseType: 'Default', BaseEntry: null }], spec); + expect(plain['StockTransferLines']).toEqual([{ ItemCode: 'A', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02' }]); + }); +}); + +describe('formulario de inventario: Copiar de / a', () => { + 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(); + }; + const menus = () => [...el().querySelectorAll('sbo-menu-button')].map((m) => m.textContent?.trim()); + + async function open(path: string, resource: string, docEntry: string | null, doc?: Record, copyFrom?: unknown) { + const screen = DOCUMENT_SCREENS.find((s) => s.path === path)!; + 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); + const router = TestBed.inject(Router); + navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); + vi.spyOn(router, 'navigateByUrl').mockResolvedValue(true); + if (copyFrom) vi.spyOn(router, 'currentNavigation').mockReturnValue({ extras: { state: { copyFrom } } } as never); + fixture = TestBed.createComponent(DocumentFormPage); + await tick(); + ctrl.match((r) => r.url.includes('SeriesService')).forEach((r) => r.flush({ value: [] })); + if (docEntry) ctrl.expectOne(`/api/sl/${resource}/${docEntry}`).flush(doc ?? {}); + await tick(); + } + + beforeEach(() => TestBed.resetTestingModule()); + + it('una solicitud de traslado abierta ofrece «Copiar a» transferencia de stock', async () => { + await open('/inventario/solicitudes-traslado', 'InventoryTransferRequests', '472', request()); + expect(menus().join(' ')).toContain('Copiar a'); + expect(page().copyToItems().map((i: { id: string; disabled: boolean }) => [i.id, i.disabled])).toEqual([['/inventario/transferencias', false]]); + }); + + it('una entrada de inventario no ofrece copiar', async () => { + await open('/inventario/entradas', 'InventoryGenEntries', null); + expect(menus().join(' ')).not.toContain('Copiar'); + }); + + it('«Copiar a» abre la transferencia nueva llevando el origen', async () => { + await open('/inventario/solicitudes-traslado', 'InventoryTransferRequests', '472', request()); + page().copyTo('/inventario/transferencias'); + expect(TestBed.inject(Router).navigateByUrl).toHaveBeenCalledWith('/inventario/transferencias/nuevo', { + state: { copyFrom: { path: '/inventario/solicitudes-traslado', docEntry: 472 } }, + }); + }); + + it('la transferencia nueva con origen carga los almacenes y las líneas pendientes, y al crear envía la referencia', async () => { + await open('/inventario/transferencias', 'StockTransfers', null, undefined, { path: '/inventario/solicitudes-traslado', docEntry: 472 }); + ctrl.expectOne('/api/sl/InventoryTransferRequests/472').flush(request()); + await tick(); + expect(page().header().FromWarehouse).toBe('01'); + expect(page().header().ToWarehouse).toBe('02'); + expect(page().lines()).toHaveLength(1); + expect(el().querySelector('.status')?.textContent).toContain('Copiado de'); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')); + expect(req.request.url).toBe('/api/sl/StockTransfers'); + const body = req.request.body as { FromWarehouse: string; StockTransferLines: Record[] }; + expect(body.FromWarehouse).toBe('01'); + expect(body.StockTransferLines[0]).toMatchObject({ ItemCode: 'SU01', Quantity: 3, BaseType: 'InventoryTransferRequest', BaseEntry: 472, BaseLine: 0 }); + req.flush({ DocEntry: 300, DocNum: 300 }); + await tick(); + expect(navigate).toHaveBeenCalled(); + }); + + it('«Copiar de» en una transferencia nueva lista las solicitudes abiertas con sus almacenes', async () => { + await open('/inventario/transferencias', 'StockTransfers', null); + expect(page().copyFromItems().map((i: { id: string }) => i.id)).toEqual(['/inventario/solicitudes-traslado']); + expect(page().copyListColumns.map((c: { key: string }) => c.key)).toEqual(['DocNum', 'FromWarehouse', 'ToWarehouse', 'DocDate']); + expect(page().copyListFilter()).toBe(`DocumentStatus eq 'bost_Open' and Cancelled eq 'tNO'`); + }); + + it('una transferencia contabilizada no se puede copiar a nada ni ofrece «Copiar a»', async () => { + await open('/inventario/transferencias', 'StockTransfers', '287', { DocEntry: 287, DocNum: 287, FromWarehouse: '01', ToWarehouse: '02', DocumentStatus: 'bost_Open', Cancelled: 'tNO', StockTransferLines: [] }); + expect(menus().join(' ')).not.toContain('Copiar a'); + }); +}); diff --git a/apps/web/src/app/features/documents/document-copy.ts b/apps/web/src/app/features/documents/document-copy.ts index e799d29..a0979d8 100644 --- a/apps/web/src/app/features/documents/document-copy.ts +++ b/apps/web/src/app/features/documents/document-copy.ts @@ -3,7 +3,7 @@ import type { DocRecord } from './document-model'; import { newDocumentLine, recalcLine } from './line-columns'; export { OBJECT_TYPES } from './document-objects'; -import { OBJECT_TYPES } from './document-objects'; +import { OBJECT_TYPES, STOCK_BASE_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> = { @@ -13,6 +13,8 @@ export const COPY_FLOWS: Readonly> = { '/ventas/devoluciones': ['/ventas/notas-credito'], '/ventas/facturas': ['/ventas/notas-credito'], '/ventas/boletas': ['/ventas/notas-credito'], + // Inventario: la transferencia de stock se hace a partir de una solicitud de traslado. + '/inventario/solicitudes-traslado': ['/inventario/transferencias'], '/compras/ofertas': ['/compras/ordenes'], '/compras/ordenes': ['/compras/recepcion', '/compras/facturas'], '/compras/recepcion': ['/compras/facturas', '/compras/devoluciones'], @@ -47,7 +49,8 @@ export interface CopiedDocument { * cantidad pendiente y la referencia al origen (`BaseType`/`BaseEntry`/`BaseLine`), con lo que SAP * enlaza y cierra las cantidades del origen. */ -export function copyFromDocument(source: DocRecord, sourcePath: string, sourceLabel: string): CopiedDocument { +export function copyFromDocument(source: DocRecord, sourcePath: string, sourceLabel: string, linesKey = 'DocumentLines'): CopiedDocument { + if (linesKey === 'StockTransferLines') return copyTransferFromDocument(source, sourcePath, sourceLabel); const baseType = OBJECT_TYPES[sourcePath]; const lines: GridLine[] = []; let skipped = 0; @@ -92,3 +95,49 @@ export function copyFromDocument(source: DocRecord, sourcePath: string, sourceLa skipped, }; } + +/** + * Datos de una solicitud de traslado para una transferencia de stock: los almacenes de la cabecera y sus + * líneas abiertas con la cantidad pendiente, ambos almacenes y la referencia al origen (en las líneas de + * transferencia `BaseType` es el enumerado de texto de SAP, p. ej. `InventoryTransferRequest`). + */ +export function copyTransferFromDocument(source: DocRecord, sourcePath: string, sourceLabel: string): CopiedDocument { + const baseType = STOCK_BASE_TYPES[OBJECT_TYPES[sourcePath]] ?? 'Default'; + const lines: GridLine[] = []; + let skipped = 0; + for (const l of (source['StockTransferLines'] as GridLine[] | undefined) ?? []) { + const remaining = Number(l['RemainingOpenQuantity'] ?? l['Quantity'] ?? 0); + if (l['LineStatus'] === 'bost_Close' || !(remaining > 0)) { + skipped++; + continue; + } + lines.push({ + ItemCode: l['ItemCode'] ?? '', + ItemDescription: l['ItemDescription'] ?? '', + Quantity: remaining, + FromWarehouseCode: l['FromWarehouseCode'] ?? '', + WarehouseCode: l['WarehouseCode'] ?? '', + UoMEntry: l['UoMEntry'] ?? undefined, + 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'], + }); + } + return { + header: { + FromWarehouse: source['FromWarehouse'] ?? '', + ToWarehouse: source['ToWarehouse'] ?? '', + Project: source['Project'] ?? '', + Indicator: source['Indicator'] ?? '', + Comments: `Basado en ${sourceLabel.toLowerCase()} ${String(source['DocNum'])}.`, + }, + lines, + skipped, + }; +} 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 8f692bc..f6197f7 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -55,10 +55,10 @@ } {{ readOnly() ? 'Cerrar' : 'Cancelar' }} - @if (partner && copyFromItems().length) { + @if (copyFromItems().length) { } - @if (partner && copyToItems().length) { + @if (copyToItems().length) { } 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 764998a..c8d722e 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -247,12 +247,19 @@ export class DocumentFormPage { const card = text(this.header()['CardCode']); return [`DocumentStatus eq 'bost_Open'`, `Cancelled eq 'tNO'`, ...(card ? [`CardCode eq '${card.replace(/'/g, "''")}'`] : [])].join(' and '); }); - protected readonly copyListColumns = [ - docNum, - { key: 'CardCode', title: this.partner?.label ?? 'Socio', width: '120px', mono: true }, - { key: 'CardName', title: 'Nombre' }, - { key: 'DocDate', title: 'Fecha', type: 'date', align: 'center', width: '105px' }, - ] as const; + protected readonly copyListColumns = this.partner + ? ([ + docNum, + { key: 'CardCode', title: this.partner.label, width: '120px', mono: true }, + { key: 'CardName', title: 'Nombre' }, + { key: 'DocDate', title: 'Fecha', type: 'date', align: 'center', width: '105px' }, + ] as const) + : ([ + docNum, + { key: 'FromWarehouse', title: 'Almacén origen', width: '130px', mono: true }, + { key: 'ToWarehouse', title: 'Almacén destino', width: '130px', mono: true }, + { key: 'DocDate', title: 'Fecha', type: 'date', align: 'center', width: '105px' }, + ] as const); protected copyTo(path: string): void { const docEntry = this.docEntry(); @@ -278,7 +285,7 @@ export class DocumentFormPage { if (!source) return; try { const doc = await this.api.get(source.resource, origin.docEntry); - const copied = copyFromDocument(doc, origin.path, menuLabel(origin.path)); + const copied = copyFromDocument(doc, origin.path, menuLabel(origin.path), source.layout.lines.key); const card = text(this.header()['CardCode']); if (card && card !== text(copied.header['CardCode'])) { this.message.set({ kind: 'warn', text: `El documento es de ${text(copied.header['CardCode'])}; este es de ${card}.` }); diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index 6ad5d35..2daf05c 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -1,4 +1,5 @@ import type { GridLine } from '../../shared/grid/grid.types'; +import { hasBaseRef } from './document-objects'; /** Documento tal como lo devuelve / recibe el Service Layer. */ export type DocRecord = Record; @@ -126,7 +127,7 @@ export const MARKETING_LINES: LinesSpec = { key: 'DocumentLines', fields: LINE_F /** 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']); +const hasBase = (l: GridLine): boolean => hasBaseRef(l['BaseType'], l['BaseEntry']); /** Una línea vacía (la fila en blanco que deja la grilla) no se envía. */ export const isBlankLine = (line: GridLine): boolean => isEmpty(line['ItemCode']); diff --git a/apps/web/src/app/features/documents/document-objects.ts b/apps/web/src/app/features/documents/document-objects.ts index 8fbf518..21962a1 100644 --- a/apps/web/src/app/features/documents/document-objects.ts +++ b/apps/web/src/app/features/documents/document-objects.ts @@ -34,9 +34,29 @@ const PATH_OF_OBJECT: ReadonlyMap = new Map( .map(([path, type]) => [type, path]), ); +/** + * En las líneas de una transferencia de stock, `BaseType` no es el número del objeto sino un enumerado de + * texto de SAP (`InvBaseDocTypeEnum`; sin origen vale `Default`). Objeto → nombre del enumerado. + */ +export const STOCK_BASE_TYPES: Readonly> = { + 1250000001: 'InventoryTransferRequest', + 67: 'WarehouseTransfers', + 59: 'InventoryGeneralEntry', + 20: 'PurchaseDeliveryNotes', +}; + +const OBJECT_OF_ENUM: ReadonlyMap = new Map(Object.entries(STOCK_BASE_TYPES).map(([obj, name]) => [name, Number(obj)])); + +/** La línea referencia a un documento origen: `BaseType` útil (número > 0 o nombre del enumerado) y `BaseEntry`. */ +export function hasBaseRef(baseType: unknown, baseEntry: unknown): boolean { + if (baseEntry === null || baseEntry === undefined || baseEntry === '') return false; + if (typeof baseType === 'string') return OBJECT_OF_ENUM.has(baseType) || Number(baseType) > 0; + return Number(baseType) > 0; +} + /** 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 code = typeof type === 'string' && OBJECT_OF_ENUM.has(type) ? OBJECT_OF_ENUM.get(type)! : 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) : ''); } diff --git a/apps/web/src/app/features/documents/inventario/inventario.layouts.ts b/apps/web/src/app/features/documents/inventario/inventario.layouts.ts index d80fd15..f2af35a 100644 --- a/apps/web/src/app/features/documents/inventario/inventario.layouts.ts +++ b/apps/web/src/app/features/documents/inventario/inventario.layouts.ts @@ -11,7 +11,7 @@ import { warehouseColumn, } from '../document-layouts'; import type { GridColumn } from '../../../shared/grid/grid.types'; -import { ANALYTIC_COLUMNS, lineFieldsOf } from '../line-columns'; +import { ANALYTIC_COLUMNS, LINE_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]; @@ -44,9 +44,13 @@ const transfer = (immutable: boolean): DocumentLayout => { }, { ...warehouseColumn, title: 'Almacén destino', required: true }, ...ANALYTIC_COLUMNS, + LINE_COLUMNS.baseType, + LINE_COLUMNS.baseEntry, + LINE_COLUMNS.baseLine, ]; return { - lines: { key: 'StockTransferLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: false }, + // `base: true`: una transferencia hecha desde una solicitud de traslado envía la referencia al origen. + lines: { key: 'StockTransferLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: true }, header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse, FIELDS.indicator, FIELDS.project], right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.taxDate, FIELDS.dueDate, FIELDS.reference2, FIELDS.journalMemo], diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts index 6442658..038f147 100644 --- a/apps/web/src/app/features/documents/line-columns.ts +++ b/apps/web/src/app/features/documents/line-columns.ts @@ -1,6 +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'; +import { hasBaseRef, objectLabel } from './document-objects'; const round2 = (n: number): number => Math.round((n + Number.EPSILON) * 100) / 100; const num = (v: unknown): number => { @@ -67,7 +67,7 @@ export const newDocumentLine = (): GridLine => ({ }); /** 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']); +const hasBase = (l: GridLine): boolean => hasBaseRef(l['BaseType'], 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 {