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
3 changes: 3 additions & 0 deletions packages/warehouse-core/src/inventory/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export { BinId } from './bin-id.js';
export { StockItemId } from './stock-item-id.js';
export { StockMovementId } from './stock-movement-id.js';
export {
WarehouseKind,
WarehouseStatus,
ZoneStatus,
ZoneKind,
Expand All @@ -35,9 +36,11 @@ export {
WarehouseValidationError,
DuplicateZoneCodeError,
WarehouseArchivedError,
WarehouseNotEmptyError,
BinValidationError,
BinArchivedError,
} from './inventory-errors.js';
export { assertWarehouseCanBeArchived } from './warehouse-archival.js';
export {
StockValidationError,
QuantityUnitMismatchError,
Expand Down
11 changes: 11 additions & 0 deletions packages/warehouse-core/src/inventory/inventory-enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ export enum WarehouseStatus {
Archived = 'archived',
}

/**
* Naturaleza física de un {@link Warehouse}. `fixed` = almacén de obra (edificio,
* depósito); `vehicle` = almacén móvil (camión/furgón) que se carga, descarga e
* inspecciona a lo largo del tiempo y tiene una carga útil máxima (`maxCapacity`).
* Sólo los vehículos declaran capacidad. Por defecto `fixed`.
*/
export enum WarehouseKind {
Fixed = 'fixed',
Vehicle = 'vehicle',
}

/**
* Lifecycle of a {@link Zone}. Mirrors the warehouse: `active` or `archived`.
* Archiving a zone (e.g. a bay taken out of service) keeps its id valid for
Expand Down
14 changes: 14 additions & 0 deletions packages/warehouse-core/src/inventory/inventory-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ export class WarehouseArchivedError extends Error {
}
}

/**
* Raised when archiving a warehouse that still holds stock (StockItems on
* board). Archiving it would orphan that inventory, so the host must relocate or
* unload it first. The HTTP layer maps this to 409 Conflict.
*/
export class WarehouseNotEmptyError extends Error {
constructor(stockItemCount: number) {
super(
`Warehouse still holds ${stockItemCount} stock item(s) and cannot be archived`,
);
this.name = 'WarehouseNotEmptyError';
}
}

/**
* Raised on invalid bin input (empty/oversized code, empty warehouse id). The
* HTTP layer of each host maps it to 422.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { assertWarehouseCanBeArchived } from './warehouse-archival.js';
import { WarehouseNotEmptyError } from './inventory-errors.js';

test('allows archiving an empty warehouse (zero stock items)', () => {
assert.doesNotThrow(() => assertWarehouseCanBeArchived(0));
});

test('throws WarehouseNotEmptyError when stock remains on board', () => {
assert.throws(() => assertWarehouseCanBeArchived(1), WarehouseNotEmptyError);
assert.throws(() => assertWarehouseCanBeArchived(42), WarehouseNotEmptyError);
});
16 changes: 16 additions & 0 deletions packages/warehouse-core/src/inventory/warehouse-archival.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { WarehouseNotEmptyError } from './inventory-errors.js';

/**
* Guard puro de "no archivar con carga": archivar un {@link Warehouse} (fijo o
* vehículo) que todavía tiene stock a bordo dejaría inventario huérfano. El
* conteo de StockItems lo aporta el host (el paquete no conoce la persistencia);
* esta función sólo aplica la política.
*
* Es una precondición del port de archivado del host/app: DEBE llamarse antes de
* archivar. Lanza {@link WarehouseNotEmptyError} si queda carga.
*/
export function assertWarehouseCanBeArchived(stockItemCount: number): void {
if (stockItemCount > 0) {
throw new WarehouseNotEmptyError(stockItemCount);
}
}
112 changes: 111 additions & 1 deletion packages/warehouse-core/src/inventory/warehouse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import assert from 'node:assert/strict';
import { Warehouse, Zone } from './warehouse.js';
import { WarehouseId } from './warehouse-id.js';
import { ZoneId } from './zone-id.js';
import { WarehouseStatus, ZoneKind, ZoneStatus } from './inventory-enums.js';
import {
WarehouseKind,
WarehouseStatus,
ZoneKind,
ZoneStatus,
} from './inventory-enums.js';
import {
DuplicateZoneCodeError,
WarehouseArchivedError,
Expand Down Expand Up @@ -204,3 +209,108 @@ test('round-trips through a snapshot', () => {
assert.ok(restored instanceof Warehouse);
assert.ok(restored.zones[0] instanceof Zone);
});

// --- kind + maxCapacity (vehículo como almacén móvil) ----------------------

test('defaults kind to fixed with null maxCapacity', () => {
const w = make();
assert.equal(w.kind, WarehouseKind.Fixed);
assert.equal(w.maxCapacity, null);
});

test('creates a vehicle warehouse with partial (weight-only) capacity', () => {
const w = Warehouse.create({
id: WarehouseId.create(),
scopeId: ScopeId.fromString(SCOPE),
code: 'VEH-1',
name: 'Camión 1',
kind: WarehouseKind.Vehicle,
maxCapacity: { weightKg: 3500, volumeM3: null },
});
assert.equal(w.kind, WarehouseKind.Vehicle);
assert.deepEqual(w.maxCapacity, { weightKg: 3500, volumeM3: null });
});

test('allows a vehicle with an as-yet-undeclared (null) maxCapacity', () => {
const w = Warehouse.create({
id: WarehouseId.create(),
scopeId: ScopeId.fromString(SCOPE),
code: 'VEH-2',
name: 'Camión 2',
kind: WarehouseKind.Vehicle,
maxCapacity: null,
});
assert.equal(w.kind, WarehouseKind.Vehicle);
assert.equal(w.maxCapacity, null);
});

test('rejects a fixed warehouse that declares a maxCapacity', () => {
assert.throws(
() =>
Warehouse.create({
id: WarehouseId.create(),
scopeId: ScopeId.fromString(SCOPE),
code: 'ALM-X',
name: 'Almacén X',
kind: WarehouseKind.Fixed,
maxCapacity: { weightKg: 1000, volumeM3: null },
}),
WarehouseValidationError,
);
});

test('rejects a maxCapacity with no dimension at all (VO invariant)', () => {
assert.throws(
() =>
Warehouse.create({
id: WarehouseId.create(),
scopeId: ScopeId.fromString(SCOPE),
code: 'VEH-3',
name: 'Camión 3',
kind: WarehouseKind.Vehicle,
maxCapacity: { weightKg: null, volumeM3: null },
}),
Error,
);
});

test('setMaxCapacity sets and clears the capacity of a vehicle', () => {
const w = Warehouse.create({
id: WarehouseId.create(),
scopeId: ScopeId.fromString(SCOPE),
code: 'VEH-4',
name: 'Camión 4',
kind: WarehouseKind.Vehicle,
maxCapacity: null,
});
w.setMaxCapacity({ weightKg: 2000, volumeM3: 12 });
assert.deepEqual(w.maxCapacity, { weightKg: 2000, volumeM3: 12 });
w.setMaxCapacity(null);
assert.equal(w.maxCapacity, null);
});

test('setMaxCapacity rejects a non-null capacity on a fixed warehouse', () => {
const w = make(); // fixed
assert.throws(
() => w.setMaxCapacity({ weightKg: 1000, volumeM3: null }),
WarehouseValidationError,
);
});

test('round-trips a vehicle warehouse with capacity through a snapshot', () => {
const w = Warehouse.create({
id: WarehouseId.create(),
scopeId: ScopeId.fromString(SCOPE),
code: 'VEH-5',
name: 'Camión 5',
kind: WarehouseKind.Vehicle,
maxCapacity: { weightKg: 3500, volumeM3: null },
});
const snap = w.toSnapshot();
assert.equal(snap.kind, WarehouseKind.Vehicle);
assert.deepEqual(snap.maxCapacity, { weightKg: 3500, volumeM3: null });
const restored = Warehouse.fromSnapshot(snap);
assert.deepEqual(restored.toSnapshot(), snap);
assert.equal(restored.kind, WarehouseKind.Vehicle);
assert.deepEqual(restored.maxCapacity, { weightKg: 3500, volumeM3: null });
});
66 changes: 64 additions & 2 deletions packages/warehouse-core/src/inventory/warehouse.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { WarehouseId } from './warehouse-id.js';
import { ZoneId } from './zone-id.js';
import { WarehouseStatus, ZoneKind, ZoneStatus } from './inventory-enums.js';
import {
WarehouseKind,
WarehouseStatus,
ZoneKind,
ZoneStatus,
} from './inventory-enums.js';
import {
DuplicateZoneCodeError,
WarehouseArchivedError,
WarehouseValidationError,
} from './inventory-errors.js';
import { ScopeId } from '../kernel/index.js';
import { Capacity, ScopeId } from '../kernel/index.js';
import type { CapacityProps } from '../kernel/index.js';

/** Optional geographic point of the warehouse building (WGS84). */
export interface WarehouseGeo {
Expand All @@ -29,6 +35,13 @@ export interface CreateWarehouseProps {
address?: string | null;
geo?: WarehouseGeo | null;
zones?: AddZoneProps[];
/** Naturaleza física del almacén. Por defecto `fixed`. */
kind?: WarehouseKind;
/**
* Carga útil máxima (payload). Sólo válida en vehículos; un almacén fijo la
* deja `null`. Puede ser parcial (sólo peso o sólo volumen).
*/
maxCapacity?: CapacityProps | null;
}

export interface ZoneSnapshot {
Expand All @@ -48,6 +61,8 @@ export interface WarehouseSnapshot {
lat: number | null;
lng: number | null;
status: WarehouseStatus;
kind: WarehouseKind;
maxCapacity: CapacityProps | null;
zones: ZoneSnapshot[];
createdAt: Date;
updatedAt: Date;
Expand Down Expand Up @@ -147,6 +162,8 @@ export class Warehouse {
private _address: string | null,
private _geo: WarehouseGeo,
private _status: WarehouseStatus,
private readonly _kind: WarehouseKind,
private _maxCapacity: Capacity | null,
private _zones: Zone[],
public readonly createdAt: Date,
private _updatedAt: Date,
Expand All @@ -157,6 +174,8 @@ export class Warehouse {
const name = normalizeName(props.name, 'Warehouse name');
const address = normalizeAddress(props.address ?? null);
const geo = normalizeGeo(props.geo ?? null);
const kind = props.kind ?? WarehouseKind.Fixed;
const maxCapacity = buildMaxCapacity(kind, props.maxCapacity ?? null);

const zones = (props.zones ?? []).map((z) => Zone.create(z));
assertUniqueZoneCodes(zones);
Expand All @@ -170,6 +189,8 @@ export class Warehouse {
address,
geo,
WarehouseStatus.Active,
kind,
maxCapacity,
zones,
now,
now,
Expand All @@ -185,6 +206,8 @@ export class Warehouse {
s.address,
{ lat: s.lat, lng: s.lng },
s.status,
s.kind,
s.maxCapacity ? Capacity.create(s.maxCapacity) : null,
s.zones.map((z) => Zone.fromSnapshot(z)),
s.createdAt,
s.updatedAt,
Expand All @@ -206,6 +229,13 @@ export class Warehouse {
get status(): WarehouseStatus {
return this._status;
}
get kind(): WarehouseKind {
return this._kind;
}
/** Carga útil máxima como props planas, o `null` (sólo la declaran vehículos). */
get maxCapacity(): CapacityProps | null {
return this._maxCapacity?.toPlain() ?? null;
}
get isArchived(): boolean {
return this._status === WarehouseStatus.Archived;
}
Expand All @@ -231,6 +261,17 @@ export class Warehouse {
this.touch();
}

/**
* Declara o borra (`null`) la carga útil máxima del vehículo. Sólo válida en
* almacenes `vehicle`; un almacén fijo la rechaza. La capacidad puede ser
* parcial (sólo peso o sólo volumen), como exige el VO {@link Capacity}.
*/
setMaxCapacity(props: CapacityProps | null): void {
this.assertActive();
this._maxCapacity = buildMaxCapacity(this._kind, props);
this.touch();
}

addZone(props: AddZoneProps): Zone {
this.assertActive();
const zone = Zone.create(props);
Expand Down Expand Up @@ -273,6 +314,8 @@ export class Warehouse {
lat: this._geo.lat,
lng: this._geo.lng,
status: this._status,
kind: this._kind,
maxCapacity: this._maxCapacity?.toPlain() ?? null,
zones: this._zones.map((z) => z.toSnapshot()),
createdAt: this.createdAt,
updatedAt: this._updatedAt,
Expand Down Expand Up @@ -354,6 +397,25 @@ function normalizeGeo(geo: WarehouseGeo | null): WarehouseGeo {
return { lat, lng };
}

/**
* Construye (y valida) la carga útil máxima según la naturaleza del almacén:
* sólo un vehículo puede declararla; un almacén fijo con capacidad es un error.
* Un vehículo sin capacidad declarada (`null`) es válido. Delega la invariante de
* "al menos una dimensión positiva" al VO {@link Capacity}.
*/
function buildMaxCapacity(
kind: WarehouseKind,
props: CapacityProps | null,
): Capacity | null {
if (props === null) return null;
if (kind !== WarehouseKind.Vehicle) {
throw new WarehouseValidationError(
'Only a vehicle warehouse can declare a maxCapacity',
);
}
return Capacity.create(props);
}

function assertUniqueZoneCodes(zones: Zone[]): void {
const seen = new Set<string>();
for (const zone of zones) {
Expand Down
Loading
Loading