Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/warehouse-core/src/inventory/fefo-allocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
),
);
});
15 changes: 13 additions & 2 deletions packages/warehouse-core/src/inventory/fefo-allocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/warehouse-core/src/inventory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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> = {}): 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 }),
),
);
});
47 changes: 47 additions & 0 deletions packages/warehouse-core/src/inventory/vehicle-load-status.ts
Original file line number Diff line number Diff line change
@@ -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;
}
82 changes: 82 additions & 0 deletions packages/warehouse-core/src/inventory/vehicle-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, SupplyLoadInfo>([
[
'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);
});
73 changes: 73 additions & 0 deletions packages/warehouse-core/src/inventory/vehicle-manifest.ts
Original file line number Diff line number Diff line change
@@ -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<string, ManifestLine>();
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,
};
}
Loading
Loading