feat(web): copiar de / a entre movimientos de inventario (solicitud de traslado → transferencia de stock)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 12:20:26 -05:00
1 parent fdc042ddcd
commit a5592fac76
10 files changed
+282 -18

No files matched your search

+2
View File
@@ -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 (`<sbo-toolbar bleed>`). 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`).
@@ -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']));
});
});
@@ -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<string, unknown> = {}) => ({
'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<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();
};
const menus = () => [...el().querySelectorAll('sbo-menu-button')].map((m) => m.textContent?.trim());
async function open(path: string, resource: string, docEntry: string | null, doc?: Record<string, unknown>, 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<string, unknown>[] };
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');
});
});
@@ -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<Record<string, readonly string[]>> = {
@@ -13,6 +13,8 @@ export const COPY_FLOWS: Readonly<Record<string, readonly string[]>> = {
'/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,
};
}
@@ -55,10 +55,10 @@
<button sboActions class="btn" type="button" (click)="reload()">Recargar</button>
}
<a sboActions class="btn" routerLink="..">{{ readOnly() ? 'Cerrar' : 'Cancelar' }}</a>
@if (partner && copyFromItems().length) {
@if (copyFromItems().length) {
<sbo-menu-button sboActionsEnd label="Copiar de" [items]="copyFromItems()" (picked)="copyFrom($event)" />
}
@if (partner && copyToItems().length) {
@if (copyToItems().length) {
<sbo-menu-button sboActionsEnd label="Copiar a" [items]="copyToItems()" (picked)="copyTo($event)" />
}
</sbo-window>
@@ -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}.` });
@@ -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<string, unknown>;
@@ -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']);
@@ -34,9 +34,29 @@ const PATH_OF_OBJECT: ReadonlyMap<number, string> = 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<Record<number, string>> = {
1250000001: 'InventoryTransferRequest',
67: 'WarehouseTransfers',
59: 'InventoryGeneralEntry',
20: 'PurchaseDeliveryNotes',
};
const OBJECT_OF_ENUM: ReadonlyMap<string, number> = 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) : '');
}
@@ -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],
@@ -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 {