diff --git a/packages/warehouse-core/src/inventory/fefo-allocation.test.ts b/packages/warehouse-core/src/inventory/fefo-allocation.test.ts index e4a25eb1..4097cce2 100644 --- a/packages/warehouse-core/src/inventory/fefo-allocation.test.ts +++ b/packages/warehouse-core/src/inventory/fefo-allocation.test.ts @@ -185,3 +185,29 @@ test('exact match allocates fully with no shortfall', () => { assert.equal(plan.shortfall.amount, 0); assert.equal(plan.lines.length, 2); }); + +test('excludeWarehouseIds: el stock de un vehículo no se asigna', () => { + const fixedItem = item({ + amount: 10, + warehouseId: WH_A, + expiresAt: '2026-03-01T00:00:00Z', + }); + const vehicleItem = item({ + amount: 10, + warehouseId: WH_B, + expiresAt: '2026-02-01T00:00:00Z', + }); + + const plan = allocateFefo(demand(15), [fixedItem, vehicleItem], { + excludeWarehouseIds: [WarehouseId.fromString(WH_B)], + }); + + // Sólo el almacén fijo (10) es elegible → 10 asignados, 5 de shortfall. + assert.equal(plan.allocated.amount, 10); + assert.equal(plan.shortfall.amount, 5); + assert.ok( + plan.lines.every((l) => + l.item.warehouseId.equals(WarehouseId.fromString(WH_A)), + ), + ); +}); diff --git a/packages/warehouse-core/src/inventory/fefo-allocation.ts b/packages/warehouse-core/src/inventory/fefo-allocation.ts index c733c199..630499c5 100644 --- a/packages/warehouse-core/src/inventory/fefo-allocation.ts +++ b/packages/warehouse-core/src/inventory/fefo-allocation.ts @@ -19,6 +19,12 @@ export interface FefoOptions { * on expiry grounds — the caller decides. */ asOf?: Date; + /** + * Almacenes cuyo stock se EXCLUYE de la asignación (p.ej. vehículos: lo que va + * en un camión preparado no está "disponible"). Vacío/ausente = no excluye + * nada. El llamador pasa los ids de los almacenes `kind=vehicle`. + */ + excludeWarehouseIds?: readonly WarehouseId[]; } /** One draw of the plan: take `quantity` from `item`. */ @@ -50,8 +56,10 @@ export interface AllocationPlan { * id so the plan is stable. * * Candidates are filtered to the demand's scope, `supplyId` and unit (and - * warehouse if given), skipping empty items and — when `asOf` is set — expired - * lots. + * warehouse if given), skipping empty items, — when `asOf` is set — expired + * lots, and — when `excludeWarehouseIds` is set — stock sitting in any of + * those warehouses (e.g. vehicles: material already loaded on a truck isn't + * "available" to allocate again). */ export function allocateFefo( demand: FefoDemand, @@ -102,6 +110,9 @@ function isEligible( } if (options.asOf !== undefined && item.isExpiredAt(options.asOf)) return false; + if (options.excludeWarehouseIds?.some((id) => item.warehouseId.equals(id))) { + return false; + } return true; } diff --git a/packages/warehouse-core/src/inventory/index.ts b/packages/warehouse-core/src/inventory/index.ts index 1e419d87..d252f9c7 100644 --- a/packages/warehouse-core/src/inventory/index.ts +++ b/packages/warehouse-core/src/inventory/index.ts @@ -107,6 +107,10 @@ export type { LoadUnknown, LoadTotals, } from './compute-load.js'; +export { vehicleLoadStatus } from './vehicle-load-status.js'; +export type { VehicleLoadStatus } from './vehicle-load-status.js'; +export { buildVehicleManifest } from './vehicle-manifest.js'; +export type { ManifestLine, VehicleManifest } from './vehicle-manifest.js'; export { WAREHOUSE_REPOSITORY, type WarehouseRepository, diff --git a/packages/warehouse-core/src/inventory/vehicle-load-status.test.ts b/packages/warehouse-core/src/inventory/vehicle-load-status.test.ts new file mode 100644 index 00000000..2008f1c2 --- /dev/null +++ b/packages/warehouse-core/src/inventory/vehicle-load-status.test.ts @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { vehicleLoadStatus } from './vehicle-load-status.js'; +import type { LoadTotals } from './compute-load.js'; + +function totals(over: Partial = {}): LoadTotals { + return { + weightKg: 0, + volumeM3: 0, + weightComplete: true, + volumeComplete: true, + complete: true, + unknowns: [], + personnel: [], + ...over, + }; +} + +test('sin capacidad máxima: utilización null, nunca overflow', () => { + const s = vehicleLoadStatus(null, totals({ weightKg: 999 })); + assert.equal(s.maxWeightKg, null); + assert.equal(s.weightUtilizationPct, null); + assert.equal(s.overWeight, false); +}); + +test('utilización y overflow por peso', () => { + const s = vehicleLoadStatus( + { weightKg: 1000, volumeM3: null }, + totals({ weightKg: 1200, volumeM3: 3 }), + ); + assert.equal(s.weightUtilizationPct, 120); + assert.equal(s.overWeight, true); + assert.equal(s.volumeUtilizationPct, null); // no hay límite de volumen + assert.equal(s.overVolume, false); +}); + +test('dentro de capacidad: sin overflow', () => { + const s = vehicleLoadStatus( + { weightKg: 1000, volumeM3: 10 }, + totals({ weightKg: 800, volumeM3: 4 }), + ); + assert.equal(s.weightUtilizationPct, 80); + assert.equal(s.volumeUtilizationPct, 40); + assert.equal(s.overWeight, false); + assert.equal(s.overVolume, false); +}); + +test('dato incompleto se propaga (el total es límite inferior)', () => { + const s = vehicleLoadStatus( + { weightKg: 1000, volumeM3: null }, + totals({ weightKg: 500, weightComplete: false, complete: false }), + ); + assert.equal(s.incomplete, true); +}); + +test('nunca lanza (soft-warn), incluso muy por encima', () => { + assert.doesNotThrow(() => + vehicleLoadStatus( + { weightKg: 1, volumeM3: 1 }, + totals({ weightKg: 1e6, volumeM3: 1e6 }), + ), + ); +}); diff --git a/packages/warehouse-core/src/inventory/vehicle-load-status.ts b/packages/warehouse-core/src/inventory/vehicle-load-status.ts new file mode 100644 index 00000000..ce94392f --- /dev/null +++ b/packages/warehouse-core/src/inventory/vehicle-load-status.ts @@ -0,0 +1,47 @@ +import type { CapacityProps } from '../kernel/index.js'; +import type { LoadTotals } from './compute-load.js'; + +/** + * Estado de carga de un vehículo frente a su carga útil máxima (spec §3). Es una + * DERIVACIÓN en vivo, no una barrera: `overWeight`/`overVolume` avisan pero + * nunca lanzan (soft-warn — en emergencias decide el operador). La utilización + * es `null` en la dimensión sin límite declarado (capacidad parcial). `incomplete` + * refleja que el total de carga es un límite inferior (faltan datos de peso/volumen + * de algún ítem), así que el % podría quedarse corto. + */ +export interface VehicleLoadStatus { + weightKg: number; + volumeM3: number; + maxWeightKg: number | null; + maxVolumeM3: number | null; + weightUtilizationPct: number | null; + volumeUtilizationPct: number | null; + overWeight: boolean; + overVolume: boolean; + incomplete: boolean; +} + +export function vehicleLoadStatus( + maxCapacity: CapacityProps | null, + totals: LoadTotals, +): VehicleLoadStatus { + const maxWeightKg = maxCapacity?.weightKg ?? null; + const maxVolumeM3 = maxCapacity?.volumeM3 ?? null; + return { + weightKg: totals.weightKg, + volumeM3: totals.volumeM3, + maxWeightKg, + maxVolumeM3, + weightUtilizationPct: utilization(totals.weightKg, maxWeightKg), + volumeUtilizationPct: utilization(totals.volumeM3, maxVolumeM3), + overWeight: maxWeightKg !== null && totals.weightKg > maxWeightKg, + overVolume: maxVolumeM3 !== null && totals.volumeM3 > maxVolumeM3, + incomplete: !totals.weightComplete || !totals.volumeComplete, + }; +} + +/** % de utilización redondeado a 1 decimal; null si no hay límite (o límite ≤ 0). */ +function utilization(value: number, max: number | null): number | null { + if (max === null || max <= 0) return null; + return Math.round((value / max) * 1000) / 10; +} diff --git a/packages/warehouse-core/src/inventory/vehicle-manifest.test.ts b/packages/warehouse-core/src/inventory/vehicle-manifest.test.ts new file mode 100644 index 00000000..0765ae47 --- /dev/null +++ b/packages/warehouse-core/src/inventory/vehicle-manifest.test.ts @@ -0,0 +1,82 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildVehicleManifest } from './vehicle-manifest.js'; +import type { SupplyLoadInfo, SupplyLoadLookup } from './compute-load.js'; + +const CAT = new Map([ + [ + 'water', + { + unitWeightKg: 1.5, + unitVolumeM3: 0.0016, + defaultUnit: 'und', + nature: 'fungible', + }, + ], + [ + 'medic', + { + unitWeightKg: null, + unitVolumeM3: null, + defaultUnit: 'und', + nature: 'human', + }, + ], +]); +const lookup: SupplyLoadLookup = (id) => CAT.get(id) ?? null; + +test('agrega el cargo por supplyId sumando suelto + líneas de container', () => { + const m = buildVehicleManifest( + [{ supplyId: 'water', quantity: 10, unit: 'und', ref: 'L1' }], + [ + { + id: 'BOX-1', + parentId: null, + grossWeightKg: null, + grossVolumeM3: null, + lines: [{ supplyId: 'water', quantity: 5, unit: 'und', ref: 'L2' }], + }, + ], + lookup, + { weightKg: 1000, volumeM3: null }, + ); + assert.deepEqual(m.cargo, [{ supplyId: 'water', quantity: 15, unit: 'und' }]); + assert.equal(m.totals.weightKg, 22.5); // 15 × 1.5 + assert.equal(m.status.weightUtilizationPct, 2.3); +}); + +test('el personal va aparte, no en el cargo', () => { + const m = buildVehicleManifest( + [{ supplyId: 'medic', quantity: 3, unit: 'und', ref: 'P1' }], + [], + lookup, + null, + ); + assert.deepEqual(m.cargo, []); + assert.equal(m.personnel.length, 1); + assert.equal(m.personnel[0]!.quantity, 3); +}); + +test('líneas sin supplyId no entran en el cargo (pero cuentan como incompletas en totals)', () => { + const m = buildVehicleManifest( + [{ supplyId: null, quantity: 2, unit: null, ref: 'X' }], + [], + lookup, + null, + ); + assert.deepEqual(m.cargo, []); + assert.equal(m.totals.complete, false); +}); + +test('el mismo supplyId con unidades distintas se agrega por (supplyId, unit)', () => { + const m = buildVehicleManifest( + [ + { supplyId: 'water', quantity: 2, unit: 'und', ref: 'a' }, + { supplyId: 'water', quantity: 1, unit: 'palet', ref: 'b' }, + ], + [], + lookup, + null, + ); + assert.equal(m.cargo.length, 2); +}); diff --git a/packages/warehouse-core/src/inventory/vehicle-manifest.ts b/packages/warehouse-core/src/inventory/vehicle-manifest.ts new file mode 100644 index 00000000..2c8f5ed6 --- /dev/null +++ b/packages/warehouse-core/src/inventory/vehicle-manifest.ts @@ -0,0 +1,73 @@ +import type { CapacityProps } from '../kernel/index.js'; +import { + computeLoad, + type ContainerLoadNode, + type LoadLine, + type LoadTotals, + type SupplyLoadLookup, +} from './compute-load.js'; +import { + vehicleLoadStatus, + type VehicleLoadStatus, +} from './vehicle-load-status.js'; + +/** Una entrada del manifiesto: cuánto de un producto va a bordo, agregado. */ +export interface ManifestLine { + supplyId: string; + quantity: number; + unit: string | null; +} + +/** + * El manifiesto de un vehículo: "abrir el camión y ver". Composición PURA (spec + * §3) — el llamador (host) consulta el stock/containers a bordo y pasa las formas + * estructurales; aquí se computa la carga (`computeLoad`), su estado frente a la + * capacidad (`vehicleLoadStatus`) y se agrega el material por `(supplyId, unit)`. + * El personal (nature=human) va aparte, no como cargo. + */ +export interface VehicleManifest { + totals: LoadTotals; + status: VehicleLoadStatus; + cargo: ManifestLine[]; + personnel: LoadLine[]; +} + +export function buildVehicleManifest( + looseLines: readonly LoadLine[], + containers: readonly ContainerLoadNode[], + lookup: SupplyLoadLookup, + maxCapacity: CapacityProps | null, +): VehicleManifest { + const totals = computeLoad(looseLines, containers, lookup); + const status = vehicleLoadStatus(maxCapacity, totals); + + // Agrega TODO el material a bordo por (supplyId, unit) — suelto + líneas de + // cualquier container (el recuento de contenido es independiente de la regla + // del nodo más alto, que sólo rige el peso/volumen). Excluye personal y + // líneas sin supplyId (no verificables). + const personnelRefs = new Set(totals.personnel); + const byKey = new Map(); + const allLines: LoadLine[] = [ + ...looseLines, + ...containers.flatMap((c) => c.lines), + ]; + for (const line of allLines) { + if (line.supplyId === null || personnelRefs.has(line)) continue; + const key = `${line.supplyId} ${line.unit ?? ''}`; + const existing = byKey.get(key); + if (existing) existing.quantity += line.quantity; + else + byKey.set(key, { + supplyId: line.supplyId, + quantity: line.quantity, + unit: line.unit, + }); + } + + return { + totals, + status, + cargo: [...byKey.values()], + personnel: totals.personnel, + }; +} diff --git a/packages/warehouse-postgres/src/inventory/consumer-validation.test.ts b/packages/warehouse-postgres/src/inventory/consumer-validation.test.ts index ba2e0c48..8985d0a9 100644 --- a/packages/warehouse-postgres/src/inventory/consumer-validation.test.ts +++ b/packages/warehouse-postgres/src/inventory/consumer-validation.test.ts @@ -9,6 +9,7 @@ import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import { Warehouse, WarehouseId, + WarehouseKind, ZoneId, ZoneKind, Bin, @@ -23,6 +24,11 @@ import { Quantity, StockStatus, MovementKind, + vehicleLoadStatus, + buildVehicleManifest, + type LoadLine, + type SupplyLoadInfo, + type SupplyLoadLookup, } from '@globalemergency/warehouse-core/inventory'; import { Container, @@ -426,4 +432,204 @@ describe('Consumidor standalone: flujo WMS end-to-end (dominio + persistencia)', assert.equal(await containers.findById(orphanPallet.id), null); assert.equal(await items.findById(orphanStock.id), null); }); + + it('un host carga un vehículo, lee su manifiesto y lo descarga (Inc 5 vehículos)', async () => { + const scopeId = ScopeId.create(); + const supplyId = uuid(); + const unit = 'und'; + const actorId = 'op-1'; + + // Catálogo en memoria: el consumidor NO depende de dónde viva (supplies vive + // en otro contexto/paquete); sólo necesita este port síncrono. + const catalog = new Map([ + [ + supplyId, + { + unitWeightKg: 1.5, + unitVolumeM3: 0.0016, + defaultUnit: unit, + nature: 'fungible', + }, + ], + ]); + const lookup: SupplyLoadLookup = (id) => catalog.get(id) ?? null; + + // --- 1. Layout: almacén fijo (con bin) + vehículo (con bin), capacidad + // parcial (sólo peso) para poder forzar overWeight de forma determinista. --- + const fixedZoneId = ZoneId.create(); + const fixedWarehouse = Warehouse.create({ + id: WarehouseId.create(), + scopeId, + code: 'ALM-FIJO', + name: 'Almacén Fijo', + zones: [ + { + id: fixedZoneId, + code: 'ALM', + name: 'Almacenaje', + kind: ZoneKind.Storage, + }, + ], + }); + await warehouses.save(fixedWarehouse); + const fixedBin = Bin.create({ + id: BinId.create(), + scopeId, + warehouseId: fixedWarehouse.id, + zoneId: fixedZoneId, + code: 'F-01', + kind: BinKind.Shelf, + }); + await bins.save(fixedBin); + + const vehicleZoneId = ZoneId.create(); + const vehicleWarehouse = Warehouse.create({ + id: WarehouseId.create(), + scopeId, + code: 'CAMION-01', + name: 'Camión 01', + kind: WarehouseKind.Vehicle, + maxCapacity: { weightKg: 20, volumeM3: null }, + zones: [ + { + id: vehicleZoneId, + code: 'CARGA', + name: 'Caja de carga', + kind: ZoneKind.Storage, + }, + ], + }); + await warehouses.save(vehicleWarehouse); + const vehicleBin = Bin.create({ + id: BinId.create(), + scopeId, + warehouseId: vehicleWarehouse.id, + zoneId: vehicleZoneId, + code: 'V-01', + kind: BinKind.Shelf, + }); + await bins.save(vehicleBin); + + // --- 2. Recepción: 15 und en el bin fijo (15 × 1.5 kg = 22.5 kg > 20 kg). --- + const fixedItem = StockItem.create({ + id: StockItemId.create(), + scopeId, + warehouseId: fixedWarehouse.id, + binId: fixedBin.id, + supplyId, + lot: null, + quantity: Quantity.of(0, unit), + status: StockStatus.Available, + }); + await items.save(fixedItem); + const receipt = StockMovement.record({ + id: StockMovementId.create(), + scopeId, + kind: MovementKind.Receipt, + quantity: Quantity.of(15, unit), + toItemId: fixedItem.id, + actorId, + }); + applyStockMovement(receipt, { to: fixedItem }); + await items.save(fixedItem); + await movements.append(receipt); + + // --- 3. Carga: traslado atómico fijo → vehículo (item nuevo en su bin). --- + const vehicleItem = StockItem.create({ + id: StockItemId.create(), + scopeId, + warehouseId: vehicleWarehouse.id, + binId: vehicleBin.id, + supplyId, + lot: null, + quantity: Quantity.of(0, unit), + status: StockStatus.Available, + }); + await items.save(vehicleItem); + + const load = StockMovement.record({ + id: StockMovementId.create(), + scopeId, + kind: MovementKind.Transfer, + quantity: Quantity.of(15, unit), + fromItemId: fixedItem.id, + toItemId: vehicleItem.id, + idempotencyKey: 'carga-1', + actorId, + }); + const fixedBeforeLoad = await items.findById(fixedItem.id); + assert.ok(fixedBeforeLoad); + applyStockMovement(load, { from: fixedBeforeLoad, to: vehicleItem }); + await runInWmsTransaction(db, async (uow) => { + await uow.items.save(fixedBeforeLoad); + await uow.items.save(vehicleItem); + await uow.movements.append(load); + }); + + assert.equal((await items.findById(fixedItem.id))?.quantity.amount, 0); + assert.equal((await items.findById(vehicleItem.id))?.quantity.amount, 15); + + // --- 4. Estado + manifiesto del vehículo, leídos sólo de su stock. -------- + const aboard = await items.findByWarehouse(vehicleWarehouse.id, {}); + const looseLines: LoadLine[] = aboard.map((item) => ({ + supplyId: item.supplyId, + quantity: item.quantity.amount, + unit: item.quantity.unit, + ref: item.id.value, + })); + + const manifest = buildVehicleManifest( + looseLines, + [], + lookup, + vehicleWarehouse.maxCapacity, + ); + assert.deepEqual(manifest.cargo, [{ supplyId, quantity: 15, unit }]); + assert.equal(manifest.personnel.length, 0); + assert.equal(manifest.totals.weightKg, 22.5); // 15 × 1.5 kg + assert.equal(manifest.status.overWeight, true); // 22.5 kg > 20 kg de capacidad + assert.equal(manifest.status.weightUtilizationPct, 112.5); + + const status = vehicleLoadStatus( + vehicleWarehouse.maxCapacity, + manifest.totals, + ); + assert.equal(status.overWeight, true); + assert.equal(status.overVolume, false); // sin límite de volumen declarado + + // --- 5. Descarga: traslado inverso vehículo → fijo; el vehículo queda vacío. --- + const unload = StockMovement.record({ + id: StockMovementId.create(), + scopeId, + kind: MovementKind.Transfer, + quantity: Quantity.of(15, unit), + fromItemId: vehicleItem.id, + toItemId: fixedItem.id, + idempotencyKey: 'descarga-1', + actorId, + }); + const vehicleBeforeUnload = await items.findById(vehicleItem.id); + const fixedBeforeUnload = await items.findById(fixedItem.id); + assert.ok(vehicleBeforeUnload && fixedBeforeUnload); + applyStockMovement(unload, { + from: vehicleBeforeUnload, + to: fixedBeforeUnload, + }); + await runInWmsTransaction(db, async (uow) => { + await uow.items.save(vehicleBeforeUnload); + await uow.items.save(fixedBeforeUnload); + await uow.movements.append(unload); + }); + + assert.equal((await items.findById(vehicleItem.id))?.quantity.amount, 0); + assert.equal((await items.findById(fixedItem.id))?.quantity.amount, 15); + const vehicleAfterUnload = await items.findByWarehouse( + vehicleWarehouse.id, + {}, + ); + assert.equal( + vehicleAfterUnload.reduce((sum, i) => sum + i.quantity.amount, 0), + 0, + ); + }); });