diff --git a/apps/edr-freight-api/src/migrations/1790000000001-WarehouseBatch2.ts b/apps/edr-freight-api/src/migrations/1790000000001-WarehouseBatch2.ts new file mode 100644 index 000000000..a1431d7bd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1790000000001-WarehouseBatch2.ts @@ -0,0 +1,123 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class WarehouseBatch21790000000001 implements MigrationInterface { + name = 'WarehouseBatch21790000000001'; + + public async up(queryRunner: QueryRunner): Promise { + // ── Capacity columns (weight + volume) on warehouse / yard / zone ────── + for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) { + await queryRunner.query(` + ALTER TABLE freight.${table} + ADD COLUMN IF NOT EXISTS max_weight NUMERIC(14,3) NULL, + ADD COLUMN IF NOT EXISTS max_volume NUMERIC(14,3) NULL, + ADD COLUMN IF NOT EXISTS current_volume NUMERIC(14,3) NOT NULL DEFAULT 0; + `); + // Backfill max_weight from the Batch 1 capacity_weight column. + await queryRunner.query(` + UPDATE freight.${table} SET max_weight = capacity_weight WHERE max_weight IS NULL; + `); + } + + // ── Inventory lifecycle: migrate Batch 1 statuses to Batch 2 set ─────── + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ALTER COLUMN status SET DEFAULT 'RECEIVED'; + `); + await queryRunner.query(` + UPDATE freight.warehouse_inventory SET status = 'RECEIVED' WHERE status = 'ARRIVED_AT_WAREHOUSE'; + `); + await queryRunner.query(` + UPDATE freight.warehouse_inventory SET status = 'STORED' WHERE status = 'UNDER_INSPECTION'; + `); + + // ── New lifecycle timestamps ────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS stored_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ NULL, + ADD COLUMN IF NOT EXISTS dispatched_at TIMESTAMPTZ NULL; + `); + + // booking_id becomes nullable (inventory can exist before booking linkage). + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory ALTER COLUMN booking_id DROP NOT NULL; + `); + + // ── Movement history ────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_inventory_movement ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE, + from_warehouse_id UUID NOT NULL, + from_yard_id UUID NOT NULL, + from_zone_id UUID NOT NULL, + to_warehouse_id UUID NOT NULL, + to_yard_id UUID NOT NULL, + to_zone_id UUID NOT NULL, + remarks TEXT NULL, + moved_by VARCHAR(120) NULL, + moved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_movement_inventory_id + ON freight.warehouse_inventory_movement(inventory_id); + `); + + // ── Activity log ────────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_activity_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + inventory_id UUID NULL, + warehouse_id UUID NULL, + activity_type VARCHAR(40) NOT NULL, + description TEXT NULL, + performed_by VARCHAR(120) NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_inventory_id + ON freight.warehouse_activity_log(inventory_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_warehouse_id + ON freight.warehouse_activity_log(warehouse_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_activity_type + ON freight.warehouse_activity_log(activity_type); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_activity_log;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory_movement;`); + + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + DROP COLUMN IF EXISTS stored_at, + DROP COLUMN IF EXISTS reserved_at, + DROP COLUMN IF EXISTS loaded_at, + DROP COLUMN IF EXISTS dispatched_at; + `); + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory ALTER COLUMN status SET DEFAULT 'RECEIVED'; + `); + + for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) { + await queryRunner.query(` + ALTER TABLE freight.${table} + DROP COLUMN IF EXISTS max_weight, + DROP COLUMN IF EXISTS max_volume, + DROP COLUMN IF EXISTS current_volume; + `); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index 18dc12c78..ccdda90d8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -34,4 +34,16 @@ export class CreateWarehouseYardDto { @IsNumber() @Min(0) capacityContainers?: number; + + @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @IsOptional() + @IsNumber() + @Min(0) + maxWeight?: number; + + @ApiPropertyOptional({ description: 'Max volume capacity (m³).' }) + @IsOptional() + @IsNumber() + @Min(0) + maxVolume?: number; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts index bd6f08e51..fbb057fd5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-zone.dto.ts @@ -34,4 +34,16 @@ export class CreateWarehouseZoneDto { @IsNumber() @Min(0) capacityContainers?: number; + + @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @IsOptional() + @IsNumber() + @Min(0) + maxWeight?: number; + + @ApiPropertyOptional({ description: 'Max volume capacity (m³).' }) + @IsOptional() + @IsNumber() + @Min(0) + maxVolume?: number; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts index 0fdb48fa0..3760033b1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -40,4 +40,16 @@ export class CreateWarehouseDto { @IsNumber() @Min(0) capacityContainers?: number; + + @ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' }) + @IsOptional() + @IsNumber() + @Min(0) + maxWeight?: number; + + @ApiPropertyOptional({ description: 'Max volume capacity (m³).' }) + @IsOptional() + @IsNumber() + @Min(0) + maxVolume?: number; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts new file mode 100644 index 000000000..1aae7896f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/move-inventory.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class MoveInventoryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + movedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts index b0dc16282..46fecb044 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/receive-inventory.dto.ts @@ -14,9 +14,10 @@ export class ReceiveWarehouseInventoryDto { @IsUUID() zoneId!: string; - @ApiProperty({ format: 'uuid' }) + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() @IsUUID() - bookingId!: string; + bookingId?: string; @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @@ -53,4 +54,9 @@ export class ReceiveWarehouseInventoryDto { @IsOptional() @IsString() notes?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; } diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/reserve-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/reserve-inventory.dto.ts new file mode 100644 index 000000000..f1cb05d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/reserve-inventory.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class ReserveInventoryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + bookingId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + inventoryId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts new file mode 100644 index 000000000..4521bb808 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const WAREHOUSE_ACTIVITY_TYPES = [ + 'INVENTORY_RECEIVED', + 'INVENTORY_STORED', + 'INVENTORY_MOVED', + 'INVENTORY_RESERVED', + 'READY_FOR_LOADING', + 'INVENTORY_LOADED', + 'INVENTORY_DISPATCHED', +] as const; +export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_activity_log' }) +@Index(['inventoryId']) +@Index(['warehouseId']) +@Index(['activityType']) +export class WarehouseActivityLog extends BaseEntity { + @Column({ name: 'inventory_id', type: 'uuid', nullable: true }) + inventoryId?: string | null; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string | null; + + @Column({ name: 'activity_type', type: 'varchar', length: 40 }) + activityType!: WarehouseActivityType; + + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + @Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true }) + performedBy?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory-movement.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory-movement.entity.ts new file mode 100644 index 000000000..4ecfdc0dd --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory-movement.entity.ts @@ -0,0 +1,42 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseInventory } from './warehouse-inventory.entity'; + +@Entity({ schema: 'freight', name: 'warehouse_inventory_movement' }) +@Index(['inventoryId']) +export class WarehouseInventoryMovement extends BaseEntity { + @Column({ name: 'inventory_id', type: 'uuid' }) + inventoryId!: string; + + @ManyToOne(() => WarehouseInventory, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'inventory_id' }) + inventory?: WarehouseInventory; + + @Column({ name: 'from_warehouse_id', type: 'uuid' }) + fromWarehouseId!: string; + + @Column({ name: 'from_yard_id', type: 'uuid' }) + fromYardId!: string; + + @Column({ name: 'from_zone_id', type: 'uuid' }) + fromZoneId!: string; + + @Column({ name: 'to_warehouse_id', type: 'uuid' }) + toWarehouseId!: string; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @Column({ name: 'to_zone_id', type: 'uuid' }) + toZoneId!: string; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string | null; + + @Column({ name: 'moved_by', type: 'varchar', length: 120, nullable: true }) + movedBy?: string | null; + + @Column({ name: 'moved_at', type: 'timestamptz' }) + movedAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 7cdb99aeb..d957f794d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -1,17 +1,35 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Cargo } from '../../cargoes/entities/cargoes.entity'; +import { Container } from '../../container-management/entities/container.entity'; import { Warehouse } from './warehouse.entity'; import { WarehouseYard } from './warehouse-yard.entity'; import { WarehouseZone } from './warehouse-zone.entity'; +// Batch 2 lifecycle. Supersedes the Batch 1 set +// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place. export const WAREHOUSE_INVENTORY_STATUSES = [ - 'ARRIVED_AT_WAREHOUSE', - 'UNDER_INSPECTION', + 'RECEIVED', + 'STORED', + 'RESERVED', 'READY_FOR_LOADING', + 'LOADED', + 'DISPATCHED', ] as const; export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number]; +/** Allowed forward transitions for the inventory lifecycle. */ +export const WAREHOUSE_INVENTORY_TRANSITIONS: Record = { + RECEIVED: ['STORED'], + STORED: ['RESERVED'], + RESERVED: ['READY_FOR_LOADING'], + READY_FOR_LOADING: ['LOADED'], + LOADED: ['DISPATCHED'], + DISPATCHED: [], +}; + @Entity({ schema: 'freight', name: 'warehouse_inventory' }) @Index(['warehouseId']) @Index(['yardId']) @@ -43,15 +61,27 @@ export class WarehouseInventory extends BaseEntity { @JoinColumn({ name: 'zone_id' }) zone?: WarehouseZone; - @Column({ name: 'booking_id', type: 'uuid' }) - bookingId!: string; + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; @Column({ name: 'cargo_id', type: 'uuid', nullable: true }) cargoId?: string | null; + @ManyToOne(() => Cargo, { nullable: true }) + @JoinColumn({ name: 'cargo_id' }) + cargo?: Cargo | null; + @Column({ name: 'container_id', type: 'uuid', nullable: true }) containerId?: string | null; + @ManyToOne(() => Container, { nullable: true }) + @JoinColumn({ name: 'container_id' }) + container?: Container | null; + @Column({ name: 'goods_id', type: 'uuid', nullable: true }) goodsId?: string | null; @@ -64,18 +94,30 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true }) volume?: number | null; - @Column({ name: 'status', type: 'varchar', length: 32, default: 'ARRIVED_AT_WAREHOUSE' }) + @Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' }) status!: WarehouseInventoryStatus; @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) arrivedAt?: Date | null; + @Column({ name: 'stored_at', type: 'timestamptz', nullable: true }) + storedAt?: Date | null; + + @Column({ name: 'reserved_at', type: 'timestamptz', nullable: true }) + reservedAt?: Date | null; + @Column({ name: 'inspected_at', type: 'timestamptz', nullable: true }) inspectedAt?: Date | null; @Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true }) readyForLoadingAt?: Date | null; + @Column({ name: 'loaded_at', type: 'timestamptz', nullable: true }) + loadedAt?: Date | null; + + @Column({ name: 'dispatched_at', type: 'timestamptz', nullable: true }) + dispatchedAt?: Date | null; + @Column({ name: 'notes', type: 'text', nullable: true }) notes?: string | null; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts index 90d381580..6e3c93292 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts @@ -49,6 +49,15 @@ export class WarehouseYard extends BaseEntity { @Column({ name: 'current_containers', type: 'int', default: 0 }) currentContainers!: number; + @Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxWeight?: number | null; + + @Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxVolume?: number | null; + + @Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentVolume!: number; + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) status!: WarehouseYardStatus; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts index 5f9996809..9cfaad6de 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-zone.entity.ts @@ -48,6 +48,15 @@ export class WarehouseZone extends BaseEntity { @Column({ name: 'current_containers', type: 'int', default: 0 }) currentContainers!: number; + @Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxWeight?: number | null; + + @Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxVolume?: number | null; + + @Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentVolume!: number; + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) status!: WarehouseZoneStatus; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts index ee04f77e9..44ce1f64d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -42,6 +42,16 @@ export class Warehouse extends BaseEntity { @Column({ name: 'current_containers', type: 'int', default: 0 }) currentContainers!: number; + // Batch 2 capacity (weight + volume). maxWeight backfilled from capacityWeight. + @Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxWeight?: number | null; + + @Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true }) + maxVolume?: number | null; + + @Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 }) + currentVolume!: number; + @Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' }) status!: WarehouseStatus; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.repository.ts new file mode 100644 index 000000000..2e4eb7fae --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.repository.ts @@ -0,0 +1,13 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; + +@Injectable() +export class WarehouseActivityLogRepository extends BaseRepository { + constructor(@InjectRepository(WarehouseActivityLog) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.service.ts new file mode 100644 index 000000000..74c823a03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-activity-log.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager } from 'typeorm'; + +import { + WarehouseActivityLog, + WarehouseActivityType, +} from './entities/warehouse-activity-log.entity'; +import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository'; + +interface LogInput { + activityType: WarehouseActivityType; + description?: string; + inventoryId?: string | null; + warehouseId?: string | null; + performedBy?: string | null; +} + +@Injectable() +export class WarehouseActivityLogService { + constructor(private readonly logRepository: WarehouseActivityLogRepository) {} + + /** Persist an activity record. Pass a transaction manager to enrol in the caller's transaction. */ + async record(input: LogInput, manager?: EntityManager): Promise { + const data = { + activityType: input.activityType, + description: input.description ?? null, + inventoryId: input.inventoryId ?? null, + warehouseId: input.warehouseId ?? null, + performedBy: input.performedBy ?? 'system', + }; + + if (manager) { + await manager.getRepository(WarehouseActivityLog).save( + manager.getRepository(WarehouseActivityLog).create(data), + ); + return; + } + + await this.logRepository.create(data); + } + + findByInventory(inventoryId: string): Promise { + return this.logRepository.findAll({ + where: { inventoryId }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts new file mode 100644 index 000000000..1bb5b1289 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Warehouse } from './entities/warehouse.entity'; +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; + +export interface WarehouseDashboard { + totalWarehouses: number; + totalInventory: number; + receivedToday: number; + stored: number; + reserved: number; + readyForLoading: number; + loaded: number; + dispatched: number; +} + +@Injectable() +export class WarehouseDashboardService { + constructor(private readonly dataSource: DataSource) {} + + async getDashboard(): Promise { + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + + const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] = + await Promise.all([ + warehouseRepo.count(), + inventoryRepo.count(), + inventoryRepo.count({ where: { status: 'STORED' } }), + inventoryRepo.count({ where: { status: 'RESERVED' } }), + inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }), + inventoryRepo.count({ where: { status: 'LOADED' } }), + inventoryRepo.count({ where: { status: 'DISPATCHED' } }), + inventoryRepo + .createQueryBuilder('inv') + .where('inv.arrived_at >= :start', { start: startOfToday }) + .getCount(), + ]); + + return { + totalWarehouses, + totalInventory, + receivedToday, + stored, + reserved, + readyForLoading, + loaded, + dispatched, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory-movement.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory-movement.repository.ts new file mode 100644 index 000000000..ccc0bcd41 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory-movement.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; + +@Injectable() +export class WarehouseInventoryMovementRepository extends BaseRepository { + constructor( + @InjectRepository(WarehouseInventoryMovement) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 27282fc1d..58b125209 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -1,9 +1,11 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; +import { MoveInventoryDto } from './dto/move-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { WarehouseInventoryService } from './warehouse-inventory.service'; @ApiTags('warehouse-inventory') @@ -36,15 +38,51 @@ export class WarehouseInventoryController { return this.inventoryService.receive(dto); } - @Patch(':id/inspect') - @ApiOperation({ summary: 'Move inventory to UNDER_INSPECTION' }) - inspect(@Param('id', ParseUUIDPipe) id: string) { - return this.inventoryService.inspect(id); + @Post('reserve') + @ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' }) + reserve(@Body() dto: ReserveInventoryDto) { + return this.inventoryService.reserve(dto); } - @Patch(':id/ready-for-loading') - @ApiOperation({ summary: 'Move inventory to READY_FOR_LOADING' }) - readyForLoading(@Param('id', ParseUUIDPipe) id: string) { - return this.inventoryService.readyForLoading(id); + @Get(':id/movements') + @ApiOperation({ summary: 'Inventory movement history' }) + movements(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.findMovements(id); + } + + @Get(':id/activity') + @ApiOperation({ summary: 'Inventory activity log' }) + activity(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.findActivity(id); + } + + @Post(':id/move') + @ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' }) + move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) { + return this.inventoryService.move(id, dto); + } + + @Post(':id/store') + @ApiOperation({ summary: 'Mark received inventory as STORED' }) + store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.store(id, performedBy); + } + + @Post(':id/ready-for-loading') + @ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' }) + readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.readyForLoading(id, performedBy); + } + + @Post(':id/load') + @ApiOperation({ summary: 'Mark inventory LOADED' }) + load(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.load(id, performedBy); + } + + @Post(':id/dispatch') + @ApiOperation({ summary: 'Mark inventory DISPATCHED' }) + dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.dispatch(id, performedBy); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 18f7c0f61..34ebc2171 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -3,16 +3,25 @@ import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; +import { MoveInventoryDto } from './dto/move-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; -import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; +import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; +import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; +import { + WAREHOUSE_INVENTORY_TRANSITIONS, + WarehouseInventory, + WarehouseInventoryStatus, +} from './entities/warehouse-inventory.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; import { WarehouseZone } from './entities/warehouse-zone.entity'; import { Warehouse } from './entities/warehouse.entity'; +import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; export interface InventoryInquiryResult { id: string; - bookingId: string; + bookingId: string | null; bookingNumber: string | null; customerName: string | null; containerNumber: string | null; @@ -29,11 +38,22 @@ export interface InventoryInquiryResult { readyForLoadingAt: Date | null; } +interface LocationNode { + maxWeight?: number | null; + capacityWeight?: number | null; + maxVolume?: number | null; + capacityContainers?: number | null; + currentWeight: number; + currentVolume: number; + currentContainers: number; +} + @Injectable() export class WarehouseInventoryService { constructor( private readonly dataSource: DataSource, private readonly inventoryRepository: WarehouseInventoryRepository, + private readonly activityLog: WarehouseActivityLogService, ) {} // ── Listing ──────────────────────────────────────────────────────────── @@ -78,20 +98,23 @@ export class WarehouseInventoryService { return item; } - // ── Receive (with location + capacity validation) ────────────────────── + // ── Receive ────────────────────────────────────────────────────────────── async receive(dto: ReceiveWarehouseInventoryDto): Promise { const weight = Number(dto.weight) || 0; + const volume = Number(dto.volume) || 0; const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0; const id = await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, dto); - await this.assertBookingExists(manager, dto.bookingId); + if (dto.bookingId) { + await this.assertBookingExists(manager, dto.bookingId); + } - this.assertCapacity('Warehouse', warehouse, weight, containerCount); - this.assertCapacity('Yard', yard, weight, containerCount); - this.assertCapacity('Zone', zone, weight, containerCount); + this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount); + this.assertCapacity('Yard', yard, weight, volume, containerCount); + this.assertCapacity('Zone', zone, weight, volume, containerCount); const now = new Date(); const saved = await manager.getRepository(WarehouseInventory).save( @@ -99,20 +122,31 @@ export class WarehouseInventoryService { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, - bookingId: dto.bookingId, + bookingId: dto.bookingId ?? null, cargoId: dto.cargoId ?? null, containerId: dto.containerId ?? null, goodsId: dto.goodsId ?? null, quantity: Number(dto.quantity) || 0, weight, volume: dto.volume ?? null, - status: 'ARRIVED_AT_WAREHOUSE', + status: 'RECEIVED', arrivedAt: now, notes: dto.notes?.trim() ?? null, }), ); - await this.applyCapacityDelta(manager, dto, weight, containerCount); + await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); + + await this.activityLog.record( + { + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `Received ${weight}kg at warehouse location`, + performedBy: dto.performedBy, + }, + manager, + ); return saved.id; }); @@ -120,43 +154,181 @@ export class WarehouseInventoryService { return this.findById(id); } - // ── Status transitions ───────────────────────────────────────────────── + // ── Lifecycle transitions ──────────────────────────────────────────────── - async inspect(id: string): Promise { - const item = await this.findById(id); + store(id: string, performedBy?: string): Promise { + return this.transition(id, 'STORED', { + timestampField: 'storedAt', + activityType: 'INVENTORY_STORED', + description: 'Inventory stored', + performedBy, + }); + } - if (item.status !== 'ARRIVED_AT_WAREHOUSE') { - throw new BadRequestException( - `Only items in ARRIVED_AT_WAREHOUSE can be inspected (current: ${item.status})`, - ); + async reserve(dto: ReserveInventoryDto): Promise { + const item = await this.findById(dto.inventoryId); + + if (item.status !== 'STORED') { + throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`); } - await this.inventoryRepository.update(id, { - status: 'UNDER_INSPECTION', - inspectedAt: new Date(), + const status = await this.getBookingStatus(dto.bookingId); + if (!status) { + throw new NotFoundException(`Booking ${dto.bookingId} not found`); + } + if (status !== 'PAID') { + throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(dto.inventoryId, { + status: 'RESERVED', + bookingId: dto.bookingId, + reservedAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_RESERVED', + inventoryId: dto.inventoryId, + warehouseId: item.warehouseId, + description: `Reserved for booking ${dto.bookingId}`, + performedBy: dto.performedBy, + }, + manager, + ); + }); + + return this.findById(dto.inventoryId); + } + + async readyForLoading(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) { + throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep'); + } + return this.transition(id, 'READY_FOR_LOADING', { + timestampField: 'readyForLoadingAt', + activityType: 'READY_FOR_LOADING', + description: 'Inventory ready for loading', + performedBy, + preloaded: item, + }); + } + + load(id: string, performedBy?: string): Promise { + return this.transition(id, 'LOADED', { + timestampField: 'loadedAt', + activityType: 'INVENTORY_LOADED', + description: 'Inventory loaded', + performedBy, + }); + } + + async dispatch(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + this.assertTransition(item.status, 'DISPATCHED'); + + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + status: 'DISPATCHED', + dispatchedAt: new Date(), + }); + // Item physically leaves the warehouse — free up capacity. + await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); + await this.activityLog.record( + { + activityType: 'INVENTORY_DISPATCHED', + inventoryId: id, + warehouseId: item.warehouseId, + description: 'Inventory dispatched', + performedBy, + }, + manager, + ); }); return this.findById(id); } - async readyForLoading(id: string): Promise { - const item = await this.findById(id); + // ── Movement ────────────────────────────────────────────────────────────── - if (item.status !== 'UNDER_INSPECTION') { - throw new BadRequestException( - `Only items in UNDER_INSPECTION can be marked READY_FOR_LOADING (current: ${item.status})`, - ); + async move(id: string, dto: MoveInventoryDto): Promise { + const item = await this.findById(id); + if (item.status === 'DISPATCHED') { + throw new BadRequestException('Dispatched inventory cannot be moved'); } - await this.inventoryRepository.update(id, { - status: 'READY_FOR_LOADING', - readyForLoadingAt: new Date(), + const weight = Number(item.weight) || 0; + const volume = Number(item.volume) || 0; + const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0; + + const from = { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId }; + + await this.dataSource.transaction(async (manager) => { + const { warehouse } = await this.validateLocation(manager, dto); + + // Capacity check at the destination (item is added there). + const dest = await this.loadLocation(manager, dto); + this.assertCapacity('Warehouse', dest.warehouse, weight, volume, containerCount); + this.assertCapacity('Yard', dest.yard, weight, volume, containerCount); + this.assertCapacity('Zone', dest.zone, weight, volume, containerCount); + + // Free the old location, occupy the new one. + await this.applyCapacityDelta(manager, from.warehouseId, from.yardId, from.zoneId, weight, volume, containerCount, -1); + await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1); + + await manager.getRepository(WarehouseInventory).update(id, { + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + }); + + await manager.getRepository(WarehouseInventoryMovement).save( + manager.getRepository(WarehouseInventoryMovement).create({ + inventoryId: id, + fromWarehouseId: from.warehouseId, + fromYardId: from.yardId, + fromZoneId: from.zoneId, + toWarehouseId: dto.warehouseId, + toYardId: dto.yardId, + toZoneId: dto.zoneId, + remarks: dto.remarks?.trim() ?? null, + movedBy: dto.movedBy ?? 'system', + movedAt: new Date(), + }), + ); + + await this.activityLog.record( + { + activityType: 'INVENTORY_MOVED', + inventoryId: id, + warehouseId: warehouse.id, + description: dto.remarks?.trim() || 'Inventory moved', + performedBy: dto.movedBy, + }, + manager, + ); }); return this.findById(id); } - // ── Inquiry ──────────────────────────────────────────────────────────── + findMovements(id: string): Promise { + return this.dataSource.getRepository(WarehouseInventoryMovement).find({ + where: { inventoryId: id }, + order: { movedAt: 'DESC' }, + }); + } + + findActivity(id: string): Promise { + return this.activityLog.findByInventory(id); + } + + // ── Inquiry (Batch 1) ────────────────────────────────────────────────── async inquiry(filter: InquiryWarehouseInventoryDto): Promise { const qb = this.dataSource @@ -187,21 +359,12 @@ export class WarehouseInventoryService { qb.andWhere('cargo_type.cargo_type_name ILIKE :ctype', { ctype: `%${filter.cargoType.trim()}%` }); } if (filter.goodsName?.trim()) { - // No dedicated goods entity in Batch 1 — best-effort match against notes / cargo description. qb.andWhere('(inv.notes ILIKE :gn OR cargo.description ILIKE :gn)', { gn: `%${filter.goodsName.trim()}%` }); } - if (filter.warehouseId) { - qb.andWhere('inv.warehouse_id = :wid', { wid: filter.warehouseId }); - } - if (filter.yardId) { - qb.andWhere('inv.yard_id = :yid', { yid: filter.yardId }); - } - if (filter.zoneId) { - qb.andWhere('inv.zone_id = :zid', { zid: filter.zoneId }); - } - if (filter.status) { - qb.andWhere('inv.status = :status', { status: filter.status }); - } + if (filter.warehouseId) qb.andWhere('inv.warehouse_id = :wid', { wid: filter.warehouseId }); + if (filter.yardId) qb.andWhere('inv.yard_id = :yid', { yid: filter.yardId }); + if (filter.zoneId) qb.andWhere('inv.zone_id = :zid', { zid: filter.zoneId }); + if (filter.status) qb.andWhere('inv.status = :status', { status: filter.status }); const { entities, raw } = await qb.getRawAndEntities(); @@ -209,7 +372,7 @@ export class WarehouseInventoryService { const row = raw[index] ?? {}; return { id: inv.id, - bookingId: inv.bookingId, + bookingId: inv.bookingId ?? null, bookingNumber: row.b_reference ?? null, customerName: row.c_name ?? null, containerNumber: row.ct_number ?? null, @@ -232,40 +395,71 @@ export class WarehouseInventoryService { // ── Helpers ────────────────────────────────────────────────────────────── + private async transition( + id: string, + to: WarehouseInventoryStatus, + opts: { + timestampField: keyof WarehouseInventory; + activityType: Parameters[0]['activityType']; + description: string; + performedBy?: string; + preloaded?: WarehouseInventory; + }, + ): Promise { + const item = opts.preloaded ?? (await this.findById(id)); + this.assertTransition(item.status, to); + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + status: to, + [opts.timestampField]: new Date(), + }); + await this.activityLog.record( + { + activityType: opts.activityType, + inventoryId: id, + warehouseId: item.warehouseId, + description: opts.description, + performedBy: opts.performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void { + if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) { + throw new BadRequestException(`Invalid transition ${from} → ${to}`); + } + } + private async validateLocation( manager: EntityManager, - dto: ReceiveWarehouseInventoryDto, + dto: { warehouseId: string; yardId: string; zoneId: string }, + ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { + const { warehouse, yard, zone } = await this.loadLocation(manager, dto); + + if (warehouse.status !== 'ACTIVE') throw new BadRequestException('Warehouse is not ACTIVE'); + if (yard.warehouseId !== warehouse.id) throw new BadRequestException('Yard does not belong to the selected warehouse'); + if (yard.status !== 'ACTIVE') throw new BadRequestException('Yard is not ACTIVE'); + if (zone.yardId !== yard.id) throw new BadRequestException('Zone does not belong to the selected yard'); + if (zone.status !== 'ACTIVE') throw new BadRequestException('Zone is not ACTIVE'); + + return { warehouse, yard, zone }; + } + + private async loadLocation( + manager: EntityManager, + dto: { warehouseId: string; yardId: string; zoneId: string }, ): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> { const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } }); - if (!warehouse) { - throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`); - } - if (warehouse.status !== 'ACTIVE') { - throw new BadRequestException('Warehouse is not ACTIVE'); - } - + if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`); const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: dto.yardId } }); - if (!yard) { - throw new NotFoundException(`Yard ${dto.yardId} not found`); - } - if (yard.warehouseId !== warehouse.id) { - throw new BadRequestException('Yard does not belong to the selected warehouse'); - } - if (yard.status !== 'ACTIVE') { - throw new BadRequestException('Yard is not ACTIVE'); - } - + if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`); const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } }); - if (!zone) { - throw new NotFoundException(`Zone ${dto.zoneId} not found`); - } - if (zone.yardId !== yard.id) { - throw new BadRequestException('Zone does not belong to the selected yard'); - } - if (zone.status !== 'ACTIVE') { - throw new BadRequestException('Zone is not ACTIVE'); - } - + if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`); return { warehouse, yard, zone }; } @@ -279,45 +473,63 @@ export class WarehouseInventoryService { } } + private async getBookingStatus(bookingId: string): Promise { + const rows = await this.dataSource.query( + 'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1', + [bookingId], + ); + return rows?.[0]?.status ?? null; + } + private assertCapacity( label: string, - node: { capacityWeight?: number | null; capacityContainers?: number | null; currentWeight: number; currentContainers: number }, + node: LocationNode, weightAdd: number, + volumeAdd: number, containerAdd: number, ): void { - if (node.capacityWeight != null) { + const maxWeight = node.maxWeight ?? node.capacityWeight; + if (maxWeight != null) { const projected = Number(node.currentWeight) + weightAdd; - if (projected > Number(node.capacityWeight)) { - throw new BadRequestException( - `${label} weight capacity exceeded (${projected} / ${node.capacityWeight})`, - ); + if (projected > Number(maxWeight)) { + throw new BadRequestException(`${label} weight capacity exceeded (${projected} / ${maxWeight})`); + } + } + if (node.maxVolume != null && volumeAdd > 0) { + const projected = Number(node.currentVolume) + volumeAdd; + if (projected > Number(node.maxVolume)) { + throw new BadRequestException(`${label} volume capacity exceeded (${projected} / ${node.maxVolume})`); } } - if (node.capacityContainers != null && containerAdd > 0) { const projected = Number(node.currentContainers) + containerAdd; if (projected > Number(node.capacityContainers)) { - throw new BadRequestException( - `${label} container capacity exceeded (${projected} / ${node.capacityContainers})`, - ); + throw new BadRequestException(`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`); } } } private async applyCapacityDelta( manager: EntityManager, - dto: ReceiveWarehouseInventoryDto, - weightAdd: number, - containerAdd: number, + warehouseId: string, + yardId: string, + zoneId: string, + weight: number, + volume: number, + containers: number, + sign: 1 | -1, ): Promise { - await manager.increment(Warehouse, { id: dto.warehouseId }, 'currentWeight', weightAdd); - await manager.increment(WarehouseYard, { id: dto.yardId }, 'currentWeight', weightAdd); - await manager.increment(WarehouseZone, { id: dto.zoneId }, 'currentWeight', weightAdd); + const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager); + const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [ + [Warehouse, warehouseId], + [WarehouseYard, yardId], + [WarehouseZone, zoneId], + ]; - if (containerAdd > 0) { - await manager.increment(Warehouse, { id: dto.warehouseId }, 'currentContainers', containerAdd); - await manager.increment(WarehouseYard, { id: dto.yardId }, 'currentContainers', containerAdd); - await manager.increment(WarehouseZone, { id: dto.zoneId }, 'currentContainers', containerAdd); + for (const [entity, id] of targets) { + if (weight) await apply(entity, { id }, 'currentWeight', weight); + if (volume) await apply(entity, { id }, 'currentVolume', volume); + if (containers) await apply(entity, { id }, 'currentContainers', containers); } } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts new file mode 100644 index 000000000..dcf685c9f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts @@ -0,0 +1,63 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; + +/** + * READ-ONLY bridge that exposes warehouse inventory to the Train Scheduling + * domain. It only reads warehouse data — it never assigns wagons, creates or + * mutates schedules, and is intentionally NOT imported by the scheduling module. + */ +@Injectable() +export class WarehouseSchedulingAdapterService { + constructor(private readonly dataSource: DataSource) {} + + private get repo() { + return this.dataSource.getRepository(WarehouseInventory); + } + + getReadyForLoadingInventory(): Promise { + return this.repo.find({ + where: { status: 'READY_FOR_LOADING' }, + relations: { warehouse: true, yard: true, zone: true }, + order: { readyForLoadingAt: 'ASC' }, + }); + } + + getReservedInventory(): Promise { + return this.repo.find({ + where: { status: 'RESERVED' }, + relations: { warehouse: true, yard: true, zone: true }, + order: { reservedAt: 'ASC' }, + }); + } + + getInventoryByBooking(bookingId: string): Promise { + return this.repo.find({ + where: { bookingId }, + relations: { warehouse: true, yard: true, zone: true }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Inventory whose origin booking runs on the given route. Best-effort, read-only: + * matches the route's origin/destination yards against the booking's yards. + */ + async getInventoryByRoute(routeId: string): Promise { + return this.repo + .createQueryBuilder('inv') + .leftJoinAndSelect('inv.warehouse', 'warehouse') + .leftJoinAndSelect('inv.yard', 'yard') + .leftJoinAndSelect('inv.zone', 'zone') + .innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') + .innerJoin( + 'freight.routes', + 'route', + 'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)', + { routeId }, + ) + .orderBy('inv.created_at', 'DESC') + .getMany(); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 84a8dc8da..f65e4593e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -45,8 +45,11 @@ export class WarehouseYardsService { type: dto.type, capacityWeight: dto.capacityWeight ?? null, capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, currentWeight: 0, currentContainers: 0, + currentVolume: 0, status: 'ACTIVE', isActive: true, }); @@ -67,6 +70,8 @@ export class WarehouseYardsService { type: dto.type ?? existing.type, capacityWeight: dto.capacityWeight ?? existing.capacityWeight, capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, status, isActive: status === 'ACTIVE', }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index d3689865e..a2f3800cd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -44,8 +44,11 @@ export class WarehouseZonesService { type: dto.type, capacityWeight: dto.capacityWeight ?? null, capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, currentWeight: 0, currentContainers: 0, + currentVolume: 0, status: 'ACTIVE', isActive: true, }); @@ -66,6 +69,8 @@ export class WarehouseZonesService { type: dto.type ?? existing.type, capacityWeight: dto.capacityWeight ?? existing.capacityWeight, capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, status, isActive: status === 'ACTIVE', }); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index fd52be8e2..bb7702603 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -5,6 +5,7 @@ import { CreateWarehouseDto } from './dto/create-warehouse.dto'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; import { UpdateWarehouseDto } from './dto/update-warehouse.dto'; +import { WarehouseDashboardService } from './warehouse-dashboard.service'; import { WarehouseYardsService } from './warehouse-yards.service'; import { WarehousesService } from './warehouses.service'; @@ -15,6 +16,7 @@ export class WarehousesController { constructor( private readonly warehousesService: WarehousesService, private readonly yardsService: WarehouseYardsService, + private readonly dashboardService: WarehouseDashboardService, ) {} @Get() @@ -23,6 +25,12 @@ export class WarehousesController { return this.warehousesService.findAll(filter); } + @Get('dashboard') + @ApiOperation({ summary: 'Warehouse dashboard metrics' }) + dashboard() { + return this.dashboardService.getDashboard(); + } + @Post() @ApiOperation({ summary: 'Create warehouse' }) create(@Body() dto: CreateWarehouseDto) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index e5ad8d371..72719a620 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -1,13 +1,20 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; import { WarehouseZone } from './entities/warehouse-zone.entity'; import { Warehouse } from './entities/warehouse.entity'; +import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository'; +import { WarehouseActivityLogService } from './warehouse-activity-log.service'; +import { WarehouseDashboardService } from './warehouse-dashboard.service'; import { WarehouseInventoryController } from './warehouse-inventory.controller'; +import { WarehouseInventoryMovementRepository } from './warehouse-inventory-movement.repository'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseInventoryService } from './warehouse-inventory.service'; +import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service'; import { WarehouseYardsController } from './warehouse-yards.controller'; import { WarehouseYardsRepository } from './warehouse-yards.repository'; import { WarehouseYardsService } from './warehouse-yards.service'; @@ -19,7 +26,16 @@ import { WarehousesRepository } from './warehouses.repository'; import { WarehousesService } from './warehouses.service'; @Module({ - imports: [TypeOrmModule.forFeature([Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory])], + imports: [ + TypeOrmModule.forFeature([ + Warehouse, + WarehouseYard, + WarehouseZone, + WarehouseInventory, + WarehouseInventoryMovement, + WarehouseActivityLog, + ]), + ], controllers: [ WarehousesController, WarehouseYardsController, @@ -31,11 +47,22 @@ import { WarehousesService } from './warehouses.service'; WarehouseYardsRepository, WarehouseZonesRepository, WarehouseInventoryRepository, + WarehouseInventoryMovementRepository, + WarehouseActivityLogRepository, WarehousesService, WarehouseYardsService, WarehouseZonesService, WarehouseInventoryService, + WarehouseActivityLogService, + WarehouseDashboardService, + WarehouseSchedulingAdapterService, + ], + exports: [ + WarehousesService, + WarehouseYardsService, + WarehouseZonesService, + WarehouseInventoryService, + WarehouseSchedulingAdapterService, ], - exports: [WarehousesService, WarehouseYardsService, WarehouseZonesService, WarehouseInventoryService], }) export class WarehousesModule {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index a77a02678..b4904f39d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -56,8 +56,11 @@ export class WarehousesService { locationName: dto.locationName?.trim() ?? null, capacityWeight: dto.capacityWeight ?? null, capacityContainers: dto.capacityContainers ?? null, + maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, + maxVolume: dto.maxVolume ?? null, currentWeight: 0, currentContainers: 0, + currentVolume: 0, status: 'ACTIVE', isActive: true, }); @@ -80,6 +83,8 @@ export class WarehousesService { locationName: dto.locationName?.trim() ?? existing.locationName, capacityWeight: dto.capacityWeight ?? existing.capacityWeight, capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + maxWeight: dto.maxWeight ?? existing.maxWeight, + maxVolume: dto.maxVolume ?? existing.maxVolume, status, isActive: status === 'ACTIVE', }); diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f5ceb858b..c71f85aff 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -49,6 +49,7 @@ import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { @@ -121,6 +122,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Warehouse Management", items: [ + { + label: "Warehouse Dashboard", + href: "/dashboard/warehouse-dashboard", + icon: , + }, { label: "Warehouses", href: "/dashboard/warehouses", @@ -284,6 +290,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx new file mode 100644 index 000000000..49e8828dd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ActivityTimeline.tsx @@ -0,0 +1,62 @@ +import { Center, Loader, Text, Timeline } from '@mantine/core'; +import { + ArrowRightLeft, + ClipboardCheck, + PackageCheck, + PackagePlus, + Send, + Truck, + Warehouse, +} from 'lucide-react'; + +import { useInventoryActivity } from '@/hooks/useWarehouses'; +import type { ActivityType } from '@/types/warehouse'; +import { formatDate, humanizeEnum } from './options'; + +const activityIcon: Record = { + INVENTORY_RECEIVED: , + INVENTORY_STORED: , + INVENTORY_MOVED: , + INVENTORY_RESERVED: , + READY_FOR_LOADING: , + INVENTORY_LOADED: , + INVENTORY_DISPATCHED: , +}; + +export function ActivityTimeline({ inventoryId }: { inventoryId: string }) { + const { data, isLoading } = useInventoryActivity(inventoryId); + const items = data ?? []; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (items.length === 0) { + return ( + + No activity recorded yet. + + ); + } + + return ( + + {items.map((log) => ( + + {log.description && ( + + {log.description} + + )} + + {log.performedBy ?? 'system'} · {formatDate(log.createdAt)} + + + ))} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/BookingSelect.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/BookingSelect.tsx new file mode 100644 index 000000000..5da428c29 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/BookingSelect.tsx @@ -0,0 +1,41 @@ +import { Select } from '@mantine/core'; +import { useQuery } from '@tanstack/react-query'; + +import { bookingsService } from '@/services/bookings.service'; + +interface BookingSelectProps { + value: string; + onChange: (bookingId: string) => void; + label?: string; + required?: boolean; + /** Comma-separated statuses to restrict the list (e.g. "PAID" for reservations). */ + statuses?: string; +} + +/** Searchable booking picker — shows the human reference (e.g. BKG-BULK-002), submits the UUID. */ +export function BookingSelect({ value, onChange, label = 'Booking', required, statuses }: BookingSelectProps) { + const { data, isLoading } = useQuery({ + queryKey: ['bookings', 'options', statuses ?? 'all'], + queryFn: () => + bookingsService.list({ pageSize: 200, ...(statuses ? { statuses } : {}) }).then((r) => r.items), + }); + + const options = (data ?? []).map((b) => ({ + value: b.id, + label: b.status ? `${b.reference} · ${b.status}` : b.reference, + })); + + return ( + { + setWarehouseId(v ?? ''); + setYardId(''); + setZoneId(''); + }} + /> + setZoneId(v ?? '')} + /> +