diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 2bee84923..72a5952d7 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -53,23 +53,23 @@ import { } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; -import { DemoUsersSeeder } from "./seed/demo-users.seeder"; -import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; +// Disabled seeds — imports commented out with their provider/injection/run below. +// import { DemoUsersSeeder } from "./seed/demo-users.seeder"; +// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; -import { PricingDataSeeder } from "./seed/pricing-data.seeder"; +// 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 { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; -import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.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 { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; +// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; -import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; -import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; -import { EdrTruckFleetSeeder } from "./seed/edr-truck-fleet.seeder"; +// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; +// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules @@ -195,22 +195,22 @@ import { LoggerMiddleware } from "./logger.middleware"; providers: [ EdrOrgSeeder, FreightPositionsSeeder, - DemoUsersSeeder, - FreightStaffUsersSeeder, - PricingDataSeeder, FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, - DemoFreightDataSeeder, - GovCompaniesSeeder, - EdrTruckFleetSeeder, - IndodeFacilitySeeder, - Batch14TestDataSeeder, - Batch5TestDataSeeder, - Batch7TestDataSeeder, - Batch8TestDataSeeder, - WarehouseDemoSeeder, - ExportDjiboutiInterchangeDemoSeeder, - MarshallingDemoTrainsSeeder, + // Disabled seeds — providers commented out (imports/injection/run too): + // DemoUsersSeeder, + // FreightStaffUsersSeeder, + // PricingDataSeeder, + // DemoFreightDataSeeder, + // GovCompaniesSeeder, + // IndodeFacilitySeeder, + // Batch14TestDataSeeder, + // Batch5TestDataSeeder, + // Batch7TestDataSeeder, + // Batch8TestDataSeeder, + // WarehouseDemoSeeder, + // ExportDjiboutiInterchangeDemoSeeder, + // MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, PaidImportExportMileDemoSeeder, ], @@ -220,53 +220,66 @@ export class AppModule implements OnApplicationBootstrap { private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, - 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 exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, - private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, - private readonly demoFreightDataSeeder: DemoFreightDataSeeder, - private readonly govCompaniesSeeder: GovCompaniesSeeder, - private readonly edrTruckFleetSeeder: EdrTruckFleetSeeder, + // Disabled seeds — injections commented out (imports/provider/run too): + // private readonly demoUsersSeeder: DemoUsersSeeder, + // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, + // private readonly pricingDataSeeder: PricingDataSeeder, + // 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 exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, + // private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, + // private readonly demoFreightDataSeeder: DemoFreightDataSeeder, + // private readonly govCompaniesSeeder: GovCompaniesSeeder, ) { } async onApplicationBootstrap() { + // ── Enabled: permissions + file-upload settings (+ dropdown settings) only ── + // Everything else below is intentionally disabled. Seeders stay registered + // as providers and injected; only their .run() calls are commented out, so + // re-enabling any of them is a one-line uncomment. + + // Permissions foundation — keep enabled: + // freightPermissionKeyMigration → renames legacy permission keys + // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions + // edrOrgSeeder → seeds org/unit + the Permission catalog + // freightPositionsSeeder → seeds Position + PositionPermission rows + // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); await this.seeder.run(); await this.edrOrgSeeder.run(); await this.freightPositionsSeeder.run(); - await this.demoUsersSeeder.run(); - await this.freightStaffUsersSeeder.run(); - await this.pricingDataSeeder.run(); + + // File upload settings — keep enabled. 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(); - await this.exportDjiboutiInterchangeDemoSeeder.run(); - await this.marshallingDemoTrainsSeeder.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. - // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval - // rules are disabled inside the seeder). Kept running for the staff users. - await this.demoFreightDataSeeder.run(); - // Government entities (with importer/exporter profiles) that government - // bookings bill to. Idempotent — keyed by fixed IDs. - await this.govCompaniesSeeder.run(); - await this.edrTruckFleetSeeder.run(); + + // Dropdown settings are not seeded on boot; run them with + // `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts). + + // ── Disabled: demo / test / reference data seeds ── + // Uncomment a line to re-enable that seed. + // await this.demoUsersSeeder.run(); + // await this.freightStaffUsersSeeder.run(); + // await this.pricingDataSeeder.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(); + // await this.exportDjiboutiInterchangeDemoSeeder.run(); + // await this.marshallingDemoTrainsSeeder.run(); + // demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval + // rules are already disabled inside the seeder). + // await this.demoFreightDataSeeder.run(); + // Government entities (importer/exporter profiles) that government bookings + // bill to. Idempotent — keyed by fixed IDs. + // await this.govCompaniesSeeder.run(); } configure(consumer: MiddlewareConsumer) { diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 7eae4e94d..9769a7f18 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -26,6 +26,18 @@ export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view); export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); +/** Requester creates a wagon-transfer request (count-only, no wagon picks). */ +export const WagonTransferRequest = () => + BookingStaff(FREIGHT_PERMS.wagons.transferRequest); + +/** OCC fulfils a wagon-transfer request — picks the wagons and executes the move. */ +export const WagonTransferFulfill = () => + BookingStaff(FREIGHT_PERMS.wagons.transferFulfill); + +/** Admin: read every staffer's wagon-transfer history (not just one's own). */ +export const WagonTransferHistoryAll = () => + BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll); + /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); diff --git a/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts b/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts new file mode 100644 index 000000000..4e4c5f5fc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * The person who signs off a handover must record their full name (a signature + * is optional, especially for self-haul). Stored per handover record. + */ +export class AddHandoverSignerName2130000000000 implements MigrationInterface { + name = "AddHandoverSignerName2130000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + ADD COLUMN IF NOT EXISTS signer_name varchar(160) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + DROP COLUMN IF EXISTS signer_name + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts b/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts new file mode 100644 index 000000000..5e13a6e3d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Accrual alert acknowledgements: ops can mark an in-warehouse item's fee + * accrual as reviewed (optionally snoozed until a date) so it stops nudging and + * drops down the accrual dashboard. One row per inventory item. + */ +export class CreateAccrualAcks2140000000000 implements MigrationInterface { + name = "CreateAccrualAcks2140000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inventory_id uuid NOT NULL UNIQUE, + acknowledged_by uuid, + acknowledged_at timestamptz NOT NULL DEFAULT now(), + snooze_until timestamptz, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2150000000000-TrainBuilder.ts b/apps/edr-freight-api/src/migrations/2150000000000-TrainBuilder.ts new file mode 100644 index 000000000..24fa50feb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2150000000000-TrainBuilder.ts @@ -0,0 +1,105 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Train Builder: a `Train` becomes a first-class buildable consist — a coded + * train (e.g. 81001) assembled in one yard from 2+ locomotives and ordered + * wagons, then reused by scheduling ("schedule the train" instead of picking + * locomotives per departure). + * + * - `freight.train_locomotives` — link table train ⇄ locomotive with an order + * index (mirrors `train_set_locomotives`). + * - `trains.current_yard_id` — yard the train sits in; wagons/locomotives may + * only be attached from this yard. + * - `train_sets.train_id` — which built train an operational set was formed + * from, so schedules can surface the train code and the lifecycle can sync + * the train's status/yard on dispatch/arrival/cancel. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class TrainBuilder2150000000000 implements MigrationInterface { + name = 'TrainBuilder2150000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_locomotives ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + train_id uuid NOT NULL, + locomotive_id uuid NOT NULL, + sequence_no int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "PK_train_locomotives" PRIMARY KEY (id), + CONSTRAINT "FK_train_locomotives_train" FOREIGN KEY (train_id) + REFERENCES freight.trains (id) ON DELETE CASCADE, + CONSTRAINT "FK_train_locomotives_locomotive" FOREIGN KEY (locomotive_id) + REFERENCES freight.locomotives (id) + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_train_locomotives_train_loco" + ON freight.train_locomotives (train_id, locomotive_id); + `); + + await queryRunner.query(` + ALTER TABLE freight.trains + ADD COLUMN IF NOT EXISTS current_yard_id uuid; + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_trains_current_yard' + ) THEN + ALTER TABLE freight.trains + ADD CONSTRAINT "FK_trains_current_yard" FOREIGN KEY (current_yard_id) + REFERENCES freight.yards (id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_trains_current_yard_id" + ON freight.trains (current_yard_id); + `); + + await queryRunner.query(` + ALTER TABLE freight.train_sets + ADD COLUMN IF NOT EXISTS train_id uuid; + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_train_sets_train' + ) THEN + ALTER TABLE freight.train_sets + ADD CONSTRAINT "FK_train_sets_train" FOREIGN KEY (train_id) + REFERENCES freight.trains (id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_train_sets_train_id" + ON freight.train_sets (train_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_train_sets_train_id";`); + await queryRunner.query(` + ALTER TABLE freight.train_sets + DROP CONSTRAINT IF EXISTS "FK_train_sets_train", + DROP COLUMN IF EXISTS train_id; + `); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_trains_current_yard_id";`); + await queryRunner.query(` + ALTER TABLE freight.trains + DROP CONSTRAINT IF EXISTS "FK_trains_current_yard", + DROP COLUMN IF EXISTS current_yard_id; + `); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_train_locomotives_train_loco";`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_locomotives;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2160000000000-MultiWagonTypePerCargoAndContainer.ts b/apps/edr-freight-api/src/migrations/2160000000000-MultiWagonTypePerCargoAndContainer.ts new file mode 100644 index 000000000..0fc391904 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2160000000000-MultiWagonTypePerCargoAndContainer.ts @@ -0,0 +1,119 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * A container type / cargo type can now be carried by SEVERAL wagon types + * (e.g. a 20ft container rides NX70 or NW5). Replaces the single + * `wagon_type_id` FK on both tables with proper link tables; train scheduling + * resolves the wagon type from the list, picking whichever type the schedule's + * built train (or the yard) actually has. + * + * Backfills one link row from each existing `wagon_type_id`, then drops the + * old column — the single-FK field is removed from the API and UI entirely. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class MultiWagonTypePerCargoAndContainer2160000000000 implements MigrationInterface { + name = 'MultiWagonTypePerCargoAndContainer2160000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.container_type_wagon_types ( + container_type_id uuid NOT NULL, + wagon_type_id uuid NOT NULL, + CONSTRAINT "PK_container_type_wagon_types" PRIMARY KEY (container_type_id, wagon_type_id), + CONSTRAINT "FK_ctwt_container_type" FOREIGN KEY (container_type_id) + REFERENCES freight.container_types (id) ON DELETE CASCADE, + CONSTRAINT "FK_ctwt_wagon_type" FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types (id) ON DELETE RESTRICT + ); + `); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.cargo_type_wagon_types ( + cargo_type_id uuid NOT NULL, + wagon_type_id uuid NOT NULL, + CONSTRAINT "PK_cargo_type_wagon_types" PRIMARY KEY (cargo_type_id, wagon_type_id), + CONSTRAINT "FK_cgwt_cargo_type" FOREIGN KEY (cargo_type_id) + REFERENCES freight.cargo_types (id) ON DELETE CASCADE, + CONSTRAINT "FK_cgwt_wagon_type" FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types (id) ON DELETE RESTRICT + ); + `); + + // Backfill from the old single FK (column may already be gone on re-run). + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'container_types' + AND column_name = 'wagon_type_id' + ) THEN + INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id) + SELECT ct.id, ct.wagon_type_id + FROM freight.container_types ct + WHERE ct.wagon_type_id IS NOT NULL + ON CONFLICT DO NOTHING; + END IF; + END $$; + `); + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'freight' AND table_name = 'cargo_types' + AND column_name = 'wagon_type_id' + ) THEN + INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id) + SELECT cg.id, cg.wagon_type_id + FROM freight.cargo_types cg + WHERE cg.wagon_type_id IS NOT NULL + ON CONFLICT DO NOTHING; + END IF; + END $$; + `); + + // Old single-FK column is fully retired (API + UI now use the lists). + await queryRunner.query(` + ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagon_type_id; + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS wagon_type_id; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.container_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid + REFERENCES freight.wagon_types (id) ON DELETE RESTRICT; + `); + await queryRunner.query(` + ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS wagon_type_id uuid + REFERENCES freight.wagon_types (id) ON DELETE RESTRICT; + `); + // Restore the first linked wagon type per row, then drop the link tables. + await queryRunner.query(` + UPDATE freight.container_types ct + SET wagon_type_id = link.wagon_type_id + FROM ( + SELECT DISTINCT ON (container_type_id) container_type_id, wagon_type_id + FROM freight.container_type_wagon_types + ORDER BY container_type_id, wagon_type_id + ) link + WHERE link.container_type_id = ct.id; + `); + await queryRunner.query(` + UPDATE freight.cargo_types cg + SET wagon_type_id = link.wagon_type_id + FROM ( + SELECT DISTINCT ON (cargo_type_id) cargo_type_id, wagon_type_id + FROM freight.cargo_type_wagon_types + ORDER BY cargo_type_id, wagon_type_id + ) link + WHERE link.cargo_type_id = cg.id; + `); + await queryRunner.query(`DROP TABLE IF EXISTS freight.container_type_wagon_types;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.cargo_type_wagon_types;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2170000000000-CreateWagonTransferRequests.ts b/apps/edr-freight-api/src/migrations/2170000000000-CreateWagonTransferRequests.ts new file mode 100644 index 000000000..2b73eaa0c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2170000000000-CreateWagonTransferRequests.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Two-person wagon-transfer request queue. A requester records a count-only + * request (N wagons of a type, from yard → to yard); OCC staff later pick the + * physical wagons and execute the move. Replaces the single-step instant + * bulk-transfer as the customer-facing yard-to-yard relocation path. + */ +export class CreateWagonTransferRequests2170000000000 + implements MigrationInterface +{ + name = 'CreateWagonTransferRequests2170000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_transfer_requests ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + from_yard_id uuid NOT NULL, + to_yard_id uuid NOT NULL, + wagon_type_id uuid NOT NULL, + quantity integer NOT NULL, + status varchar(20) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + fulfilled_by_user_id uuid NULL, + fulfilled_at timestamptz NULL, + note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_wagon_transfer_requests PRIMARY KEY (id), + CONSTRAINT fk_wtr_from_yard FOREIGN KEY (from_yard_id) REFERENCES freight.yards (id), + CONSTRAINT fk_wtr_to_yard FOREIGN KEY (to_yard_id) REFERENCES freight.yards (id), + CONSTRAINT fk_wtr_wagon_type FOREIGN KEY (wagon_type_id) REFERENCES freight.wagon_types (id), + CONSTRAINT chk_wtr_quantity CHECK (quantity > 0) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wtr_status_from_yard + ON freight.wagon_transfer_requests (status, from_yard_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_wtr_status_from_yard`, + ); + await queryRunner.query( + `DROP TABLE IF EXISTS freight.wagon_transfer_requests`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts b/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts new file mode 100644 index 000000000..7fecfccbb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Consist adjustments from a schedule: staff can trim free wagons off a built + * train when their tare pushes gross weight over the locomotives' pull limit + * (incl. overage tolerance), or couple extra yard wagons on while weight and + * length headroom remain. Each add/remove is logged here so the schedule keeps + * an auditable history; the built train itself is updated in place. + * + * Plain columns (no FKs) so the history survives wagon/train deletion. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface { + name = 'ScheduleWagonAdjustmentLogs2170000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + train_schedule_id uuid NOT NULL, + train_id uuid NOT NULL, + action varchar(10) NOT NULL, + wagon_id uuid NOT NULL, + wagon_number varchar(50) NOT NULL, + adjusted_by_user_id uuid, + occurred_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id) + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id" + ON freight.schedule_wagon_adjustment_logs (train_schedule_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_swal_train_id" + ON freight.schedule_wagon_adjustment_logs (train_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts b/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts new file mode 100644 index 000000000..3f10fa874 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Link each physical wagon move back to the transfer request that drove it, so + * the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103". + * Nullable — legacy moves and non-request manual corrections carry no request. + * Also indexes `moved_by_user_id` for the per-user history queries. + */ +export class LinkWagonMovementToTransferRequest2180000000000 + implements MigrationInterface +{ + name = 'LinkWagonMovementToTransferRequest2180000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request' + ) THEN + ALTER TABLE freight.wagon_movements + ADD CONSTRAINT fk_wm_transfer_request + FOREIGN KEY (transfer_request_id) + REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wm_transfer_request + ON freight.wagon_movements (transfer_request_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wm_moved_by + ON freight.wagon_movements (moved_by_user_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`); + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + DROP CONSTRAINT IF EXISTS fk_wm_transfer_request + `); + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + DROP COLUMN IF EXISTS transfer_request_id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 6a04014a7..317066cc4 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1255,10 +1255,11 @@ export class BookingsRepository extends BaseRepository { destinationYard: true, // units carry the real per-container numbers entered at booking time — // the wagon plan shows those instead of generated placeholders. - // containerType.wagonType + cargoType.wagonType drive wagon-type - // resolution during scheduling (FK, not the old load-type string map). - bookingContainers: { containerType: { wagonType: true }, units: true }, - cargoType: { wagonType: true }, + // containerType.wagonTypes + cargoType.wagonTypes drive wagon-type + // resolution during scheduling (many-to-many lists — the plan mixes + // wagon types within one consist). + bookingContainers: { containerType: { wagonTypes: true }, units: true }, + cargoType: { wagonTypes: true }, }, order: { priorityScore: 'DESC', createdAt: 'ASC' }, }); diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index b61d0078d..f366faa96 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -328,18 +328,21 @@ export class CustomerTruckService { if (assignment.departedAt) { throw new ConflictException('This truck has already left — its load is locked'); } - // Containers can only be loaded after the truck has physically arrived at the - // warehouse (arrival weighing recorded). Assignment alone is just planning. - if (!assignment.arrivedAt) { - throw new BadRequestException( - 'Record the truck arrival before loading — containers can only be loaded onto an arrived truck', - ); - } + // Loading a truck at the warehouse implies it is physically present, so a + // truck that is still only assigned (not yet marked arrived) is auto-arrived + // here rather than blocking the operator — the real gross is weighed on + // departure anyway. + const needsArrival = !assignment.arrivedAt; const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); if (!requested.length) { throw new BadRequestException('Select at least one container to load onto the truck'); } + // Capacity is size-based: a truck carries at most 2 containers, and a 40ft + // container fills the truck (max 1) — mirror the addTruck/updateTruck rule. + if (requested.length > 2) { + throw new BadRequestException('A truck carries at most 2 containers'); + } const bookingNumbers = await this.bookingContainerNumbers(bookingId); for (const n of requested) { if (!bookingNumbers.includes(n)) { @@ -352,6 +355,12 @@ export class CustomerTruckService { throw new ConflictException(`Container ${n} is already loaded onto another truck`); } } + const sizes = await this.containerSizes(bookingId, requested); + if (sizes.some((s) => s.includes('40')) && requested.length > 1) { + throw new BadRequestException( + 'A 40ft container fills the truck — load only 1 container onto this truck', + ); + } const grossTons = await this.vgmTonsForContainers(bookingId, requested); await this.dataSource.transaction(async (manager) => { @@ -371,9 +380,20 @@ export class CustomerTruckService { ); // Provisional gross (tonnes) from the loaded containers' VGM — overridden // by the weighed gross on departure. (Column is *_kg but holds tonnes.) + // Auto-stamp arrival if the truck was still only assigned. await manager.getRepository(CustomerTruckAssignment).update(assignmentId, { grossWeightKg: grossTons, + ...(needsArrival ? { arrivedAt: new Date() } : {}), }); + if (needsArrival) { + await manager.query( + `UPDATE freight.bookings + SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()), + updated_at = NOW() + WHERE id = $1`, + [bookingId], + ); + } }); return this.listTrucks(bookingId); } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts index 11c80f687..1386e5bd4 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/load-customer-truck.dto.ts @@ -1,9 +1,11 @@ -import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; +import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator'; /** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */ export class LoadCustomerTruckDto { @IsArray() @ArrayMinSize(1) + // A truck carries at most 2 containers (two 20ft, or one 40ft). + @ArrayMaxSize(2) @ArrayUnique() @Matches(/^[A-Z]{4}\d{7}$/, { each: true, diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index abee4d53d..fa7ee59ec 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -67,6 +67,12 @@ export class LastMileController { return this.lastMileService.findById(id); } + @Get('booking/:bookingId/arrival-trucks') + @ApiOperation({ summary: "Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill)" }) + arrivalTrucks(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.lastMileService.arrivalTrucksForBooking(bookingId); + } + @Post('accept/:reference') @BookingStaff(FREIGHT_PERMS.lastMile.accept) @ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' }) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 999137e6e..1ee31c63a 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -256,7 +256,95 @@ export class LastMileService { return this.findById(id); } + /** + * The EDR last-mile trucks assigned to a booking, joined with driver details, + * shaped for the arrival/exit weighing prefill (plate, driver, type, container). + * Returns [] when the booking has no last-mile truck assigned. Lets the + * warehouse arrival/load modals surface an assigned EDR truck the same way the + * self-haul customer trucks are surfaced. + */ + async arrivalTrucksForBooking(bookingId: string): Promise< + Array<{ + vehicleId: string; + truckPlateNumber: string | null; + trailerPlateNumber: string | null; + driverName: string | null; + driverLicense: string | null; + driverPhone: string | null; + truckType: string | null; + containerNumber: string | null; + }> + > { + const [lm] = await this.lastMileRepository.findAll({ + where: { bookingId }, + relations: { vehicle: true, vehicleAssignments: { vehicle: true } }, + take: 1, + }); + if (!lm) return []; + + // Prefer the multi-truck junction; fall back to the legacy single vehicle. + const sources = lm.vehicleAssignments?.length + ? lm.vehicleAssignments.map((va) => ({ + vehicle: va.vehicle, + containerNumber: va.containerNumber ?? null, + })) + : lm.vehicle + ? [{ vehicle: lm.vehicle, containerNumber: null }] + : []; + + const out: Array<{ + vehicleId: string; + truckPlateNumber: string | null; + trailerPlateNumber: string | null; + driverName: string | null; + driverLicense: string | null; + driverPhone: string | null; + truckType: string | null; + containerNumber: string | null; + }> = []; + for (const { vehicle, containerNumber } of sources) { + if (!vehicle) continue; + let driverName = vehicle.assignedDriverName ?? null; + let driverLicense: string | null = null; + let driverPhone: string | null = null; + if (vehicle.assignedDriverId) { + try { + const d = await this.driversService.findById(vehicle.assignedDriverId); + driverName = driverName || `${d.firstName ?? ''} ${d.lastName ?? ''}`.trim() || null; + driverLicense = d.licenseNumber ?? null; + driverPhone = d.phoneNumber ?? null; + } catch { + /* driver lookup is best-effort — plate still prefills */ + } + } + out.push({ + vehicleId: vehicle.id, + truckPlateNumber: vehicle.powerPlateNo || vehicle.plateNumber || null, + trailerPlateNumber: vehicle.trailerPlateNo || null, + driverName, + driverLicense, + driverPhone, + truckType: vehicle.vehicleType || null, + containerNumber, + }); + } + return out; + } + async create(dto: CreateLastMileDto): Promise { + // Idempotent: a booking gets exactly one last-mile record. Extra trucks live + // inside that record (vehicleAssignments), never as additional rows — so if a + // last-mile already exists for this booking, return it instead of inserting a + // duplicate delivery row (which is what made the same booking appear twice in + // the Assign-Mile list). + const [existing] = await this.lastMileRepository.findAll({ + where: { bookingId: dto.bookingId }, + take: 1, + }); + if (existing) { + return existing; + } + const record = await this.lastMileRepository.create({ bookingId: dto.bookingId, status: dto.status ?? 'READY_TO_TRANSIT', @@ -318,6 +406,17 @@ export class LastMileService { } } + // A last-mile truck must have a driver before it can be assigned (same rule + // as setVehicles) — block driverless single-vehicle (re)assignment too. + if (dto.vehicleId && dto.vehicleId !== existing.vehicleId) { + const vehicle = await this.vehiclesService.findById(dto.vehicleId); + if (!vehicle?.assignedDriverId) { + throw new BadRequestException( + `Truck ${vehicle?.plateNumber ?? dto.vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`, + ); + } + } + const dtoAny = dto as any; const updated = await this.lastMileRepository.update(id, { ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), @@ -478,6 +577,17 @@ export class LastMileService { )]; const added = desired.filter((v) => !junctionSet.has(v)); const removed = releaseIds.filter((v) => !desiredSet.has(v)); + + // A last-mile truck must have a driver before it can be assigned — a delivery + // can't run driverless, and the arrival/exit weighing needs the driver. + for (const vehicleId of added) { + const vehicle = await this.vehiclesService.findById(vehicleId); + if (!vehicle?.assignedDriverId) { + throw new BadRequestException( + `Truck ${vehicle?.plateNumber ?? vehicleId} has no assigned driver — assign a driver to the truck before adding it to this last-mile delivery`, + ); + } + } // Vehicles that stay but whose container number changed. const changed = current.filter( (a) => diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 76db7fa78..c2c034c18 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CargoUnitOfMeasure } from '@edr/types'; -import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateCargoTypeDto { @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @@ -22,12 +22,15 @@ export class CreateCargoTypeDto { parentGroupId?: string; @ApiPropertyOptional({ + type: [String], + format: 'uuid', description: - 'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.', + 'Wagon types that can carry this (bulk) cargo. Drives train scheduling wagon-type resolution; at least one is required for bulk commodities that are scheduled.', }) @IsOptional() - @IsUUID('4') - wagonTypeId?: string | null; + @IsArray() + @IsUUID('4', { each: true }) + wagonTypeIds?: string[]; @ApiPropertyOptional({ default: false }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts index e0baf7251..a01ba4b5b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-container-type.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; export class CreateContainerTypeDto { @ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 }) @@ -31,12 +31,15 @@ export class CreateContainerTypeDto { isOpenTop?: boolean; @ApiPropertyOptional({ + type: [String], + format: 'uuid', description: - 'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.', + 'Wagon types that can carry this container. Drives train scheduling wagon-type resolution; at least one is required when this container type is scheduled.', }) @IsOptional() - @IsUUID('4') - wagonTypeId?: string | null; + @IsArray() + @IsUUID('4', { each: true }) + wagonTypeIds?: string[]; @ApiPropertyOptional({ default: true }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index ac8a2ea24..7396595d1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -1,13 +1,21 @@ import { BaseEntity } from '@edr/api-common'; import { CargoUnitOfMeasure } from '@edr/types'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, +} from 'typeorm'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ schema: 'freight', name: 'cargo_types' }) @Index(['isActive']) @Index(['displayOrder']) @Index(['parentGroupId']) -@Index(['wagonTypeId']) @Index(['code']) export class CargoType extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' }) @@ -28,17 +36,19 @@ export class CargoType extends BaseEntity { unitOfMeasure?: CargoUnitOfMeasure | null; /** - * Wagon type that carries this (bulk) cargo. Replaces the former hardcoded - * cargo-code → wagon-code map: train scheduling resolves the bulk wagon type - * through this FK. Nullable — grouping rows and container/legacy cargo never - * carry it; scheduling throws if a scheduled bulk cargo type leaves it unset. + * Wagon types that can carry this (bulk) cargo. Train scheduling resolves the + * bulk wagon type through this list, picking whichever type the schedule's + * train (or yard) actually has. Grouping rows and container/legacy cargo + * leave it empty; scheduling throws if a scheduled bulk cargo type has none. */ - @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true }) - wagonTypeId?: string | null; - - @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' }) - @JoinColumn({ name: 'wagon_type_id' }) - wagonType?: WagonType | null; + @ManyToMany(() => WagonType) + @JoinTable({ + name: 'cargo_type_wagon_types', + schema: 'freight', + joinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'wagon_type_id', referencedColumnName: 'id' }, + }) + wagonTypes?: WagonType[]; @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts index f7cbeed99..2347426ca 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/container-type.entity.ts @@ -1,12 +1,11 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinTable, ManyToMany, OneToMany } from 'typeorm'; import { WeightLimitRule } from './weight-limit-rule.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; @Entity({ schema: 'freight', name: 'container_types' }) @Index(['code']) @Index(['isActive']) -@Index(['wagonTypeId']) export class ContainerType extends BaseEntity { @Column({ name: 'code', type: 'varchar', length: 20, unique: true }) code!: string; @@ -27,17 +26,19 @@ export class ContainerType extends BaseEntity { isOpenTop!: boolean; /** - * Wagon type that carries this container. Replaces the former hardcoded - * container wagon-code default (NW5): train scheduling resolves the container - * wagon type through this FK. Nullable; scheduling throws if a scheduled - * container type leaves it unset. + * Wagon types that can carry this container (e.g. a 20ft rides NX70 or NW5). + * Train scheduling resolves the container wagon type through this list, + * picking whichever type the schedule's train (or yard) actually has. + * Scheduling throws if a scheduled container type has none configured. */ - @Column({ name: 'wagon_type_id', type: 'uuid', nullable: true }) - wagonTypeId?: string | null; - - @ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' }) - @JoinColumn({ name: 'wagon_type_id' }) - wagonType?: WagonType | null; + @ManyToMany(() => WagonType) + @JoinTable({ + name: 'container_type_wagon_types', + schema: 'freight', + joinColumn: { name: 'container_type_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'wagon_type_id', referencedColumnName: 'id' }, + }) + wagonTypes?: WagonType[]; @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts index 8e4be5a3b..4df961aaa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -15,7 +15,7 @@ export class CargoTypesRepository implements ICargoTypesRepository { } findById(id: string): Promise { - return this.repo.findOne({ where: { id }, relations: { parent: true } }); + return this.repo.findOne({ where: { id }, relations: { parent: true, wagonTypes: true } }); } findByCode(code: string): Promise { @@ -35,6 +35,7 @@ export class CargoTypesRepository implements ICargoTypesRepository { const qb = this.repo .createQueryBuilder('cargoType') .leftJoinAndSelect('cargoType.parent', 'parent') + .leftJoinAndSelect('cargoType.wagonTypes', 'wagonType') .orderBy(`cargoType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC'); if (query.isActive !== undefined) { @@ -63,7 +64,18 @@ export class CargoTypesRepository implements ICargoTypesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data as never); + // Relation lists can't ride a column UPDATE — sync them via entity save. + const { wagonTypes, ...columns } = data; + if (Object.keys(columns).length) { + await this.repo.update(id, columns as never); + } + if (wagonTypes) { + const entity = await this.repo.findOne({ where: { id } }); + if (entity) { + entity.wagonTypes = wagonTypes; + await this.repo.save(entity); + } + } return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts index ff5a3f994..cc65bf546 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/container-types.repository.ts @@ -15,7 +15,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository { } findById(id: string): Promise { - return this.repo.findOne({ where: { id } }); + return this.repo.findOne({ where: { id }, relations: { wagonTypes: true } }); } findByCode(code: string): Promise { @@ -34,6 +34,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository { findPaged(query: ListContainerTypesQueryDto): Promise> { const qb = this.repo .createQueryBuilder('containerType') + .leftJoinAndSelect('containerType.wagonTypes', 'wagonType') .orderBy(`containerType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC'); if (query.isActive !== undefined) { @@ -54,7 +55,18 @@ export class ContainerTypesRepository implements IContainerTypesRepository { } async update(id: string, data: Partial): Promise { - await this.repo.update(id, data as never); + // Relation lists can't ride a column UPDATE — sync them via entity save. + const { wagonTypes, ...columns } = data; + if (Object.keys(columns).length) { + await this.repo.update(id, columns as never); + } + if (wagonTypes) { + const entity = await this.repo.findOne({ where: { id } }); + if (entity) { + entity.wagonTypes = wagonTypes; + await this.repo.save(entity); + } + } return this.findById(id); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 941d35f2f..8a72cf1f8 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -6,6 +6,7 @@ import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoType } from '../entities/cargo-type.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { CARGO_TYPES_REPOSITORY, ICargoTypesRepository, @@ -59,7 +60,8 @@ export class CargoTypesService { requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, unitOfMeasure: dto.unitOfMeasure ?? null, - wagonTypeId: dto.wagonTypeId ?? null, + // Join rows are written by the save (RESTRICT FK rejects unknown ids). + wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType), displayOrder, }); } @@ -72,7 +74,13 @@ export class CargoTypesService { const parent = await this.repository.findById(dto.parentGroupId); if (!parent) throw new NotFoundException(`Parent cargo type ${dto.parentGroupId} not found`); } - const updated = await this.repository.update(id, dto); + const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto; + const updated = await this.repository.update(id, { + ...columns, + ...(wagonTypeIds + ? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) } + : {}), + }); if (!updated) throw new NotFoundException(`Cargo type ${id} not found`); return updated; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts index da641afe0..42ce389e1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/container-types.service.ts @@ -6,6 +6,7 @@ import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { ReorderItemsDto } from '../dto/reorder-items.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { ContainerType } from '../entities/container-type.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { CONTAINER_TYPES_REPOSITORY, IContainerTypesRepository, @@ -51,7 +52,8 @@ export class ContainerTypesService { isReefer: dto.isReefer ?? false, isOpenTop: dto.isOpenTop ?? false, isActive: dto.isActive ?? true, - wagonTypeId: dto.wagonTypeId ?? null, + // Join rows are written by the save (RESTRICT FK rejects unknown ids). + wagonTypes: (dto.wagonTypeIds ?? []).map((id) => ({ id }) as WagonType), displayOrder, }); } @@ -59,7 +61,13 @@ export class ContainerTypesService { /** Update an existing container type. */ async update(id: string, dto: UpdateContainerTypeDto): Promise { await this.findById(id); - const updated = await this.repository.update(id, dto); + const { wagonTypeIds, insertAfterId: _insertAfterId, ...columns } = dto; + const updated = await this.repository.update(id, { + ...columns, + ...(wagonTypeIds + ? { wagonTypes: wagonTypeIds.map((wagonTypeId) => ({ id: wagonTypeId }) as WagonType) } + : {}), + }); if (!updated) throw new NotFoundException(`Container type ${id} not found`); return updated; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts new file mode 100644 index 000000000..485d29452 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts @@ -0,0 +1,38 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const; +export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number]; + +/** + * History row for a consist adjustment made from a schedule: staff coupled a + * wagon onto (ADD) or detached one from (REMOVE) the schedule's built train — + * e.g. trimming free wagons whose tare pushed gross weight over the + * locomotives' pull limit. Plain columns (no FK relations) so the history + * survives the wagon or train being deleted later. + */ +@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' }) +@Index(['trainScheduleId']) +@Index(['trainId']) +export class ScheduleWagonAdjustmentLog extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @Column({ name: 'train_id', type: 'uuid' }) + trainId!: string; + + @Column({ name: 'action', type: 'varchar', length: 10 }) + action!: WagonAdjustmentAction; + + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + @Column({ name: 'wagon_number', type: 'varchar', length: 50 }) + wagonNumber!: string; + + @Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true }) + adjustedByUserId!: string | null; + + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) + occurredAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index f32f2094e..8e40c0384 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -29,6 +29,7 @@ export class TrainSchedulesRepository extends BaseRepository { trainSet: { locomotive: true, locomotives: { locomotive: true }, + train: true, wagons: { wagonType: true, physicalWagon: true, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index a87b22c3e..c8e254141 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -886,7 +886,7 @@ describe('BookingBatchService — wagonsFor', () => { }; it('charges a bulk booking the tare of ITS wagon type, not the representative', () => { - const booking = bulk(2100, { cargoType: { wagonTypeId: 'pw2-id' } }); + const booking = bulk(2100, { cargoType: { wagonTypes: [{ id: 'pw2-id' }] } }); const need = service.needFor(booking, dimsWithTypes); expect(need.wagons).toBe(30); expect(need.weightTons).toBe(2856); // 2100 + 30 × 25.2 — matches allocation @@ -905,7 +905,7 @@ describe('BookingBatchService — wagonsFor', () => { { quantity: 2, wagonsRequired: 2, - containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypeId: 'pw2-id' }, + containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] }, }, ], }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 1c829b4ce..e51d4ad23 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -208,6 +208,8 @@ export interface BatchBoardScheduleDetail { docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingCycleNo: number; + /** Built train (Train Builder) behind this departure, when scheduled by train. */ + train: BatchBoardSchedule["train"]; locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; @@ -235,6 +237,12 @@ export interface BatchBoardSchedule { docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingCycleNo: number; + /** Built train (Train Builder) behind this departure, when scheduled by train. */ + train: { + id: string; + code: string; + trainName: string | null; + } | null; locomotive: { code: string; name: string | null; @@ -877,7 +885,7 @@ export class BookingBatchService implements OnModuleInit { const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { - trainSet: { locomotive: true }, + trainSet: { locomotive: true, train: true }, originStation: true, destinationStation: true, // Yards supply the route's display name for `routeName` below; @@ -1128,6 +1136,13 @@ export class BookingBatchService implements OnModuleInit { ? s.paymentPhaseEndsAt.toISOString() : null, bookingCycleNo: s.bookingCycleNo ?? 0, + train: s.trainSet?.train + ? { + id: s.trainSet.train.id, + code: s.trainSet.train.code, + trainName: s.trainSet.train.trainName ?? null, + } + : null, locomotive: loco ? { code: loco.code, @@ -1247,6 +1262,13 @@ export class BookingBatchService implements OnModuleInit { ? s.paymentPhaseEndsAt.toISOString() : null, bookingCycleNo: s.bookingCycleNo ?? 0, + train: s.trainSet?.train + ? { + id: s.trainSet.train.id, + code: s.trainSet.train.code, + trainName: s.trainSet.train.trainName ?? null, + } + : null, locomotive: loco ? { code: loco.code, @@ -2913,21 +2935,23 @@ export class BookingBatchService implements OnModuleInit { /** * Dimensions of the wagon type THIS booking rides: bulk resolves through its - * cargo type's wagon_type_id, container through the first container line's - * type — the same FK resolution `resolveWagonType` applies when the paid - * booking is allocated. Board/fill math measured on a representative wagon - * while allocation validated the real one let a selected batch flunk the - * post-payment gross-weight check; sharing the resolution closes that gap. - * Falls back to the representative dims when the FK or relation is absent. + * cargo type's allowed wagon-type list, container through the first container + * line's — the same list resolution the scheduling planner applies when the + * paid booking is allocated. Board/fill math measured on a representative + * wagon while allocation validated the real one let a selected batch flunk + * the post-payment gross-weight check; sharing the resolution closes that + * gap. Uses the first configured type (the fill engine has no train context); + * falls back to the representative dims when the list or relation is absent. */ private dimsFor(booking: Booking, wagonDims: WagonDims): PerWagonDims { const fallback = booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container; const wagonTypeId = booking.freightType === "BULK" - ? booking.cargoType?.wagonTypeId + ? booking.cargoType?.wagonTypes?.[0]?.id : (booking.bookingContainers ?? []) - .map((line) => line.containerType?.wagonTypeId) + .flatMap((line) => line.containerType?.wagonTypes ?? []) + .map((wagonType) => wagonType.id) .find((id): id is string => Boolean(id)); const dims = wagonTypeId ? wagonDims.byWagonTypeId.get(wagonTypeId) : undefined; if (!dims) return fallback; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts new file mode 100644 index 000000000..e031bf9f0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts @@ -0,0 +1,26 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsUUID } from 'class-validator'; + +export class AdjustScheduleConsistDto { + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: + "AVAILABLE wagons from the train's current yard to couple onto the built train (blocked when they push gross weight or length past the locomotive limits incl. tolerance).", + }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + addWagonIds?: string[]; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: + 'Free (unloaded) wagons to detach permanently from the built train — e.g. trimming tare when gross weight exceeds the pull limit.', + }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + removeWagonIds?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-trains-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-trains-query.dto.ts new file mode 100644 index 000000000..7ef63e9db --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-trains-query.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class AvailableTrainsQueryDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + routeId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts index 60aab2862..5b3e93ba7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -20,15 +20,26 @@ export class CreateContainerTrainScheduleDto { @IsDateString() scheduleDate!: string; - @ApiProperty({ + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Built train (Train Builder) to run this departure — its locomotive set is used. Provide either trainId or locomotiveIds.', + }) + @IsOptional() + @IsUUID() + trainId?: string; + + @ApiPropertyOptional({ type: [String], format: 'uuid', - description: 'Locomotives pulling the train (minimum 2 — front and back)', + description: + 'Hand-picked locomotives pulling the train (minimum 2 — front and back). Ignored when trainId is provided.', }) + @IsOptional() @IsArray() @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) @IsUUID('all', { each: true }) - locomotiveIds!: string[]; + locomotiveIds?: string[]; @ApiPropertyOptional({ description: 'Maximum total booking weight allowed on this train' }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 4196e0972..c6e22589f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -39,6 +39,8 @@ import { UploadImportDjiboutiDocumentDto, } from "./dto/import-djibouti-operation.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; +import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto"; +import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto"; import { BatchBoardQueryDto } from "./dto/batch-board-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto"; @@ -153,6 +155,46 @@ export class TrainSchedulingController { ); } + @Get("available-trains") + @TrainSchedulingView() + @ApiOperation({ + summary: + "List built trains (Train Builder) schedulable on a route, annotated with yard position and future runs", + }) + getAvailableTrains(@Query() query: AvailableTrainsQueryDto) { + return this.trainSchedulingService.getAvailableTrainsForRoute( + query.routeId, + ); + } + + @Get("schedules/:id/consist") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Built-train consist snapshot for a schedule: gross weight/length vs locomotive limits (incl. tolerance), trimmable + addable wagons, adjustment history", + }) + getScheduleConsist(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getScheduleConsist(id); + } + + @Post("schedules/:id/adjust-consist") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", + }) + adjustScheduleConsist( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: AdjustScheduleConsistDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.adjustScheduleConsist( + id, + dto, + resolveAuthUserId(user), + ); + } + @Get("bookable-schedules") // No staff guard: customers hit this while creating a booking to find OPEN // same-route schedules. Do not attach train_scheduling permissions here. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index e97b3de56..c4f2e7d6d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -72,7 +72,7 @@ const makeBooking = ( wagonsRequired, vgmPerUnitTons: weight / quantity, isOverweight: false, - containerType: { code: containerCode, label: containerCode, wagonTypeId: nw5.id }, + containerType: { id: 'ct-1', code: containerCode, label: containerCode, wagonTypes: [nw5] }, }, ], ...extra, @@ -80,7 +80,7 @@ const makeBooking = ( describe('TrainSchedulingService', () => { let service: TrainSchedulingService; - let dataSource: { getRepository: jest.Mock; transaction: jest.Mock }; + let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock }; let bookingsRepository: Record; let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; let wagonTypesRepository: { findAll: jest.Mock }; @@ -91,7 +91,12 @@ describe('TrainSchedulingService', () => { let wagonAllocationBulkLoadsRepository: Record; beforeEach(() => { - dataSource = { getRepository: jest.fn(), transaction: jest.fn() }; + dataSource = { + getRepository: jest.fn(), + transaction: jest.fn(), + // Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows". + query: jest.fn().mockResolvedValue([]), + }; bookingsRepository = { findEligibleForScheduling: jest.fn(), findByIdsForScheduling: jest.fn(), @@ -259,8 +264,10 @@ describe('TrainSchedulingService', () => { expect(result.valid).toBe(true); expect(result.violations).toEqual([]); - expect(result.summary.wagonsNeeded).toBe(45); - expect(result.wagonPlan).toHaveLength(45); + // TEU packing: 20 + 15 wagons of 40ft plus 10×20ft at two per wagon (5) — + // the planner packs by container size, not the stored per-line fallback. + expect(result.summary.wagonsNeeded).toBe(40); + expect(result.wagonPlan).toHaveLength(40); }); it('returns soft hold warnings without forceAssign', async () => { @@ -293,7 +300,7 @@ describe('TrainSchedulingService', () => { wagonsRequired: 80, vgmPerUnitTons: 45, isOverweight: true, - containerType: { code: '40FT', label: '40FT', wagonTypeId: nw5.id }, + containerType: { id: 'ct-1', code: '40FT', label: '40FT', wagonTypes: [nw5] }, }, ], }), @@ -655,10 +662,12 @@ describe('TrainSchedulingService', () => { destinationStationId: 'yard-djibouti', }); - expect(result.valid).toBe(false); - expect( - result.violations.some((v) => v.includes('available at yard') && v.includes('NW5')), - ).toBe(true); + // List-based planner: a booking with no plannable wagon at the yard is + // DEFERRED with the wagon-type reason (assign still hard-fails when no + // booking fits), instead of surfacing a phantom-slot violation. + expect(result.valid).toBe(true); + expect(result.wagonPlan).toHaveLength(0); + expect(result.deferredBookings.some((d) => d.reason.includes('NW5'))).toBe(true); }); it('assignBookingsToSchedule rejects when physical wagons cannot be pinned', async () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 9e82bca33..04583c777 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,5 +1,6 @@ import { AllocationLoadType, + Freight, LoadingStatus, SchedulingStatus, TrainCheckpointKind, @@ -24,6 +25,7 @@ import { FindOptionsWhere, ILike, In, + IsNull, Not, QueryFailedError, Raw, @@ -43,9 +45,11 @@ import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { LocomotivesRepository } from '../locomotives/locomotives.repository'; import { formatRouteLabel, Route } from '../routes/entities/route.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; +import { Train } from '../trains/entities/train.entity'; import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; @@ -59,6 +63,7 @@ import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-book import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; +import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; @@ -90,9 +95,7 @@ import { type BookingWindowConfig } from './booking-window.config'; import { BookingWindowGateway } from './booking-window.gateway'; import { BookingNotifierService } from './booking-notifier.service'; import { - buildCappedWagonPlan, computeFleetAvailability, - selectBookingsWithinFleetCap, summarizeFleetWarnings, totalAssignedWeight, wagonsRequiredForBooking, @@ -100,9 +103,12 @@ import { type FleetAvailabilityRow, } from './fleet-plan.util'; import { - buildBulkWagonPlan, - buildContainerWagonPlan, - buildMixedWagonPlan, + planWagonsWithStock, + unboundedStock, + type AllowedWagonTypeMap, + type WagonStock, +} from './wagon-plan-flex.util'; +import { expandBookingContainerUnits, getContainerSlotSequenceNos, roundTons, @@ -110,7 +116,6 @@ import { type TrainLimitConfig, validateContainerPlacements, validateMixedTrainLimits, - validateTrainLimits, type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; @@ -280,6 +285,8 @@ interface BookingWindowRow { origin_code: string | null; destination_label: string | null; destination_code: string | null; + /** Full ordered corridor (origin → milestones → destination) from the schedule's route. */ + route_stations: string[] | null; } @Injectable() @@ -291,7 +298,9 @@ export class TrainSchedulingService { private readonly dataSource: DataSource, private readonly bookingsRepository: BookingsRepository, private readonly locomotivesRepository: LocomotivesRepository, - private readonly wagonTypesRepository: WagonTypesRepository, + // Kept in the DI signature for constructor-arity stability (specs mock it); + // wagon-type resolution now flows through the config lists on the bookings. + _wagonTypesRepository: WagonTypesRepository, private readonly trainSchedulesRepository: TrainSchedulesRepository, private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository, private readonly wagonBookingAllocationsRepository: WagonBookingAllocationsRepository, @@ -980,12 +989,52 @@ export class TrainSchedulingService { async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { const route = await this.getSchedulableRoute(dto.routeId); - const locomotiveIds = [...new Set(dto.locomotiveIds)]; - if (locomotiveIds.length < 2) { - throw new BadRequestException('A train must be pulled by at least two locomotives'); + const scheduleWarnings: string[] = []; + + // The pulling set comes either from a built train (Train Builder) or from + // hand-picked locomotive ids (legacy path). A built train also links the + // schedule's train set back to it (`train_sets.train_id`) so its lifecycle + // and yard follow the schedule. + let builtTrain: Train | null = null; + let locomotiveIds: string[]; + if (dto.trainId) { + builtTrain = await this.dataSource.getRepository(Train).findOne({ + where: { id: dto.trainId }, + relations: { locomotives: true }, + order: { locomotives: { sequenceNo: 'ASC' } }, + }); + if (!builtTrain) { + throw new NotFoundException(`Train ${dto.trainId} not found`); + } + if ( + builtTrain.status === Freight.TrainStatus.OutOfService || + builtTrain.status === Freight.TrainStatus.UnderMaintenance + ) { + throw new ConflictException( + `Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`, + ); + } + locomotiveIds = (builtTrain.locomotives ?? []) + .slice() + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((link) => link.locomotiveId); + if (locomotiveIds.length < 2) { + throw new BadRequestException( + `Train ${builtTrain.code} has fewer than two locomotives; rebuild it before scheduling`, + ); + } + if (builtTrain.currentYardId !== route.originYardId) { + scheduleWarnings.push( + `Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`, + ); + } + } else { + locomotiveIds = [...new Set(dto.locomotiveIds ?? [])]; + if (locomotiveIds.length < 2) { + throw new BadRequestException('A train must be pulled by at least two locomotives'); + } } - const scheduleWarnings: string[] = []; const createdScheduleId = await this.dataSource.transaction(async (manager) => { // Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on // multiple future schedules and does not need to be at the origin yard yet — staff @@ -1020,7 +1069,11 @@ export class TrainSchedulingService { // getSchedulableRoute already rejected DOMESTIC (intercity). const direction = this.resolveRouteDirection(route); - const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); + const trainSet = await this.buildEmptyTrainSet( + manager, + lockedLocomotives, + builtTrain?.id ?? null, + ); // Effective capacity is capped by the weakest locomotive in the set. const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; const departure = new Date(dto.scheduleDate); @@ -1089,8 +1142,16 @@ export class TrainSchedulingService { ? this.groupWindowFieldsFrom(groupAnchor, departure) : computedTimes), }; - const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco)) - .maxWagonsPerTrain; + // A built train's own consist is the schedule's capacity: full when all + // its wagons are allocated. Trains built without wagons yet fall back to + // the configured limit. + const builtTrainWagonCount = builtTrain + ? await manager.getRepository(Wagon).count({ where: { trainId: builtTrain.id } }) + : 0; + const maxWagons = + builtTrainWagonCount > 0 + ? builtTrainWagonCount + : (await this.resolveTrainLimitConfig(dto, limitLoco)).maxWagonsPerTrain; // Retry past a concurrent insert that grabbed the same S- sequence // (the unique index rejects the loser; it re-reads the max and tries again). const saved = await this.insertScheduleWithReference(manager, (reference) => @@ -1109,6 +1170,9 @@ export class TrainSchedulingService { ); // Locomotives stay in their current status until dispatch — advance scheduling // must not block the locomotive from serving earlier trains. + if (builtTrain) { + await this.syncBuiltTrainAfterScheduleChange(manager, builtTrain.id); + } return saved.id; }); @@ -1255,7 +1319,7 @@ export class TrainSchedulingService { }); } - const { bookings, wagonType, wagonPlan, warnings, deferredBookings } = validation; + const { bookings, wagonPlan, warnings, deferredBookings } = validation; const totalWeightTons = validation.summary.totalWeightTons; const totalLengthMeters = validation.summary.totalLengthMeters; @@ -1270,9 +1334,29 @@ export class TrainSchedulingService { limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0); const lengthCapWithOverage = limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0); - if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) { + // The locomotives pull GROSS weight: the customers' cargo plus wagon tare. + // A built train hauls EVERY coupled wagon's tare — empty ones included — + // so train-bound schedules count the full consist, not just planned slots. + const consistWagons = schedule.trainSet?.trainId + ? await this.dataSource.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + }) + : null; + const planTareTons = consistWagons + ? roundTons( + consistWagons.reduce( + (sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0), + 0, + ), + ) + : roundTons( + wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0), + ); + const grossWeightTons = roundTons(totalWeightTons + planTareTons); + if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) { throw new BadRequestException( - `Train set locomotives cannot pull ${totalWeightTons}T`, + `Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`, ); } if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) { @@ -1313,7 +1397,6 @@ export class TrainSchedulingService { const savedWagons = await this.persistTrainSetWagons( manager, trainSetId, - wagonType, wagonPlan, ); @@ -1782,6 +1865,10 @@ export class TrainSchedulingService { if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' }); } + // A built train follows its schedule out: IN_SERVICE until arrival. + if (schedule.trainSet?.trainId) { + await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId); + } // The train is out — every pinned wagon is ASSIGNED to this schedule and // stays pinned so no other schedule can pick it while it's rolling. const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? []) @@ -2862,6 +2949,15 @@ export class TrainSchedulingService { status: 'COMPLETED', }); } + // A built train arrives with its schedule: settle it at the destination + // yard and re-derive its status (AVAILABLE, or SCHEDULED if more runs wait). + if (schedule.trainSet?.trainId) { + await this.syncBuiltTrainAfterScheduleChange( + manager, + schedule.trainSet.trainId, + schedule.destinationStationId, + ); + } // Per-booking journey: bookings destined for the FINAL yard that the // operator didn't unload individually get their arrival stamped now as a @@ -2894,7 +2990,9 @@ export class TrainSchedulingService { await manager.getRepository(Wagon).update(wagon.id, { currentTrainScheduleId: null, trainSetWagonId: null, - status: WagonStatus.Available, + // A wagon that belongs to a built train stays coupled to it (ASSIGNED); + // only loose wagons return to the open AVAILABLE pool. + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, currentYardId: settleYardId, }); // Ledger: the wagon rode this schedule to its settle yard. @@ -2987,7 +3085,7 @@ export class TrainSchedulingService { const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { - trainSet: { locomotive: true, locomotives: { locomotive: true } }, + trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, // Yards carry the route's display name used by mapScheduleListItem; // milestones (with yards) let it show the full corridor path. route: { originYard: true, destinationYard: true, milestones: { yard: true } }, @@ -3038,6 +3136,11 @@ export class TrainSchedulingService { if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); } + // Cancelled run: the built train never left — re-derive its status + // (back to AVAILABLE unless other runs still reference it). + if (schedule.trainSet?.trainId) { + await this.syncBuiltTrainAfterScheduleChange(manager, schedule.trainSet.trainId); + } // Locomotives are only ASSIGNED while out on a dispatched train. Release ours, // but never stomp a locomotive that is currently pulling another dispatched train. const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); @@ -3059,7 +3162,11 @@ export class TrainSchedulingService { await manager.getRepository(Wagon).update(wagon.physicalWagonId, { currentTrainScheduleId: null, trainSetWagonId: null, - status: WagonStatus.Available, + // Built-train wagons stay coupled to their train (ASSIGNED); loose + // wagons return to the open AVAILABLE pool. + status: wagon.physicalWagon?.trainId + ? WagonStatus.Assigned + : WagonStatus.Available, // A cancelled train never left — its wagons stay/return at the origin // yard, free to be re-pinned onto another schedule from there. currentYardId: schedule.originStationId, @@ -3194,92 +3301,65 @@ export class TrainSchedulingService { } } - let wagonType: WagonType; - let containerWagonType: WagonType; - let bulkWagonType: WagonType; - let demandPlan: WagonPlanSlot[]; - let fittingBookings = bookings; - let deferredBookings: DeferredBookingRow[] = []; - let fleetAvailability: FleetAvailabilityRow[] = []; + // Wagon-type resolution is list-based: each container/cargo type carries + // the wagon types that can haul it, and the plan mixes wagon types within + // one consist. A schedule created from a built train (Train Builder) plans + // against ONLY that train's own wagons — full when every consist wagon is + // allocated; legacy schedules plan against the boarding yards' pool. + const allowed = await this.loadAllowedWagonTypes(bookings); + const builtTrainId = await this.builtTrainIdOfSchedule(targetScheduleId); - if (resolvedMode === 'MIXED') { - const containerBookings = bookings.filter((b) => b.freightType === 'CONTAINER'); - const bulkBookings = bookings.filter((b) => b.freightType === 'BULK'); - containerWagonType = await this.resolveWagonType('CONTAINER', bookingIds); - bulkWagonType = await this.resolveWagonType('BULK', bookingIds); - wagonType = containerWagonType; - demandPlan = buildMixedWagonPlan( - containerBookings, - bulkBookings, - containerWagonType, - bulkWagonType, - ); - } else { - wagonType = await this.resolveWagonType(resolvedMode, bookingIds); - containerWagonType = wagonType; - bulkWagonType = wagonType; - demandPlan = - resolvedMode === 'CONTAINER' - ? buildContainerWagonPlan(bookings, wagonType) - : buildBulkWagonPlan(bookings, wagonType); - } + // Pure demand (unbounded stock) drives the availability report rows. + const demandPlan = planWagonsWithStock({ + bookings, + allowed, + stock: unboundedStock(allowed), + }).plan; const originYardId = dto.originStationId; - // Dynamic consist: a slot's physical wagon may ride from the train's origin - // OR already sit at the booking's own boarding yard and attach there — so - // the usable fleet is the union across the origin and every boarding yard. - const boardYardIds = [ - ...new Set( - [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), - ), - ]; - const fleetCountsByYard = await Promise.all( - boardYardIds.map((yardId) => - this.countFleetAvailability(yardId, targetScheduleId), - ), - ); - const mergedFleet = new Map(); - for (const rows of fleetCountsByYard) { - for (const row of rows) { - const existing = mergedFleet.get(row.wagonTypeId) ?? { - code: row.wagonTypeCode, - available: 0, - }; - existing.available += row.available; - mergedFleet.set(row.wagonTypeId, existing); + let stock: WagonStock; + if (builtTrainId) { + stock = await this.builtTrainStock(builtTrainId); + } else { + // Dynamic consist: a slot's physical wagon may ride from the train's origin + // OR already sit at the booking's own boarding yard and attach there — so + // the usable fleet is the union across the origin and every boarding yard. + const boardYardIds = [ + ...new Set( + [originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean), + ), + ]; + const fleetCountsByYard = await Promise.all( + boardYardIds.map((yardId) => + this.countFleetAvailability(yardId, targetScheduleId), + ), + ); + const remainingByTypeId = new Map(); + const codesByTypeId = new Map(); + for (const rows of fleetCountsByYard) { + for (const row of rows) { + remainingByTypeId.set( + row.wagonTypeId, + (remainingByTypeId.get(row.wagonTypeId) ?? 0) + row.available, + ); + codesByTypeId.set(row.wagonTypeId, row.wagonTypeCode); + } } + stock = { mode: 'YARD', remainingByTypeId, codesByTypeId }; } - const fleetCounts = [...mergedFleet.entries()].map( - ([wagonTypeId, value]) => ({ - wagonTypeId, - wagonTypeCode: value.code, - available: value.available, - }), - ); - const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available])); - fleetAvailability = computeFleetAvailability( + + const planned = planWagonsWithStock({ bookings, allowed, stock }); + violations.push(...planned.configIssues); + const fittingBookings = planned.fitting; + const deferredBookings: DeferredBookingRow[] = planned.deferred; + const wagonPlan = planned.plan; + + const fleetAvailability: FleetAvailabilityRow[] = computeFleetAvailability( demandPlan, - fleetByTypeId, - new Map(fleetCounts.map((row) => [row.wagonTypeId, row.wagonTypeCode])), + stock.remainingByTypeId, + stock.codesByTypeId, ); - - const selection = selectBookingsWithinFleetCap( - bookings, - fleetByTypeId, - (booking) => - booking.freightType === 'BULK' ? bulkWagonType.id : containerWagonType.id, - Number(bulkWagonType.capacityTons), - ); - fittingBookings = selection.fitting; - deferredBookings = selection.deferred; warnings.push(...summarizeFleetWarnings(fleetAvailability, deferredBookings)); - - const wagonPlan = buildCappedWagonPlan({ - bookings: fittingBookings, - resolvedMode, - containerWagonType, - bulkWagonType, - }); this.stampSlotLegs( wagonPlan, fittingBookings, @@ -3307,40 +3387,34 @@ export class TrainSchedulingService { const pushLimit = (issues: string[]) => forceAssign ? warnings.push(...issues) : violations.push(...issues); - if (resolvedMode === 'MIXED') { - pushLimit( - validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), + // The plan can mix wagon types, so limit math always runs against the + // distinct types actually planned (shortest length drives the wagon-count + // fallback — validateMixedTrainLimits generalizes the single-type check). + const plannedWagonTypes = [ + ...new Map( + wagonPlan.map((slot) => [slot.wagonTypeId, { lengthMeters: slot.lengthMeters }]), + ).values(), + ]; + pushLimit( + validateMixedTrainLimits( + wagonPlan, + plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }], + trainLimits, + ), + ); + if (requireContainerPlacements && resolvedMode !== 'BULK') { + const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); + violations.push( + ...validateContainerPlacements( + containerBookings, + wagonPlan, + containerPlacements, + placementRules, + ), + ); + violations.push( + ...(await this.validateFleetContainers(containerPlacements, containerBookings)), ); - if (requireContainerPlacements) { - const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); - violations.push( - ...validateContainerPlacements( - containerBookings, - wagonPlan, - containerPlacements, - placementRules, - ), - ); - violations.push( - ...(await this.validateFleetContainers(containerPlacements, containerBookings)), - ); - } - } else { - pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits)); - - if (requireContainerPlacements && resolvedMode === 'CONTAINER') { - violations.push( - ...validateContainerPlacements( - fittingBookings, - wagonPlan, - containerPlacements, - placementRules, - ), - ); - violations.push( - ...(await this.validateFleetContainers(containerPlacements, fittingBookings)), - ); - } } const totalWeightTons = totalAssignedWeight(fittingBookings); @@ -3405,20 +3479,21 @@ export class TrainSchedulingService { } } + const plannedTypeCodes = [...new Set(wagonPlan.map((slot) => slot.wagonTypeCode))]; + return { valid: violations.length === 0, violations, warnings, bookings: fittingBookings, - wagonType, wagonPlan, fleetAvailability, deferredBookings, summary: { totalBookings: fittingBookings.length, totalWeightTons, - wagonType: - resolvedMode === 'MIXED' ? 'MIXED' : wagonType.code, + // Human-readable wagon type(s) of the plan — mixed consists list all. + wagonType: plannedTypeCodes.join('/') || 'NONE', wagonsNeeded: wagonPlan.length, totalLengthMeters, freightMode: resolvedMode, @@ -3576,23 +3651,51 @@ export class TrainSchedulingService { ]; } + /** + * Built train (Train Builder) behind a schedule's train set, if the schedule + * was created by picking a train instead of loose locomotives. + */ + private async builtTrainIdOfSchedule( + scheduleId: string | undefined, + manager?: EntityManager, + ): Promise { + if (!scheduleId) return null; + const runner = manager ?? this.dataSource; + const rows: { train_id: string | null }[] = await runner.query( + `SELECT tset.train_id + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE ts.id = $1`, + [scheduleId], + ); + return rows[0]?.train_id ?? null; + } + private async countFleetAvailability( originYardId: string, targetScheduleId?: string, ): Promise> { - const [wagons, wagonTypes] = await Promise.all([ + const [wagons, wagonTypes, builtTrainId] = await Promise.all([ this.dataSource.getRepository(Wagon).find(), this.dataSource.getRepository(WagonType).find(), + this.builtTrainIdOfSchedule(targetScheduleId), ]); const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); for (const wagon of wagons) { - const pinnedOnTarget = targetScheduleId - ? wagon.currentTrainScheduleId === targetScheduleId - : false; - if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; - if (wagon.currentYardId !== originYardId) continue; + // Train-bound schedule: the built train's own consist IS the fleet — only + // its wagons count (wherever they currently sit; they travel with the + // train), and loose yard wagons never do. + if (builtTrainId) { + if (wagon.trainId !== builtTrainId) continue; + } else { + const pinnedOnTarget = targetScheduleId + ? wagon.currentTrainScheduleId === targetScheduleId + : false; + if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; + if (wagon.currentYardId !== originYardId) continue; + } const typeId = wagon.wagonTypeId; const code = typeCodeById.get(typeId) ?? typeId; @@ -3651,10 +3754,16 @@ export class TrainSchedulingService { private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) { const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } }); - for (const slot of slots) { - if (!slot.physicalWagonId) continue; - await manager.getRepository(Wagon).update(slot.physicalWagonId, { - status: WagonStatus.Available, + const physicalIds = slots + .map((slot) => slot.physicalWagonId) + .filter((id): id is string => Boolean(id)); + if (!physicalIds.length) return; + const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } }); + for (const wagon of wagons) { + await manager.getRepository(Wagon).update(wagon.id, { + // Built-train wagons stay coupled to their train (ASSIGNED); loose + // wagons return to the open AVAILABLE pool. + status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, trainSetWagonId: null, currentTrainScheduleId: null, }); @@ -3669,6 +3778,7 @@ export class TrainSchedulingService { ) { const wagons = await manager.getRepository(Wagon).find(); const wagonTypes = await manager.getRepository(WagonType).find(); + const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager); const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code])); const planSlots = [...slots] @@ -3686,6 +3796,7 @@ export class TrainSchedulingService { wagons, scheduleId, originYardId, + builtTrainId, ); if (unpinnable.length) { throw new BadRequestException({ @@ -3702,6 +3813,7 @@ export class TrainSchedulingService { scheduleId, originYardId, assignedPhysicalIds, + builtTrainId, ); if (!physical) continue; @@ -3726,7 +3838,10 @@ export class TrainSchedulingService { ): Promise { if (!wagonPlan.length) return []; - const wagons = await this.dataSource.getRepository(Wagon).find(); + const [wagons, builtTrainId] = await Promise.all([ + this.dataSource.getRepository(Wagon).find(), + this.builtTrainIdOfSchedule(targetScheduleId), + ]); return this.findUnpinnableWagonSlots( wagonPlan.map((slot) => ({ sequenceNo: slot.sequenceNo, @@ -3737,6 +3852,7 @@ export class TrainSchedulingService { wagons, targetScheduleId, originYardId, + builtTrainId, ); } @@ -3750,6 +3866,7 @@ export class TrainSchedulingService { wagons: Wagon[], scheduleId: string | undefined, originYardId: string, + builtTrainId: string | null = null, ): string[] { const violations: string[] = []; const assignedPhysicalIds = new Set(); @@ -3761,6 +3878,7 @@ export class TrainSchedulingService { scheduleId, originYardId, assignedPhysicalIds, + builtTrainId, ); if (!physical) { violations.push( @@ -3785,6 +3903,7 @@ export class TrainSchedulingService { scheduleId: string | undefined, originYardId: string, assignedPhysicalIds: Set, + builtTrainId: string | null = null, ): Wagon | undefined { const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; @@ -3794,6 +3913,17 @@ export class TrainSchedulingService { : false; return wagon.status === WagonStatus.Available || pinnedOnSchedule; }; + // Train-bound schedule: ONLY the built train's own wagons may be pinned — + // wherever they currently sit (they travel with the train), never a loose + // yard wagon. + if (builtTrainId) { + return wagons.find( + (w) => + w.trainId === builtTrainId && + w.wagonTypeId === slot.wagonTypeId && + !assignedPhysicalIds.has(w.id), + ); + } // Prefer a wagon already waiting at the slot's board yard (no empty haul); // fall back to one riding from the train's origin. if (slot.boardYardId) { @@ -3866,61 +3996,54 @@ export class TrainSchedulingService { * when the relevant type has no wagon type configured — scheduling is blocked * until an admin assigns one on the cargo-type / container-type config screen. */ - private async resolveWagonType( - freightType: 'CONTAINER' | 'BULK', - bookingIds: string[], - ): Promise { - const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds); + /** + * Wagon types allowed to carry each container/cargo type on these bookings. + * The many-to-many configuration lists (active wagon types only), keyed by + * type id — the flexible planner mixes wagon types within one consist. + * Bookings must arrive from findByIdsForScheduling so the `wagonTypes` + * relations are loaded. + */ + private loadAllowedWagonTypes(bookings: Booking[]): AllowedWagonTypeMap { + const byContainerTypeId = new Map(); + const byCargoTypeId = new Map(); + const active = (list?: WagonType[] | null) => + (list ?? []).filter((wt) => wt.isActive !== false); - if (freightType === 'CONTAINER') { - // First container type present on the batch drives the container wagon - // type (matches the prior single-wagon-type-per-consist behavior). - const containerType = bookings - .flatMap((b) => b.bookingContainers ?? []) - .map((line) => line.containerType) - .find((ct): ct is NonNullable => Boolean(ct)); - if (!containerType) { - throw new BadRequestException('No container type found on the container booking(s)'); + for (const booking of bookings) { + for (const line of booking.bookingContainers ?? []) { + const containerType = line.containerType; + if (containerType && !byContainerTypeId.has(containerType.id)) { + byContainerTypeId.set(containerType.id, active(containerType.wagonTypes)); + } + } + const cargoType = booking.cargoType; + if (cargoType && !byCargoTypeId.has(cargoType.id)) { + byCargoTypeId.set(cargoType.id, active(cargoType.wagonTypes)); } - const wagonType = await this.loadWagonTypeForType( - containerType.wagonTypeId ?? null, - `Container type "${containerType.label ?? containerType.code}"`, - ); - return wagonType; } - - const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct)); - if (!cargoType) { - throw new BadRequestException('No cargo type found on the bulk booking(s)'); - } - return this.loadWagonTypeForType( - cargoType.wagonTypeId ?? null, - `Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`, - ); + return { byContainerTypeId, byCargoTypeId }; } /** - * Load an active wagon type by FK id, throwing a clear error when the id is - * unset (type not configured) or points at a missing/inactive wagon type. + * TRAIN-mode wagon stock: the built train's own consist, grouped by wagon + * type. This is the whole plannable pool for its schedules — the plan is + * full when every consist wagon is allocated. */ - private async loadWagonTypeForType( - wagonTypeId: string | null, - typeLabel: string, - ): Promise { - if (!wagonTypeId) { - throw new BadRequestException( - `${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`, - ); - } - const [wagonType] = await this.wagonTypesRepository.findAll({ - where: { id: wagonTypeId, isActive: true }, + private async builtTrainStock(builtTrainId: string): Promise { + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: { trainId: builtTrainId }, + relations: { wagonType: true }, }); - if (!wagonType) { - throw new NotFoundException( - `${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`, + const remainingByTypeId = new Map(); + const codesByTypeId = new Map(); + for (const wagon of wagons) { + remainingByTypeId.set( + wagon.wagonTypeId, + (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1, ); + if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code); } - return wagonType; + return { mode: 'TRAIN', remainingByTypeId, codesByTypeId }; } /** @@ -3962,13 +4085,12 @@ export class TrainSchedulingService { private async persistTrainSetWagons( manager: EntityManager, trainSetId: string, - wagonType: WagonType, wagonPlan: WagonPlanSlot[], ) { const wagons = wagonPlan.map((slot) => manager.getRepository(TrainSetWagon).create({ trainSetId, - wagonTypeId: slot.wagonTypeId ?? wagonType.id, + wagonTypeId: slot.wagonTypeId, sequenceNo: slot.sequenceNo, capacityTons: slot.capacityTons, lengthMeters: slot.lengthMeters, @@ -4198,11 +4320,17 @@ export class TrainSchedulingService { return locomotive; } - private async buildEmptyTrainSet(manager: EntityManager, locomotives: Locomotive[]) { + private async buildEmptyTrainSet( + manager: EntityManager, + locomotives: Locomotive[], + builtTrainId: string | null = null, + ) { const [primary] = locomotives; const trainSet = manager.getRepository(TrainSet).create({ // `locomotiveId` retained as the primary locomotive for single-loco read paths. locomotiveId: primary.id, + // Built fleet train this set was formed from (Train Builder), if any. + trainId: builtTrainId, totalWeightTons: 0, totalLengthMeters: 0, wagonCount: 0, @@ -4358,6 +4486,14 @@ export class TrainSchedulingService { origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + // Built train (Train Builder) behind this departure, when scheduled by train. + train: schedule.trainSet?.train + ? { + id: schedule.trainSet.train.id, + code: schedule.trainSet.train.code, + trainName: schedule.trainSet.train.trainName ?? null, + } + : null, locomotive: schedule.trainSet?.locomotive ? { id: schedule.trainSet.locomotive.id, @@ -4431,6 +4567,441 @@ export class TrainSchedulingService { })); } + /** + * All schedulable built trains (Train Builder), annotated for the + * schedule-creation picker. Mirrors the locomotive picker's advance-scheduling + * philosophy: nothing serviceable is filtered out — staff see the status, + * whether the train sits at the origin yard yet, and its future schedules. + * Trains with fewer than two locomotives are omitted (never schedulable). + */ + async getAvailableTrainsForRoute(routeId: string) { + const route = await this.getSchedulableRoute(routeId); + + const trains = await this.dataSource.getRepository(Train).find({ + where: { + status: Not( + In([Freight.TrainStatus.OutOfService, Freight.TrainStatus.UnderMaintenance]), + ), + }, + relations: { + currentYard: true, + locomotives: { locomotive: true }, + wagons: { wagonType: true }, + }, + order: { code: 'ASC', locomotives: { sequenceNo: 'ASC' } }, + }); + + const counts: { train_id: string; future_count: string }[] = trains.length + ? await this.dataSource.query( + `SELECT tset.train_id, COUNT(DISTINCT ts.id) AS future_count + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.deleted_at IS NULL + AND tset.train_id = ANY($1) + GROUP BY tset.train_id`, + [trains.map((t) => t.id)], + ) + : []; + const futureCounts = new Map(counts.map((c) => [c.train_id, Number(c.future_count)])); + + return trains + .filter((train) => (train.locomotives ?? []).length >= 2) + .map((train) => { + const wagons = train.wagons ?? []; + return { + id: train.id, + code: train.code, + trainName: train.trainName ?? null, + status: train.status, + currentYardId: train.currentYardId ?? null, + currentYard: train.currentYard + ? { + id: train.currentYard.id, + code: train.currentYard.code, + label: train.currentYard.label, + } + : null, + locomotives: (train.locomotives ?? []) + .filter((link) => link.locomotive) + .map((link) => ({ + id: link.locomotive!.id, + code: link.locomotive!.code, + name: link.locomotive!.name ?? null, + })), + wagonCount: wagons.length, + totalTareTons: roundTons( + wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0), + ), + totalLengthMeters: roundTons( + wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), + ), + maxPullWeightTons: roundTons(Number(train.capacityTons)), + atOriginYard: train.currentYardId === route.originYardId, + futureScheduleCount: futureCounts.get(train.id) ?? 0, + }; + }); + } + + /** + * Consist snapshot for the adjust-consist UI: the built train's wagons with + * loaded/removable flags, gross weight (cargo + FULL consist tare) and length + * against the locomotive limits incl. overage tolerance, addable yard wagons, + * and the adjustment history. + */ + async getScheduleConsist(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + const builtTrain = schedule.trainSet?.train; + if (!builtTrain) { + throw new BadRequestException( + 'This schedule was not created from a built train — its consist cannot be adjusted here', + ); + } + + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: { trainId: builtTrain.id }, + relations: { wagonType: true }, + order: { sequenceNumber: 'ASC' }, + }); + const addableWagons = await this.dataSource.getRepository(Wagon).find({ + where: { + trainId: IsNull(), + status: WagonStatus.Available, + currentYardId: builtTrain.currentYardId ?? undefined, + }, + relations: { wagonType: true }, + order: { wagonNumber: 'ASC' }, + }); + const adjustments = await this.dataSource + .getRepository(ScheduleWagonAdjustmentLog) + .find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 }); + + // Slots with cargo aboard — their physical wagons are "loaded" and can + // never be trimmed. + const loadedWagonIds = new Set( + (schedule.trainSet?.wagons ?? []) + .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) + .map((slot) => slot.physicalWagonId as string), + ); + + const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); + const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0); + const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0)); + const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0); + + const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)); + const consistTareTons = roundTons( + wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0), + ); + const consistLengthMeters = roundTons( + wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0), + ); + + const mapWagon = (wagon: Wagon) => ({ + id: wagon.id, + wagonNumber: wagon.wagonNumber, + sequenceNumber: wagon.sequenceNumber, + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)), + capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)), + lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)), + } + : null, + }); + + return { + schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status }, + train: { + id: builtTrain.id, + code: builtTrain.code, + trainName: builtTrain.trainName ?? null, + currentYardId: builtTrain.currentYardId ?? null, + }, + limits: { + maxPullWeightTons, + overageToleranceTons, + pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons), + maxTrainLengthMeters, + overageToleranceMeters, + lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters), + }, + totals: { + wagonCount: wagons.length, + cargoTons, + consistTareTons, + grossTons: roundTons(cargoTons + consistTareTons), + consistLengthMeters, + }, + wagons: wagons.map((wagon) => ({ + ...mapWagon(wagon), + loaded: loadedWagonIds.has(wagon.id), + // Free = not pinned to any run; only free wagons can be trimmed. + removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id), + })), + addableWagons: addableWagons.map(mapWagon), + adjustments: adjustments.map((log) => ({ + id: log.id, + action: log.action, + wagonId: log.wagonId, + wagonNumber: log.wagonNumber, + adjustedByUserId: log.adjustedByUserId, + occurredAt: log.occurredAt, + })), + editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status), + }; + } + + /** + * Permanently adjust the built train's consist from a schedule: trim free + * wagons (their tare no longer rides — the usual fix when gross weight beats + * the pull limit) and/or couple extra AVAILABLE yard wagons while weight and + * length headroom remain (limits incl. overage tolerance). The built train + * updates in place, the schedule's wagon cap follows, and every change is + * logged for the schedule's history. + */ + async adjustScheduleConsist( + scheduleId: string, + dto: AdjustScheduleConsistDto, + userId?: string | null, + ) { + const addWagonIds = [...new Set(dto.addWagonIds ?? [])]; + const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])]; + if (!addWagonIds.length && !removeWagonIds.length) { + throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove'); + } + const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id)); + if (overlap.length) { + throw new BadRequestException('A wagon cannot be added and removed in the same adjustment'); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + 'The consist is frozen once the train is dispatched — adjust before departure', + ); + } + const builtTrainRef = schedule.trainSet?.train; + if (!builtTrainRef) { + throw new BadRequestException( + 'This schedule was not created from a built train — its consist cannot be adjusted here', + ); + } + const loadedWagonIds = new Set( + (schedule.trainSet?.wagons ?? []) + .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) + .map((slot) => slot.physicalWagonId as string), + ); + const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const pullCapTons = roundTons( + Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), + ); + const lengthCapMeters = roundTons( + Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0), + ); + + await this.dataSource.transaction(async (manager) => { + const train = await manager.getRepository(Train).findOne({ + where: { id: builtTrainRef.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`); + + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: train.id }, + relations: { wagonType: true }, + order: { sequenceNumber: 'ASC' }, + }); + const consistById = new Map(consist.map((w) => [w.id, w])); + + // --- validate removals: must be coupled and free (no cargo, no pin) --- + const removed: Wagon[] = []; + for (const wagonId of removeWagonIds) { + const wagon = consistById.get(wagonId); + if (!wagon) { + throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); + } + if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`, + ); + } + removed.push(wagon); + } + + // --- validate additions: AVAILABLE, loose, standing in the train's yard --- + const added: Wagon[] = []; + for (const wagonId of addWagonIds) { + const wagon = await manager.getRepository(Wagon).findOne({ + where: { id: wagonId }, + relations: { wagonType: true }, + lock: { mode: 'pessimistic_write' }, + }); + if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`); + if (wagon.trainId) { + throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`); + } + if (wagon.status !== WagonStatus.Available) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is not available (${wagon.status})`, + ); + } + if (wagon.currentYardId !== train.currentYardId) { + throw new BadRequestException( + `Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`, + ); + } + added.push(wagon); + } + + // --- headroom check (only additions can push the train over a cap) --- + const removedIds = new Set(removed.map((w) => w.id)); + const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added]; + const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0); + const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0); + const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0)); + const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0)); + const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)); + const finalGrossTons = roundTons(cargoTons + finalTareTons); + if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) { + throw new BadRequestException( + `Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`, + ); + } + if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) { + throw new BadRequestException( + `Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`, + ); + } + + // --- apply: detach trims, couple additions, compact the sequence --- + for (const wagon of removed) { + await manager.getRepository(Wagon).update(wagon.id, { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + }); + } + const remaining = consist.filter((w) => !removedIds.has(w.id)); + for (let i = 0; i < remaining.length; i++) { + if (remaining[i].sequenceNumber !== i + 1) { + await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 }); + } + } + let sequence = remaining.length; + for (const wagon of added) { + sequence += 1; + await manager.getRepository(Wagon).update(wagon.id, { + trainId: train.id, + sequenceNumber: sequence, + status: WagonStatus.Assigned, + }); + } + + // The schedule is full when every consist wagon is allocated. + await manager + .getRepository(TrainSchedule) + .update(scheduleId, { maxWagons: finalConsist.length }); + + const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog); + const now = new Date(); + await logRepo.save( + [ + ...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })), + ...added.map((wagon) => ({ action: 'ADD' as const, wagon })), + ].map(({ action, wagon }) => + logRepo.create({ + trainScheduleId: scheduleId, + trainId: train.id, + action, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: userId ?? null, + occurredAt: now, + }), + ), + ); + }); + + return this.getScheduleConsist(scheduleId); + } + + /** + * Re-derive a built train's lifecycle status from its schedules after one of + * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → + * SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival + * at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE) + * keep their status — staff own that flag, not the scheduler. + */ + private async syncBuiltTrainAfterScheduleChange( + manager: EntityManager, + trainId: string, + moveToYardId?: string | null, + ): Promise { + const train = await manager.getRepository(Train).findOne({ where: { id: trainId } }); + if (!train) return; + + const yardPatch = moveToYardId ? { currentYardId: moveToYardId } : {}; + const managed = [ + Freight.TrainStatus.Available, + Freight.TrainStatus.Scheduled, + Freight.TrainStatus.InService, + ]; + if (!managed.includes(train.status)) { + if (moveToYardId) await manager.getRepository(Train).update(trainId, yardPatch); + return; + } + + const rows: { status: string }[] = await manager.query( + `SELECT DISTINCT ts.status + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`, + [trainId], + ); + const statuses = new Set(rows.map((r) => r.status)); + const next = statuses.has('DISPATCHED') + ? Freight.TrainStatus.InService + : statuses.size + ? Freight.TrainStatus.Scheduled + : Freight.TrainStatus.Available; + await manager.getRepository(Train).update(trainId, { status: next, ...yardPatch }); + } + + /** + * A contract_route (`cr`) serves a schedule (`ts`) when its yard pair is a + * FORWARD sub-leg of the schedule's corridor — both yards sit on `ts.route`'s + * milestones with the destination stop AFTER the origin stop — OR (routes with + * no milestones recorded) the pair equals the schedule's own endpoints. This + * mirrors the sub-leg matching the create-booking path already does + * (`getBookableScheduleEntities`), so a through train (Djibouti → Kality → + * Dire) is announced and bookable for every leg it actually serves + * (Djibouti → Kality, Djibouti → Dire, Kality → Dire) — not only its two + * endpoints. Static SQL fragment (no user input) interpolated into the window + * queries below; `ts` and `cr` must be the schedule and contract_route aliases. + */ + private readonly CONTRACT_ROUTE_SERVES_SCHEDULE = `( + EXISTS ( + SELECT 1 + FROM freight.route_milestones mo + JOIN freight.route_milestones md + ON md.route_id = mo.route_id + AND md.sequence_no > mo.sequence_no + WHERE mo.route_id = ts.route_id + AND mo.yard_id = cr.origin_yard_id + AND md.yard_id = cr.destination_yard_id + ) + OR (cr.origin_yard_id = ts.origin_station_id + AND cr.destination_yard_id = ts.destination_station_id) + )`; + /** * Upcoming/open booking windows announced on the portal home "booking * windows" section. ALL window-engine schedules (IMPORT cycle / EXPORT lead) @@ -4441,6 +5012,8 @@ export class TrainSchedulingService { * LEFT-JOINed in so the row carries `contractId`/`contractKind` (enabling * "Book now"); customers with no covering contract still see the window with a * null contract, and the portal routes them to the contract list to get one. + * A contract covering any FORWARD sub-leg of the corridor counts as covering + * the lane (see `CONTRACT_ROUTE_SERVES_SCHEDULE`). */ async getBookingWindowsForCompany(companyId: string | null) { const rows: Array = await this.dataSource.query( @@ -4459,12 +5032,15 @@ export class TrainSchedulingService { ts.booking_cycle_no, ts.scheduled_departure_date, oy.label AS origin_label, oy.code AS origin_code, - dy.label AS destination_label, dy.code AS destination_code + dy.label AS destination_label, dy.code AS destination_code, + (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no) + FROM freight.route_milestones rm + JOIN freight.yards rmy ON rmy.id = rm.yard_id + WHERE rm.route_id = ts.route_id) AS route_stations FROM freight.train_schedules ts LEFT JOIN freight.contract_routes cr - ON cr.origin_yard_id = ts.origin_station_id - AND cr.destination_yard_id = ts.destination_station_id - AND cr.deleted_at IS NULL + ON cr.deleted_at IS NULL + AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE} LEFT JOIN freight.contracts c ON c.id = cr.contract_id AND c.company_id = $1 @@ -4512,13 +5088,16 @@ export class TrainSchedulingService { ts.booking_cycle_no, ts.scheduled_departure_date, oy.label AS origin_label, oy.code AS origin_code, - dy.label AS destination_label, dy.code AS destination_code + dy.label AS destination_label, dy.code AS destination_code, + (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no) + FROM freight.route_milestones rm + JOIN freight.yards rmy ON rmy.id = rm.yard_id + WHERE rm.route_id = ts.route_id) AS route_stations FROM freight.train_schedules ts JOIN freight.contract_routes cr - ON cr.origin_yard_id = ts.origin_station_id - AND cr.destination_yard_id = ts.destination_station_id - AND cr.contract_id = $1 + ON cr.contract_id = $1 AND cr.deleted_at IS NULL + AND ${this.CONTRACT_ROUTE_SERVES_SCHEDULE} JOIN freight.contracts c ON c.id = cr.contract_id AND c.deleted_at IS NULL @@ -4559,7 +5138,11 @@ export class TrainSchedulingService { ts.booking_cycle_no, ts.scheduled_departure_date, oy.label AS origin_label, oy.code AS origin_code, - dy.label AS destination_label, dy.code AS destination_code + dy.label AS destination_label, dy.code AS destination_code, + (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no) + FROM freight.route_milestones rm + JOIN freight.yards rmy ON rmy.id = rm.yard_id + WHERE rm.route_id = ts.route_id) AS route_stations 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 @@ -4581,6 +5164,17 @@ export class TrainSchedulingService { } private mapBookingWindowRow(r: BookingWindowRow) { + const origin = r.origin_label ?? r.origin_code ?? null; + const destination = r.destination_label ?? r.destination_code ?? null; + // Full corridor from the route's milestones (origin → stops → destination). + // Falls back to the schedule's origin/destination when no milestones exist. + const milestoneStops = (r.route_stations ?? []).filter( + (s): s is string => Boolean(s), + ); + const routeStations = + milestoneStops.length >= 2 + ? milestoneStops + : [origin, destination].filter((s): s is string => Boolean(s)); return { scheduleId: r.schedule_id, reference: r.reference ?? null, @@ -4596,8 +5190,9 @@ export class TrainSchedulingService { bookingWindowStatus: r.booking_window_status, bookingCycleNo: r.booking_cycle_no, departureDate: r.scheduled_departure_date, - origin: r.origin_label ?? r.origin_code ?? null, - destination: r.destination_label ?? r.destination_code ?? null, + origin, + destination, + routeStations, }; } @@ -4622,7 +5217,7 @@ export class TrainSchedulingService { bookingWindowStatus: 'OPEN', }, relations: { - trainSet: { locomotive: true, locomotives: { locomotive: true } }, + trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, route: { milestones: true }, originStation: true, destinationStation: true, @@ -4939,6 +5534,14 @@ export class TrainSchedulingService { actualDepartureAt: schedule.actualDepartureAt ?? null, originStation: schedule.originStation, destinationStation: schedule.destinationStation, + // Built train (Train Builder) behind this departure, when scheduled by train. + train: schedule.trainSet?.train + ? { + id: schedule.trainSet.train.id, + code: schedule.trainSet.train.code, + trainName: schedule.trainSet.train.trainName ?? null, + } + : null, trainSet: schedule.trainSet ? { id: schedule.trainSet.id, @@ -5557,10 +6160,16 @@ export class TrainSchedulingService { } const freightType = booking.freightType === 'BULK' ? 'BULK' : 'CONTAINER'; - let wagonType: WagonType; - try { - wagonType = await this.resolveWagonType(freightType, [booking.id]); - } catch { + const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]); + const resolvedBooking = fullBooking ?? booking; + // List-based resolution: every wagon type allowed for the booking's + // container/cargo type counts toward its availability. + const allowed = this.loadAllowedWagonTypes([resolvedBooking]); + const candidates = + freightType === 'BULK' + ? [...allowed.byCargoTypeId.values()].flat() + : [...allowed.byContainerTypeId.values()].flat(); + if (!candidates.length) { return { wagonsRequired: 0, requiredWagonTypeCode: '', @@ -5571,11 +6180,15 @@ export class TrainSchedulingService { } const bulkCapacity = - freightType === 'BULK' ? Number(wagonType.capacityTons) : undefined; - const [fullBooking] = await this.bookingsRepository.findByIdsForScheduling([booking.id]); - const resolvedBooking = fullBooking ?? booking; + freightType === 'BULK' + ? Math.max(...candidates.map((wt) => Number(wt.capacityTons))) + : undefined; const wagonsRequired = wagonsRequiredForBooking(resolvedBooking, bulkCapacity); - const yardWagonsAvailable = fleetByTypeId.get(wagonType.id)?.available ?? 0; + const requiredWagonTypeCode = [...new Set(candidates.map((wt) => wt.code))].join('/'); + const yardWagonsAvailable = candidates.reduce( + (sum, wt) => sum + (fleetByTypeId.get(wt.id)?.available ?? 0), + 0, + ); const allBookingIds = [...wagonAssignedIds, booking.id]; const previewDto = { @@ -5603,7 +6216,7 @@ export class TrainSchedulingService { } catch (err) { return { wagonsRequired, - requiredWagonTypeCode: wagonType.code, + requiredWagonTypeCode, yardWagonsAvailable, canAssign: false, blockReason: err instanceof Error ? err.message : 'Validation failed', @@ -5613,7 +6226,7 @@ export class TrainSchedulingService { if (!validation.valid) { return { wagonsRequired, - requiredWagonTypeCode: wagonType.code, + requiredWagonTypeCode, yardWagonsAvailable, canAssign: false, blockReason: validation.violations[0] ?? 'Booking validation failed', @@ -5625,17 +6238,17 @@ export class TrainSchedulingService { const deferred = validation.deferredBookings.find((d) => d.id === booking.id); const yardShortfall = yardWagonsAvailable < wagonsRequired - ? `No ${wagonType.code} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)` + ? `No ${requiredWagonTypeCode} wagons at origin yard (need ${wagonsRequired}, ${yardWagonsAvailable} available)` : null; return { wagonsRequired, - requiredWagonTypeCode: wagonType.code, + requiredWagonTypeCode, yardWagonsAvailable, canAssign: false, blockReason: deferred?.reason ?? yardShortfall ?? - `Need ${wagonsRequired} ${wagonType.code} wagon(s) at origin yard`, + `Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`, }; } @@ -5650,7 +6263,7 @@ export class TrainSchedulingService { if (missing) { return { wagonsRequired, - requiredWagonTypeCode: wagonType.code, + requiredWagonTypeCode, yardWagonsAvailable, canAssign: false, blockReason: missing.issue, @@ -5660,7 +6273,7 @@ export class TrainSchedulingService { return { wagonsRequired, - requiredWagonTypeCode: wagonType.code, + requiredWagonTypeCode, yardWagonsAvailable, canAssign: true, blockReason: null, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts new file mode 100644 index 000000000..b4b8657ba --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -0,0 +1,299 @@ +import { AllocationLoadType } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util'; +import { + MAX_TEU_SLOTS_PER_WAGON, + expandBookingContainerUnits, + roundTons, + tareTonsOf, + teuSlotsForSizeFt, + type SlotLoadType, + type WagonPlanSlot, +} from './wagon-plan.util'; + +/** + * Wagon types allowed to carry each container type / bulk cargo type — the + * many-to-many configuration lists, resolved once per validation run. + */ +export type AllowedWagonTypeMap = { + byContainerTypeId: Map; + byCargoTypeId: Map; +}; + +/** + * Plannable wagon inventory. TRAIN mode is the built train's own consist — + * a hard cap, the plan never reaches for loose yard wagons. YARD mode is the + * AVAILABLE pool at the boarding yards (legacy schedules). + */ +export type WagonStock = { + mode: 'TRAIN' | 'YARD'; + /** Remaining plannable wagons per wagon type id. Missing type = 0. */ + remainingByTypeId: Map; + /** Wagon-type code per id, for human-readable shortfall messages. */ + codesByTypeId: Map; +}; + +export type FlexPlanResult = { + plan: WagonPlanSlot[]; + fitting: Booking[]; + deferred: DeferredBookingRow[]; + /** + * Misconfiguration (a scheduled type with no wagon types configured) — + * a hard violation, unlike stock shortfalls which merely defer bookings. + */ + configIssues: string[]; +}; + +type OpenSlot = { + slot: WagonPlanSlot; + teuUsed: number; + kind: SlotLoadType; + /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ + cargoTypeId: string | null; + freeCapacityTons: number; +}; + +type PlacementProblem = { kind: 'config' | 'stock'; message: string }; + +const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({ + sequenceNo: 0, // stamped at the end + wagonTypeId: wagonType.id, + wagonTypeCode: wagonType.code, + capacityTons: Number(wagonType.capacityTons), + lengthMeters: Number(wagonType.lengthMeters), + tareWeightTons: tareTonsOf(wagonType), + assignedWeightTons: 0, + allocations: [], + slotLoadType: kind, +}); + +const addAllocation = ( + slot: WagonPlanSlot, + bookingId: string, + bookingReference: string, + weightTons: number, + loadType: AllocationLoadType, +) => { + let allocation = slot.allocations.find((a) => a.bookingId === bookingId); + if (!allocation) { + allocation = { bookingId, bookingReference, allocatedWeightTons: 0, loadType }; + slot.allocations.push(allocation); + } + allocation.allocatedWeightTons = roundTons(allocation.allocatedWeightTons + weightTons); + slot.assignedWeightTons = roundTons(slot.assignedWeightTons + weightTons); +}; + +/** + * Build the wagon plan against a wagon-type inventory, mixing wagon types + * within one consist. Each booking is atomic: it either fits entirely (its + * containers/tonnage placed on wagons whose type is allowed for its container + * or cargo type) or is deferred with the shortfall reason. Wagon purity rules: + * a wagon carries one kind at a time — containers pack by TEU (one 40ft, or + * two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon + * with a different cargo type. + */ +export function planWagonsWithStock(params: { + bookings: Booking[]; + allowed: AllowedWagonTypeMap; + stock: WagonStock; +}): FlexPlanResult { + const { bookings, allowed, stock } = params; + const remaining = new Map(stock.remainingByTypeId); + const openSlots: OpenSlot[] = []; + const fitting: Booking[] = []; + const deferred: DeferredBookingRow[] = []; + const configIssues = new Set(); + + const noStockMessage = (candidates: WagonType[]): string => { + const codes = candidates.map((wt) => wt.code).join('/'); + return stock.mode === 'TRAIN' + ? `Train has no free ${codes} wagon left` + : `No available ${codes} wagon at the yard`; + }; + + /** Open a new wagon of one of the candidate types, consuming stock. */ + const openSlot = ( + candidates: WagonType[], + kind: SlotLoadType, + cargoTypeId: string | null, + ): OpenSlot | PlacementProblem => { + const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0); + if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) }; + // Bulk favors the largest wagon (fewest wagons for the tonnage); containers + // favor the deepest stock so the consist drains evenly. Ties keep config order. + const chosen = [...inStock].sort((a, b) => + kind === 'BULK' + ? Number(b.capacityTons) - Number(a.capacityTons) || + (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0) + : (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0), + )[0]; + remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1); + const open: OpenSlot = { + slot: slotFromWagonType(chosen, kind), + teuUsed: 0, + kind, + cargoTypeId, + freeCapacityTons: Number(chosen.capacityTons), + }; + openSlots.push(open); + return open; + }; + + const tryPlaceBooking = (booking: Booking): PlacementProblem | null => { + if (booking.freightType === 'CONTAINER') { + const units = expandBookingContainerUnits([booking]); + if (!units.length) { + // Degenerate container booking with no lines still reserves one wagon + // (legacy behavior) — but there is no container type to resolve against. + return { + kind: 'config', + message: `Booking ${booking.reference} has no container lines to plan`, + }; + } + for (const unit of units) { + const candidates = allowed.byContainerTypeId.get(unit.containerTypeId) ?? []; + if (!candidates.length) { + return { + kind: 'config', + message: `Container type "${unit.containerTypeCode}" has no wagon types configured — set them in its configuration before scheduling.`, + }; + } + const allowedIds = new Set(candidates.map((wt) => wt.id)); + const teu = unit.teuSlots ?? teuSlotsForSizeFt(unit.sizeFt ?? 20); + let target = openSlots.find( + (open) => + open.kind === 'CONTAINER' && + allowedIds.has(open.slot.wagonTypeId) && + open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON, + ); + if (!target) { + const openedSlot = openSlot(candidates, 'CONTAINER', null); + if ('message' in openedSlot) return openedSlot; + target = openedSlot; + } + addAllocation( + target.slot, + unit.bookingId, + unit.bookingReference, + unit.grossWeightTons, + AllocationLoadType.Container, + ); + target.teuUsed += teu; + } + return null; + } + + // BULK — weight-based, one cargo type per wagon. + const cargoTypeId = booking.cargoTypeId ?? booking.cargoType?.id ?? null; + const candidates = cargoTypeId ? (allowed.byCargoTypeId.get(cargoTypeId) ?? []) : []; + if (!candidates.length) { + return { + kind: 'config', + message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`, + }; + } + const allowedIds = new Set(candidates.map((wt) => wt.id)); + let remainingWeight = roundTons(Number(booking.cargoTotalWeightVgm ?? 0)); + let placedAnywhere = false; + + // Top off wagons already carrying THIS cargo type before opening new ones. + for (const open of openSlots) { + if (remainingWeight <= 0) break; + if (open.kind !== 'BULK') continue; + if (open.cargoTypeId !== cargoTypeId) continue; + if (!allowedIds.has(open.slot.wagonTypeId)) continue; + if (open.freeCapacityTons <= 0) continue; + const take = roundTons(Math.min(open.freeCapacityTons, remainingWeight)); + addAllocation( + open.slot, + booking.id, + booking.reference, + take, + AllocationLoadType.Bulk, + ); + open.freeCapacityTons = roundTons(open.freeCapacityTons - take); + remainingWeight = roundTons(remainingWeight - take); + placedAnywhere = true; + } + + while (remainingWeight > 0 || !placedAnywhere) { + const openedSlot = openSlot(candidates, 'BULK', cargoTypeId); + if ('message' in openedSlot) return openedSlot; + const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); + addAllocation( + openedSlot.slot, + booking.id, + booking.reference, + take, + AllocationLoadType.Bulk, + ); + openedSlot.freeCapacityTons = roundTons(openedSlot.freeCapacityTons - take); + remainingWeight = roundTons(remainingWeight - take); + placedAnywhere = true; + } + return null; + }; + + for (const booking of sortBookingsForScheduling(bookings)) { + // Snapshot so a booking that doesn't fully fit leaves no half-placed wagons. + const remainingSnapshot = new Map(remaining); + const slotCountSnapshot = openSlots.length; + const slotStateSnapshot = openSlots.map((open) => ({ + teuUsed: open.teuUsed, + freeCapacityTons: open.freeCapacityTons, + assignedWeightTons: open.slot.assignedWeightTons, + allocationCount: open.slot.allocations.length, + allocationWeights: open.slot.allocations.map((a) => a.allocatedWeightTons), + })); + + const problem = tryPlaceBooking(booking); + if (!problem) { + fitting.push(booking); + continue; + } + + // Roll back this booking's partial placements. + remaining.clear(); + for (const [key, value] of remainingSnapshot) remaining.set(key, value); + openSlots.length = slotCountSnapshot; + openSlots.forEach((open, index) => { + const snap = slotStateSnapshot[index]; + if (!snap) return; + open.teuUsed = snap.teuUsed; + open.freeCapacityTons = snap.freeCapacityTons; + open.slot.assignedWeightTons = snap.assignedWeightTons; + open.slot.allocations.length = snap.allocationCount; + snap.allocationWeights.forEach((weight, allocationIndex) => { + open.slot.allocations[allocationIndex].allocatedWeightTons = weight; + }); + }); + + if (problem.kind === 'config') configIssues.add(problem.message); + deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message }); + } + + return { + plan: openSlots.map((open, index) => ({ ...open.slot, sequenceNo: index + 1 })), + fitting, + deferred, + configIssues: [...configIssues], + }; +} + +/** Unbounded stock — used to compute pure demand for availability reporting. */ +export function unboundedStock(allowed: AllowedWagonTypeMap): WagonStock { + const remainingByTypeId = new Map(); + const codesByTypeId = new Map(); + for (const list of [ + ...allowed.byContainerTypeId.values(), + ...allowed.byCargoTypeId.values(), + ]) { + for (const wagonType of list) { + remainingByTypeId.set(wagonType.id, Number.MAX_SAFE_INTEGER); + codesByTypeId.set(wagonType.id, wagonType.code); + } + } + return { mode: 'YARD', remainingByTypeId, codesByTypeId }; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 2af606cdc..78cd8bf54 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -514,7 +514,7 @@ export function validateTrainLimits( */ export function validateMixedTrainLimits( wagonPlan: WagonPlanSlot[], - wagonTypes: WagonType[], + wagonTypes: Array>, limits?: TrainLimitConfig, ): string[] { const maxLengthMeters = limits?.maxLengthMeters ?? MAX_TRAIN_LENGTH_METERS; diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts index fde6d75c6..c82cfd2eb 100644 --- a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { Train } from '../../trains/entities/train.entity'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; import { TrainSetLocomotive } from './train-set-locomotive.entity'; import { TrainSetWagon } from './train-set-wagon.entity'; @@ -32,6 +33,14 @@ export class TrainSet extends BaseEntity { @OneToMany(() => TrainSetLocomotive, (link) => link.trainSet) locomotives?: TrainSetLocomotive[]; + /** Built fleet train this set was formed from (Train Builder), when scheduled by train. */ + @Column({ name: 'train_id', type: 'uuid', nullable: true }) + trainId!: string | null; + + @ManyToOne(() => Train, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'train_id' }) + train?: Train | null; + @Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 }) totalWeightTons!: number; diff --git a/apps/edr-freight-api/src/modules/trains/dto/assign-train-wagons.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/assign-train-wagons.dto.ts new file mode 100644 index 000000000..e9f150d40 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/assign-train-wagons.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +export class AssignTrainWagonsDto { + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'Wagons to append to the consist, in order. Each must be AVAILABLE in the train\'s yard.', + }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('all', { each: true }) + wagonIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts new file mode 100644 index 000000000..c44be8786 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMinSize, + IsArray, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; + +export class BuildTrainDto { + @ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' }) + @IsString() + @MaxLength(32) + code!: string; + + @ApiProperty({ format: 'uuid', description: 'Yard the train is built in' }) + @IsUUID() + currentYardId!: string; + + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'Locomotives pulling the train (minimum 2 — front and back), in consist order', + }) + @IsArray() + @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @IsUUID('all', { each: true }) + locomotiveIds!: string[]; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: 'Wagons to attach at build time, in consist order (must sit in the same yard)', + }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + wagonIds?: string[]; + + @ApiPropertyOptional({ maxLength: 100 }) + @IsOptional() + @IsString() + @MaxLength(100) + trainName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/list-built-trains-query.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/list-built-trains-query.dto.ts new file mode 100644 index 000000000..4c2e259a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/list-built-trains-query.dto.ts @@ -0,0 +1,22 @@ +import { Freight } from '@edr/types'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsIn, IsOptional, IsUUID } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +export class ListBuiltTrainsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ enum: Freight.TrainStatus }) + @IsOptional() + @IsEnum(Freight.TrainStatus) + status?: Freight.TrainStatus; + + @ApiPropertyOptional({ format: 'uuid', description: 'Only trains sitting in this yard' }) + @IsOptional() + @IsUUID() + currentYardId?: string; + + @ApiPropertyOptional({ enum: ['code', 'trainName', 'status', 'createdAt'] }) + @IsOptional() + @IsIn(['code', 'trainName', 'status', 'createdAt']) + sortBy?: 'code' | 'trainName' | 'status' | 'createdAt'; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/reorder-train-wagons.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/reorder-train-wagons.dto.ts new file mode 100644 index 000000000..15e5020ca --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/reorder-train-wagons.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +export class ReorderTrainWagonsDto { + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'Every wagon of the train, in the new consist order', + }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('all', { each: true }) + wagonIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts new file mode 100644 index 000000000..36562e970 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +export class UpdateTrainLocomotivesDto { + @ApiProperty({ + type: [String], + format: 'uuid', + description: 'Full replacement locomotive set (minimum 2), in consist order', + }) + @IsArray() + @ArrayMinSize(2, { message: 'A train must be pulled by at least two locomotives' }) + @IsUUID('all', { each: true }) + locomotiveIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts new file mode 100644 index 000000000..428651288 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class UpdateTrainYardDto { + @ApiProperty({ + format: 'uuid', + description: + 'Yard the train now sits in. The coupled locomotives and wagons are relocated with it.', + }) + @IsUUID() + currentYardId!: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts new file mode 100644 index 000000000..681b39a55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts @@ -0,0 +1,34 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { Train } from './train.entity'; + +/** + * Link row joining a built train to one of its locomotives. A train must be + * pulled by at least two locomotives (front + back); `sequenceNo` is the order + * in the consist — 0 is the lead locomotive. + * + * Mirrors `train_set_locomotives`, but for the persistent fleet `Train` built + * in the Train Builder rather than the per-departure operational train set. + */ +@Entity({ schema: 'freight', name: 'train_locomotives' }) +@Index(['trainId', 'locomotiveId'], { unique: true }) +export class TrainLocomotive extends BaseEntity { + @Column({ name: 'train_id', type: 'uuid' }) + trainId!: string; + + @ManyToOne(() => Train, (train) => train.locomotives, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_id' }) + train?: Train; + + @Column({ name: 'locomotive_id', type: 'uuid' }) + locomotiveId!: string; + + @ManyToOne(() => Locomotive) + @JoinColumn({ name: 'locomotive_id' }) + locomotive?: Locomotive; + + @Column({ name: 'sequence_no', type: 'int', default: 0 }) + sequenceNo!: number; +} diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index ab6b49b1d..078b07a20 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -1,12 +1,16 @@ // apps/edr-freight-api/src/modules/trains/entities/train.entity.ts import { BaseEntity } from '@edr/api-common'; import { Freight } from '@edr/types'; -import { Column, Entity, OneToMany } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Yard } from '../../rule-engine/entities/yard.entity'; import { Wagon } from '../../wagons/entities/wagon.entity'; +import { TrainLocomotive } from './train-locomotive.entity'; /** - * Fleet master data — named wagon consist in inventory (POST /trains). - * Operational departures use train_schedules + locomotives; scheduling never creates trains rows. + * Fleet master data — a train built in the Train Builder: a coded consist + * (e.g. 81001) of 2+ locomotives and ordered wagons, assembled in one yard. + * Operational departures reference it through `train_sets.train_id`; the + * schedule's own composition still lives on the train set. */ @Entity({ schema: 'freight', name: 'trains' }) export class Train extends BaseEntity { @@ -56,7 +60,19 @@ export class Train extends BaseEntity { @Column({ name: 'remarks', type: 'text', nullable: true }) remarks?: string; + /** Yard where the train currently sits (set at build, moved on schedule arrival). */ + @Column({ name: 'current_yard_id', type: 'uuid', nullable: true }) + currentYardId!: string | null; + + @ManyToOne(() => Yard, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'current_yard_id' }) + currentYard?: Yard | null; + // --- relationships --- - @OneToMany(() => Wagon, (wagon) => wagon.train) - wagons!: Wagon[]; // fixed typo: was 'wagens' + @OneToMany(() => Wagon, (wagon) => wagon.train) + wagons!: Wagon[]; + + /** Locomotives pulling this train (minimum 2), ordered by sequenceNo. */ + @OneToMany(() => TrainLocomotive, (link) => link.train) + locomotives?: TrainLocomotive[]; } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts new file mode 100644 index 000000000..7ed3e4c42 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -0,0 +1,102 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; +import { BuildTrainDto } from './dto/build-train.dto'; +import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; +import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; +import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; +import { TrainBuilderService } from './train-builder.service'; + +@ApiTags('train-builder') +@ApiBearerAuth() +@Controller('train-builder') +@FleetView() +export class TrainBuilderController { + constructor(private readonly trainBuilderService: TrainBuilderService) {} + + @Post() + @FleetManage() + @ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' }) + build(@Body() dto: BuildTrainDto) { + return this.trainBuilderService.buildTrain(dto); + } + + @Get() + @ApiOperation({ summary: 'Paginated built trains with composition summary' }) + list(@Query() query: ListBuiltTrainsQueryDto) { + return this.trainBuilderService.listBuilt(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' }) + composition(@Param('id', ParseUUIDPipe) id: string) { + return this.trainBuilderService.getComposition(id); + } + + @Put(':id/locomotives') + @FleetManage() + @ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' }) + setLocomotives( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTrainLocomotivesDto, + ) { + return this.trainBuilderService.setLocomotives(id, dto); + } + + @Patch(':id/yard') + @FleetManage() + @ApiOperation({ + summary: 'Relocate the train — its locomotives and wagons move to the new yard with it', + }) + setYard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainYardDto) { + return this.trainBuilderService.setYard(id, dto.currentYardId); + } + + @Post(':id/wagons') + @FleetManage() + @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) + assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) { + return this.trainBuilderService.assignWagons(id, dto); + } + + @Delete(':id/wagons/:wagonId') + @FleetManage() + @ApiOperation({ summary: 'Detach one wagon from the consist' }) + removeWagon( + @Param('id', ParseUUIDPipe) id: string, + @Param('wagonId', ParseUUIDPipe) wagonId: string, + ) { + return this.trainBuilderService.removeWagon(id, wagonId); + } + + @Post(':id/reorder-wagons') + @FleetManage() + @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) + reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) { + return this.trainBuilderService.reorderWagons(id, dto); + } + + @Delete(':id') + @FleetManage() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' }) + disband(@Param('id', ParseUUIDPipe) id: string) { + return this.trainBuilderService.disband(id); + } +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts new file mode 100644 index 000000000..16ff29d38 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -0,0 +1,619 @@ +import { Freight, WagonMovementKind, WagonStatus } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager, ILike, In } from 'typeorm'; + +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; +import { BuildTrainDto } from './dto/build-train.dto'; +import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; +import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; +import { TrainLocomotive } from './entities/train-locomotive.entity'; +import { Train } from './entities/train.entity'; +import { + buildPaginationMeta, + normalizePagination, +} from '../../common/utils/pagination.util'; + +const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100; + +/** + * Train Builder — assembles persistent fleet trains (code + 2+ locomotives + + * ordered wagons, all in one yard) that scheduling can later reference as a + * unit instead of hand-picking locomotives per departure. + * + * Resource rules: + * - Locomotive double-use is prevented through the `train_locomotives` link + * table (a locomotive rides at most one built train); its `status` column + * keeps its operational meaning (ASSIGNED = out on a dispatched train). + * - Wagons attached to a train are flipped to ASSIGNED (same semantic the + * legacy assign-train flow uses), so no other train or schedule grabs them. + */ +@Injectable() +export class TrainBuilderService { + constructor(private readonly dataSource: DataSource) {} + + async buildTrain(dto: BuildTrainDto) { + const locomotiveIds = [...new Set(dto.locomotiveIds)]; + if (locomotiveIds.length < 2) { + throw new BadRequestException('A train must be pulled by at least two locomotives'); + } + + const trainId = await this.dataSource.transaction(async (manager) => { + const code = dto.code.trim(); + const existing = await manager.getRepository(Train).findOne({ where: { code } }); + if (existing) { + throw new ConflictException(`Train code ${code} is already in use`); + } + + const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } }); + if (!yard) throw new NotFoundException(`Yard ${dto.currentYardId} not found`); + + const locomotives = await this.validateAndLockLocomotives( + manager, + locomotiveIds, + yard, + null, + ); + + // Effective haul capacity is capped by the weakest locomotive in the set. + const limits = minLocomotiveLimits(locomotives); + const train = await manager.getRepository(Train).save( + manager.getRepository(Train).create({ + code, + currentYardId: yard.id, + capacityTons: round(limits?.maxPullWeightTons ?? 0), + status: Freight.TrainStatus.Available, + trainName: dto.trainName?.trim() || undefined, + notes: dto.notes?.trim() || undefined, + }), + ); + + await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds); + + if (dto.wagonIds?.length) { + await this.attachWagons(manager, train, dto.wagonIds, 0); + } + return train.id; + }); + + return this.getComposition(trainId); + } + + /** Paginated builder list with a composition summary per train. */ + async listBuilt(query: ListBuiltTrainsQueryDto) { + const { page, pageSize, skip, take } = normalizePagination(query); + const search = query.search?.trim(); + const filters = { + ...(query.status ? { status: query.status } : {}), + ...(query.currentYardId ? { currentYardId: query.currentYardId } : {}), + }; + const where = search + ? [ + { ...filters, code: ILike(`%${search}%`) }, + { ...filters, trainName: ILike(`%${search}%`) }, + ] + : filters; + + const [trains, total] = await this.dataSource.getRepository(Train).findAndCount({ + where, + relations: { + currentYard: true, + locomotives: { locomotive: true }, + wagons: { wagonType: true }, + }, + order: { [query.sortBy ?? 'createdAt']: query.sortOrder ?? 'DESC' }, + skip, + take, + }); + + return { + items: trains.map((train) => this.mapSummary(train)), + meta: buildPaginationMeta(total, page, pageSize), + }; + } + + /** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */ + async getComposition(id: string) { + const train = await this.dataSource.getRepository(Train).findOne({ + where: { id }, + relations: { + currentYard: true, + locomotives: { locomotive: { currentYard: true } }, + wagons: { wagonType: true, currentYard: true }, + }, + order: { + locomotives: { sequenceNo: 'ASC' }, + wagons: { sequenceNumber: 'ASC' }, + }, + }); + if (!train) throw new NotFoundException(`Train ${id} not found`); + + const schedules: { id: string; status: string; reference: string | null }[] = + await this.dataSource.query( + `SELECT ts.id, ts.status, ts.reference + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + ORDER BY ts.scheduled_departure_date ASC`, + [id], + ); + + const locomotives = (train.locomotives ?? []) + .filter((link) => link.locomotive) + .map((link, index) => ({ + id: link.locomotive!.id, + code: link.locomotive!.code, + name: link.locomotive!.name ?? null, + locomotiveType: link.locomotive!.locomotiveType, + status: link.locomotive!.status, + sequenceNo: link.sequenceNo, + role: index === 0 ? 'LEAD' : 'ASSIST', + currentYardId: link.locomotive!.currentYardId ?? null, + currentYard: link.locomotive!.currentYard + ? { + id: link.locomotive!.currentYard.id, + code: link.locomotive!.currentYard.code, + label: link.locomotive!.currentYard.label, + } + : null, + maxPullWeightTons: round(link.locomotive!.maxPullWeightTons), + maxTrainLengthMeters: round(link.locomotive!.maxTrainLengthMeters), + })); + + const wagons = (train.wagons ?? []).map((wagon) => ({ + id: wagon.id, + wagonNumber: wagon.wagonNumber, + sequenceNumber: wagon.sequenceNumber, + status: wagon.status, + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, + capacityTons: round(wagon.wagonType.capacityTons), + tareWeightTons: round(wagon.wagonType.tareWeightTons), + lengthMeters: round(wagon.wagonType.lengthMeters), + } + : null, + })); + + const limits = minLocomotiveLimits( + (train.locomotives ?? []) + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)), + ); + const totalTareTons = round( + wagons.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ?? 0), 0), + ); + const totalCapacityTons = round( + wagons.reduce((sum, w) => sum + (w.wagonType?.capacityTons ?? 0), 0), + ); + const totalLengthMeters = round( + wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0), + ); + const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0); + const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0); + + return { + id: train.id, + code: train.code, + trainName: train.trainName ?? null, + status: train.status, + notes: train.notes ?? null, + createdAt: train.createdAt, + currentYard: train.currentYard + ? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label } + : null, + locomotives, + wagons, + totals: { + wagonCount: wagons.length, + totalTareTons, + // Informational only — building never checks against full capacity; + // the real gross check (cargo + tare vs haul limit) runs at allocation. + totalCapacityTons, + totalLengthMeters, + maxPullWeightTons, + maxTrainLengthMeters, + // Cargo the locomotives can still haul once pulling the empty consist. + payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)), + // Share of the haul limit consumed by the empty wagons alone. + tareUtilizationPct: maxPullWeightTons + ? round((totalTareTons / maxPullWeightTons) * 100) + : null, + lengthUtilizationPct: maxTrainLengthMeters + ? round((totalLengthMeters / maxTrainLengthMeters) * 100) + : null, + }, + activeSchedules: schedules, + // Composition is frozen while the train is out on a dispatched run. + editable: !schedules.some((s) => s.status === 'DISPATCHED'), + }; + } + + /** Replace the locomotive set (still minimum 2, same-yard rule applies). */ + async setLocomotives(id: string, dto: UpdateTrainLocomotivesDto) { + const locomotiveIds = [...new Set(dto.locomotiveIds)]; + if (locomotiveIds.length < 2) { + throw new BadRequestException('A train must be pulled by at least two locomotives'); + } + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const yard = await manager + .getRepository(Yard) + .findOne({ where: { id: train.currentYardId ?? '' } }); + if (!yard) { + throw new BadRequestException('Train has no yard; set the yard before changing locomotives'); + } + const locomotives = await this.validateAndLockLocomotives( + manager, + locomotiveIds, + yard, + train.id, + ); + await this.replaceLocomotiveLinks(manager, train.id, locomotiveIds); + const limits = minLocomotiveLimits(locomotives); + await manager + .getRepository(Train) + .update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) }); + }); + return this.getComposition(id); + } + + /** + * Relocate the train to another yard. The consist moves as one unit: every + * coupled locomotive and wagon follows to the new yard (so their current + * yards always match the train's), and each wagon gets a movement-ledger row. + * Blocked while the train is out on a dispatched run. + */ + async setYard(id: string, currentYardId: string) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + if (train.currentYardId === currentYardId) return; + const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); + if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); + + await manager.getRepository(Train).update(train.id, { currentYardId: yard.id }); + + const links = await manager + .getRepository(TrainLocomotive) + .find({ where: { trainId: train.id } }); + if (links.length) { + await manager + .getRepository(Locomotive) + .update( + { id: In(links.map((link) => link.locomotiveId)) }, + { currentYardId: yard.id }, + ); + } + + const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } }); + const now = new Date(); + for (const wagon of wagons) { + if (wagon.currentYardId === yard.id) continue; + await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id }); + // Ledger row keeps the wagon's yard history auditable (mirrors the + // manual-relocation path in the wagons service). + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + kind: WagonMovementKind.Manual, + occurredAt: now, + }), + ); + } + }); + return this.getComposition(id); + } + + /** Append AVAILABLE wagons from the train's own yard to the consist. */ + async assignWagons(id: string, dto: AssignTrainWagonsDto) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const currentCount = await manager + .getRepository(Wagon) + .count({ where: { trainId: train.id } }); + await this.attachWagons(manager, train, dto.wagonIds, currentCount); + }); + return this.getComposition(id); + } + + /** Detach one wagon and close the sequence gap it leaves. */ + async removeWagon(id: string, wagonId: string) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); + } + if (wagon.currentTrainScheduleId) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, + ); + } + await manager.getRepository(Wagon).update(wagon.id, { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + }); + await this.resequenceWagons(manager, train.id); + }); + return this.getComposition(id); + } + + /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ + async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagons = await manager + .getRepository(Wagon) + .find({ where: { trainId: train.id } }); + const current = new Set(wagons.map((w) => w.id)); + const incoming = new Set(dto.wagonIds); + if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) { + throw new BadRequestException('Reorder must include every wagon of the train exactly once'); + } + for (let i = 0; i < dto.wagonIds.length; i++) { + await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 }); + } + }); + return this.getComposition(id); + } + + /** Disband the train: release wagons and locomotives, then delete it. */ + async disband(id: string): Promise { + await this.dataSource.transaction(async (manager) => { + const train = await manager.getRepository(Train).findOne({ where: { id } }); + if (!train) throw new NotFoundException(`Train ${id} not found`); + const active: { count: string }[] = await manager.query( + `SELECT COUNT(*)::text AS count + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`, + [id], + ); + if (Number(active[0]?.count ?? 0) > 0) { + throw new ConflictException( + 'Train has active schedules; cancel them before disbanding the train', + ); + } + await manager + .getRepository(Wagon) + .update( + { trainId: train.id }, + { trainId: null, sequenceNumber: null, status: WagonStatus.Available }, + ); + await manager.getRepository(TrainLocomotive).delete({ trainId: train.id }); + await manager.getRepository(Train).remove(train); + }); + } + + // ---------------------------------------------------------------- internals + + private mapSummary(train: Train) { + const locomotives = [...(train.locomotives ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)); + const wagons = train.wagons ?? []; + const totalTareTons = round( + wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0), + ); + return { + id: train.id, + code: train.code, + trainName: train.trainName ?? null, + status: train.status, + createdAt: train.createdAt, + currentYard: train.currentYard + ? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label } + : null, + locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })), + wagonCount: wagons.length, + totalTareTons, + totalLengthMeters: round( + wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), + ), + maxPullWeightTons: round(train.capacityTons), + }; + } + + /** Load + freeze the train row for edit; block edits while it is out on a run. */ + private async getEditableTrain(manager: EntityManager, id: string): Promise { + const train = await manager.getRepository(Train).findOne({ + where: { id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!train) throw new NotFoundException(`Train ${id} not found`); + if (train.status === Freight.TrainStatus.InService) { + throw new ConflictException( + `Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`, + ); + } + return train; + } + + /** + * Lock and validate the locomotives for a build/replace: each must exist, be + * serviceable, sit in the train's yard, and not ride another built train. + */ + private async validateAndLockLocomotives( + manager: EntityManager, + locomotiveIds: string[], + yard: Yard, + ownTrainId: string | null, + ): Promise { + const locomotives: Locomotive[] = []; + for (const locomotiveId of locomotiveIds) { + const locked = await manager.getRepository(Locomotive).findOne({ + where: { id: locomotiveId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!locked) throw new NotFoundException(`Locomotive ${locomotiveId} not found`); + if (locked.status === 'OUT_OF_SERVICE' || locked.status === 'MAINTENANCE') { + throw new ConflictException(`Locomotive ${locked.code} is ${locked.status.toLowerCase().replace('_', ' ')}`); + } + if (locked.currentYardId !== yard.id) { + throw new BadRequestException( + `Locomotive ${locked.code} is not in yard ${yard.label ?? yard.code}; a train can only be built from locomotives in its own yard`, + ); + } + locomotives.push(locked); + } + + const taken = await manager.getRepository(TrainLocomotive).find({ + where: { locomotiveId: In(locomotiveIds) }, + relations: { train: true }, + }); + const conflict = taken.find((link) => link.trainId !== ownTrainId); + if (conflict) { + const loco = locomotives.find((l) => l.id === conflict.locomotiveId); + throw new ConflictException( + `Locomotive ${loco?.code ?? conflict.locomotiveId} is already coupled to train ${conflict.train?.code ?? conflict.trainId}`, + ); + } + return locomotives; + } + + private async replaceLocomotiveLinks( + manager: EntityManager, + trainId: string, + locomotiveIds: string[], + ): Promise { + await manager.getRepository(TrainLocomotive).delete({ trainId }); + await manager.getRepository(TrainLocomotive).save( + locomotiveIds.map((locomotiveId, index) => + manager.getRepository(TrainLocomotive).create({ trainId, locomotiveId, sequenceNo: index }), + ), + ); + } + + private async attachWagons( + manager: EntityManager, + train: Train, + wagonIds: string[], + startCount: number, + ): Promise { + const uniqueIds = [...new Set(wagonIds)]; + const wagonRepo = manager.getRepository(Wagon); + + // First pass: lock + validate every wagon so the length gate below sees + // the full incoming set before any row is written. + const toAttach: Wagon[] = []; + for (const wagonId of uniqueIds) { + const wagon = await wagonRepo.findOne({ + where: { id: wagonId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`); + if (wagon.trainId === train.id) continue; + if (wagon.trainId) { + throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`); + } + if (wagon.status !== WagonStatus.Available) { + throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`); + } + if (wagon.currentYardId !== train.currentYardId) { + throw new BadRequestException( + `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, + ); + } + toAttach.push(wagon); + } + if (!toAttach.length) return; + + await this.assertConsistLengthWithinLimit(manager, train, toAttach); + + let sequence = startCount; + for (const wagon of toAttach) { + sequence += 1; + await wagonRepo.update(wagon.id, { + trainId: train.id, + sequenceNumber: sequence, + status: WagonStatus.Assigned, + }); + } + } + + /** + * The consist (already-attached wagons + the incoming ones) must fit the + * train's locomotive length limit — the weakest locomotive of the set caps + * the train, mirroring how scheduling derives capacity. + */ + private async assertConsistLengthWithinLimit( + manager: EntityManager, + train: Train, + incoming: Wagon[], + ): Promise { + const links = await manager.getRepository(TrainLocomotive).find({ + where: { trainId: train.id }, + relations: { locomotive: true }, + }); + const limits = minLocomotiveLimits( + links + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)), + ); + const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0); + if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return; + + const existing = await manager.getRepository(Wagon).find({ + where: { trainId: train.id }, + relations: { wagonType: true }, + }); + const currentLength = existing.reduce( + (sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), + 0, + ); + + const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))]; + const types = typeIds.length + ? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } }) + : []; + const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0])); + const addedLength = incoming.reduce( + (sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0), + 0, + ); + + const totalLength = currentLength + addedLength; + if (totalLength > maxLengthMeters) { + throw new BadRequestException( + `Cannot attach wagons — train length would be ${round(totalLength)} m ` + + `(current ${round(currentLength)} m + ${round(addedLength)} m added), ` + + `over the locomotive limit of ${round(maxLengthMeters)} m. ` + + 'Remove wagons from the consist or use locomotives with a higher length limit.', + ); + } + } + + /** Compact wagon sequence numbers back to 1..n after a removal. */ + private async resequenceWagons(manager: EntityManager, trainId: string): Promise { + const wagons = await manager.getRepository(Wagon).find({ + where: { trainId }, + order: { sequenceNumber: 'ASC' }, + }); + for (let i = 0; i < wagons.length; i++) { + if (wagons[i].sequenceNumber !== i + 1) { + await manager.getRepository(Wagon).update(wagons[i].id, { sequenceNumber: i + 1 }); + } + } + } +} diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 61098ff40..0c2ce8ded 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -1,14 +1,17 @@ // apps/edr-freight-api/src/modules/trains/trains.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { TrainLocomotive } from './entities/train-locomotive.entity'; import { Train } from './entities/train.entity'; +import { TrainBuilderController } from './train-builder.controller'; +import { TrainBuilderService } from './train-builder.service'; import { TrainsController } from './trains.controller'; import { TrainsService } from './trains.service'; @Module({ - imports: [TypeOrmModule.forFeature([Train])], - controllers: [TrainsController], - providers: [TrainsService], - exports: [TrainsService], // if other modules need it + imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])], + controllers: [TrainsController, TrainBuilderController], + providers: [TrainsService, TrainBuilderService], + exports: [TrainsService, TrainBuilderService], }) -export class TrainsModule {} \ No newline at end of file +export class TrainsModule {} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts new file mode 100644 index 000000000..4dd9f0f75 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -0,0 +1,28 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; + +/** + * A count-only wagon-transfer request. The requester picks source yard, wagon + * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks + * those at fulfilment. + */ +export class CreateTransferRequestDto { + @IsUUID() + fromYardId!: string; + + @IsUUID() + toYardId!: string; + + @IsUUID() + wagonTypeId!: string; + + @IsInt() + @Min(1) + @Max(1000) + quantity!: number; + + @ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' }) + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/fulfill-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/fulfill-transfer-request.dto.ts new file mode 100644 index 000000000..565156304 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/fulfill-transfer-request.dto.ts @@ -0,0 +1,13 @@ +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +/** + * OCC fulfilment: the specific wagons hand-picked to satisfy a transfer request. + * The service validates they all sit in the request's source yard, match its + * wagon type, and number exactly the requested quantity. + */ +export class FulfillTransferRequestDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts index 7c5ed092c..a36e5deea 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { Wagon } from './wagon.entity'; +import { WagonTransferRequest } from './wagon-transfer-request.entity'; /** * Ledger of every physical wagon relocation between yards — one row per move. @@ -51,6 +52,14 @@ export class WagonMovement extends BaseEntity { @Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true }) movedByUserId?: string | null; + /** The transfer request this move fulfilled, when it came from one. */ + @Column({ name: 'transfer_request_id', type: 'uuid', nullable: true }) + transferRequestId?: string | null; + + @ManyToOne(() => WagonTransferRequest, { nullable: true }) + @JoinColumn({ name: 'transfer_request_id' }) + transferRequest?: WagonTransferRequest | null; + @Column({ name: 'occurred_at', type: 'timestamptz' }) occurredAt!: Date; diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts new file mode 100644 index 000000000..40005de26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from '@edr/api-common'; +import { WagonTransferRequestStatus } from '@edr/types'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; + +/** + * A two-person wagon relocation request. A requester asks for `quantity` wagons + * of `wagonTypeId` to move from `fromYardId` to `toYardId` — specifying a count + * only, never the physical wagons. OCC staff later open the PENDING request, + * hand-pick the actual wagons in the source yard, and execute the transfer + * (which writes the `wagon_movements` ledger and marks this FULFILLED). + */ +@Entity({ schema: 'freight', name: 'wagon_transfer_requests' }) +@Index(['status', 'fromYardId']) +export class WagonTransferRequest extends BaseEntity { + @Column({ name: 'from_yard_id', type: 'uuid' }) + fromYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'from_yard_id' }) + fromYard?: Yard | null; + + @Column({ name: 'to_yard_id', type: 'uuid' }) + toYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'to_yard_id' }) + toYard?: Yard | null; + + @Column({ name: 'wagon_type_id', type: 'uuid' }) + wagonTypeId!: string; + + @ManyToOne(() => WagonType) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType | null; + + /** How many wagons of `wagonTypeId` to move out of `fromYardId`. */ + @Column({ name: 'quantity', type: 'int' }) + quantity!: number; + + @Column({ + name: 'status', + type: 'varchar', + length: 20, + default: WagonTransferRequestStatus.Pending, + }) + status!: WagonTransferRequestStatus; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true }) + fulfilledByUserId?: string | null; + + @Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true }) + fulfilledAt?: Date | null; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts new file mode 100644 index 000000000..12fdaf27c --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -0,0 +1,101 @@ +import { WagonTransferRequestStatus } from '@edr/types'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { + FleetManage, + FleetView, + WagonTransferFulfill, + WagonTransferHistoryAll, + WagonTransferRequest, +} from '../../common/booking-guards'; +import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; +import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; +import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; + +/** + * Two-person wagon-transfer queue. Requester (transfer_request perm) files a + * count-only request; OCC (transfer_fulfill perm) picks the wagons and executes + * the move. Separate top-level path so it never collides with `wagons/:id`. + */ +@ApiTags('wagon-transfer-requests') +@Controller('wagon-transfer-requests') +@FleetView() +export class WagonTransferRequestsController { + constructor(private readonly service: WagonTransferRequestsService) {} + + @Post() + @WagonTransferRequest() + @ApiOperation({ summary: 'File a count-only wagon-transfer request' }) + create( + @Body() dto: CreateTransferRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.createRequest(dto, user?.id); + } + + @Get() + @ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus }) + @ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' }) + list(@Query('status') status?: WagonTransferRequestStatus) { + return this.service.listRequests(status); + } + + // NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express + // matches in declaration order, so `/history` would otherwise be captured by + // the `:id` param route (and rejected by ParseUUIDPipe). + @Get('history') + @ApiOperation({ + summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)", + }) + myHistory(@CurrentUser() user: TCurrentUser) { + // Never fall through to the all-staff view: getHistory(undefined) means + // "everyone", so a missing caller id must return empty, not leak scope. + if (!user?.id) return { requests: [], movements: [] }; + return this.service.getHistory(user.id); + } + + @Get('history/all') + @WagonTransferHistoryAll() + @ApiQuery({ name: 'userId', required: false }) + @ApiOperation({ + summary: "Admin: any/all staff's transfer history (optional ?userId filter)", + }) + allHistory(@Query('userId') userId?: string) { + return this.service.getHistory(userId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get one transfer request' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.service.findById(id); + } + + @Post(':id/fulfill') + @WagonTransferFulfill() + @ApiOperation({ summary: 'OCC: pick wagons and execute the transfer' }) + fulfill( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: FulfillTransferRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.fulfillRequest(id, dto, user?.id); + } + + @Post(':id/cancel') + @FleetManage() + @ApiOperation({ summary: 'Withdraw a pending transfer request' }) + cancel(@Param('id', ParseUUIDPipe) id: string) { + return this.service.cancelRequest(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts new file mode 100644 index 000000000..bf69d767d --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -0,0 +1,199 @@ +import { WagonTransferRequestStatus } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, IsNull, Not, Repository } from 'typeorm'; + +import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; +import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; +import { Wagon } from './entities/wagon.entity'; +import { WagonMovement } from './entities/wagon-movement.entity'; +import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; +import { WagonsService } from './wagons.service'; + +/** Bundled per-user activity: requests they touched + wagons they moved. */ +export interface TransferHistory { + requests: WagonTransferRequest[]; + movements: WagonMovement[]; +} + +/** How many ledger rows the history returns at most (newest first). */ +const HISTORY_LIMIT = 500; + +const REQUEST_RELATIONS = { + fromYard: true, + toYard: true, + wagonType: true, +} as const; + +/** + * Two-person wagon-transfer workflow. A requester records a count-only request + * (see `createRequest`); OCC staff later open the PENDING queue, hand-pick the + * physical wagons, and `fulfillRequest` validates + executes the move. Replaces + * the single-step instant bulk transfer. + */ +@Injectable() +export class WagonTransferRequestsService { + constructor( + @InjectRepository(WagonTransferRequest) + private readonly requestRepo: Repository, + @InjectRepository(Wagon) + private readonly wagonRepo: Repository, + @InjectRepository(WagonMovement) + private readonly movementRepo: Repository, + private readonly wagonsService: WagonsService, + ) {} + + /** Record a PENDING request. Count-only — no wagons are picked here. */ + async createRequest( + dto: CreateTransferRequestDto, + userId?: string | null, + ): Promise { + if (dto.fromYardId === dto.toYardId) { + throw new BadRequestException( + 'Source and destination yard must be different', + ); + } + const request = this.requestRepo.create({ + fromYardId: dto.fromYardId, + toYardId: dto.toYardId, + wagonTypeId: dto.wagonTypeId, + quantity: dto.quantity, + status: WagonTransferRequestStatus.Pending, + requestedByUserId: userId ?? null, + note: dto.note ?? null, + }); + const saved = await this.requestRepo.save(request); + return this.findById(saved.id); + } + + /** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */ + async listRequests( + status?: WagonTransferRequestStatus, + ): Promise { + return this.requestRepo.find({ + where: status ? { status } : {}, + relations: REQUEST_RELATIONS, + order: { createdAt: 'DESC' }, + }); + } + + async findById(id: string): Promise { + const request = await this.requestRepo.findOne({ + where: { id }, + relations: REQUEST_RELATIONS, + }); + if (!request) throw new NotFoundException(`Transfer request ${id} not found`); + return request; + } + + /** + * OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit + * in the request's source yard, match its wagon type, and the count must equal + * the requested quantity — then the transfer runs and the request is marked + * FULFILLED. + */ + async fulfillRequest( + id: string, + dto: FulfillTransferRequestDto, + userId?: string | null, + ): Promise { + const request = await this.findById(id); + if (request.status !== WagonTransferRequestStatus.Pending) { + throw new ConflictException( + `Request is already ${request.status.toLowerCase()}`, + ); + } + + const wagonIds = [...new Set(dto.wagonIds)]; + if (wagonIds.length !== request.quantity) { + throw new BadRequestException( + `Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`, + ); + } + + const wagons = await this.wagonRepo.find({ where: { id: In(wagonIds) } }); + if (wagons.length !== wagonIds.length) { + throw new NotFoundException('One or more selected wagons not found'); + } + const offSource = wagons.filter((w) => w.currentYardId !== request.fromYardId); + if (offSource.length) { + throw new BadRequestException( + `These wagons are not in the source yard: ${offSource + .map((w) => w.wagonNumber) + .join(', ')}`, + ); + } + const wrongType = wagons.filter((w) => w.wagonTypeId !== request.wagonTypeId); + if (wrongType.length) { + throw new BadRequestException( + `These wagons are the wrong type: ${wrongType + .map((w) => w.wagonNumber) + .join(', ')}`, + ); + } + + // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows, + // each stamped with this request's id so history can link them back). + await this.wagonsService.bulkTransfer( + { wagonIds, toYardId: request.toYardId }, + userId, + { transferRequestId: request.id }, + ); + + request.status = WagonTransferRequestStatus.Fulfilled; + request.fulfilledByUserId = userId ?? null; + request.fulfilledAt = new Date(); + await this.requestRepo.save(request); + return this.findById(id); + } + + /** + * Per-user transfer history: the requests a user filed OR fulfilled, plus the + * individual wagons they physically moved (linked back to their request when + * one drove the move). Pass a `userId` to scope to one staffer; pass + * `undefined` for the admin all-staff view. Scope is decided by the CALLER + * (the controller passes the caller's id unless they hold the history-all + * permission) — this method trusts its argument. + */ + async getHistory(userId?: string | null): Promise { + const requests = await this.requestRepo.find({ + where: userId + ? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }] + : {}, + relations: REQUEST_RELATIONS, + order: { createdAt: 'DESC' }, + take: HISTORY_LIMIT, + }); + + const movements = await this.movementRepo.find({ + // Own view: moves I made. All view: every user-attributed move (skip the + // system-written loaded/reposition legs that carry no mover). + where: userId + ? { movedByUserId: userId } + : { movedByUserId: Not(IsNull()) }, + relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true }, + order: { occurredAt: 'DESC' }, + take: HISTORY_LIMIT, + }); + + return { requests, movements }; + } + + /** Withdraw a still-PENDING request. */ + async cancelRequest(id: string): Promise { + const request = await this.findById(id); + if (request.status !== WagonTransferRequestStatus.Pending) { + throw new ConflictException( + `Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`, + ); + } + request.status = WagonTransferRequestStatus.Cancelled; + await this.requestRepo.save(request); + return this.findById(id); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index bffe28860..107a31e7e 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -1,15 +1,31 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Wagon } from './entities/wagon.entity'; +import { WagonMovement } from './entities/wagon-movement.entity'; +import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { WagonsController, TrainWagonsReorderController } from './wagons.controller'; +import { WagonTransferRequestsController } from './wagon-transfer-requests.controller'; import { WagonsService } from './wagons.service'; +import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; @Module({ - imports: [TypeOrmModule.forFeature([Wagon, Train, Yard])], - controllers: [WagonsController, TrainWagonsReorderController], - providers: [WagonsService], - exports: [WagonsService], + imports: [ + TypeOrmModule.forFeature([ + Wagon, + WagonMovement, + WagonTransferRequest, + Train, + Yard, + ]), + ], + controllers: [ + WagonsController, + TrainWagonsReorderController, + WagonTransferRequestsController, + ], + providers: [WagonsService, WagonTransferRequestsService], + exports: [WagonsService, WagonTransferRequestsService], }) export class WagonsModule {} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index ad39fbf7a..55dc177c9 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -180,6 +180,7 @@ export class WagonsService { async bulkTransfer( dto: BulkTransferWagonsDto, userId?: string | null, + opts?: { transferRequestId?: string | null }, ): Promise<{ moved: number }> { const { wagonIds, toYardId } = dto; if (!wagonIds.length) return { moved: 0 }; @@ -215,6 +216,7 @@ export class WagonsService { toYardId, kind: WagonMovementKind.Manual, movedByUserId: userId ?? null, + transferRequestId: opts?.transferRequestId ?? null, occurredAt: new Date(), }), ); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts new file mode 100644 index 000000000..6b3697f03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +/** Acknowledge (optionally snooze) an item's fee-accrual alert. */ +export class AcknowledgeAccrualDto { + @ApiPropertyOptional({ minimum: 1, maximum: 90, description: 'Days to suppress alerts; omit = indefinitely.' }) + @IsOptional() + @IsInt() + @Min(1) + @Max(90) + snoozeDays?: number; + + @ApiPropertyOptional({ description: 'Optional reason / note.' }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts new file mode 100644 index 000000000..2ef499201 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +/** The customer approving a handover must record their full name (signature optional). */ +export class ApproveDeliveryDto { + @ApiProperty({ description: 'Full name of the person approving delivery.' }) + @IsString() + @IsNotEmpty() + @MaxLength(160) + signerName!: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts index f5a730ea8..a0a7ad70f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -37,6 +37,10 @@ export class BookingHandover extends BaseEntity { @Column({ name: 'signed_at', type: 'timestamptz', nullable: true }) signedAt?: Date | null; + /** Full name of the person who signed off the handover (required at sign time). */ + @Column({ name: 'signer_name', type: 'varchar', length: 160, nullable: true }) + signerName?: string | null; + @Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true }) signedByUserId?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index 48ab3ac48..d50b27150 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -183,12 +183,20 @@ export class HandoverService { } /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ - async signForBooking(bookingId: string, userId?: string | null): Promise { + async signForBooking( + bookingId: string, + userId?: string | null, + signerName?: string | null, + ): Promise { await this.dataSource .getRepository(BookingHandover) .update( { bookingId, signedAt: IsNull() }, - { signedAt: new Date(), signedByUserId: userId ?? null }, + { + signedAt: new Date(), + signedByUserId: userId ?? null, + signerName: signerName?.trim() || null, + }, ); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 14a3375c7..92aa36b85 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -1,7 +1,10 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { ExchangeService } from '@edr/api-common'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource } from 'typeorm'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; @@ -26,6 +29,35 @@ interface ItemAttributes { zoneId: string | null; } +export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING'; + +export interface AccrualDashboardRow { + inventoryId: string; + status: string; + bookingId: string | null; + companyId: string | null; + bookingReference: string | null; + customerName: string | null; + warehouseCode: string | null; + zoneCode: string | null; + receivedAt: string | null; + currency: string; + accruedAmount: number; + freeDaysLeft: number | null; + charging: boolean; + alert: AccrualAlert; + /** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */ + acknowledged: boolean; + snoozeUntil: string | null; + breakdown: Array<{ + type: FeeRuleType; + amount: number; + freeDays: number; + elapsedDays: number; + chargeableDays: number; + }>; +} + export interface FeePreview { ruleType: FeeRuleType; /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ @@ -70,12 +102,82 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; @Injectable() export class WarehouseFeeService { + private readonly logger = new Logger(WarehouseFeeService.name); + constructor( private readonly dataSource: DataSource, private readonly feeRuleRepository: WarehouseFeeRuleRepository, private readonly exchangeService: ExchangeService, + private readonly inbox: NotificationInboxService, ) {} + /** + * Daily accrual alerts: for every in-warehouse item that is charging or within + * its last free days, send the customer an in-app notification with the + * outstanding accrued amount so they can collect before (more) charges hit. + */ + @Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' }) + async sendAccrualAlerts(): Promise { + try { + const alerts = (await this.accrualDashboard()).filter( + (r) => r.alert !== 'OK' && !r.acknowledged, + ); + if (!alerts.length) return; + this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`); + + // Per-customer: notify each company about its own items. + for (const row of alerts.filter((r) => r.companyId)) { + const ref = row.bookingReference ?? row.inventoryId.slice(0, 8); + const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`; + const body = row.charging + ? `Storage/demurrage is now charging on booking ${ref} — ${amount} accrued. Collect the cargo to stop further charges.` + : `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`; + try { + await this.inbox.notify({ + recipients: { companyId: row.companyId! }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: row.charging ? 'Storage charges accruing' : 'Free days ending soon', + body, + link: row.bookingId ? `/bookings/${row.bookingId}` : undefined, + data: { + inventoryId: row.inventoryId, + bookingId: row.bookingId, + alert: row.alert, + accruedAmount: row.accruedAmount, + action: 'ACCRUAL_ALERT', + }, + }); + } catch (err) { + this.logger.warn( + `Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`, + ); + } + } + + // Ops staff: one digest covering every alerting item. + const charging = alerts.filter((r) => r.charging).length; + const nearing = alerts.length - charging; + const currency = alerts[0]?.currency ?? 'USD'; + const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0); + try { + await this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Warehouse fee accruals need attention', + body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`, + link: '/dashboard/warehouse-fee-invoices', + data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' }, + }); + } catch (err) { + this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`); + } + } catch (err) { + this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`); + } + } + // ── Rule CRUD ────────────────────────────────────────────────────────────── listRules(): Promise { return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } }); @@ -416,6 +518,138 @@ export class WarehouseFeeService { }; } + /** + * Live accrual dashboard: for every item still in the warehouse, the fees + * accruing right now (storage + demurrage + double-handling), how many free + * days remain, and an alert level so staff can act before charges land. + */ + async accrualDashboard(billingCurrency = 'USD'): Promise { + const items: Array<{ + id: string; + status: string; + bookingId: string | null; + companyId: string | null; + bookingReference: string | null; + customerName: string | null; + warehouseCode: string | null; + zoneCode: string | null; + receivedAt: string | null; + }> = await this.dataSource.query( + `SELECT inv.id, + inv.status, + b.id AS "bookingId", + b.company_id AS "companyId", + b.reference AS "bookingReference", + c.name AS "customerName", + w.code AS "warehouseCode", + z.code AS "zoneCode", + inv.created_at AS "receivedAt" + 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 c ON c.id = b.company_id + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id + WHERE inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') + ORDER BY inv.created_at ASC`, + ); + + const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> = + await this.dataSource.query( + `SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil" + FROM freight.warehouse_accrual_acks`, + ); + const now = new Date(); + const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil])); + + const rows = await Promise.all( + items.map(async (it): Promise => { + const previews = (await this.previewForInventory(it.id, billingCurrency)).filter( + (p) => p.ruleId, + ); + const accruedAmount = + Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100; + const charging = previews.some((p) => p.chargeableDays > 0); + const freeDaysLeftVals = previews + .filter((p) => p.endIsOpen) + .map((p) => Math.max(0, p.freeDays - p.elapsedDays)); + const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null; + const alert: AccrualAlert = charging + ? 'CHARGING' + : freeDaysLeft != null && freeDaysLeft <= 2 + ? 'WARNING' + : 'OK'; + return { + inventoryId: it.id, + status: it.status, + bookingId: it.bookingId, + companyId: it.companyId, + bookingReference: it.bookingReference, + customerName: it.customerName, + warehouseCode: it.warehouseCode, + zoneCode: it.zoneCode, + receivedAt: it.receivedAt, + currency: billingCurrency, + accruedAmount, + freeDaysLeft, + charging, + alert, + acknowledged: + acks.has(it.id) && + (acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now), + snoozeUntil: acks.get(it.id) ?? null, + breakdown: previews.map((p) => ({ + type: p.ruleType, + amount: p.amount, + freeDays: p.freeDays, + elapsedDays: p.elapsedDays, + chargeableDays: p.chargeableDays, + })), + }; + }), + ); + + const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2); + // Acknowledged items sink to the bottom; among the rest, worst alert first. + return rows.sort( + (a, b) => + Number(a.acknowledged) - Number(b.acknowledged) || + rank(a.alert) - rank(b.alert) || + b.accruedAmount - a.accruedAmount, + ); + } + + /** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */ + async acknowledgeAccrual( + inventoryId: string, + opts: { snoozeDays?: number; note?: string; userId?: string } = {}, + ): Promise { + const snoozeUntil = + opts.snoozeDays && opts.snoozeDays > 0 + ? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000) + : null; + await this.dataSource.query( + `INSERT INTO freight.warehouse_accrual_acks + (inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at) + VALUES ($1, $2, now(), $3, $4, now()) + ON CONFLICT (inventory_id) DO UPDATE + SET acknowledged_by = EXCLUDED.acknowledged_by, + acknowledged_at = now(), + snooze_until = EXCLUDED.snooze_until, + note = EXCLUDED.note, + updated_at = now()`, + [inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null], + ); + } + + /** Remove an acknowledgement so the item re-surfaces for alerts. */ + async unacknowledgeAccrual(inventoryId: string): Promise { + await this.dataSource.query( + `DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`, + [inventoryId], + ); + } + /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { const item = await this.loadItem(inventoryId); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index 533b0c6ae..7bd56e593 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -12,6 +12,8 @@ import { import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { WarehouseInspectionService } from './warehouse-inspection.service'; @@ -19,10 +21,12 @@ import { WarehouseInspectionService } from './warehouse-inspection.service'; @ApiTags('warehouse-inspection') @ApiBearerAuth() @Controller() +@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view) export class WarehouseInspectionController { constructor(private readonly inspectionService: WarehouseInspectionService) {} @Post('warehouse-inventory/:inventoryId/inspection-reports') + @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.create) @ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' }) create( @Param('inventoryId', ParseUUIDPipe) inventoryId: string, @@ -46,12 +50,14 @@ export class WarehouseInspectionController { } @Patch('warehouse-inspection-reports/:id') + @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update) @ApiOperation({ summary: 'Update an inspection report' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) { return this.inspectionService.update(id, dto); } @Post('warehouse-inspection-reports/:id/attachments') + @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload inspection images / documents' }) 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 21f2663b6..8b14e6cdc 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 @@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Reques import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -11,6 +13,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ApproveDeliveryDto } from './dto/approve-delivery.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; @@ -29,48 +32,63 @@ export class WarehouseInventoryController { ) {} @Get() + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List warehouse inventory' }) findAll(@Query() filter: FilterWarehouseInventoryDto) { return this.inventoryService.findAll(filter); } @Get('ready-for-loading') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List inventory ready for loading' }) findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { return this.inventoryService.findReadyForLoading(filter); } @Get('inquiry') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Locate any item inside the warehouse' }) inquiry(@Query() filter: InquiryWarehouseInventoryDto) { return this.inventoryService.inquiry(filter); } @Get('arrival-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' }) arrivalQueue() { return this.inventoryService.arrivalQueue(); } + @Get('ops-stats') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' }) + opsStats() { + return this.inventoryService.opsStats(); + } + @Get('zone-occupancy') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' }) zoneOccupancy(@Query('yardId') yardId?: string) { return this.inventoryService.zoneOccupancy(yardId); } @Post('auto-unload-arrived') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) autoUnloadArrived() { return this.inventoryService.autoUnloadArrived(); } @Post('auto-load-ready') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.load) @ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' }) autoLoadReady() { return this.inventoryService.autoLoadReady(); } @Get('eligible-bookings') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @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; @@ -78,6 +96,7 @@ export class WarehouseInventoryController { } @Post('receive-bulk') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) receiveBulk(@Body() dto: BulkReceiveDto) { return this.inventoryService.bulkReceive(dto); @@ -85,36 +104,42 @@ export class WarehouseInventoryController { @Get('ready-to-load-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) readyToLoadExport() { return this.inventoryService.readyToLoadExport(); } @Get('received-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' }) receivedExport() { return this.inventoryService.receivedExport(); } @Get('loaded-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' }) loadedExport() { return this.inventoryService.loadedExport(); } @Get('loadable-trains') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' }) loadableTrains() { return this.inventoryService.loadableTrains(); } @Get('train/:scheduleId/loadable-items') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' }) trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { return this.inventoryService.trainLoadableItems(scheduleId); } @Post('train/:scheduleId/load') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.load) @ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' }) loadItemsOntoTrain( @Param('scheduleId', ParseUUIDPipe) scheduleId: string, @@ -124,18 +149,21 @@ export class WarehouseInventoryController { } @Post('bulk-dispatch-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch) @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') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.inspect) @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') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Unload a single arrived booking into a location' }) unloadBooking( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -145,24 +173,28 @@ export class WarehouseInventoryController { } @Post(':id/gate-clearance') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass) @ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' }) gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { return this.inventoryService.gateClearance(id, performedBy); } @Get('import/arrive-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' }) importArriveQueue() { return this.scheduling.importArriveQueue(); } @Get('import/trains/:scheduleId/items') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @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') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' }) autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; @@ -179,12 +211,14 @@ export class WarehouseInventoryController { } @Get('import/unloaded-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' }) importUnloadedQueue() { return this.inventoryService.importUnloadedQueue(); } @Get('export/djibouti-arrival-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' }) exportDjiboutiArrivalQueue( @Query('scheduleId') scheduleId?: string, @@ -203,102 +237,119 @@ export class WarehouseInventoryController { } @Get('export/djibouti-trains/:scheduleId/items') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' }) exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { return this.scheduling.exportDjiboutiTrainDetail(scheduleId); } @Post('export/auto-unload-at-djibouti') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' }) autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) { return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy); } @Get('import/pickup-ready-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' }) importPickupReadyQueue() { return this.inventoryService.importPickupReadyQueue(); } @Get('loadable-wagons') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) loadableWagons() { return this.scheduling.listLoadableWagons(); } @Get('booking/:bookingId/schedule') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' }) bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.scheduling.getBookingSchedule(bookingId); } @Post('receive') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @ApiOperation({ summary: 'Receive inventory at a warehouse location' }) receive(@Body() dto: ReceiveWarehouseInventoryDto) { return this.inventoryService.receive(dto); } @Post('reserve') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' }) reserve(@Body() dto: ReserveInventoryDto) { return this.inventoryService.reserve(dto); } @Get(':id/movements') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Inventory movement history' }) movements(@Param('id', ParseUUIDPipe) id: string) { return this.inventoryService.findMovements(id); } @Get(':id/activity') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Inventory activity log' }) activity(@Param('id', ParseUUIDPipe) id: string) { return this.inventoryService.findActivity(id); } @Get(':id/loadings') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Loading records for an inventory item' }) loadings(@Param('id', ParseUUIDPipe) id: string) { return this.inventoryService.findLoadingsByInventory(id); } @Post(':id/move') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.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') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) { return this.inventoryService.store(id, dto.performedBy, dto); } @Post(':id/ready-for-loading') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @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') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.load) @ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' }) load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) { return this.inventoryService.load(id, dto); } @Post(':id/ready-for-pickup') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @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') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.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); } @Get(':id/release-document') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'View warehouse release / exit paper PDF' }) async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.releaseDocument(id); @@ -309,6 +360,7 @@ export class WarehouseInventoryController { } @Get('customer-truck-exit-paper/:assignmentId') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' }) async truckExitPaper( @Param('assignmentId', ParseUUIDPipe) assignmentId: string, @@ -322,6 +374,7 @@ export class WarehouseInventoryController { } @Get(':id/grn-document') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'View goods received note PDF' }) async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.grnDocument(id); @@ -342,12 +395,17 @@ export class WarehouseInventoryController { } @Post('bookings/:bookingId/approve-delivery') - @ApiOperation({ summary: "Approve delivery using the current customer's saved signature" }) + @ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" }) approveDeliveryForBooking( @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: ApproveDeliveryDto, @Request() req: { user?: { id?: string; sub?: string } }, ) { - return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub); + return this.inventoryService.approveDeliveryForBooking( + bookingId, + req.user?.id ?? req.user?.sub, + dto.signerName, + ); } @Get('bookings/:bookingId/handovers') @@ -362,6 +420,26 @@ export class WarehouseInventoryController { return this.handoverService.requestSignature(bookingId); } + @Get('bookings/:bookingId/grn-document') + @ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' }) + async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + + @Get('bookings/:bookingId/release-document') + @ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' }) + async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get('bookings/:bookingId/handover-document') @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { @@ -385,12 +463,14 @@ export class WarehouseInventoryController { } @Post(':id/deliver') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.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') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch) @ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' }) 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 7c8edcce0..9a42bc895 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 @@ -397,6 +397,45 @@ export class WarehouseInventoryService { * but has no customer truck assigned yet, nudge the customer to assign one — with * a deep-link to the booking's truck-assignment card. Fire-and-forget. */ + /** + * At-a-glance warehouse ops counters for the KPI strip: + * - receivedToday: items received today + * - pendingInspection: RECEIVED items not yet inspected + * - trucksOnSite: customer trucks arrived but not departed + * - itemsAging: in-warehouse items older than 7 days (demurrage risk) + */ + async opsStats(): Promise<{ + receivedToday: number; + pendingInspection: number; + trucksOnSite: number; + itemsAging: number; + }> { + const [row]: Array<{ + receivedToday: number; + pendingInspection: number; + trucksOnSite: number; + itemsAging: number; + }> = await this.dataSource.query( + `SELECT + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday", + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection", + (SELECT count(*)::int FROM freight.customer_truck_assignments + WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite", + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL + AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') + AND created_at < now() - interval '7 days') AS "itemsAging"`, + ); + return { + receivedToday: row?.receivedToday ?? 0, + pendingInspection: row?.pendingInspection ?? 0, + trucksOnSite: row?.trucksOnSite ?? 0, + itemsAging: row?.itemsAging ?? 0, + }; + } + /** * Live occupancy per zone: rated capacity vs the weight/items currently held * (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard @@ -454,14 +493,17 @@ export class WarehouseInventoryService { return rows.map((r) => { const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null; + // Zone capacity_weight is in TONNES; inventory weight is in KG — normalise + // used weight to tonnes before comparing so weight occupancy is correct. + const usedWeightTons = r.usedWeight / 1000; const byWeight = - capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null; + capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null; const byItems = r.capacityContainers && r.capacityContainers > 0 ? (r.usedItems / r.capacityContainers) * 100 : null; - // Prefer container-count occupancy (unit-consistent). Weight capacity is - // tonnes while inventory weight is kg, so weight% is only a rough fallback. + // Container zones use item-count occupancy; bulk zones (no container cap) + // fall back to the now unit-correct weight occupancy. const pct = byItems ?? byWeight; return { id: r.id, @@ -2788,6 +2830,7 @@ export class WarehouseInventoryService { Array<{ containerNumber: string; goods: string | null; + containerSize: string | null; stage: 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | 'LOADED' | 'LEFT' | 'DELIVERED'; grnNumber: string | null; truckAssignmentId: string | null; @@ -2804,6 +2847,7 @@ export class WarehouseInventoryService { const rows: Array<{ containerNumber: string; goods: string | null; + containerSize: string | null; received: boolean; grnNumber: string | null; truckAssignmentId: string | null; @@ -2818,6 +2862,7 @@ export class WarehouseInventoryService { }> = await this.dataSource.query( `SELECT bcu.container_number AS "containerNumber", COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, + bc.container_size AS "containerSize", bcu.received_to_port AS received, bcu.grn_number AS "grnNumber", ctc.assignment_id AS "truckAssignmentId", @@ -2854,6 +2899,7 @@ export class WarehouseInventoryService { return rows.map((r) => ({ containerNumber: r.containerNumber, goods: r.goods, + containerSize: r.containerSize, // A container the customer assigned to a truck is ASSIGNED (planned); it // only becomes LOADED once the operator loads it (loaded_at) on truck // leaving. Departed → LEFT, delivered → DELIVERED. @@ -3132,16 +3178,20 @@ export class WarehouseInventoryService { async approveDeliveryForBooking( bookingId: string, userId?: string, + signerName?: string, ): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> { if (!userId) { throw new BadRequestException('Authentication is required to approve delivery'); } - - const signature = await this.signatures.getForUser(userId); - if (!signature?.signatureImageUrl) { - throw new BadRequestException('Please save your signature before approving delivery'); + const name = signerName?.trim(); + if (!name) { + throw new BadRequestException('Please enter your full name to approve delivery'); } + // A saved signature is applied when available; otherwise the typed full name + // is the record of who approved (self-haul customers may have no signature). + const signature = await this.signatures.getForUser(userId).catch(() => null); + const [item]: Array<{ id: string; warehouseId: string | null; @@ -3176,8 +3226,8 @@ export class WarehouseInventoryService { const approvedAt = new Date(); const approval = { approvedAt: approvedAt.toISOString(), - signerDisplayName: signature.signerDisplayName, - signatureImageUrl: signature.signatureImageUrl, + signerDisplayName: name, + signatureImageUrl: signature?.signatureImageUrl ?? null, userId, }; const existingNotes = this.stripCustomerDeliveryApproval(item.notes); @@ -3192,8 +3242,8 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RELEASED', inventoryId: item.id, warehouseId: item.warehouseId, - description: `Customer approved delivery as ${signature.signerDisplayName}`, - performedBy: signature.signerDisplayName, + description: `Customer approved delivery as ${name}`, + performedBy: name, }, manager, ); @@ -3201,13 +3251,13 @@ export class WarehouseInventoryService { // Sign the structured handover record(s) for this booking (self-haul: before // the truck leaves). Kept alongside the legacy approval note. - await this.handover.signForBooking(bookingId, userId); + await this.handover.signForBooking(bookingId, userId, name); return { bookingId, inventoryId: item.id, approvedAt: approval.approvedAt, - signerDisplayName: signature.signerDisplayName, + signerDisplayName: name, }; } @@ -3226,6 +3276,31 @@ export class WarehouseInventoryService { return this.handoverDocument(inv.id); } + /** Resolve the primary warehouse-inventory item for a booking (most recent). */ + private async primaryInventoryIdForBooking(bookingId: string): Promise { + const [inv]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY updated_at DESC NULLS LAST, created_at DESC + LIMIT 1`, + [bookingId], + ); + if (!inv) { + throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); + } + return inv.id; + } + + /** Booking-scoped GRN document (customer portal). */ + async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId)); + } + + /** Booking-scoped gate-clearance / release document (customer portal). */ + async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId)); + } + async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index a1c5db837..635ca1a10 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; @@ -13,12 +15,14 @@ export class WarehouseInvoiceController { constructor(private readonly invoiceService: WarehouseInvoiceService) {} @Post('warehouse-inventory/:id/generate-fee-invoice') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate) @ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' }) generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) { return this.invoiceService.generateForInventory(id, dto); } @Post('last-mile/:id/generate-truck-detention-invoice') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate) @ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' }) generateTruckDetention( @Param('id', ParseUUIDPipe) id: string, @@ -28,6 +32,7 @@ export class WarehouseInvoiceController { } @Get('warehouse-inventory/:id/fee-invoices') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view) @ApiOperation({ summary: 'List fee invoices for an inventory item' }) listForInventory(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.listForInventory(id); @@ -40,6 +45,7 @@ export class WarehouseInvoiceController { } @Get('warehouse-fee-invoices') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view) @ApiOperation({ summary: 'List / filter warehouse fee invoices' }) findAll( @Query('status') status?: string, @@ -86,12 +92,14 @@ export class WarehouseInvoiceController { } @Patch('warehouse-fee-invoices/:id/cancel') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.cancel) @ApiOperation({ summary: 'Cancel a warehouse fee invoice' }) cancel(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.cancel(id); } @Post('warehouse-fee-invoices/:id/pay') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.pay) @ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' }) pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) { return this.invoiceService.pay(id, dto); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts index aab8e18b2..32860082e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts @@ -1,11 +1,14 @@ import { Controller, Get, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { WarehouseInventoryService } from './warehouse-inventory.service'; @ApiTags('warehouse-loadings') @ApiBearerAuth() @Controller('warehouse-loadings') +@BookingStaff(FREIGHT_PERMS.warehouseInventory.view) export class WarehouseLoadingsController { constructor(private readonly inventoryService: WarehouseInventoryService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index d35587d9e..7a5c53d38 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -1,12 +1,15 @@ import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AllocationPreviewDto, CreateAllocationRuleDto, UpdateAllocationRuleDto, } from './dto/allocation-rule.dto'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; +import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto'; import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseFeeService } from './warehouse-fee.service'; @@ -21,18 +24,21 @@ export class WarehouseRulesController { // ── Allocation rules ─────────────────────────────────────────────────────── @Get('warehouse-allocation-rules') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view) @ApiOperation({ summary: 'List warehouse allocation rules' }) listAllocationRules() { return this.allocationService.listRules(); } @Post('warehouse-allocation-rules') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.create) @ApiOperation({ summary: 'Create a warehouse allocation rule' }) createAllocationRule(@Body() dto: CreateAllocationRuleDto) { return this.allocationService.createRule(dto); } @Patch('warehouse-allocation-rules/:id') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.update) @ApiOperation({ summary: 'Update a warehouse allocation rule' }) updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) { return this.allocationService.updateRule(id, dto); @@ -40,12 +46,14 @@ export class WarehouseRulesController { @Delete('warehouse-allocation-rules/:id') @HttpCode(204) + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.delete) @ApiOperation({ summary: 'Delete a warehouse allocation rule' }) deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) { return this.allocationService.deleteRule(id); } @Post('warehouse-allocation/preview') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view) @ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' }) previewAllocation(@Body() dto: AllocationPreviewDto) { return this.allocationService.resolveLocation(dto); @@ -53,18 +61,21 @@ export class WarehouseRulesController { // ── Fee rules ──────────────────────────────────────────────────────────────── @Get('warehouse-fee-rules') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) @ApiOperation({ summary: 'List storage / demurrage fee rules' }) listFeeRules() { return this.feeService.listRules(); } @Post('warehouse-fee-rules') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.create) @ApiOperation({ summary: 'Create a storage / demurrage fee rule' }) createFeeRule(@Body() dto: CreateFeeRuleDto) { return this.feeService.createRule(dto); } @Patch('warehouse-fee-rules/:id') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) @ApiOperation({ summary: 'Update a fee rule' }) updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) { return this.feeService.updateRule(id, dto); @@ -72,12 +83,42 @@ export class WarehouseRulesController { @Delete('warehouse-fee-rules/:id') @HttpCode(204) + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.delete) @ApiOperation({ summary: 'Delete a fee rule' }) deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) { return this.feeService.deleteRule(id); } + @Get('warehouse-fees/accrual-dashboard') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) + @ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' }) + accrualDashboard(@Query('billingCurrency') billingCurrency?: string) { + return this.feeService.accrualDashboard(billingCurrency); + } + + @Post('warehouse-fees/accrual/:inventoryId/acknowledge') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) + @ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' }) + acknowledgeAccrual( + @Param('inventoryId', ParseUUIDPipe) inventoryId: string, + @Body() dto: AcknowledgeAccrualDto, + ) { + return this.feeService.acknowledgeAccrual(inventoryId, { + snoozeDays: dto.snoozeDays, + note: dto.note, + }); + } + + @Delete('warehouse-fees/accrual/:inventoryId/acknowledge') + @HttpCode(204) + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) + @ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' }) + unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) { + return this.feeService.unacknowledgeAccrual(inventoryId); + } + @Get('warehouse-inventory/:id/fee-preview') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) @ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' }) feePreview( @Param('id', ParseUUIDPipe) id: string, @@ -87,6 +128,7 @@ export class WarehouseRulesController { } @Get('last-mile/:id/truck-detention-preview') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) @ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' }) truckDetentionPreview( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index c14cea7a4..fdfbc36be 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,6 +1,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { WarehouseYardsService } from './warehouse-yards.service'; @@ -9,6 +11,7 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-yards') @ApiBearerAuth() @Controller('warehouse-yards') +@BookingStaff(FREIGHT_PERMS.warehouseYards.view) export class WarehouseYardsController { constructor( private readonly yardsService: WarehouseYardsService, @@ -28,18 +31,21 @@ export class WarehouseYardsController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouseYards.update) @ApiOperation({ summary: 'Update warehouse yard' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) { return this.yardsService.update(id, dto); } @Get(':yardId/zones') + @BookingStaff(FREIGHT_PERMS.warehouseZones.view) @ApiOperation({ summary: 'List zones within a yard' }) listZones(@Param('yardId', ParseUUIDPipe) yardId: string) { return this.zonesService.findByYard(yardId); } @Post(':yardId/zones') + @BookingStaff(FREIGHT_PERMS.warehouseZones.create) @ApiOperation({ summary: 'Create a zone within a yard' }) createZone( @Param('yardId', ParseUUIDPipe) yardId: string, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 7d51feac3..b0371cbcc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -1,12 +1,15 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-zones') @ApiBearerAuth() @Controller('warehouse-zones') +@BookingStaff(FREIGHT_PERMS.warehouseZones.view) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} @@ -23,6 +26,7 @@ export class WarehouseZonesController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouseZones.update) @ApiOperation({ summary: 'Update warehouse zone' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) { return this.zonesService.update(id, dto); 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 bb7702603..63c40de94 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -1,6 +1,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseDto } from './dto/create-warehouse.dto'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; @@ -12,6 +14,7 @@ import { WarehousesService } from './warehouses.service'; @ApiTags('warehouses') @ApiBearerAuth() @Controller('warehouses') +@BookingStaff(FREIGHT_PERMS.warehouses.view) export class WarehousesController { constructor( private readonly warehousesService: WarehousesService, @@ -26,12 +29,14 @@ export class WarehousesController { } @Get('dashboard') + @BookingStaff(FREIGHT_PERMS.warehouseDashboard.view) @ApiOperation({ summary: 'Warehouse dashboard metrics' }) dashboard() { return this.dashboardService.getDashboard(); } @Post() + @BookingStaff(FREIGHT_PERMS.warehouses.create) @ApiOperation({ summary: 'Create warehouse' }) create(@Body() dto: CreateWarehouseDto) { return this.warehousesService.create(dto); @@ -44,18 +49,21 @@ export class WarehousesController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouses.update) @ApiOperation({ summary: 'Update warehouse' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) { return this.warehousesService.update(id, dto); } @Get(':warehouseId/yards') + @BookingStaff(FREIGHT_PERMS.warehouseYards.view) @ApiOperation({ summary: 'List yards within a warehouse' }) listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) { return this.yardsService.findByWarehouse(warehouseId); } @Post(':warehouseId/yards') + @BookingStaff(FREIGHT_PERMS.warehouseYards.create) @ApiOperation({ summary: 'Create a yard within a warehouse' }) createYard( @Param('warehouseId', ParseUUIDPipe) warehouseId: string, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index e281e6464..6e4ec81b1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -175,6 +175,9 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ perm('e1b00001-0001-4000-8000-000000000002', 'edr_freight_app:wagons:create', 'Create wagon'), perm('e1b00001-0001-4000-8000-000000000003', 'edr_freight_app:wagons:update', 'Update wagon'), perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'), + perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'), + perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'), + perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"), perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'), perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'), perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'), @@ -440,6 +443,13 @@ export const FREIGHT_PERMS = { create: 'edr_freight_app:wagons:create', update: 'edr_freight_app:wagons:update', delete: 'edr_freight_app:wagons:delete', + // Requester creates a transfer request; OCC fulfils it (picks the wagons and + // executes the move). Distinct keys so OCC can hold fulfil without request. + transferRequest: 'edr_freight_app:wagons:transfer_request', + transferFulfill: 'edr_freight_app:wagons:transfer_fulfill', + // Admin: read every staffer's transfer history. Without it, a user only sees + // their own (the /history endpoint uses the caller id, backend-enforced). + transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all', }, trains: { view: 'edr_freight_app:trains:view', diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 18e291872..0da9fe3f0 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -4,6 +4,7 @@ import { Container, FileSignature, FileText, + Hammer, LayoutDashboard, LayoutGrid, MapPin, @@ -109,6 +110,8 @@ import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityP import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; +import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage"; +import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; @@ -261,6 +264,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Train Builder", + href: "/dashboard/train-builder", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, // { // label: "Wagon types", @@ -1032,6 +1041,22 @@ const App = () => { } /> + + + + } + /> + + + + } + /> { } /> + + + + } + /> + + + + } + /> (); const [searchParams] = useSearchParams(); const requestIdParam = searchParams.get("requestId"); + // Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same + // contract (GL only picks a new schedule). Set by the clearance Rebook action. + const copyFromParam = searchParams.get("copyFrom"); const navigate = useNavigate(); const { data: contract, isLoading } = useContractDetail(id); const mutations = useContractMutations(id ?? ""); @@ -205,6 +209,13 @@ export default function GlCreateBookingForm() { enabled: Boolean(requestId), }); + // The expired booking a Rebook is copying from (its cargo seeds the form). + const { data: copyFromBooking } = useQuery({ + queryKey: ["rebook-copy-from", copyFromParam], + queryFn: () => bookingsService.getById(copyFromParam!), + enabled: Boolean(copyFromParam), + }); + // Same window-gating the customer sees: booking is only allowed while a // window is OPEN for one of the contract's routes. Intercity contracts are // never window-gated — the shipment rides a passing train staff pick later. @@ -363,6 +374,28 @@ export default function GlCreateBookingForm() { if (bookingRequest.notes) setNotes(bookingRequest.notes); }, [bookingRequest, prefilled]); + // Rebook seed: copy the source booking's container lines once. (Bulk weight / + // item count isn't on the booking payload yet, so bulk rebooks fall through to + // the normal contract seed and GL re-enters the quantity.) + useEffect(() => { + if (!copyFromBooking || prefilled) return; + const lines = copyFromBooking.bookingContainers ?? []; + if (!lines.length) return; + setPrefilled(true); + setContainerLines( + lines.map((c) => { + const qty = Math.max(1, c.quantity); + return { + containerSize: String(c.containerType?.sizeFt ?? ""), + quantity: String(qty), + hazardousQuantity: "0", + reeferQuantity: "0", + units: Array.from({ length: qty }, emptyUnit), + }; + }), + ); + }, [copyFromBooking, prefilled]); + // Seed one shipment line per contracted size exactly once — same seeding the // portal form does. Subsequent renders reuse the lines. useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 8c1aed302..feaf8760d 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -5,6 +5,7 @@ import { Button, TextInput, Textarea, + MultiSelect, Select, Switch, Stack, @@ -79,8 +80,12 @@ const buildInitialValues = ( ): Record => { const values: Record = {}; for (const field of fields) { - const raw = record?.[field.name]; - if (raw !== undefined && raw !== null) { + const raw = field.getInitialValue && record + ? field.getInitialValue(record) + : record?.[field.name]; + if (field.type === "multiselect") { + values[field.name] = Array.isArray(raw) ? raw.map(String) : []; + } else if (raw !== undefined && raw !== null) { if (field.type === "date" && typeof raw === "string") { values[field.name] = raw.slice(0, 10); } else if (Array.isArray(raw)) { @@ -197,7 +202,10 @@ const RuleEngineFormDialog = ({ for (const field of visibleFields) { const raw = values[field.name]; - if (field.type === "number") { + if (field.type === "multiselect") { + // Always the full replacement list — the API syncs the relation to it. + payload[field.name] = Array.isArray(raw) ? raw : []; + } else if (field.type === "number") { if (raw === "" || raw === undefined) continue; payload[field.name] = Number(raw); } else if (field.type === "boolean") { @@ -258,6 +266,38 @@ const RuleEngineFormDialog = ({ const label = ; + if (field.type === "multiselect") { + const options = field.optionsFromValues + ? field.optionsFromValues(values) + : (field.options ?? []); + const selected = Array.isArray(values[field.name]) + ? (values[field.name] as string[]) + : []; + return ( + setField(field.name, v)} + disabled={selectOptionsLoading} + data={options + .filter((opt) => opt.value !== "" && opt.value !== RULE_ENGINE_SELECT_NONE) + .map((opt) => ({ label: opt.label, value: opt.value }))} + searchable + clearable + size="md" + radius="md" + styles={inputStyles} + /> + ); + } + if (field.type === "select") { // Dynamic options (e.g. rate unit) resolve from the live form values so // the choices track the other fields the admin has picked. diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx new file mode 100644 index 000000000..a75449a9d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx @@ -0,0 +1,152 @@ +import { Freight } from "@edr/types"; +import { + Button, + Checkbox, + Group, + ScrollArea, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { Plus, Search } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { api } from "@/services/api"; + +/** + * AVAILABLE wagons standing in the train's own yard — the only ones that can + * be coupled. Pick any number and append them to the consist. + */ +export default function AvailableWagonsPanel({ + yardId, + yardLabel, + onAssign, + assigning, +}: AvailableWagonsPanelProps) { + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState("ALL"); + const [selected, setSelected] = useState([]); + + const wagonsQuery = useQuery( + api.wagons.list.queryOptions({ + input: { + filters: { status: Freight.WagonStatus.Available, currentYardId: yardId }, + }, + enabled: Boolean(yardId), + }), + ); + + const wagons = useMemo(() => { + const q = search.trim().toLowerCase(); + return (wagonsQuery.data ?? []).filter((wagon) => { + if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false; + if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false; + return true; + }); + }, [wagonsQuery.data, search, typeFilter]); + + const typeOptions = useMemo(() => { + const byId = new Map(); + for (const wagon of wagonsQuery.data ?? []) { + if (wagon.wagonType) byId.set(wagon.wagonType.id, wagon.wagonType.name); + } + return [ + { value: "ALL", label: "All types" }, + ...[...byId.entries()].map(([value, label]) => ({ value, label })), + ]; + }, [wagonsQuery.data]); + + const toggle = (wagonId: string, checked: boolean) => { + setSelected((prev) => + checked ? [...prev, wagonId] : prev.filter((id) => id !== wagonId), + ); + }; + + const handleAssign = () => { + if (!selected.length) return; + onAssign(selected); + setSelected([]); + }; + + return ( + + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + /> + ({ + value: y.id, + label: y.label ?? y.code, + }))} + value={yardId || null} + onChange={(v) => setYardId(v ?? "")} + searchable + /> + 0 && locomotiveIds.length < 2 + ? "Select at least two locomotives" + : undefined + } + nothingFoundMessage={ + yardId ? "No available locomotives in this yard" : "Select a yard first" + } + /> +