diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 7857d7c7e..a5ee4c293 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -47,6 +47,10 @@ import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; +import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; +import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; +import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; +import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; //New Trains, Wagons, Container and Cargo management modules @@ -129,6 +133,10 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module'; DemoFreightDataSeeder, IndodeFacilitySeeder, Batch14TestDataSeeder, + Batch5TestDataSeeder, + Batch7TestDataSeeder, + Batch8TestDataSeeder, + WarehouseDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -137,6 +145,14 @@ export class AppModule implements OnApplicationBootstrap { private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, + private readonly pricingDataSeeder: PricingDataSeeder, + private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, + private readonly indodeFacilitySeeder: IndodeFacilitySeeder, + private readonly batch14TestDataSeeder: Batch14TestDataSeeder, + private readonly batch5TestDataSeeder: Batch5TestDataSeeder, + private readonly batch7TestDataSeeder: Batch7TestDataSeeder, + private readonly batch8TestDataSeeder: Batch8TestDataSeeder, + private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } @@ -147,6 +163,16 @@ export class AppModule implements OnApplicationBootstrap { await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); await this.freightStaffUsersSeeder.run(); + await this.pricingDataSeeder.run(); + await this.fileUploadSettingsSeeder.run(); + await this.indodeFacilitySeeder.run(); + await this.batch14TestDataSeeder.run(); + await this.batch5TestDataSeeder.run(); + await this.batch7TestDataSeeder.run(); + await this.batch8TestDataSeeder.run(); + await this.warehouseDemoSeeder.run(); + // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. + // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, // FileUploadSettingsSeeder) are intentionally disabled — they stay // registered as providers but are not run. Re-inject + call .run() to enable. diff --git a/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts similarity index 97% rename from apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts rename to apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts index 05b1259f4..fa6087faa 100644 --- a/apps/edr-freight-api/src/migrations/1790000000000-AddWarehouseAllocationAndFeeRules.ts +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts @@ -2,9 +2,9 @@ import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; /** * Batch 5 — warehouse allocation rules, storage/demurrage fee rules, - * and demurrage lifecycle timestamps on inventory. + * and demurrage lifecycle timestamps on inventory. Idempotent. */ -export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface { +export class AddWarehouseAllocationAndFeeRules1791000000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ diff --git a/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts b/apps/edr-freight-api/src/migrations/1791000000001-AddWarehouseFeeInvoices.ts similarity index 96% rename from apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts rename to apps/edr-freight-api/src/migrations/1791000000001-AddWarehouseFeeInvoices.ts index c34eb240a..662a35739 100644 --- a/apps/edr-freight-api/src/migrations/1790000000001-AddWarehouseFeeInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1791000000001-AddWarehouseFeeInvoices.ts @@ -1,7 +1,7 @@ import { MigrationInterface, QueryRunner, Table } from 'typeorm'; -/** Batch 6 — warehouse fee invoices + invoice items. */ -export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface { +/** Batch 6 — warehouse fee invoices + invoice items. Idempotent (createTable ifNotExists). */ +export class AddWarehouseFeeInvoices1791000000001 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { await queryRunner.createTable( new Table({ diff --git a/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts b/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts new file mode 100644 index 000000000..3e272c859 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Import pickup branch on warehouse_inventory: + * - release_order_reference: DO / release order number sent to the customer + * - delivered_at: when the goods were handed over (proof of delivery) + * + * Idempotent: the shared dev DB may already carry some of these columns + * (added by another checkout), so only add what is missing. + */ +export class AddImportPickupDeliveryColumns1791000000002 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'release_order_reference'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'release_order_reference', type: 'varchar', length: '100', isNullable: true }), + ); + } + if (!(await queryRunner.hasColumn(this.table, 'delivered_at'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'delivered_at', type: 'timestamptz', isNullable: true }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn(this.table, 'release_order_reference')) { + await queryRunner.dropColumn(this.table, 'release_order_reference'); + } + if (await queryRunner.hasColumn(this.table, 'delivered_at')) { + await queryRunner.dropColumn(this.table, 'delivered_at'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts b/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts new file mode 100644 index 000000000..6009d9ea4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000003-AddInventoryUnloadedAt.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Batch 8 — train-arrival unload landing state on warehouse_inventory: + * - unloaded_at: when the goods were unloaded off the arrived train (before storage/inspection) + * + * The `status` column is a free varchar, so the new 'UNLOADED' value needs no schema change. + * Idempotent: the shared dev DB may already carry this column (added by another checkout). + */ +export class AddInventoryUnloadedAt1791000000003 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'unloaded_at'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'unloaded_at', type: 'timestamptz', isNullable: true }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasColumn(this.table, 'unloaded_at')) { + await queryRunner.dropColumn(this.table, 'unloaded_at'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts new file mode 100644 index 000000000..bfdc813f0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-inspect.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** Bulk-mark received inventory items as inspection PASSED. */ +export class BulkInspectDto { + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + inventoryIds!: string[]; + + @ApiPropertyOptional({ description: 'Inspection type label (e.g. ORIGIN_INSPECTION).' }) + @IsOptional() + @IsString() + inspectionType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + inspectedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts new file mode 100644 index 000000000..9bb734512 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; + +/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */ +export class BulkReceiveDto { + @ApiProperty({ enum: ['IMPORT', 'EXPORT'] }) + @IsIn(['IMPORT', 'EXPORT']) + direction!: 'IMPORT' | 'EXPORT'; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + warehouseId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + yardId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + zoneId!: string; + + @ApiProperty({ type: [String], format: 'uuid' }) + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + bookingIds!: string[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts new file mode 100644 index 000000000..b2491472d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +/** Proof of delivery captured when import goods are handed over to the customer. */ +export class DeliverInventoryDto { + @ApiProperty({ description: 'Name of the person who received the goods' }) + @IsString() + receiverName!: string; + + @ApiPropertyOptional({ description: 'When the goods were delivered (defaults to now)' }) + @IsOptional() + @IsDateString() + deliveredAt?: string; + + @ApiPropertyOptional({ description: 'Delivery remarks / notes' }) + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + performedBy?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts new file mode 100644 index 000000000..9d4e3eb4f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -0,0 +1,20 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +/** Records a DO / release order being sent to the customer for import pickup. */ +export class ReleaseOrderDto { + @ApiPropertyOptional({ description: 'DO / release order reference number' }) + @IsOptional() + @IsString() + reference?: string; + + @ApiPropertyOptional({ description: 'Release date (defaults to now)' }) + @IsOptional() + @IsDateString() + releaseDate?: 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 index 4521bb808..8df9c0dd8 100644 --- 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 @@ -3,12 +3,16 @@ import { Column, Entity, Index } from 'typeorm'; export const WAREHOUSE_ACTIVITY_TYPES = [ 'INVENTORY_RECEIVED', + 'INVENTORY_UNLOADED', 'INVENTORY_STORED', 'INVENTORY_MOVED', 'INVENTORY_RESERVED', 'READY_FOR_LOADING', 'INVENTORY_LOADED', 'INVENTORY_DISPATCHED', + 'READY_FOR_PICKUP', + 'INVENTORY_RELEASED', + 'INVENTORY_DELIVERED', ] as const; export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number]; 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 6c9270987..a5d4e1eeb 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 @@ -8,26 +8,40 @@ 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 +// Lifecycle. Supersedes the Batch 1 set // (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place. +// After RECEIVED + inspection (PASSED), the flow branches by booking trade direction: +// EXPORT/DOMESTIC: STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED +// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery) export const WAREHOUSE_INVENTORY_STATUSES = [ + 'UNLOADED', 'RECEIVED', 'STORED', 'RESERVED', 'READY_FOR_LOADING', 'LOADED', 'DISPATCHED', + 'READY_FOR_PICKUP', + 'DELIVERED', ] 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'], + // UNLOADED = train-arrival landing state (Batch 8). Not yet stored/inspected. + // Mirrors RECEIVED so the import flow can store or go straight to pickup after inspection. + UNLOADED: ['STORED', 'READY_FOR_PICKUP'], + RECEIVED: ['STORED', 'READY_FOR_PICKUP'], STORED: ['RESERVED'], RESERVED: ['READY_FOR_LOADING'], READY_FOR_LOADING: ['LOADED'], LOADED: ['DISPATCHED'], DISPATCHED: [], + // Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched + // out by EDR (DISPATCHED) — kept separate — or be put into storage (STORED) if no one collects + // it / customs or inspection hold / operator chooses to store. + READY_FOR_PICKUP: ['DELIVERED', 'STORED', 'DISPATCHED'], + DELIVERED: [], }; @Entity({ schema: 'freight', name: 'warehouse_inventory' }) @@ -104,6 +118,10 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'arrived_at', type: 'timestamptz', nullable: true }) arrivedAt?: Date | null; + // Batch 8: when the goods were unloaded off the arrived train (before storage/inspection). + @Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true }) + unloadedAt?: Date | null; + @Column({ name: 'stored_at', type: 'timestamptz', nullable: true }) storedAt?: Date | null; @@ -135,6 +153,14 @@ export class WarehouseInventory extends BaseEntity { @Column({ name: 'release_date', type: 'timestamptz', nullable: true }) releaseDate?: Date | null; + // Import branch: reference of the DO / release order sent to the customer. + @Column({ name: 'release_order_reference', type: 'varchar', length: 100, nullable: true }) + releaseOrderReference?: string | null; + + // Import branch: when the goods were handed over to the customer (proof of delivery). + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt?: Date | null; + @Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true }) gateClearedAt?: Date | null; 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 9d27ed64a..267fcc8af 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 @@ -1,5 +1,5 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Facility } from '../../facilities/entities/facility.entity'; import { WarehouseYard } from './warehouse-yard.entity'; @@ -63,6 +63,7 @@ export class Warehouse extends BaseEntity { facilityId?: string | null; @ManyToOne(() => Facility, (facility) => facility.warehouses, { nullable: true }) + @JoinColumn({ name: 'facility_id' }) facility?: Facility | null; @OneToMany(() => WarehouseYard, (yard) => yard.warehouse) diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index de5a791c1..76e1fe892 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; + /** * READ-ONLY view into the train-scheduling / wagons domain for the warehouse module. * @@ -9,6 +11,32 @@ import { DataSource } from 'typeorm'; * It is intentionally decoupled (raw SQL) so it does not import the scheduling * services/entities and cannot accidentally write to them. */ +export interface ImportTrainRow { + scheduleId: string; + trainNumber: string | null; + route: string | null; + origin: string | null; + destination: string | null; + arrivalTime: string | null; + totalBookings: number; + totalContainers: number; + totalCargoes: number; + status: string; +} + +export interface ImportTrainItemRow { + bookingId: string; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + arrivalTime: string | null; + currentStatus: string | null; + lastMileRequested: boolean; + pickupOption: string; +} export interface WagonView { id: string; wagonNumber: string; @@ -115,4 +143,81 @@ export class SchedulingReadFacade { departureStatus: schedule?.status ?? null, }; } + + /** + * ARRIVED train schedules whose route is IMPORT (origin country = Djibouti), with per-train + * booking/container/cargo counts. Direction is derived from the origin/destination station + * countries (route-based), so EXPORT/DOMESTIC trains never appear. Read-only. + */ + async importArriveQueue(): Promise { + const rows: Array< + ImportTrainRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT ts.id AS "scheduleId", + ts.train_number AS "trainNumber", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", + ts.status, + (SELECT count(*) FROM freight.train_schedule_bookings tsb + WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings", + (SELECT count(*) FROM freight.containers c + JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL + WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers", + (SELECT count(*) FROM freight.cargoes cg + JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL + WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status = 'ARRIVED' + ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`, + ); + + return rows + .filter( + (r) => + deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT', + ) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({ + ...rest, + totalBookings: Number(rest.totalBookings) || 0, + totalContainers: Number(rest.totalContainers) || 0, + totalCargoes: Number(rest.totalCargoes) || 0, + route: rest.origin || rest.destination ? `${rest.origin ?? '?'} → ${rest.destination ?? '?'}` : null, + })); + } + + /** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */ + async importTrainDetail(scheduleId: string): Promise { + const rows: ImportTrainItemRow[] = await this.dataSource.query( + `SELECT b.id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + (SELECT c.container_number FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.container_number LIMIT 1) AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + b.cargo_total_weight_vgm AS "weight", + COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", + COALESCE(inv.status, b.status) AS "currentStatus", + (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + CASE WHEN b.last_mile_delivery_address IS NOT NULL + THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption" + FROM freight.train_schedule_bookings tsb + JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL + ORDER BY b.reference ASC NULLS LAST`, + [scheduleId], + ); + return rows; + } } 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 index 1bb5b1289..fcc09f668 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, IsNull } from 'typeorm'; import { Warehouse } from './entities/warehouse.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; @@ -8,11 +8,18 @@ export interface WarehouseDashboard { totalWarehouses: number; totalInventory: number; receivedToday: number; + // Inspection gate + awaitingInspection: number; + inspected: number; + // Export branch stored: number; reserved: number; readyForLoading: number; loaded: number; dispatched: number; + // Import branch + readyForPickup: number; + delivered: number; } @Injectable() @@ -26,30 +33,50 @@ export class WarehouseDashboardService { 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 { + const [ totalWarehouses, totalInventory, - receivedToday, + awaitingInspection, + inspected, stored, reserved, readyForLoading, loaded, dispatched, + readyForPickup, + delivered, + receivedToday, + ] = await Promise.all([ + warehouseRepo.count(), + inventoryRepo.count(), + inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }), + inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }), + 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.count({ where: { status: 'READY_FOR_PICKUP' } }), + inventoryRepo.count({ where: { status: 'DELIVERED' } }), + inventoryRepo + .createQueryBuilder('inv') + .where('inv.arrived_at >= :start', { start: startOfToday }) + .getCount(), + ]); + + return { + totalWarehouses, + totalInventory, + receivedToday, + awaitingInspection, + inspected, + stored, + reserved, + readyForLoading, + loaded, + dispatched, + readyForPickup, + delivered, }; } } 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 ce8a2c188..2699adbef 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,11 +1,15 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BulkReceiveDto } from './dto/bulk-receive.dto'; +import { BulkInspectDto } from './dto/bulk-inspect.dto'; +import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { SchedulingReadFacade } from './scheduling-read.facade'; @@ -56,6 +60,49 @@ export class WarehouseInventoryController { return this.inventoryService.autoLoadReady(); } + @Get('eligible-bookings') + @ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' }) + eligibleBookings(@Query('direction') direction?: string) { + const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined; + return this.inventoryService.eligibleBookings(dir); + } + + @Post('receive-bulk') + @ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) + receiveBulk(@Body() dto: BulkReceiveDto) { + return this.inventoryService.bulkReceive(dto); + } + + @Post('load-passed-export') + @ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' }) + loadPassedExport(@Body('performedBy') performedBy?: string) { + return this.inventoryService.loadPassedExport(performedBy); + } + + @Get('ready-to-load-export') + @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) + readyToLoadExport() { + return this.inventoryService.readyToLoadExport(); + } + + @Get('loaded-export') + @ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' }) + loadedExport() { + return this.inventoryService.loadedExport(); + } + + @Post('bulk-dispatch-export') + @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) + bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { + return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy); + } + + @Post('bulk-mark-inspected') + @ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' }) + bulkMarkInspected(@Body() dto: BulkInspectDto) { + return this.inventoryService.bulkMarkInspected(dto); + } + @Post('bookings/:bookingId/unload') @ApiOperation({ summary: 'Unload a single arrived booking into a location' }) unloadBooking( @@ -71,6 +118,36 @@ export class WarehouseInventoryController { return this.inventoryService.gateClearance(id, performedBy); } + @Get('import/arrive-queue') + @ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' }) + importArriveQueue() { + return this.scheduling.importArriveQueue(); + } + + @Get('import/trains/:scheduleId/items') + @ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' }) + importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { + return this.scheduling.importTrainDetail(scheduleId); + } + + @Post('import/auto-unload-arrived-bookings') + @ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' }) + autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) { + return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy); + } + + @Get('import/unloaded-queue') + @ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' }) + importUnloadedQueue() { + return this.inventoryService.importUnloadedQueue(); + } + + @Get('import/pickup-ready-queue') + @ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' }) + importPickupReadyQueue() { + return this.inventoryService.importPickupReadyQueue(); + } + @Get('loadable-wagons') @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) loadableWagons() { @@ -137,6 +214,24 @@ export class WarehouseInventoryController { return this.inventoryService.load(id, dto); } + @Post(':id/ready-for-pickup') + @ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' }) + readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { + return this.inventoryService.readyForPickup(id, performedBy); + } + + @Post(':id/release') + @ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' }) + release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) { + return this.inventoryService.release(id, dto); + } + + @Post(':id/deliver') + @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) + deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { + return this.inventoryService.deliver(id, dto); + } + @Patch(':id/dispatch') @ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' }) dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { 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 652be600c..31975b089 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 @@ -1,14 +1,21 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; +import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { BulkInspectDto } from './dto/bulk-inspect.dto'; +import { BulkReceiveDto } from './dto/bulk-receive.dto'; +import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { WarehouseAllocationService } from './warehouse-allocation.service'; +import { WarehouseInspectionService } from './warehouse-inspection.service'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity'; import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; @@ -113,6 +120,85 @@ export interface AutoLoadResult { results: { inventoryId: string; status: string; reason?: string }[]; } +// ── Receive (Import/Export bulk) shapes ────────────────────────────────────── +export interface EligibleBookingRow { + id: string; + reference: string; + customerId: string | null; + customer: string | null; + direction: string; + origin: string | null; + destination: string | null; + freightType: string | null; + cargo: string | null; + weight: string | null; + paymentStatus: string; + status: string; +} + +export interface BulkReceiveResult { + receivedCount: number; + skippedCount: number; + results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[]; +} + +export interface LoadPassedExportResult { + loadedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +export interface BulkInspectResult { + inspectedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +export interface ReadyToLoadRow { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + origin: string | null; + destination: string | null; + inspectionStatus: string | null; + status: string; +} + +export interface BulkDispatchResult { + dispatchedCount: number; + skippedCount: number; + results: { inventoryId: string; status: string; reason?: string }[]; +} + +export interface AutoUnloadArrivedResult { + unloadedCount: number; + skippedCount: number; + failedCount: number; + results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[]; +} + +export interface ImportUnloadedRow { + id: string; + bookingId: string | null; + bookingReference: string | null; + customerId: string | null; + customerName: string | null; + arrivalTime: string | null; + containerNumber: string | null; + cargoType: string | null; + weight: number | null; + trainSchedule: string | null; + inspectionStatus: string | null; + pickupOption: string; + lastMileRequested: boolean; + currentStatus: string; +} + @Injectable() export class WarehouseInventoryService { constructor( @@ -123,6 +209,7 @@ export class WarehouseInventoryService { private readonly scheduling: SchedulingReadFacade, private readonly allocation: WarehouseAllocationService, private readonly invoices: WarehouseInvoiceService, + private readonly inspectionService: WarehouseInspectionService, ) {} /** @@ -403,6 +490,542 @@ export class WarehouseInventoryService { return result; } + // ── Receive (Import/Export bulk) ─────────────────────────────────────────── + + /** + * Eligible PAID bookings that have NOT been received yet, classified IMPORT/EXPORT by route + * (origin/destination yard countries). Pass a direction to filter to one; omit it to return + * all import + export bookings in a single call (DOMESTIC routes are excluded either way). + */ + async eligibleBookings(direction?: 'IMPORT' | 'EXPORT'): Promise { + const rows: Array< + EligibleBookingRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT b.id, + b.reference AS "reference", + b.company_id AS "customerId", + company.name AS "customer", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + b.freight_type AS "freightType", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo", + b.cargo_total_weight_vgm AS "weight", + b.payment_status AS "paymentStatus", + b.status AS "status" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL + WHERE b.deleted_at IS NULL + AND b.payment_status = 'PAID' + AND inv.id IS NULL + ORDER BY b.scheduled_date DESC NULLS LAST`, + ); + + // Direction is derived from the route (origin/destination yard countries), reusing deriveTradeDirection. + return rows + .map((r) => ({ + ...r, + direction: deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }), + })) + .filter((r) => + direction ? r.direction === direction : r.direction === 'IMPORT' || r.direction === 'EXPORT', + ); + } + + /** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */ + async bulkReceive(dto: BulkReceiveDto): Promise { + const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] }; + + await this.dataSource.transaction(async (manager) => { + await this.validateLocation(manager, { + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + }); + + for (const bookingId of dto.bookingIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ bookingId, status: 'SKIPPED', reason }); + }; + + const [booking] = await manager.query( + `SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight", + oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.bookings b + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!booking) { skip('Booking not found'); continue; } + if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; } + // Direction is derived from the route (yard countries), not the stored field. + const bookingDirection = deriveTradeDirection( + { country: booking.originCountry }, + { country: booking.destinationCountry }, + ); + if (bookingDirection !== dto.direction) { + skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); + continue; + } + + const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } }); + if (existing) { skip('Already received'); continue; } + + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'RECEIVED', + arrivedAt: new Date(), + notes: `Bulk received (${dto.direction})`, + }), + ); + + await this.activityLog.record( + { + activityType: 'INVENTORY_RECEIVED', + inventoryId: saved.id, + warehouseId: dto.warehouseId, + description: `Bulk received ${dto.direction} booking`, + performedBy: dto.performedBy, + }, + manager, + ); + + result.receivedCount += 1; + result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id }); + } + }); + + return result; + } + + /** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */ + async loadPassedExport(performedBy?: string): Promise { + const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } }); + const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] }; + + for (const item of ready) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason }); + }; + + if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; } + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } + const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null; + if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(item.id, { + status: 'LOADED', + loadedAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_LOADED', + inventoryId: item.id, + warehouseId: item.warehouseId, + description: 'Bulk loaded (passed export)', + performedBy, + }, + manager, + ); + }); + + result.loadedCount += 1; + result.results.push({ inventoryId: item.id, status: 'LOADED' }); + } + + return result; + } + + /** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */ + private async exportInventoryByStatus( + status: WarehouseInventoryStatus, + requireInspectionPassed = false, + ): Promise { + const rows: Array< + ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT inv.id, + inv.booking_id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + ct.container_number AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + inv.weight AS "weight", + oy.code AS "origin", + dy.code AS "destination", + oy.country AS "originCountry", + dy.country AS "destinationCountry", + inv.inspection_status AS "inspectionStatus", + inv.status + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.containers ct ON ct.id = inv.container_id + WHERE inv.deleted_at IS NULL + AND inv.status = $1 + ${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''} + ORDER BY inv.created_at DESC`, + [status], + ); + + return rows + .filter((r) => { + const dir = deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }); + return dir === 'EXPORT'; + }) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest); + } + + /** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */ + async readyToLoadExport(): Promise { + return this.exportInventoryByStatus('READY_FOR_LOADING', true); + } + + /** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */ + async loadedExport(): Promise { + return this.exportInventoryByStatus('LOADED'); + } + + /** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */ + private async importQueueByStatuses(statuses: string[]): Promise { + const rows: Array< + ImportUnloadedRow & { originCountry: string | null; destinationCountry: string | null } + > = await this.dataSource.query( + `SELECT inv.id, + inv.booking_id AS "bookingId", + b.reference AS "bookingReference", + b.company_id AS "customerId", + company.name AS "customerName", + COALESCE(inv.unloaded_at, inv.arrived_at) AS "arrivalTime", + (SELECT c.container_number FROM freight.containers c + WHERE c.booking_id = b.id AND c.deleted_at IS NULL + ORDER BY c.container_number LIMIT 1) AS "containerNumber", + COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + inv.weight AS "weight", + ts.train_number AS "trainSchedule", + inv.inspection_status AS "inspectionStatus", + CASE WHEN b.last_mile_delivery_address IS NOT NULL + THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", + (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", + inv.status AS "currentStatus", + oy.country AS "originCountry", + dy.country AS "destinationCountry" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = b.company_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL + LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id + WHERE inv.deleted_at IS NULL + AND inv.status = ANY($1) + ORDER BY inv.created_at DESC`, + [statuses], + ); + + return rows + .filter( + (r) => + deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT', + ) + .map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest); + } + + /** + * Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection + * states), with the columns the inspection screen needs. Read-only. + */ + importUnloadedQueue(): Promise { + return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']); + } + + /** + * Batch 10 — IMPORT inventory that passed inspection and is PICKUP_READY (READY_FOR_PICKUP), + * awaiting customer pickup / last mile / store / dispatch. Read-only. + */ + importPickupReadyQueue(): Promise { + return this.importQueueByStatuses(['READY_FOR_PICKUP']); + } + + /** + * Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition + * (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not + * LOADED or not EXPORT are skipped. The train/schedule flow later moves DISPATCHED → IN_TRANSIT. + */ + async bulkDispatchExport(inventoryIds: string[], performedBy?: string): Promise { + const result: BulkDispatchResult = { dispatchedCount: 0, skippedCount: 0, results: [] }; + + for (const inventoryId of inventoryIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId, status: 'SKIPPED', reason }); + }; + + const item = await this.inventoryRepository.findById(inventoryId); + if (!item) { skip('Inventory not found'); continue; } + if (item.status !== 'LOADED') { skip(`Status is ${item.status}, not LOADED`); continue; } + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; } + + try { + await this.dispatch(inventoryId, performedBy); + result.dispatchedCount += 1; + result.results.push({ inventoryId, status: 'DISPATCHED' }); + } catch (error) { + skip(error instanceof Error ? error.message : String(error)); + } + } + + return result; + } + + /** Booking statuses eligible to be unloaded off an arrived import train (Batch 8). */ + private readonly IMPORT_UNLOAD_ELIGIBLE_STATUSES = [ + 'IN_TRANSIT', + 'ARRIVED_AT_INDODE', + 'ARRIVED_AT_DESTINATION', + 'ARRIVED_AT_FACILITY', + ]; + + /** + * Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state. + * Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect — + * items land in UNLOADED so the operator drives store/inspect/reserve/dispatch from the queue. + */ + async autoUnloadArrivedBookings( + scheduleId: string, + performedBy?: string, + ): Promise { + const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] }; + + // 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries). + const [schedule] = await this.dataSource.query( + `SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.train_schedules ts + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.id = $1 AND ts.deleted_at IS NULL + LIMIT 1`, + [scheduleId], + ); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.status !== 'ARRIVED') { + throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`); + } + const direction = deriveTradeDirection( + { country: schedule.originCountry }, + { country: schedule.destinationCountry }, + ); + if (direction !== 'IMPORT') { + throw new BadRequestException(`Train schedule route is ${direction}, not IMPORT`); + } + + // 2. Assigned bookings on this train. + const bookings: { + id: string; + status: string; + weight: string | null; + freightType: string | null; + tradeDirection: string | null; + cargoTypeCode: string | null; + }[] = await this.dataSource.query( + `SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight, + b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", + cgt.code AS "cargoTypeCode" + FROM freight.train_schedule_bookings tsb + JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`, + [scheduleId], + ); + + const fallback = await this.pickDefaultLocation(); + const now = new Date(); + + for (const booking of bookings) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ bookingId: booking.id, status: 'SKIPPED', reason }); + }; + const fail = (reason: string) => { + result.failedCount += 1; + result.results.push({ bookingId: booking.id, status: 'FAILED', reason }); + }; + + if (!this.IMPORT_UNLOAD_ELIGIBLE_STATUSES.includes(booking.status)) { + skip(`Booking status ${booking.status} is not unload-eligible`); + continue; + } + + try { + const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0]; + + // Already unloaded or further along — leave it (do not regress the lifecycle). + if (existing && existing.status !== 'RECEIVED') { + skip(`Inventory already ${existing.status}`); + continue; + } + + if (existing) { + await this.inventoryRepository.update(existing.id, { + status: 'UNLOADED', + unloadedAt: now, + arrivedAt: existing.arrivedAt ?? now, + }); + await this.activityLog.record({ + activityType: 'INVENTORY_UNLOADED', + inventoryId: existing.id, + warehouseId: existing.warehouseId, + description: 'Unloaded from arrived import train', + performedBy, + }); + result.unloadedCount += 1; + result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' }); + continue; + } + + // No inventory yet — create it at the allocated (or default) location, in UNLOADED state. + const allocated = await this.allocation.resolveLocation({ + freightType: booking.freightType, + tradeDirection: booking.tradeDirection, + cargoTypeCode: booking.cargoTypeCode, + }); + const location = allocated ?? fallback; + if (!location) { + fail('No warehouse/yard/zone configured'); + continue; + } + + const saved = await this.inventoryRepository.create({ + warehouseId: location.warehouseId, + yardId: location.yardId, + zoneId: location.zoneId, + bookingId: booking.id, + quantity: 1, + weight: Number(booking.weight) || 0, + status: 'UNLOADED', + arrivedAt: now, + unloadedAt: now, + notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', + }); + await this.activityLog.record({ + activityType: 'INVENTORY_UNLOADED', + inventoryId: saved.id, + warehouseId: saved.warehouseId, + description: 'Unloaded from arrived import train', + performedBy, + }); + result.unloadedCount += 1; + result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' }); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } + } + + return result; + } + + /** + * Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal + * report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING. + * For damage / weight-loss / images, use the per-item Inspect / Report action instead. + */ + async bulkMarkInspected(dto: BulkInspectDto): Promise { + const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] }; + // UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state). + const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; + + for (const inventoryId of dto.inventoryIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ inventoryId, status: 'SKIPPED', reason }); + }; + + const item = await this.inventoryRepository.findById(inventoryId); + if (!item) { skip('Inventory not found'); continue; } + if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; } + if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; } + + // Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt. + await this.inspectionService.create(inventoryId, { + reportType: 'INSPECTION', + inspectionStatus: 'PASSED', + remarks: dto.remarks ?? 'Bulk marked inspected (PASSED).', + inspectedById: dto.inspectedBy, + }); + + // A passed item advances by trade direction: + // EXPORT → Ready To Load (READY_FOR_LOADING) + // IMPORT → Pickup Ready (READY_FOR_PICKUP) — NOT ready-for-loading. + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction === 'EXPORT') { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + status: 'READY_FOR_LOADING', + readyForLoadingAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'READY_FOR_LOADING', + inventoryId, + warehouseId: item.warehouseId, + description: 'Inspection passed → ready for loading', + performedBy: dto.inspectedBy, + }, + manager, + ); + }); + result.results.push({ inventoryId, status: 'READY_FOR_LOADING' }); + } else if (direction === 'IMPORT') { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(inventoryId, { + status: 'READY_FOR_PICKUP', + readyForPickupAt: new Date(), + }); + await this.activityLog.record( + { + activityType: 'READY_FOR_PICKUP', + inventoryId, + warehouseId: item.warehouseId, + description: 'Destination inspection passed → pickup ready', + performedBy: dto.inspectedBy, + }, + manager, + ); + }); + result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' }); + } else { + result.results.push({ inventoryId, status: 'INSPECTED' }); + } + result.inspectedCount += 1; + } + + return result; + } + // ── Receive ────────────────────────────────────────────────────────────── async receive(dto: ReceiveWarehouseInventoryDto): Promise { @@ -511,6 +1134,9 @@ export class WarehouseInventoryService { if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) { throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep'); } + if (item.inspectionStatus !== 'PASSED') { + throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading'); + } return this.transition(id, 'READY_FOR_LOADING', { timestampField: 'readyForLoadingAt', activityType: 'READY_FOR_LOADING', @@ -520,6 +1146,112 @@ export class WarehouseInventoryService { }); } + // ── Import branch (READY_FOR_PICKUP → DELIVERED) ─────────────────────────── + + /** Mark inspected IMPORT inventory ready for customer pickup (RECEIVED → READY_FOR_PICKUP). */ + async readyForPickup(id: string, performedBy?: string): Promise { + const item = await this.findById(id); + + if (item.inspectionStatus !== 'PASSED') { + throw new BadRequestException('Inventory must pass inspection before it can be marked ready for pickup'); + } + + const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null; + if (direction !== 'IMPORT') { + throw new BadRequestException('Only IMPORT inventory can be marked ready for pickup'); + } + + return this.transition(id, 'READY_FOR_PICKUP', { + timestampField: 'readyForPickupAt', + activityType: 'READY_FOR_PICKUP', + description: 'Inventory ready for customer pickup', + performedBy, + preloaded: item, + }); + } + + /** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */ + async release(id: string, dto: ReleaseOrderDto): Promise { + const item = await this.findById(id); + if (item.status !== 'READY_FOR_PICKUP') { + throw new BadRequestException( + `Inventory must be READY_FOR_PICKUP to issue a release order (current: ${item.status})`, + ); + } + + const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date(); + const reference = dto.reference?.trim() || null; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + releaseDate, + releaseOrderReference: reference, + }); + await this.activityLog.record( + { + activityType: 'INVENTORY_RELEASED', + inventoryId: id, + warehouseId: item.warehouseId, + description: reference + ? `Release order ${reference} sent to customer` + : 'Release order sent to customer', + performedBy: dto.performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + + /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ + async deliver(id: string, dto: DeliverInventoryDto): Promise { + const item = await this.findById(id); + this.assertTransition(item.status, 'DELIVERED'); + + if (!item.releaseDate) { + throw new BadRequestException('A release order must be issued before the goods can be delivered'); + } + + const receiverName = dto.receiverName.trim(); + const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : 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; + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WarehouseInventory).update(id, { + status: 'DELIVERED', + deliveredAt, + }); + + // Goods physically leave the warehouse on pickup — free up capacity. + await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1); + + // Proof of delivery is captured on the linked cargo. + if (item.cargoId) { + await manager.getRepository(Cargo).update(item.cargoId, { + receiverName, + deliveredAt, + deliveryRemarks: dto.remarks?.trim() ?? null, + }); + } + + await this.activityLog.record( + { + activityType: 'INVENTORY_DELIVERED', + inventoryId: id, + warehouseId: item.warehouseId, + description: `Delivered to ${receiverName}`, + performedBy: dto.performedBy, + }, + manager, + ); + }); + + return this.findById(id); + } + /** * Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record. * Reads wagon/schedule data read-only — never modifies scheduling. @@ -886,6 +1618,23 @@ export class WarehouseInventoryService { return rows?.[0]?.status ?? null; } + /** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */ + private async getBookingDirection(bookingId: string): Promise { + const rows = await this.dataSource.query( + `SELECT oy.country AS "originCountry", dy.country AS "destinationCountry" + FROM freight.bookings b + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!rows?.[0]) return null; + return deriveTradeDirection( + { country: rows[0].originCountry }, + { country: rows[0].destinationCountry }, + ); + } + private assertCapacity( label: string, node: LocationNode, 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 3cbcc6833..f92401dfc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -1,5 +1,5 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { FindManyOptions, ILike } from 'typeorm'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { FindManyOptions, ILike, QueryFailedError } from 'typeorm'; import { CreateWarehouseDto } from './dto/create-warehouse.dto'; import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; @@ -49,23 +49,27 @@ export class WarehousesService { async create(dto: CreateWarehouseDto): Promise { await this.assertCodeUnique(dto.code.trim()); - return this.warehousesRepository.create({ - name: dto.name.trim(), - code: dto.code.trim(), - type: dto.type, - stationId: dto.stationId ?? null, - facilityId: dto.facilityId ?? null, - 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, - }); + try { + return await this.warehousesRepository.create({ + name: dto.name.trim(), + code: dto.code.trim(), + type: dto.type, + stationId: dto.stationId ?? null, + facilityId: dto.facilityId ?? null, + 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, + }); + } catch (error) { + this.mapDbError(error); + } } async update(id: string, dto: UpdateWarehouseDto): Promise { @@ -77,20 +81,25 @@ export class WarehousesService { const status = dto.status ?? existing.status; - const updated = await this.warehousesRepository.update(id, { - name: dto.name?.trim() ?? existing.name, - code: dto.code?.trim() ?? existing.code, - type: dto.type ?? existing.type, - stationId: dto.stationId ?? existing.stationId, - facilityId: dto.facilityId ?? existing.facilityId, - 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', - }); + let updated; + try { + updated = await this.warehousesRepository.update(id, { + name: dto.name?.trim() ?? existing.name, + code: dto.code?.trim() ?? existing.code, + type: dto.type ?? existing.type, + stationId: dto.stationId ?? existing.stationId, + facilityId: dto.facilityId ?? existing.facilityId, + 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', + }); + } catch (error) { + this.mapDbError(error); + } if (!updated) { throw new NotFoundException(`Warehouse ${id} not found`); @@ -99,6 +108,21 @@ export class WarehousesService { return this.findById(id); } + /** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */ + private mapDbError(error: unknown): never { + if (error instanceof QueryFailedError) { + const driver = (error as QueryFailedError & { driverError?: { code?: string; detail?: string } }).driverError; + if (driver?.code === '23503') { + throw new BadRequestException('Selected facility does not exist.'); + } + if (driver?.code === '22001') { + throw new BadRequestException('A field is too long (code max 40, name max 160 characters).'); + } + throw new BadRequestException(driver?.detail ?? error.message ?? 'Invalid warehouse data.'); + } + throw error as Error; + } + private async assertCodeUnique(code: string, ignoreId?: string): Promise { const [existing] = await this.warehousesRepository.findAll({ where: { code } }); diff --git a/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts new file mode 100644 index 000000000..f9e4e2af7 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts @@ -0,0 +1,139 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; + +const SEED_REFS = ['SEED-B5-EXP-001', 'SEED-B5-EXP-002', 'SEED-B5-EXP-003']; + +const SEEDS = [ + { ref: 'SEED-B5-EXP-001', weight: 5000, notes: 'Electronics export cargo' }, + { ref: 'SEED-B5-EXP-002', weight: 8500, notes: 'Textile export cargo' }, + { ref: 'SEED-B5-EXP-003', weight: 3200, notes: 'Coffee export cargo' }, +]; + +/** + * Seeds 3 EXPORT+PAID bookings with READY_FOR_LOADING + inspection PASSED inventory + * so the Batch 5 "Ready To Load" tab has visible rows to test against. + * + * Origin: any Ethiopian yard (route-based direction = EXPORT when dest = Djibouti) + * Destination: any Djiboutian yard + * Uses the INDODE_OPEN warehouse created by IndodeFacilitySeeder. + */ +@Injectable() +export class Batch5TestDataSeeder { + private readonly logger = new Logger(Batch5TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + + const existing = await bookingRepo.findOne({ where: { reference: SEED_REFS[0] } }); + if (existing) { + this.logger.log('Batch 5 test data already seeded, skipping'); + return; + } + + try { + const yardRepo = this.dataSource.getRepository(Yard); + const serviceTypeRepo = this.dataSource.getRepository(ServiceType); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard); + const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone); + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + + // Find Ethiopian origin yard and Djiboutian destination yard. + const originYard = + (await yardRepo.findOne({ where: { code: 'ADDIS_ABABA' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const destYard = + (await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + + if (!originYard || !destYard) { + this.logger.warn( + `Required yards not found (origin=${originYard?.code ?? 'none'}, dest=${destYard?.code ?? 'none'}); skipping Batch 5 seed`, + ); + return; + } + + // Find any active service type (bookings require one). + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + if (!serviceType) { + this.logger.warn('No service type found; skipping Batch 5 seed'); + return; + } + + // Find INDODE warehouse. + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + if (!warehouse) { + this.logger.warn('INDODE_OPEN warehouse not found; skipping Batch 5 seed'); + return; + } + + const warehouseYard = await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } }); + if (!warehouseYard) { + this.logger.warn('No warehouse yard found for INDODE_OPEN; skipping Batch 5 seed'); + return; + } + + const warehouseZone = await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } }); + if (!warehouseZone) { + this.logger.warn('No warehouse zone found; skipping Batch 5 seed'); + return; + } + + const now = new Date(); + + for (const seed of SEEDS) { + const booking = await bookingRepo.save( + bookingRepo.create({ + reference: seed.ref, + originYardId: originYard.id, + destinationYardId: destYard.id, + serviceTypeId: serviceType.id, + status: 'PAID', + paymentStatus: 'PAID', + tradeDirection: 'EXPORT', + freightType: 'BULK', + cargoTotalWeightVgm: seed.weight, + cargoFreeText: seed.notes, + }), + ); + + await inventoryRepo.save( + inventoryRepo.create({ + bookingId: booking.id, + warehouseId: warehouse.id, + yardId: warehouseYard.id, + zoneId: warehouseZone.id, + status: 'READY_FOR_LOADING', + inspectionStatus: 'PASSED', + inspectedAt: new Date(now.getTime() - 3600 * 1000), + quantity: 1, + weight: seed.weight, + arrivedAt: new Date(now.getTime() - 7200 * 1000), + readyForLoadingAt: new Date(now.getTime() - 1800 * 1000), + notes: `[SEED-B5] ${seed.notes}`, + }), + ); + + this.logger.log(`Seeded ${seed.ref} → READY_FOR_LOADING + PASSED`); + } + + this.logger.log('✅ Batch 5 Ready-To-Load test data seeded successfully'); + } catch (error) { + this.logger.error( + `Batch5TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts new file mode 100644 index 000000000..dec3febe3 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts @@ -0,0 +1,103 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; + +/** + * Seeds two ARRIVED train schedules so the Import Arrive Queue (Batch 7) is demonstrable: + * - SEED-IMP-TRAIN-01: DJIB_PORT → MOJO (IMPORT) linked to booking SEED-IMP-001 → SHOWS + * - SEED-EXP-TRAIN-01: MOJO → DJIB_PORT (EXPORT) linked to booking SEED-EXP-001 → must NOT show + * + * Read-only train-schedule SERVICE logic is untouched; this only inserts fixture rows. + * Idempotent: guards on the import train number. + */ +@Injectable() +export class Batch7TestDataSeeder { + private readonly logger = new Logger(Batch7TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + + const existing = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } }); + if (existing) { + this.logger.log('Batch 7 test data already seeded, skipping'); + return; + } + + try { + const bookingRepo = this.dataSource.getRepository(Booking); + const locoRepo = this.dataSource.getRepository(Locomotive); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const importBooking = await bookingRepo.findOne({ where: { reference: 'SEED-IMP-001' } }); + const exportBooking = await bookingRepo.findOne({ where: { reference: 'SEED-EXP-001' } }); + if (!importBooking) { + this.logger.warn('SEED-IMP-001 booking not found; skipping Batch 7 seed'); + return; + } + + // One shared locomotive is fine — train_set.locomotive_id is not unique. + const loco = + (await locoRepo.findOne({ where: { code: 'SEED-LOCO-01' } })) ?? + (await locoRepo.save( + locoRepo.create({ code: 'SEED-LOCO-01', name: 'Seed Locomotive', maxPullWeightTons: 4000 }), + )); + + const now = new Date(); + const arrival = new Date(now.getTime() - 3600 * 1000); + const departure = new Date(now.getTime() - 6 * 3600 * 1000); + + const makeArrivedTrain = async ( + trainNumber: string, + booking: Booking, + ): Promise => { + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: loco.id, + totalWeightTons: 500, + totalLengthMeters: 300, + wagonCount: 10, + status: 'COMPLETED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber, + }), + ); + + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: booking.id }), + ); + + this.logger.log(`Seeded arrived train ${trainNumber} → booking ${booking.reference}`); + }; + + await makeArrivedTrain('SEED-IMP-TRAIN-01', importBooking); + if (exportBooking) { + await makeArrivedTrain('SEED-EXP-TRAIN-01', exportBooking); + } + + this.logger.log('✅ Batch 7 arrive-queue test data seeded successfully'); + } catch (error) { + this.logger.error( + `Batch7TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts b/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts new file mode 100644 index 000000000..c3a98c224 --- /dev/null +++ b/apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; + +/** + * Makes the Batch 7 seed import train demonstrable for Batch 8: a booking riding an ARRIVED + * train is IN_TRANSIT until unloaded, so flip the seed import train's assigned bookings to + * IN_TRANSIT (an unload-eligible status). Idempotent — re-applying IN_TRANSIT is a no-op. + */ +@Injectable() +export class Batch8TestDataSeeder { + private readonly logger = new Logger(Batch8TestDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + try { + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + const bookingRepo = this.dataSource.getRepository(Booking); + + const train = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } }); + if (!train) { + this.logger.log('SEED-IMP-TRAIN-01 not found; skipping Batch 8 seed'); + return; + } + + const links = await scheduleBookingRepo.find({ where: { trainScheduleId: train.id } }); + let updated = 0; + for (const link of links) { + const booking = await bookingRepo.findOne({ where: { id: link.bookingId } }); + if (!booking || booking.status === 'IN_TRANSIT') continue; + await bookingRepo.update(booking.id, { status: 'IN_TRANSIT' }); + updated += 1; + } + + if (updated > 0) { + this.logger.log(`✅ Batch 8: set ${updated} import train booking(s) to IN_TRANSIT (unload-eligible)`); + } else { + this.logger.log('Batch 8: import train bookings already IN_TRANSIT, skipping'); + } + } catch (error) { + this.logger.error( + `Batch8TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts new file mode 100644 index 000000000..e00544dc0 --- /dev/null +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -0,0 +1,258 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity'; +import { Locomotive } from '../modules/locomotives/entities/locomotive.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; +import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity'; +import { TrainSet } from '../modules/train-sets/entities/train-set.entity'; +import { Warehouse } from '../modules/warehouses/entities/warehouse.entity'; +import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity'; +import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity'; +import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity'; + +/** + * One coherent warehouse dataset so EVERY queue/tab shows representative data: + * Export → Receive Queue : PAID export bookings, not yet received + * Export → Ready To Load : EXPORT inventory READY_FOR_LOADING + inspection PASSED + * Export → Loaded/Dispatch : EXPORT inventory LOADED + * Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory) + * Import → Unloaded Queue : UNLOADED import inventory + * Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED) + * + * Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it + * never collides with other seeders. To repopulate after items are walked through their lifecycle, + * delete the WH-DEMO-* bookings (cascades) and reboot. + */ +@Injectable() +export class WarehouseDemoSeeder { + private readonly logger = new Logger(WarehouseDemoSeeder.name); + private readonly SENTINEL = 'WH-DEMO-RCV-1'; + + constructor(private readonly dataSource: DataSource) {} + + async run(): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + if (await bookingRepo.findOne({ where: { reference: this.SENTINEL } })) { + this.logger.log('Warehouse demo data already seeded, skipping'); + return; + } + + try { + const yardRepo = this.dataSource.getRepository(Yard); + const serviceTypeRepo = this.dataSource.getRepository(ServiceType); + const cargoTypeRepo = this.dataSource.getRepository(CargoType); + const warehouseRepo = this.dataSource.getRepository(Warehouse); + const whYardRepo = this.dataSource.getRepository(WarehouseYard); + const whZoneRepo = this.dataSource.getRepository(WarehouseZone); + const invRepo = this.dataSource.getRepository(WarehouseInventory); + + const djibYard = + (await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ?? + (await yardRepo.findOne({ where: { country: 'Djibouti' } })); + const ethYard = + (await yardRepo.findOne({ where: { code: 'MOJO' } })) ?? + (await yardRepo.findOne({ where: { country: 'Ethiopia' } })); + const serviceType = + (await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ?? + (await serviceTypeRepo.findOne({ where: { isActive: true } })); + const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } }); + + if (!djibYard || !ethYard || !serviceType) { + this.logger.warn( + `Missing yards/service type (djib=${djibYard?.code}, eth=${ethYard?.code}, svc=${serviceType?.code}); skipping`, + ); + return; + } + + const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } }); + const whYard = warehouse ? await whYardRepo.findOne({ where: { warehouseId: warehouse.id } }) : null; + const whZone = whYard ? await whZoneRepo.findOne({ where: { yardId: whYard.id } }) : null; + if (!warehouse || !whYard || !whZone) { + this.logger.warn('INDODE_OPEN warehouse/yard/zone missing; skipping warehouse demo seed'); + return; + } + + const now = Date.now(); + const ago = (mins: number) => new Date(now - mins * 60_000); + + // EXPORT booking = Ethiopia → Djibouti; IMPORT booking = Djibouti → Ethiopia. + const makeBooking = async ( + reference: string, + direction: 'EXPORT' | 'IMPORT', + status: string, + weight: number, + idx: number, + ): Promise => + bookingRepo.save( + bookingRepo.create({ + reference, + originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id, + destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id, + serviceTypeId: serviceType.id, + status, + paymentStatus: 'PAID', + tradeDirection: direction, + freightType: idx % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType ? null : `${direction} demo cargo ${idx}`, + cargoTotalWeightVgm: weight, + }), + ); + + const makeInventory = async ( + booking: Booking, + status: string, + weight: number, + extra: Partial, + ): Promise => { + await invRepo.save( + invRepo.create({ + warehouseId: warehouse.id, + yardId: whYard.id, + zoneId: whZone.id, + bookingId: booking.id, + quantity: 1, + weight, + status: status as WarehouseInventory['status'], + notes: '[WH-DEMO]', + ...extra, + }), + ); + }; + + let created = 0; + + // 1) Export Receive Queue — 3 PAID export bookings, NO inventory. + for (let i = 1; i <= 3; i++) { + await makeBooking(`WH-DEMO-RCV-${i}`, 'EXPORT', 'PAID', 4000 + i * 500, i); + created++; + } + + // 2) Export Ready To Load — EXPORT inventory READY_FOR_LOADING + PASSED. + for (let i = 1; i <= 3; i++) { + const b = await makeBooking(`WH-DEMO-RTL-${i}`, 'EXPORT', 'PAID', 6000 + i * 500, i); + await makeInventory(b, 'READY_FOR_LOADING', 6000 + i * 500, { + inspectionStatus: 'PASSED', + arrivedAt: ago(180), + inspectedAt: ago(120), + readyForLoadingAt: ago(60), + }); + created++; + } + + // 3) Export Loaded / Dispatch Queue — EXPORT inventory LOADED. + for (let i = 1; i <= 2; i++) { + const b = await makeBooking(`WH-DEMO-LOAD-${i}`, 'EXPORT', 'PAID', 7000 + i * 500, i); + await makeInventory(b, 'LOADED', 7000 + i * 500, { + inspectionStatus: 'PASSED', + arrivedAt: ago(240), + inspectedAt: ago(180), + readyForLoadingAt: ago(120), + loadedAt: ago(30), + }); + created++; + } + + // 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored). + for (let i = 1; i <= 3; i++) { + const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i); + await makeInventory(b, 'UNLOADED', 5000 + i * 500, { + arrivedAt: ago(90), + unloadedAt: ago(45), + }); + created++; + } + + // 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED). + for (let i = 1; i <= 3; i++) { + const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i); + await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, { + inspectionStatus: 'PASSED', + arrivedAt: ago(200), + unloadedAt: ago(160), + inspectedAt: ago(120), + readyForPickupAt: ago(60), + }); + created++; + } + + // 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet. + await this.seedArrivedImportTrain(djibYard, ethYard, serviceType, cargoType, ago(60), ago(360)); + created += 1; + + this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`); + } catch (error) { + this.logger.error( + `WarehouseDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** An ARRIVED Djibouti→Ethiopia train with 3 IN_TRANSIT bookings (no inventory) for the Arrive Queue. */ + private async seedArrivedImportTrain( + djibYard: Yard, + ethYard: Yard, + serviceType: ServiceType, + cargoType: CargoType | null, + arrival: Date, + departure: Date, + ): Promise { + const bookingRepo = this.dataSource.getRepository(Booking); + const locoRepo = this.dataSource.getRepository(Locomotive); + const trainSetRepo = this.dataSource.getRepository(TrainSet); + const scheduleRepo = this.dataSource.getRepository(TrainSchedule); + const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking); + + const loco = + (await locoRepo.findOne({ where: { code: 'WH-DEMO-LOCO' } })) ?? + (await locoRepo.save(locoRepo.create({ code: 'WH-DEMO-LOCO', name: 'Demo Locomotive', maxPullWeightTons: 4000 }))); + + const trainSet = await trainSetRepo.save( + trainSetRepo.create({ + locomotiveId: loco.id, + totalWeightTons: 500, + totalLengthMeters: 300, + wagonCount: 10, + status: 'COMPLETED', + }), + ); + + const schedule = await scheduleRepo.save( + scheduleRepo.create({ + trainSetId: trainSet.id, + originStationId: djibYard.id, + destinationStationId: ethYard.id, + scheduledDepartureDate: departure, + scheduledArrivalDate: arrival, + actualArrivalAt: arrival, + status: 'ARRIVED' as TrainSchedule['status'], + trainNumber: 'WH-DEMO-IMP-TRAIN', + }), + ); + + for (let i = 1; i <= 3; i++) { + const b = await bookingRepo.save( + bookingRepo.create({ + reference: `WH-DEMO-ARR-${i}`, + originYardId: djibYard.id, + destinationYardId: ethYard.id, + serviceTypeId: serviceType.id, + status: 'IN_TRANSIT', + paymentStatus: 'PAID', + tradeDirection: 'IMPORT', + freightType: i % 2 === 0 ? 'CONTAINER' : 'BULK', + cargoTypeId: cargoType?.id ?? null, + cargoFreeText: cargoType ? null : `IMPORT arrive demo cargo ${i}`, + cargoTotalWeightVgm: 5000 + i * 400, + }), + ); + await scheduleBookingRepo.save( + scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: b.id }), + ); + } + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx new file mode 100644 index 000000000..4b5485815 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core'; +import { Info } from 'lucide-react'; + +import { useToast } from '@/hooks/use-toast'; +import { useDeliverInventory } from '@/hooks/useWarehouses'; +import type { WarehouseInventoryItem } from '@/types/warehouse'; +import { extractErrorMessage } from './options'; + +interface DeliverInventoryModalProps { + opened: boolean; + onClose: () => void; + item: WarehouseInventoryItem | null; +} + +export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) { + const { toast } = useToast(); + const deliverMutation = useDeliverInventory(); + const [receiverName, setReceiverName] = useState(''); + const [remarks, setRemarks] = useState(''); + + useEffect(() => { + if (opened) { + setReceiverName(''); + setRemarks(''); + } + }, [opened, item]); + + const handleSubmit = async () => { + if (!item) return; + if (!receiverName.trim()) { + toast({ variant: 'destructive', title: 'Receiver name is required' }); + return; + } + try { + await deliverMutation.mutateAsync({ + id: item.id, + payload: { receiverName: receiverName.trim(), remarks: remarks.trim() || undefined }, + }); + toast({ title: 'Delivered — proof of delivery captured' }); + onClose(); + } catch (error) { + toast({ variant: 'destructive', title: 'Delivery failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + } color="green" variant="light"> + + A release order must already be issued. Capturing the receiver marks the goods DELIVERED. + + + setReceiverName(e.currentTarget.value)} + /> +