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
12 changes: 8 additions & 4 deletions apps/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7396,7 +7396,8 @@
"type": "string",
"enum": [
"resource",
"shipment"
"shipment",
"vehicle"
]
}
},
Expand Down Expand Up @@ -15551,7 +15552,8 @@
"type": "string",
"enum": [
"resource",
"shipment"
"shipment",
"vehicle"
],
"example": "resource"
},
Expand Down Expand Up @@ -15707,7 +15709,8 @@
"type": "string",
"enum": [
"resource",
"shipment"
"shipment",
"vehicle"
],
"nullable": true,
"example": "resource"
Expand Down Expand Up @@ -15814,7 +15817,8 @@
"type": "string",
"enum": [
"resource",
"shipment"
"shipment",
"vehicle"
],
"nullable": true,
"example": "resource"
Expand Down
8 changes: 4 additions & 4 deletions packages/api-client/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5701,7 +5701,7 @@ export interface components {
* @example resource
* @enum {string}
*/
type: "resource" | "shipment";
type: "resource" | "shipment" | "vehicle";
/**
* Format: uuid
* @description Resource node or shipment id (polymorphic, no FK)
Expand Down Expand Up @@ -5777,7 +5777,7 @@ export interface components {
* @example resource
* @enum {string|null}
*/
holderType?: "resource" | "shipment" | null;
holderType?: "resource" | "shipment" | "vehicle" | null;
/** Format: uuid */
holderId?: string | null;
/**
Expand Down Expand Up @@ -5825,7 +5825,7 @@ export interface components {
* @example resource
* @enum {string|null}
*/
holderType?: "resource" | "shipment" | null;
holderType?: "resource" | "shipment" | "vehicle" | null;
/** Format: uuid */
holderId?: string | null;
/**
Expand Down Expand Up @@ -12362,7 +12362,7 @@ export interface operations {
query?: {
type?: "pallet" | "box" | "lote";
status?: "open" | "sealed";
holderType?: "resource" | "shipment";
holderType?: "resource" | "shipment" | "vehicle";
holderId?: string;
/** @description Only top-level containers (the roots of the trees) */
topLevelOnly?: boolean;
Expand Down
8 changes: 6 additions & 2 deletions packages/warehouse-core/src/containers/container-enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,14 @@ export enum ContainerStatus {
/**
* Where a container physically is *right now*. `resource` = parked at a
* collection point / warehouse (becomes that place's inventory); `shipment` =
* loaded onto an expedition. Polymorphic by design (no FK), mirroring how a
* shipment models its carrier. Null when it is held by neither.
* loaded onto an expedition; `vehicle` = a bordo de un vehículo en ruta
* (`Warehouse` kind=vehicle) — distinto de `resource`, que es un punto fijo.
* Polymorphic by design (no FK), mirroring how a shipment models its carrier.
* Null when it is held by neither.
*/
export enum ContainerHolderType {
Resource = 'resource',
Shipment = 'shipment',
/** Cargado en un vehículo (almacén móvil, `Warehouse` kind=vehicle). */
Vehicle = 'vehicle',
}
46 changes: 46 additions & 0 deletions packages/warehouse-core/src/containers/container.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Container } from './container.js';
import { ContainerId } from './container-id.js';
import { ContainerHolderType, ContainerType } from './container-enums.js';
import { ScopeId } from '../kernel/index.js';

const SCOPE = '11111111-1111-4111-8111-111111111111';
const RESOURCE = '22222222-2222-4222-8222-222222222222';
const VEHICLE = '33333333-3333-4333-8333-333333333333';

function make(): Container {
return Container.create({
id: ContainerId.create(),
code: 'PAL-0001',
type: ContainerType.Pallet,
scopeId: ScopeId.fromString(SCOPE),
});
}

test('moveToHolder mueve el container a un holder tipo vehicle', () => {
const c = make();
c.moveToHolder({ type: ContainerHolderType.Vehicle, id: VEHICLE });
assert.equal(c.holder?.type, ContainerHolderType.Vehicle);
assert.equal(c.holder?.id, VEHICLE);
});

test('round-trip por snapshot conserva el holder tipo vehicle', () => {
const c = make();
c.moveToHolder({ type: ContainerHolderType.Vehicle, id: VEHICLE });
const snap = c.toSnapshot();
assert.equal(snap.holderType, 'vehicle');
assert.equal(snap.holderId, VEHICLE);
const restored = Container.fromSnapshot(snap);
assert.equal(restored.holder?.type, ContainerHolderType.Vehicle);
assert.equal(restored.holder?.id, VEHICLE);
});

test('moveToHolder puede pasar de resource a vehicle y viceversa', () => {
const c = make();
c.moveToHolder({ type: ContainerHolderType.Resource, id: RESOURCE });
assert.equal(c.holder?.type, ContainerHolderType.Resource);
c.moveToHolder({ type: ContainerHolderType.Vehicle, id: VEHICLE });
assert.equal(c.holder?.type, ContainerHolderType.Vehicle);
assert.equal(c.holder?.id, VEHICLE);
});
14 changes: 14 additions & 0 deletions packages/warehouse-core/src/logistics/shipment-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ export class InvalidShipmentRouteError extends Error {
}
}

/**
* Raised when a shipment references a vehicle (`vehicleId`) but ALSO carries
* loose cargo. In vehicle mode the cargo IS the vehicle's current inventory, so
* `items` and `containerIds` must be empty — the two modes are exclusive.
*/
export class VehicleShipmentCargoError extends Error {
constructor() {
super(
'A vehicle shipment carries the vehicle inventory; loose items and containers must be empty',
);
this.name = 'VehicleShipmentCargoError';
}
}

/**
* Raised when a status transition is not allowed from the current status
* (e.g. delivering a shipment that is not in transit). Carries both ends so the
Expand Down
93 changes: 93 additions & 0 deletions packages/warehouse-core/src/logistics/shipment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Shipment, CreateShipmentProps, ShipmentSnapshot } from './shipment.js';
import { ShipmentId } from './shipment-id.js';
import { ScopeId, SupplyLine } from '../kernel/index.js';
import { Category } from '../kernel/category.js';
import {
InvalidShipmentRouteError,
ShipmentMustHaveCargoError,
VehicleShipmentCargoError,
} from './shipment-errors.js';

const SCOPE = '11111111-1111-4111-8111-111111111111';
const ORIGIN = '22222222-2222-4222-8222-222222222222';
const DESTINATION = '33333333-3333-4333-8333-333333333333';
const VEHICLE = '44444444-4444-4444-8444-444444444444';
const CONTAINER = '55555555-5555-4555-8555-555555555555';

const someLine = SupplyLine.create({
name: 'Agua embotellada',
quantity: 10,
unit: 'l',
category: Category.Water,
});

function makeShipment(overrides?: Partial<CreateShipmentProps>): Shipment {
return Shipment.create({
id: ShipmentId.create(),
code: 'EXP-0001',
scopeId: ScopeId.fromString(SCOPE),
originResourceId: ORIGIN,
destinationResourceId: DESTINATION,
items: overrides && 'items' in overrides ? overrides.items! : [someLine],
containerIds:
overrides && 'containerIds' in overrides ? overrides.containerIds! : [],
manifest: null,
...overrides,
});
}

test('modo vehículo: vehicleId presente y sin carga suelta', () => {
const s = makeShipment({ vehicleId: VEHICLE, items: [], containerIds: [] });
assert.equal(s.vehicleId, VEHICLE);
const back = Shipment.fromSnapshot(s.toSnapshot());
assert.equal(back.vehicleId, VEHICLE);
});

test('modo vehículo rechaza carga suelta (items o containers)', () => {
assert.throws(
() =>
makeShipment({ vehicleId: VEHICLE, items: [someLine], containerIds: [] }),
VehicleShipmentCargoError,
);
assert.throws(
() =>
makeShipment({
vehicleId: VEHICLE,
items: [],
containerIds: [CONTAINER],
}),
VehicleShipmentCargoError,
);
});

test('modo vehículo rechaza vehicleId no-UUID', () => {
assert.throws(
() => makeShipment({ vehicleId: 'nope', items: [], containerIds: [] }),
InvalidShipmentRouteError,
);
});

test('modo suelto (sin vehicleId) intacto: exige carga', () => {
assert.throws(
() => makeShipment({ items: [], containerIds: [] }),
ShipmentMustHaveCargoError,
);
const s = makeShipment({ items: [someLine] });
assert.equal(s.vehicleId, null);
});

test('snapshot sin vehicleId (retrocompat host) → vehicleId null', () => {
const withVehicle = makeShipment({
vehicleId: VEHICLE,
items: [],
containerIds: [],
});
const snap = withVehicle.toSnapshot();
// Simula un snapshot proveniente de un host que aún no persiste vehicleId:
// se omite la clave por completo (no null), y debe seguir funcionando.
const { vehicleId, ...withoutVehicleId } = snap;
const s = Shipment.fromSnapshot(withoutVehicleId as ShipmentSnapshot);
assert.equal(s.vehicleId, null);
});
27 changes: 26 additions & 1 deletion packages/warehouse-core/src/logistics/shipment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
InvalidShipmentRouteError,
InvalidShipmentTransitionError,
ShipmentMustHaveCargoError,
VehicleShipmentCargoError,
} from './shipment-errors.js';
import { DomainEvent } from '../kernel/index.js';
import { ShipmentDelivered } from './events/shipment-delivered.event.js';
Expand Down Expand Up @@ -42,6 +43,12 @@ export interface CreateShipmentProps {
*/
hubId?: string | null;
manifest: string | null;
/**
* Si está presente, la carga del viaje ES el inventario del vehículo
* (`Warehouse` kind=vehicle) — `items`/`containerIds` deben ir vacíos (los
* dos modos son excluyentes, ver {@link VehicleShipmentCargoError}).
*/
vehicleId?: string | null;
}

export interface ShipmentSnapshot {
Expand All @@ -60,6 +67,11 @@ export interface ShipmentSnapshot {
status: ShipmentStatus;
createdAt: Date;
updatedAt: Date;
/**
* Opcional — no-breaking para hosts que aún no persisten la columna
* `vehicle_id` (frontera 1:1, follow-up de persistencia en ResponseGrid).
*/
vehicleId?: string | null;
}

/**
Expand Down Expand Up @@ -94,6 +106,7 @@ export class Shipment {
private _carrier: CarrierPrincipal | null,
public readonly hubId: string | null,
public readonly manifest: string | null,
public readonly vehicleId: string | null,
private _status: ShipmentStatus,
public readonly createdAt: Date,
private _updatedAt: Date,
Expand All @@ -112,7 +125,16 @@ export class Shipment {
// containers carries nothing.
const containerIds = [...new Set(props.containerIds)];
containerIds.forEach((id) => Shipment.assertUuid(id, 'containerId'));
if (props.items.length === 0 && containerIds.length === 0) {
// Modos excluyentes: en modo vehículo la carga ES el inventario del
// vehículo, así que la carga suelta debe ir vacía; sin vehículo, se exige
// el comportamiento actual (al menos una línea o container).
const vehicleId = props.vehicleId ?? null;
if (vehicleId !== null) {
Shipment.assertUuid(vehicleId, 'vehicleId');
if (props.items.length > 0 || containerIds.length > 0) {
throw new VehicleShipmentCargoError();
}
} else if (props.items.length === 0 && containerIds.length === 0) {
throw new ShipmentMustHaveCargoError();
}
const code = props.code.trim();
Expand All @@ -136,6 +158,7 @@ export class Shipment {
null,
hubId,
props.manifest,
vehicleId,
ShipmentStatus.Planned,
now,
now,
Expand All @@ -157,6 +180,7 @@ export class Shipment {
: null,
s.hubId,
s.manifest,
s.vehicleId ?? null,
s.status,
s.createdAt,
s.updatedAt,
Expand Down Expand Up @@ -267,6 +291,7 @@ export class Shipment {
carrierId: this._carrier?.id ?? null,
hubId: this.hubId,
manifest: this.manifest,
vehicleId: this.vehicleId,
status: this._status,
createdAt: this.createdAt,
updatedAt: this._updatedAt,
Expand Down
Loading