diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 960589b7..83071620 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -7396,7 +7396,8 @@ "type": "string", "enum": [ "resource", - "shipment" + "shipment", + "vehicle" ] } }, @@ -15551,7 +15552,8 @@ "type": "string", "enum": [ "resource", - "shipment" + "shipment", + "vehicle" ], "example": "resource" }, @@ -15707,7 +15709,8 @@ "type": "string", "enum": [ "resource", - "shipment" + "shipment", + "vehicle" ], "nullable": true, "example": "resource" @@ -15814,7 +15817,8 @@ "type": "string", "enum": [ "resource", - "shipment" + "shipment", + "vehicle" ], "nullable": true, "example": "resource" diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index 3743dec4..49d0704f 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -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) @@ -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; /** @@ -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; /** @@ -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; diff --git a/packages/warehouse-core/src/containers/container-enums.ts b/packages/warehouse-core/src/containers/container-enums.ts index f2375ce0..72004114 100644 --- a/packages/warehouse-core/src/containers/container-enums.ts +++ b/packages/warehouse-core/src/containers/container-enums.ts @@ -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', } diff --git a/packages/warehouse-core/src/containers/container.test.ts b/packages/warehouse-core/src/containers/container.test.ts new file mode 100644 index 00000000..53b8c8a1 --- /dev/null +++ b/packages/warehouse-core/src/containers/container.test.ts @@ -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); +}); diff --git a/packages/warehouse-core/src/logistics/shipment-errors.ts b/packages/warehouse-core/src/logistics/shipment-errors.ts index e4aa3acc..9127f6d4 100644 --- a/packages/warehouse-core/src/logistics/shipment-errors.ts +++ b/packages/warehouse-core/src/logistics/shipment-errors.ts @@ -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 diff --git a/packages/warehouse-core/src/logistics/shipment.test.ts b/packages/warehouse-core/src/logistics/shipment.test.ts new file mode 100644 index 00000000..9ae0ce1c --- /dev/null +++ b/packages/warehouse-core/src/logistics/shipment.test.ts @@ -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): 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); +}); diff --git a/packages/warehouse-core/src/logistics/shipment.ts b/packages/warehouse-core/src/logistics/shipment.ts index fc8aa849..0865859d 100644 --- a/packages/warehouse-core/src/logistics/shipment.ts +++ b/packages/warehouse-core/src/logistics/shipment.ts @@ -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'; @@ -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 { @@ -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; } /** @@ -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, @@ -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(); @@ -136,6 +158,7 @@ export class Shipment { null, hubId, props.manifest, + vehicleId, ShipmentStatus.Planned, now, now, @@ -157,6 +180,7 @@ export class Shipment { : null, s.hubId, s.manifest, + s.vehicleId ?? null, s.status, s.createdAt, s.updatedAt, @@ -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,