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

@@ -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 {