From a48e8305844d5377092f7f018828a419b7deed0b Mon Sep 17 00:00:00 2001
From: natib21
Date: Tue, 14 Jul 2026 09:27:44 +0000
Subject: [PATCH 01/29] fix ui
---
.../backoffice/src/user-management/AppMenuTabs.tsx | 4 +++-
.../backoffice/src/user-management/Applayout.tsx | 7 ++++---
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx
index d4cd8eb86..fb523fc06 100644
--- a/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx
+++ b/apps/edr-freight-web/backoffice/src/user-management/AppMenuTabs.tsx
@@ -237,9 +237,11 @@ export const AppMenuTabs = () => {
// Sticky (not fixed) so it stays in flow: content below never needs a
// magic offset matching this bar's responsive height. top-16 keeps it
// pinned just below the fixed 64px header while scrolling.
+ // -mt-8 cancels the excess of 's in-flow h-24 wrapper over its 64px
+ // fixed header, so the bar sits flush under the header with no jump.
// shrink-0 is load-bearing: as a flex item with overflow-hidden this bar
// would otherwise be flex-squashed to zero height when the page overflows.
-
+
{/* Mobile View - Two separate rows */}
{/* Primary items row */}
diff --git a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx
index 7a7e56b76..3b33519b6 100644
--- a/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx
+++ b/apps/edr-freight-web/backoffice/src/user-management/Applayout.tsx
@@ -27,9 +27,10 @@ export const AppLayout = () => {
}
return (
- // pt-16 clears the fixed 64px ; AppMenuTabs is sticky and in flow,
- // so content starts right below it at any tabs height (mobile/desktop).
-
+ // renders its own in-flow h-24 wrapper around the fixed 64px header,
+ // so flow already clears the header — no extra top padding here.
+ // AppMenuTabs is sticky and in flow, so content starts right below it.
+
From 6d0cf50b4dc808b4b4729eafd3220bfb00c21f95 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Tue, 14 Jul 2026 11:06:49 +0000
Subject: [PATCH 02/29] train
---
.../src/common/booking-guards.ts | 8 +
.../migrations/2150000000000-TrainBuilder.ts | 105 +++
...0000-MultiWagonTypePerCargoAndContainer.ts | 119 +++
...70000000000-CreateWagonTransferRequests.ts | 51 ++
.../modules/bookings/bookings.repository.ts | 9 +-
.../rule-engine/dto/create-cargo-type.dto.ts | 11 +-
.../dto/create-container-type.dto.ts | 11 +-
.../rule-engine/entities/cargo-type.entity.ts | 34 +-
.../entities/container-type.entity.ts | 25 +-
.../repositories/cargo-types.repository.ts | 16 +-
.../container-types.repository.ts | 16 +-
.../services/cargo-types.service.ts | 12 +-
.../services/container-types.service.ts | 12 +-
.../train-schedules.repository.ts | 1 +
.../booking-batch.service.spec.ts | 4 +-
.../train-scheduling/booking-batch.service.ts | 18 +-
.../dto/available-trains-query.dto.ts | 8 +
.../create-container-train-schedule.dto.ts | 17 +-
.../train-scheduling.controller.ts | 13 +
.../train-scheduling.service.spec.ts | 29 +-
.../train-scheduling.service.ts | 710 ++++++++++++------
.../train-scheduling/wagon-plan-flex.util.ts | 299 ++++++++
.../train-scheduling/wagon-plan.util.ts | 2 +-
.../train-sets/entities/train-set.entity.ts | 9 +
.../trains/dto/assign-train-wagons.dto.ts | 14 +
.../src/modules/trains/dto/build-train.dto.ts | 51 ++
.../trains/dto/list-built-trains-query.dto.ts | 22 +
.../trains/dto/reorder-train-wagons.dto.ts | 14 +
.../dto/update-train-locomotives.dto.ts | 14 +
.../entities/train-locomotive.entity.ts | 34 +
.../modules/trains/entities/train.entity.ts | 26 +-
.../trains/train-builder.controller.ts | 91 +++
.../modules/trains/train-builder.service.ts | 507 +++++++++++++
.../src/modules/trains/trains.module.ts | 13 +-
.../wagons/dto/create-transfer-request.dto.ts | 28 +
.../dto/fulfill-transfer-request.dto.ts | 13 +
.../entities/wagon-transfer-request.entity.ts | 62 ++
.../wagon-transfer-requests.controller.ts | 76 ++
.../wagons/wagon-transfer-requests.service.ts | 153 ++++
.../src/modules/wagons/wagons.module.ts | 15 +-
.../src/seed/freight-permissions.registry.ts | 6 +
apps/edr-freight-web/backoffice/src/App.tsx | 41 +
.../ruleEngine/RuleEngineFormDialog.tsx | 46 +-
.../trainBuilder/AvailableWagonsPanel.tsx | 152 ++++
.../trainBuilder/BuildTrainModal.tsx | 182 +++++
.../trainBuilder/ChangeLocomotivesModal.tsx | 128 ++++
.../trainBuilder/ConsistWagonList.tsx | 171 +++++
.../trainBuilder/TrainConsistStrip.tsx | 159 ++++
.../components/trainBuilder/trainStatus.ts | 25 +
.../wagons/WagonTransferRequestsModal.tsx | 344 +++++++++
.../wagons/WagonYardWorkspaceModal.tsx | 64 +-
.../backoffice/src/constants/QUERY_KEYS.ts | 8 +
.../src/pages/fleet/FleetResourcePage.tsx | 40 +-
.../src/pages/ruleEngine/CargoTypesPage.tsx | 35 +-
.../ruleEngine/RuleEngineResourcePage.tsx | 11 +-
.../src/pages/ruleEngine/config/resources.ts | 17 +-
.../trainBuilder/TrainBuilderDetailPage.tsx | 355 +++++++++
.../trainBuilder/TrainBuilderListPage.tsx | 336 +++++++++
.../TrainScheduleV2ListPage.tsx | 84 ++-
.../backoffice/src/services/api.ts | 163 ++++
.../src/services/trainBuilder.service.ts | 172 +++++
.../backoffice/src/services/wagon.service.ts | 50 ++
.../backoffice/src/types/trainScheduling.ts | 12 +-
packages/types/src/freight/index.ts | 27 +
64 files changed, 4896 insertions(+), 404 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2150000000000-TrainBuilder.ts
create mode 100644 apps/edr-freight-api/src/migrations/2160000000000-MultiWagonTypePerCargoAndContainer.ts
create mode 100644 apps/edr-freight-api/src/migrations/2170000000000-CreateWagonTransferRequests.ts
create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/available-trains-query.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/dto/assign-train-wagons.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/dto/list-built-trains-query.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/dto/reorder-train-wagons.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/dto/update-train-locomotives.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/entities/train-locomotive.entity.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/train-builder.service.ts
create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/fulfill-transfer-request.dto.ts
create mode 100644 apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts
create mode 100644 apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts
create mode 100644 apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/AvailableWagonsPanel.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeLocomotivesModal.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
create mode 100644 apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts
index 7eae4e94d..d3344aa5a 100644
--- a/apps/edr-freight-api/src/common/booking-guards.ts
+++ b/apps/edr-freight-api/src/common/booking-guards.ts
@@ -26,6 +26,14 @@ 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);
+
/** 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/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/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/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/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..a9711c1e5 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
@@ -2913,21 +2913,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/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..f8cc7e3d1 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,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.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 +154,18 @@ 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("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..874516548 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,
@@ -43,6 +44,7 @@ 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';
@@ -90,9 +92,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 +100,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 +113,6 @@ import {
type TrainLimitConfig,
validateContainerPlacements,
validateMixedTrainLimits,
- validateTrainLimits,
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
@@ -291,7 +293,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 +984,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 +1064,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 +1137,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 +1165,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 +1314,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;
@@ -1313,7 +1372,6 @@ export class TrainSchedulingService {
const savedWagons = await this.persistTrainSetWagons(
manager,
trainSetId,
- wagonType,
wagonPlan,
);
@@ -1782,6 +1840,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 +2924,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 +2965,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 +3060,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 +3111,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 +3137,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 +3276,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 +3362,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 +3454,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 +3626,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 +3729,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 +3753,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 +3771,7 @@ export class TrainSchedulingService {
wagons,
scheduleId,
originYardId,
+ builtTrainId,
);
if (unpinnable.length) {
throw new BadRequestException({
@@ -3702,6 +3788,7 @@ export class TrainSchedulingService {
scheduleId,
originYardId,
assignedPhysicalIds,
+ builtTrainId,
);
if (!physical) continue;
@@ -3726,7 +3813,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 +3827,7 @@ export class TrainSchedulingService {
wagons,
targetScheduleId,
originYardId,
+ builtTrainId,
);
}
@@ -3750,6 +3841,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 +3853,7 @@ export class TrainSchedulingService {
scheduleId,
originYardId,
assignedPhysicalIds,
+ builtTrainId,
);
if (!physical) {
violations.push(
@@ -3785,6 +3878,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 +3888,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 +3971,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 +4060,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 +4295,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 +4461,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 +4542,159 @@ 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,
+ maxGrossTons: roundTons(
+ wagons.reduce(
+ (sum, w) =>
+ sum +
+ (Number(w.wagonType?.tareWeightTons) || 0) +
+ (Number(w.wagonType?.capacityTons) || 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,
+ };
+ });
+ }
+
+ /**
+ * 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 +4705,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(
@@ -4462,9 +4728,8 @@ export class TrainSchedulingService {
dy.label AS destination_label, dy.code AS destination_code
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
@@ -4515,10 +4780,9 @@ export class TrainSchedulingService {
dy.label AS destination_label, dy.code AS destination_code
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
@@ -4622,7 +4886,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 +5203,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 +5829,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 +5849,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 +5885,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 +5895,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 +5907,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 +5932,7 @@ export class TrainSchedulingService {
if (missing) {
return {
wagonsRequired,
- requiredWagonTypeCode: wagonType.code,
+ requiredWagonTypeCode,
yardWagonsAvailable,
canAssign: false,
blockReason: missing.issue,
@@ -5660,7 +5942,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/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..7281aca31
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
@@ -0,0 +1,91 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Param,
+ ParseUUIDPipe,
+ 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 { 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);
+ }
+
+ @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..c28873234
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
@@ -0,0 +1,507 @@
+import { Freight, 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 { 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 maxGrossTons = round(totalTareTons + totalCapacityTons);
+ 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,
+ totalCapacityTons,
+ maxGrossTons,
+ totalLengthMeters,
+ maxPullWeightTons,
+ maxTrainLengthMeters,
+ // Fully loaded gross vs. what the weakest locomotive can haul.
+ weightUtilizationPct: maxPullWeightTons
+ ? round((maxGrossTons / 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);
+ }
+
+ /** 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 maxGrossTons = round(
+ wagons.reduce(
+ (sum, w) =>
+ sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 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,
+ maxGrossTons,
+ 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)];
+ let sequence = startCount;
+ for (const wagonId of uniqueIds) {
+ const wagon = await manager.getRepository(Wagon).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`,
+ );
+ }
+ sequence += 1;
+ await manager.getRepository(Wagon).update(wagon.id, {
+ trainId: train.id,
+ sequenceNumber: sequence,
+ status: WagonStatus.Assigned,
+ });
+ }
+ }
+
+ /** 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-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..07557f431
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts
@@ -0,0 +1,76 @@
+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,
+ 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);
+ }
+
+ @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..64b408b6a
--- /dev/null
+++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts
@@ -0,0 +1,153 @@
+import { WagonTransferRequestStatus } from '@edr/types';
+import {
+ BadRequestException,
+ ConflictException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { In, 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 { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
+import { WagonsService } from './wagons.service';
+
+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,
+ 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).
+ await this.wagonsService.bulkTransfer(
+ { wagonIds, toYardId: request.toYardId },
+ userId,
+ );
+
+ request.status = WagonTransferRequestStatus.Fulfilled;
+ request.fulfilledByUserId = userId ?? null;
+ request.fulfilledAt = new Date();
+ await this.requestRepo.save(request);
+ return this.findById(id);
+ }
+
+ /** 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..4bb1afd33 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,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Wagon } from './entities/wagon.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, WagonTransferRequest, Train, Yard])],
+ controllers: [
+ WagonsController,
+ TrainWagonsReorderController,
+ WagonTransferRequestsController,
+ ],
+ providers: [WagonsService, WagonTransferRequestsService],
+ exports: [WagonsService, WagonTransferRequestsService],
})
export class WagonsModule {}
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..7c3fe0700 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,8 @@ 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('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 +442,10 @@ 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',
},
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 7ba83e50e..3d6278b12 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,
@@ -116,6 +117,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";
@@ -266,6 +269,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",
@@ -1027,6 +1036,22 @@ const App = () => {
}
/>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
{
}
/>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
=> {
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)}
+ />
+
+
+
+
+ {wagonsQuery.isLoading ? (
+
+ Loading wagons…
+
+ ) : !wagons.length ? (
+
+ No available wagons in {yardLabel ?? "this yard"}
+
+ ) : (
+ wagons.map((wagon) => (
+
+ toggle(wagon.id, e.currentTarget.checked)}
+ aria-label={`Select wagon ${wagon.wagonNumber}`}
+ />
+
+
+ {wagon.wagonNumber}
+
+
+ {wagon.wagonType
+ ? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons ?? "—"}T cap`
+ : "Unknown type"}
+
+
+
+ ))
+ )}
+
+
+
+ }
+ disabled={!selected.length}
+ loading={assigning}
+ onClick={handleAssign}
+ >
+ Add {selected.length ? `${selected.length} wagon${selected.length > 1 ? "s" : ""}` : "wagons"} to consist
+
+
+ );
+}
+
+export interface AvailableWagonsPanelProps {
+ yardId: string;
+ yardLabel?: string | null;
+ onAssign: (wagonIds: string[]) => void;
+ assigning: boolean;
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx
new file mode 100644
index 000000000..74f897728
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx
@@ -0,0 +1,182 @@
+import {
+ Button,
+ Group,
+ Modal,
+ MultiSelect,
+ Select,
+ Stack,
+ Text,
+ TextInput,
+ Textarea,
+} from "@mantine/core";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { isAxiosError } from "axios";
+import { useEffect, useState } from "react";
+
+import { api } from "@/services/api";
+import type { TrainComposition } from "@/services/trainBuilder.service";
+import { useToast } from "@/hooks/use-toast";
+
+const parseError = (error: unknown, fallback: string) => {
+ if (isAxiosError(error)) {
+ const message = error.response?.data?.message;
+ if (Array.isArray(message)) return message.join(", ");
+ if (typeof message === "string") return message;
+ }
+ return fallback;
+};
+
+/**
+ * Step one of the Train Builder: give the train its operator code, pick the
+ * yard it is being assembled in, and couple at least two locomotives from that
+ * yard. Wagons are attached afterwards on the composition page.
+ */
+export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
+ const { toast } = useToast();
+ const [code, setCode] = useState("");
+ const [trainName, setTrainName] = useState("");
+ const [yardId, setYardId] = useState("");
+ const [locomotiveIds, setLocomotiveIds] = useState([]);
+ const [notes, setNotes] = useState("");
+
+ const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
+ // Only serviceable locomotives standing in the selected yard can be coupled.
+ const locomotivesQuery = useQuery(
+ api.locomotives.listFiltered.queryOptions({
+ input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
+ enabled: Boolean(yardId),
+ }),
+ );
+ const build = useMutation(api.trainBuilder.build.mutationOptions());
+
+ // A locomotive belongs to one yard — switching yards invalidates the pick.
+ useEffect(() => {
+ setLocomotiveIds([]);
+ }, [yardId]);
+
+ useEffect(() => {
+ if (!opened) {
+ setCode("");
+ setTrainName("");
+ setYardId("");
+ setLocomotiveIds([]);
+ setNotes("");
+ }
+ }, [opened]);
+
+ const handleBuild = async () => {
+ if (!code.trim() || !yardId || locomotiveIds.length < 2) {
+ toast({
+ title: "Enter a train code, pick a yard, and couple at least two locomotives",
+ variant: "destructive",
+ });
+ return;
+ }
+ try {
+ const composition = await build.mutateAsync({
+ code: code.trim(),
+ currentYardId: yardId,
+ locomotiveIds,
+ ...(trainName.trim() ? { trainName: trainName.trim() } : {}),
+ ...(notes.trim() ? { notes: notes.trim() } : {}),
+ });
+ toast({ title: `Train ${composition.code} built` });
+ onClose();
+ onBuilt(composition);
+ } catch (err) {
+ toast({
+ title: "Build failed",
+ description: parseError(err, "Could not build the train"),
+ variant: "destructive",
+ });
+ }
+ };
+
+ const locomotiveOptions = (locomotivesQuery.data ?? []).map((loco) => ({
+ value: loco.id,
+ label: `${loco.code}${loco.name ? ` — ${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T`,
+ }));
+
+ return (
+ Build a train}
+ radius="lg"
+ centered
+ >
+
+
+ A train is assembled in one yard: two or more locomotives plus wagons
+ standing in that same yard. Wagons are attached on the next screen.
+
+
+ setCode(e.currentTarget.value)}
+ maxLength={32}
+ />
+ setTrainName(e.currentTarget.value)}
+ maxLength={100}
+ />
+
+
+
+ );
+}
+
+export interface BuildTrainModalProps {
+ opened: boolean;
+ onClose: () => void;
+ onBuilt: (composition: TrainComposition) => void;
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeLocomotivesModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeLocomotivesModal.tsx
new file mode 100644
index 000000000..24ec8ed8b
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeLocomotivesModal.tsx
@@ -0,0 +1,128 @@
+import { Button, Group, Modal, MultiSelect, Stack, Text } from "@mantine/core";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { isAxiosError } from "axios";
+import { useEffect, useMemo, useState } from "react";
+
+import { api } from "@/services/api";
+import type { TrainComposition } from "@/services/trainBuilder.service";
+import { useToast } from "@/hooks/use-toast";
+
+const parseError = (error: unknown, fallback: string) => {
+ if (isAxiosError(error)) {
+ const message = error.response?.data?.message;
+ if (Array.isArray(message)) return message.join(", ");
+ if (typeof message === "string") return message;
+ }
+ return fallback;
+};
+
+/** Swap the locomotive set of a built train (minimum 2, same-yard rule). */
+export default function ChangeLocomotivesModal({
+ composition,
+ opened,
+ onClose,
+}: ChangeLocomotivesModalProps) {
+ const { toast } = useToast();
+ const [locomotiveIds, setLocomotiveIds] = useState([]);
+
+ const yardId = composition?.currentYard?.id ?? "";
+ const availableQuery = useQuery(
+ api.locomotives.listFiltered.queryOptions({
+ input: { filters: { status: "AVAILABLE", currentYardId: yardId } },
+ enabled: opened && Boolean(yardId),
+ }),
+ );
+ const setLocomotives = useMutation(api.trainBuilder.setLocomotives.mutationOptions());
+
+ useEffect(() => {
+ if (opened && composition) {
+ setLocomotiveIds(composition.locomotives.map((l) => l.id));
+ }
+ }, [opened, composition]);
+
+ // Pickable = available locomotives in the yard + the ones already coupled
+ // to this train (valid to keep even though they are not "loose" anymore).
+ const options = useMemo(() => {
+ const seen = new Set();
+ const rows: Array<{ value: string; label: string }> = [];
+ for (const loco of composition?.locomotives ?? []) {
+ seen.add(loco.id);
+ rows.push({
+ value: loco.id,
+ label: `${loco.code}${loco.name ? ` — ${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T (coupled)`,
+ });
+ }
+ for (const loco of availableQuery.data ?? []) {
+ if (seen.has(loco.id)) continue;
+ rows.push({
+ value: loco.id,
+ label: `${loco.code}${loco.name ? ` — ${loco.name}` : ""} · pulls ${loco.maxPullWeightTons}T`,
+ });
+ }
+ return rows;
+ }, [composition, availableQuery.data]);
+
+ const handleSave = async () => {
+ if (!composition) return;
+ if (locomotiveIds.length < 2) {
+ toast({ title: "A train needs at least two locomotives", variant: "destructive" });
+ return;
+ }
+ try {
+ await setLocomotives.mutateAsync({ id: composition.id, locomotiveIds });
+ toast({ title: "Locomotives updated" });
+ onClose();
+ } catch (err) {
+ toast({
+ title: "Update failed",
+ description: parseError(err, "Could not update locomotives"),
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+ Change locomotives}
+ radius="lg"
+ centered
+ >
+
+
+ Only available locomotives standing in{" "}
+ {composition?.currentYard?.label ?? "the train's yard"} can be coupled.
+ The first pick is the lead locomotive.
+
+ 0 && locomotiveIds.length < 2
+ ? "Select at least two locomotives"
+ : undefined
+ }
+ nothingFoundMessage="No available locomotives in this yard"
+ />
+
+
+
+
+
+
+ );
+}
+
+export interface ChangeLocomotivesModalProps {
+ composition: TrainComposition | null;
+ opened: boolean;
+ onClose: () => void;
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx
new file mode 100644
index 000000000..b08e0643d
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx
@@ -0,0 +1,171 @@
+import {
+ DragDropContext,
+ Draggable,
+ Droppable,
+ type DraggableProvided,
+ type DraggableStateSnapshot,
+ type DropResult,
+} from "@hello-pangea/dnd";
+import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
+import { GripVertical, Trash2 } from "lucide-react";
+import { type ReactNode } from "react";
+import { createPortal } from "react-dom";
+
+import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
+
+/** Reparent dragged row to body — fixes position:fixed inside transformed parents. */
+const PortalAwareRow = ({
+ snapshot,
+ children,
+}: {
+ snapshot: DraggableStateSnapshot;
+ children: ReactNode;
+}) => {
+ if (snapshot.isDragging) {
+ return createPortal(children, document.body);
+ }
+ return <>{children}>;
+};
+
+/**
+ * The train's ordered wagon consist. Drag to reorder (persisted on drop),
+ * trash to detach a wagon back to the yard.
+ */
+export default function ConsistWagonList({
+ wagons,
+ editable,
+ onReorder,
+ onRemove,
+ busy = false,
+}: ConsistWagonListProps) {
+ const onDragEnd = (result: DropResult) => {
+ if (!result.destination) return;
+ const from = result.source.index;
+ const to = result.destination.index;
+ if (from === to) return;
+ const next = [...wagons];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved!);
+ onReorder(next.map((w) => w.id));
+ };
+
+ if (!wagons.length) {
+ return (
+
+ No wagons in the consist yet.
+
+ );
+ }
+
+ return (
+
+
+ {(dropProvided) => (
+
+ {wagons.map((wagon, index) => (
+
+ {(dragProvided, snapshot) => (
+
+ )}
+
+ ))}
+ {dropProvided.placeholder}
+
+ )}
+
+
+ );
+}
+
+export interface ConsistWagonListProps {
+ wagons: TrainCompositionWagon[];
+ editable: boolean;
+ onReorder: (wagonIds: string[]) => void;
+ onRemove: (wagonId: string) => void;
+ busy?: boolean;
+}
+
+function WagonRow({
+ wagon,
+ index,
+ dragProvided,
+ snapshot,
+ editable,
+ busy,
+ onRemove,
+}: {
+ wagon: TrainCompositionWagon;
+ index: number;
+ dragProvided: DraggableProvided;
+ snapshot: DraggableStateSnapshot;
+ editable: boolean;
+ busy: boolean;
+ onRemove: (wagonId: string) => void;
+}) {
+ return (
+
+
+ {editable ? (
+
+
+
+ ) : null}
+
+ {index + 1}
+
+
+
+ {wagon.wagonNumber}
+
+
+ {wagon.wagonType
+ ? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
+ : "Unknown type"}
+
+
+ {editable ? (
+
+ onRemove(wagon.id)}
+ aria-label={`Detach wagon ${wagon.wagonNumber}`}
+ >
+
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx
new file mode 100644
index 000000000..c86dbc8ef
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx
@@ -0,0 +1,159 @@
+import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
+import { Train as TrainIcon } from "lucide-react";
+
+import type {
+ TrainCompositionLocomotive,
+ TrainCompositionWagon,
+} from "@/services/trainBuilder.service";
+
+/**
+ * Visual consist: locomotives + wagons drawn in order on a rail, the way the
+ * train would leave the yard. Scrolls horizontally for long consists.
+ */
+export default function TrainConsistStrip({
+ locomotives,
+ wagons,
+ emptyHint = "No wagons attached yet — add wagons from the yard below.",
+}: TrainConsistStripProps) {
+ return (
+
+
+
+ {locomotives.map((loco, index) => (
+
+ {index > 0 ? : null}
+
+
+ ))}
+ {wagons.map((wagon) => (
+
+
+
+
+ ))}
+
+ {/* The rail */}
+
+ {!wagons.length ? (
+
+ {emptyHint}
+
+ ) : null}
+
+
+ );
+}
+
+export interface TrainConsistStripProps {
+ locomotives: TrainCompositionLocomotive[];
+ wagons: TrainCompositionWagon[];
+ emptyHint?: string;
+}
+
+function Coupler() {
+ return (
+
+ );
+}
+
+function LocomotiveCar({ locomotive }: { locomotive: TrainCompositionLocomotive }) {
+ return (
+
+
+
+
+
+ {locomotive.code}
+
+
+
+ {locomotive.role === "LEAD" ? "Lead loco" : "Assist loco"}
+
+
+
+ );
+}
+
+function WagonCar({ wagon }: { wagon: TrainCompositionWagon }) {
+ return (
+
+
+
+ #{wagon.sequenceNumber ?? "—"}
+
+
+ {wagon.wagonNumber}
+
+
+ {wagon.wagonType?.code ?? "—"}
+
+
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts b/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts
new file mode 100644
index 000000000..696e6cc6e
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/trainStatus.ts
@@ -0,0 +1,25 @@
+import type { BuiltTrainStatus } from "@/services/trainBuilder.service";
+
+/** Badge color per built-train lifecycle status (Mantine palette keys). */
+export const trainStatusColor = (status: BuiltTrainStatus | string): string => {
+ switch (status) {
+ case "AVAILABLE":
+ return "edr-green";
+ case "SCHEDULED":
+ return "blue";
+ case "IN_SERVICE":
+ return "teal";
+ case "UNDER_MAINTENANCE":
+ return "yellow";
+ case "OUT_OF_SERVICE":
+ return "red";
+ default:
+ return "gray";
+ }
+};
+
+export const trainStatusLabel = (status: BuiltTrainStatus | string): string =>
+ String(status)
+ .toLowerCase()
+ .replace(/_/g, " ")
+ .replace(/^\w/, (c) => c.toUpperCase());
diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
new file mode 100644
index 000000000..01e65deb2
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
@@ -0,0 +1,344 @@
+import { Freight } from "@edr/types";
+import {
+ Badge,
+ Button,
+ Card,
+ Checkbox,
+ Divider,
+ Group,
+ Loader,
+ Modal,
+ ScrollArea,
+ Stack,
+ Text,
+ ThemeIcon,
+} from "@mantine/core";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ ArrowRight,
+ ChevronLeft,
+ Inbox,
+ PackageCheck,
+ Warehouse,
+ X,
+} from "lucide-react";
+import { useMemo, useState } from "react";
+
+import { api } from "@/services/api";
+import { useToast } from "@/hooks/use-toast";
+import type { WagonTransferRequest } from "@/services/wagon.service";
+
+export interface WagonTransferRequestsModalProps {
+ opened: boolean;
+ onClose: () => void;
+}
+
+const PENDING = Freight.WagonTransferRequestStatus.Pending;
+const AVAILABLE = Freight.WagonStatus.Available;
+
+const yardLabel = (y?: { label?: string; code?: string } | null) =>
+ y?.label || y?.code || "—";
+const typeLabel = (t?: { code?: string; name?: string } | null) =>
+ t ? `${t.code ?? ""}${t.name ? ` · ${t.name}` : ""}` : "—";
+
+/** Requester → destination + type + count summary line, reused in list and picker. */
+const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
+
+
+ {yardLabel(r.fromYard)}
+
+
+
+ {yardLabel(r.toYard)}
+
+
+ {r.quantity}× {typeLabel(r.wagonType)}
+
+
+);
+
+/**
+ * OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
+ * one to hand-pick exactly the requested number of wagons from the source yard
+ * (of the requested type) and execute the move, or cancel the request.
+ */
+const WagonTransferRequestsModal = ({
+ opened,
+ onClose,
+}: WagonTransferRequestsModalProps) => {
+ const { toast } = useToast();
+ const [active, setActive] = useState(null);
+ const [picked, setPicked] = useState>(new Set());
+
+ const { data: requests = [], isLoading } = useQuery({
+ ...api.wagonTransferRequests.list.queryOptions({ input: { status: PENDING } }),
+ enabled: opened,
+ });
+
+ // Available wagons of the requested type sitting in the request's source yard.
+ const { data: wagons = [], isLoading: wagonsLoading } = useQuery({
+ ...api.wagons.list.queryOptions({
+ input: {
+ filters: active
+ ? {
+ currentYardId: active.fromYardId,
+ wagonTypeId: active.wagonTypeId,
+ status: AVAILABLE,
+ }
+ : {},
+ },
+ }),
+ enabled: opened && Boolean(active),
+ });
+
+ const fulfill = useMutation(api.wagonTransferRequests.fulfill.mutationOptions());
+ const cancel = useMutation(api.wagonTransferRequests.cancel.mutationOptions());
+
+ const showError = (err: unknown, fallback: string) => {
+ const message =
+ (err as { response?: { data?: { message?: string } } })?.response?.data
+ ?.message ?? fallback;
+ toast({ title: fallback, description: String(message), variant: "destructive" });
+ };
+
+ const openPicker = (r: WagonTransferRequest) => {
+ setActive(r);
+ setPicked(new Set());
+ };
+ const closePicker = () => {
+ setActive(null);
+ setPicked(new Set());
+ };
+
+ const toggle = (id: string) =>
+ setPicked((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else if (active && next.size >= active.quantity) return prev; // cap at quantity
+ else next.add(id);
+ return next;
+ });
+
+ const need = active?.quantity ?? 0;
+ const shortfall = active ? Math.max(0, need - wagons.length) : 0;
+
+ const handleFulfill = async () => {
+ if (!active || picked.size !== need) return;
+ try {
+ await fulfill.mutateAsync({ id: active.id, wagonIds: [...picked] });
+ toast({
+ title: `Transferred ${need} wagon(s) · ${yardLabel(active.fromYard)} → ${yardLabel(
+ active.toYard,
+ )}`,
+ });
+ closePicker();
+ } catch (err) {
+ showError(err, "Transfer failed");
+ }
+ };
+
+ const handleCancel = async (r: WagonTransferRequest) => {
+ try {
+ await cancel.mutateAsync({ id: r.id });
+ toast({ title: "Request cancelled" });
+ } catch (err) {
+ showError(err, "Cancel failed");
+ }
+ };
+
+ const sortedWagons = useMemo(
+ () => [...wagons].sort((a, b) => a.wagonNumber.localeCompare(b.wagonNumber)),
+ [wagons],
+ );
+
+ return (
+
+
+
+
+
@@ -289,332 +68,72 @@ export default function TariffRatesPage() {
Manage per-km fare rates and excess luggage allowances per the official EDR tariff policy
- 📱 Show the QR code at the gate for easy check-in. Arrive at least 30 minutes before departure.
+ 📱 Show the QR code at the gate for easy check-in. Arrive at least 2 hours before departure.
@@ -225,7 +225,7 @@ export default function HowToGuidePage() {
What should I bring on the day of travel?
- Bring your ticket (digital or printed), valid ID/passport, and arrive 30 minutes before departure.
+ Bring your ticket (digital or printed), valid ID/passport, and arrive 2 hours before departure.
diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
index 47ee3fdcc..dab5bba26 100644
--- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
+++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts
@@ -374,7 +374,7 @@ function drawInstructions(doc: jsPDF, y: number, margin: number, pageWidth: numb
doc.text('BEFORE YOU TRAVEL', margin + padX, y + 6, { charSpace: 0.3 });
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
doc.text('Present this voucher (printed or on your phone) at the terminal for boarding.', margin + padX, y + 11);
- doc.text('Please arrive at least 30 minutes before scheduled departure.', margin + padX, y + 15);
+ doc.text('Please arrive at least 2 hours before scheduled departure.', margin + padX, y + 15);
return y + cardH + 4;
}
From b5a97d344a6fbb98f5d84603765a48b17d590cc3 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Tue, 14 Jul 2026 13:10:00 +0000
Subject: [PATCH 12/29] train
---
apps/edr-freight-api/src/app.module.ts | 139 +++++++------
.../src/common/booking-guards.ts | 4 +
...0000-LinkWagonMovementToTransferRequest.ts | 54 ++++++
.../train-scheduling/booking-batch.service.ts | 24 ++-
.../train-scheduling.service.ts | 56 ++++--
.../trains/dto/update-train-yard.dto.ts | 12 ++
.../trains/train-builder.controller.ts | 11 ++
.../modules/trains/train-builder.service.ts | 73 +++++--
.../wagons/entities/wagon-movement.entity.ts | 9 +
.../wagon-transfer-requests.controller.ts | 25 +++
.../wagons/wagon-transfer-requests.service.ts | 50 ++++-
.../src/modules/wagons/wagons.module.ts | 11 +-
.../src/modules/wagons/wagons.service.ts | 2 +
.../src/seed/freight-permissions.registry.ts | 4 +
.../contracts/GlCreateBookingForm.tsx | 33 ++++
.../trainBuilder/ChangeYardModal.tsx | 103 ++++++++++
.../trainBuilder/TrainConsistStrip.tsx | 159 ---------------
.../TrainCompositionDiagram.tsx | 54 ++++--
.../wagons/WagonTransferRequestsModal.tsx | 183 +++++++++++++++++-
.../backoffice/src/lib/permissions.ts | 3 +
.../contracts/ContractClearanceListPage.tsx | 91 ++++++++-
.../trainBuilder/TrainBuilderDetailPage.tsx | 73 ++++---
.../trainBuilder/TrainBuilderListPage.tsx | 2 +-
.../BatchScheduleDetailPage.tsx | 6 +
.../TrainScheduleV2DetailPage.tsx | 13 +-
.../backoffice/src/services/api.ts | 25 +++
.../src/services/trainBuilder.service.ts | 14 +-
.../backoffice/src/services/wagon.service.ts | 17 ++
.../backoffice/src/types/trainScheduling.ts | 14 ++
.../components/UpcomingWindowsSection.tsx | 26 ++-
.../BookingDetailPage/ReadonlyBookingView.tsx | 17 +-
.../ContractBookingWindowsSection.tsx | 31 ++-
.../contracts/NewShipmentRequestPage.tsx | 100 ++++++++--
.../src/pages/contracts/booking-window.ts | 10 +
.../portal/src/services/bookings.service.ts | 6 +
packages/types/src/freight/index.ts | 2 +
36 files changed, 1101 insertions(+), 355 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts
create mode 100644 apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx
delete mode 100644 apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx
diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts
index afb214137..6c31c4f08 100644
--- a/apps/edr-freight-api/src/app.module.ts
+++ b/apps/edr-freight-api/src/app.module.ts
@@ -53,22 +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 { 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
@@ -192,21 +193,22 @@ import { LoggerMiddleware } from "./logger.middleware";
providers: [
EdrOrgSeeder,
FreightPositionsSeeder,
- DemoUsersSeeder,
- FreightStaffUsersSeeder,
- PricingDataSeeder,
FileUploadSettingsSeeder,
FreightPermissionKeyMigrationSeeder,
- DemoFreightDataSeeder,
- GovCompaniesSeeder,
- 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,
],
@@ -216,51 +218,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,
+ // 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();
+
+ // 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 d3344aa5a..9769a7f18 100644
--- a/apps/edr-freight-api/src/common/booking-guards.ts
+++ b/apps/edr-freight-api/src/common/booking-guards.ts
@@ -34,6 +34,10 @@ export const WagonTransferRequest = () =>
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/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/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts
index a9711c1e5..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,
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 874516548..7e0720238 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
@@ -282,6 +282,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()
@@ -1329,9 +1331,15 @@ 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 the empty
+ // weight of every planned wagon — cargo-only comparison understates the load.
+ const planTareTons = 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) {
@@ -4605,14 +4613,8 @@ export class TrainSchedulingService {
name: link.locomotive!.name ?? null,
})),
wagonCount: wagons.length,
- maxGrossTons: roundTons(
- wagons.reduce(
- (sum, w) =>
- sum +
- (Number(w.wagonType?.tareWeightTons) || 0) +
- (Number(w.wagonType?.capacityTons) || 0),
- 0,
- ),
+ 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),
@@ -4725,7 +4727,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.contract_routes cr
ON cr.deleted_at IS NULL
@@ -4777,7 +4783,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
JOIN freight.contract_routes cr
ON cr.contract_id = $1
@@ -4823,7 +4833,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
@@ -4845,6 +4859,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,
@@ -4860,8 +4885,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,
};
}
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/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
index 7281aca31..7ed3e4c42 100644
--- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
+++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts
@@ -7,6 +7,7 @@ import {
HttpStatus,
Param,
ParseUUIDPipe,
+ Patch,
Post,
Put,
Query,
@@ -19,6 +20,7 @@ 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')
@@ -57,6 +59,15 @@ export class TrainBuilderController {
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" })
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
index c28873234..b92ab21d6 100644
--- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
+++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
@@ -1,4 +1,4 @@
-import { Freight, WagonStatus } from '@edr/types';
+import { Freight, WagonMovementKind, WagonStatus } from '@edr/types';
import {
BadRequestException,
ConflictException,
@@ -10,6 +10,7 @@ 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 { 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';
@@ -202,7 +203,6 @@ export class TrainBuilderService {
const totalLengthMeters = round(
wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0),
);
- const maxGrossTons = round(totalTareTons + totalCapacityTons);
const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0);
const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0);
@@ -221,14 +221,17 @@ export class TrainBuilderService {
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,
- maxGrossTons,
totalLengthMeters,
maxPullWeightTons,
maxTrainLengthMeters,
- // Fully loaded gross vs. what the weakest locomotive can haul.
- weightUtilizationPct: maxPullWeightTons
- ? round((maxGrossTons / maxPullWeightTons) * 100)
+ // 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)
@@ -269,6 +272,54 @@ export class TrainBuilderService {
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) => {
@@ -361,12 +412,8 @@ export class TrainBuilderService {
.map((link) => link.locomotive)
.filter((loco): loco is Locomotive => Boolean(loco));
const wagons = train.wagons ?? [];
- const maxGrossTons = round(
- wagons.reduce(
- (sum, w) =>
- sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0),
- 0,
- ),
+ const totalTareTons = round(
+ wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0),
);
return {
id: train.id,
@@ -379,7 +426,7 @@ export class TrainBuilderService {
: null,
locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })),
wagonCount: wagons.length,
- maxGrossTons,
+ totalTareTons,
totalLengthMeters: round(
wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0),
),
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/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts
index 07557f431..12fdaf27c 100644
--- 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
@@ -16,6 +16,7 @@ import {
FleetManage,
FleetView,
WagonTransferFulfill,
+ WagonTransferHistoryAll,
WagonTransferRequest,
} from '../../common/booking-guards';
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
@@ -50,6 +51,30 @@ export class WagonTransferRequestsController {
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) {
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
index 64b408b6a..bf69d767d 100644
--- 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
@@ -6,14 +6,24 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { In, Repository } from '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,
@@ -33,6 +43,8 @@ export class WagonTransferRequestsService {
private readonly requestRepo: Repository,
@InjectRepository(Wagon)
private readonly wagonRepo: Repository,
+ @InjectRepository(WagonMovement)
+ private readonly movementRepo: Repository,
private readonly wagonsService: WagonsService,
) {}
@@ -125,10 +137,12 @@ export class WagonTransferRequestsService {
);
}
- // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows).
+ // 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;
@@ -138,6 +152,38 @@ export class WagonTransferRequestsService {
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);
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 4bb1afd33..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,6 +1,7 @@
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';
@@ -10,7 +11,15 @@ import { WagonsService } from './wagons.service';
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
@Module({
- imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])],
+ imports: [
+ TypeOrmModule.forFeature([
+ Wagon,
+ WagonMovement,
+ WagonTransferRequest,
+ Train,
+ Yard,
+ ]),
+ ],
controllers: [
WagonsController,
TrainWagonsReorderController,
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/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
index 7c3fe0700..6e4ec81b1 100644
--- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
+++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts
@@ -177,6 +177,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
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'),
@@ -446,6 +447,9 @@ export const FREIGHT_PERMS = {
// 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/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
index 223b357c1..0cd00d63b 100644
--- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx
@@ -49,6 +49,7 @@ import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { contractsService } from "@/services/contracts.service";
+import { bookingsService } from "@/services/bookings.service";
import {
useContractCapacity,
useContractDetail,
@@ -180,6 +181,9 @@ export default function GlCreateBookingForm() {
}>();
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/trainBuilder/ChangeYardModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx
new file mode 100644
index 000000000..0e4bddbc3
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx
@@ -0,0 +1,103 @@
+import { Alert, Button, Group, Modal, Select, Stack, Text } from "@mantine/core";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { isAxiosError } from "axios";
+import { MapPin } from "lucide-react";
+import { useEffect, useState } from "react";
+
+import { api } from "@/services/api";
+import type { TrainComposition } from "@/services/trainBuilder.service";
+import { useToast } from "@/hooks/use-toast";
+
+const parseError = (error: unknown, fallback: string) => {
+ if (isAxiosError(error)) {
+ const message = error.response?.data?.message;
+ if (Array.isArray(message)) return message.join(", ");
+ if (typeof message === "string") return message;
+ }
+ return fallback;
+};
+
+/**
+ * Relocate the train to another yard. The consist moves as one unit — every
+ * coupled locomotive and wagon follows, so their current yards always match
+ * the train's.
+ */
+export default function ChangeYardModal({ composition, opened, onClose }: ChangeYardModalProps) {
+ const { toast } = useToast();
+ const [yardId, setYardId] = useState("");
+
+ const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
+ const setYard = useMutation(api.trainBuilder.setYard.mutationOptions());
+
+ useEffect(() => {
+ if (opened) setYardId(composition?.currentYard?.id ?? "");
+ }, [opened, composition]);
+
+ const handleSave = async () => {
+ if (!composition || !yardId) return;
+ try {
+ await setYard.mutateAsync({ id: composition.id, currentYardId: yardId });
+ toast({ title: "Train relocated" });
+ onClose();
+ } catch (err) {
+ toast({
+ title: "Relocation failed",
+ description: parseError(err, "Could not change the yard"),
+ variant: "destructive",
+ });
+ }
+ };
+
+ const memberCount =
+ (composition?.locomotives.length ?? 0) + (composition?.totals.wagonCount ?? 0);
+
+ return (
+ Change yard — train {composition?.code}}
+ radius="lg"
+ centered
+ >
+
+ }>
+ The whole consist moves with the train: {composition?.locomotives.length ?? 0}{" "}
+ locomotive{(composition?.locomotives.length ?? 0) === 1 ? "" : "s"} and{" "}
+ {composition?.totals.wagonCount ?? 0} wagon
+ {(composition?.totals.wagonCount ?? 0) === 1 ? "" : "s"} ({memberCount} vehicles)
+ are relocated so their current yard always matches the train's. Wagon moves are
+ recorded in the movement ledger.
+
+ ({
+ value: y.id,
+ label: y.label ?? y.code,
+ }))}
+ value={yardId || null}
+ onChange={(v) => setYardId(v ?? "")}
+ searchable
+ />
+
+
+
+
+
+
+ );
+}
+
+export interface ChangeYardModalProps {
+ composition: TrainComposition | null;
+ opened: boolean;
+ onClose: () => void;
+}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx
deleted file mode 100644
index c86dbc8ef..000000000
--- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/TrainConsistStrip.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
-import { Train as TrainIcon } from "lucide-react";
-
-import type {
- TrainCompositionLocomotive,
- TrainCompositionWagon,
-} from "@/services/trainBuilder.service";
-
-/**
- * Visual consist: locomotives + wagons drawn in order on a rail, the way the
- * train would leave the yard. Scrolls horizontally for long consists.
- */
-export default function TrainConsistStrip({
- locomotives,
- wagons,
- emptyHint = "No wagons attached yet — add wagons from the yard below.",
-}: TrainConsistStripProps) {
- return (
-
-
-
- {locomotives.map((loco, index) => (
-
- {index > 0 ? : null}
-
-
- ))}
- {wagons.map((wagon) => (
-
-
-
-
- ))}
-
- {/* The rail */}
-
- {!wagons.length ? (
-
- {emptyHint}
-
- ) : null}
-
-
- );
-}
-
-export interface TrainConsistStripProps {
- locomotives: TrainCompositionLocomotive[];
- wagons: TrainCompositionWagon[];
- emptyHint?: string;
-}
-
-function Coupler() {
- return (
-
- );
-}
-
-function LocomotiveCar({ locomotive }: { locomotive: TrainCompositionLocomotive }) {
- return (
-
-
-
-
-
- {locomotive.code}
-
-
-
- {locomotive.role === "LEAD" ? "Lead loco" : "Assist loco"}
-
-
-
- );
-}
-
-function WagonCar({ wagon }: { wagon: TrainCompositionWagon }) {
- return (
-
-
-
- #{wagon.sequenceNumber ?? "—"}
-
-
- {wagon.wagonNumber}
-
-
- {wagon.wagonType?.code ?? "—"}
-
-
-
- );
-}
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
index 6ba1a0e29..2805ba60a 100644
--- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/TrainCompositionDiagram.tsx
@@ -506,12 +506,19 @@ function TrackBed() {
export function TrainCompositionDiagram({
locomotive,
+ locomotives,
wagons,
freightType,
trainNumber,
totalLengthMeters,
}: {
locomotive?: { code?: string | null; name?: string | null; maxPullWeightTons?: number | null } | null;
+ /** Full locomotive set (built trains, ≥2). Takes precedence over `locomotive`. */
+ locomotives?: Array<{
+ code?: string | null;
+ name?: string | null;
+ maxPullWeightTons?: number | null;
+ }> | null;
wagons: DiagramWagonInput[];
freightType?: string | null;
trainNumber?: string | null;
@@ -519,6 +526,11 @@ export function TrainCompositionDiagram({
}) {
const { ref, width } = useElementSize();
+ const locos = useMemo(
+ () => (locomotives?.length ? locomotives : locomotive ? [locomotive] : []),
+ [locomotives, locomotive],
+ );
+
const normalized = useMemo(
() => wagons.map((w) => normalizeWagon(w, freightType)),
[wagons, freightType],
@@ -533,6 +545,11 @@ export function TrainCompositionDiagram({
// ceiling the allocation engine spends from.
const totalTare = normalized.reduce((s, w) => s + w.tareWeightTons, 0);
const grossWeight = totalWeight + totalTare;
+ // Weakest locomotive caps the set — same rule the allocation engine applies.
+ const pullLimits = locos
+ .map((l) => Number(l.maxPullWeightTons))
+ .filter((v) => Number.isFinite(v) && v > 0);
+ const pullLimit = pullLimits.length ? Math.min(...pullLimits) : null;
return {
total: normalized.length,
assigned,
@@ -541,22 +558,25 @@ export function TrainCompositionDiagram({
totalTare: Math.round(totalTare * 100) / 100,
grossWeight: Math.round(grossWeight * 100) / 100,
totalCapacity,
- pullUtil:
- locomotive?.maxPullWeightTons && locomotive.maxPullWeightTons > 0
- ? Math.min(100, Math.round((grossWeight / locomotive.maxPullWeightTons) * 100))
- : null,
+ pullLimit,
+ pullUtil: pullLimit
+ ? Math.min(100, Math.round((grossWeight / pullLimit) * 100))
+ : null,
};
- }, [normalized, locomotive]);
+ }, [normalized, locos]);
- // cars-per-row from measured width; locomotive counts as one car
+ // cars-per-row from measured width; each locomotive counts as one car
const perRow = Math.max(1, Math.floor((width || CAR_WIDTH) / CAR_WIDTH));
const cars = useMemo(
- () => [{ kind: "loco" as const }, ...normalized.map((w) => ({ kind: "wagon" as const, w }))],
- [normalized],
+ () => [
+ ...locos.map((l) => ({ kind: "loco" as const, l })),
+ ...normalized.map((w) => ({ kind: "wagon" as const, w })),
+ ],
+ [locos, normalized],
);
const rows = useMemo(() => chunk(cars, perRow), [cars, perRow]);
- if (!locomotive && !wagons.length) return null;
+ if (!locos.length && !wagons.length) return null;
return (
Locomotive load ·{" "}
{stats.totalTare > 0
- ? `${stats.grossWeight}T of ${locomotive?.maxPullWeightTons}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
- : `${stats.totalWeight}T of ${locomotive?.maxPullWeightTons}T`}
+ ? `${stats.grossWeight}T of ${stats.pullLimit}T (${stats.totalWeight}T cargo + ${stats.totalTare}T tare)`
+ : `${stats.totalWeight}T of ${stats.pullLimit}T`}
95 ? "red.7" : "edr-green.7"}>
@@ -702,13 +722,11 @@ export function TrainCompositionDiagram({
{carIndex > 0 ? : null}
{car.kind === "loco" ? (
- locomotive ? (
-
- ) : null
+
) : (
)}
diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
index 01e65deb2..f17c74d9a 100644
--- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonTransferRequestsModal.tsx
@@ -10,6 +10,8 @@ import {
Modal,
ScrollArea,
Stack,
+ Switch,
+ Tabs,
Text,
ThemeIcon,
} from "@mantine/core";
@@ -17,6 +19,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import {
ArrowRight,
ChevronLeft,
+ History,
Inbox,
PackageCheck,
Warehouse,
@@ -25,8 +28,13 @@ import {
import { useMemo, useState } from "react";
import { api } from "@/services/api";
+import { useAuth } from "@/auth/useAuth";
+import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
-import type { WagonTransferRequest } from "@/services/wagon.service";
+import type {
+ WagonMovementRecord,
+ WagonTransferRequest,
+} from "@/services/wagon.service";
export interface WagonTransferRequestsModalProps {
opened: boolean;
@@ -57,16 +65,172 @@ const RequestSummary = ({ r }: { r: WagonTransferRequest }) => (
);
+const STATUS_COLOR: Record = {
+ PENDING: "gray",
+ FULFILLED: "teal",
+ CANCELLED: "red",
+};
+
+const fmtDateTime = (iso: string) =>
+ new Date(iso).toLocaleString("en-GB", {
+ day: "numeric",
+ month: "short",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ });
+
+/**
+ * Per-user transfer history. A staffer sees their OWN activity — the requests
+ * they filed or fulfilled, and the individual wagons they moved. Holders of
+ * `transfer_history_all` get an "All staff" toggle that widens the view; the
+ * backend enforces the scope regardless of the toggle.
+ */
+function HistoryPanel({ opened }: { opened: boolean }) {
+ const { user } = useAuth();
+ const canSeeAll = hasPermission(
+ user,
+ FREIGHT_PERMS.wagons.transferHistoryAll,
+ );
+ const myId = (user as { id?: string } | null | undefined)?.id;
+ const [allStaff, setAllStaff] = useState(false);
+ const scopeAll = canSeeAll && allStaff;
+
+ const mine = useQuery({
+ ...api.wagonTransferRequests.history.queryOptions(),
+ enabled: opened && !scopeAll,
+ });
+ const all = useQuery({
+ ...api.wagonTransferRequests.historyAll.queryOptions({ input: {} }),
+ enabled: opened && scopeAll,
+ });
+ const source = scopeAll ? all : mine;
+ const requests = source.data?.requests ?? [];
+ const movements: WagonMovementRecord[] = source.data?.movements ?? [];
+
+ const roleBadge = (r: WagonTransferRequest) => {
+ if (myId && r.fulfilledByUserId === myId)
+ return (
+
+ fulfilled
+
+ );
+ if (myId && r.requestedByUserId === myId)
+ return (
+
+ requested
+
+ );
+ return null;
+ };
+
+ return (
+
+ {canSeeAll ? (
+
+ setAllStaff(e.currentTarget.checked)}
+ label="All staff"
+ color="edr-green"
+ />
+
+ ) : null}
+
+ {source.isLoading ? (
+
+
+
+ ) : (
+ <>
+
+ >
+ )}
+
+ );
+}
+
/**
* OCC fulfilment queue for wagon-transfer requests. Lists PENDING requests; open
* one to hand-pick exactly the requested number of wagons from the source yard
* (of the requested type) and execute the move, or cancel the request.
+ * A second tab shows per-user transfer history.
*/
const WagonTransferRequestsModal = ({
opened,
onClose,
}: WagonTransferRequestsModalProps) => {
const { toast } = useToast();
+ const [tab, setTab] = useState("queue");
const [active, setActive] = useState(null);
const [picked, setPicked] = useState>(new Set());
@@ -175,6 +339,17 @@ const WagonTransferRequestsModal = ({
}
>
+
+
+ }>
+ Queue
+
+ }>
+ History
+
+
+
+
{!active ? (
// ---- Pending queue ----
isLoading ? (
@@ -337,6 +512,12 @@ const WagonTransferRequestsModal = ({
)}
+
+
+
+
+
+
);
};
diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
index 92fe85ad7..9bf9aab6e 100644
--- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts
+++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts
@@ -106,6 +106,9 @@ export const FREIGHT_PERMS = {
create: "edr_freight_app:wagons:create",
update: "edr_freight_app:wagons:update",
delete: "edr_freight_app:wagons:delete",
+ transferRequest: "edr_freight_app:wagons:transfer_request",
+ transferFulfill: "edr_freight_app:wagons:transfer_fulfill",
+ transferHistoryAll: "edr_freight_app:wagons:transfer_history_all",
},
trains: {
view: "edr_freight_app:trains:view",
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx
index 142173298..91d944ffc 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx
@@ -1,4 +1,11 @@
-import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
+import {
+ Fragment,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ type ReactNode,
+} from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
@@ -75,6 +82,8 @@ interface ClearanceRow {
freightType: string;
originLabel: string;
destinationLabel: string;
+ /** Full ordered corridor across the contract's route legs (origin → … → destination). */
+ routeStops: string[];
contractKind: string;
serviceTypeName: string;
customs: boolean;
@@ -93,6 +102,25 @@ function yardLabel(
return yard.label ?? yard.name ?? yard.code ?? fallback;
}
+/**
+ * Chain the contract's ordered route legs into one corridor of stops —
+ * origin of the first leg, then each leg's destination (Djibouti → Adama →
+ * Dire Dawa). A leg whose origin differs from the previous destination inserts
+ * that stop too, so gapped route lists stay readable.
+ */
+function contractRouteStops(routes: Freight.IContractRoute[]): string[] {
+ const stops: string[] = [];
+ for (const r of routes) {
+ const origin = yardLabel(r.originYard);
+ const destination = yardLabel(r.destinationYard);
+ if (stops.length === 0 || stops[stops.length - 1] !== origin) {
+ stops.push(origin);
+ }
+ stops.push(destination);
+ }
+ return stops;
+}
+
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
@@ -109,6 +137,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
freightType: contract.freightType ?? "—",
originLabel: yardLabel(first?.originYard),
destinationLabel: yardLabel(last?.destinationYard),
+ routeStops: contractRouteStops(routes),
contractKind: contract.contractKind,
serviceTypeName: contract.serviceType?.serviceName ?? "—",
customs:
@@ -412,14 +441,23 @@ export default function ContractClearanceListPage() {
const r = row.original;
return (
-
-
- {r.originLabel}
-
-
-
- {r.destinationLabel}
-
+
+ {(r.routeStops.length >= 2
+ ? r.routeStops
+ : [r.originLabel, r.destinationLabel]
+ ).map((stop, i) => (
+
+ {i > 0 ? (
+
+ ) : null}
+
+ {stop}
+
+
+ ))}
@@ -638,6 +676,11 @@ export default function ContractClearanceListPage() {
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
+ onRebook={(row) =>
+ navigate(
+ `/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
+ )
+ }
onViewContract={(contractId) =>
navigate(`/dashboard/contracts/clearance/${contractId}`)
}
@@ -731,6 +774,7 @@ function ShipmentBookingsTable({
canCreateBooking,
onOpen,
onCreateBooking,
+ onRebook,
onViewContract,
}: {
rows: ShipmentBookingRow[];
@@ -739,6 +783,7 @@ function ShipmentBookingsTable({
canCreateBooking: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
+ onRebook: (row: ShipmentBookingRow) => void;
onViewContract: (contractId: string) => void;
}) {
// A bare initiated instance that has cleared but not yet been created by GL.
@@ -748,6 +793,14 @@ function ShipmentBookingsTable({
!r.bookingCreated &&
r.status === "CLEARANCE_READY";
+ // A customs shipment whose booking lost its slot — GL rebooks it (customer
+ // can't self-rebook a customs booking). Copies the expired booking's cargo.
+ const isRebookable = (r: ShipmentBookingRow) =>
+ canCreateBooking &&
+ Boolean(r.contractId) &&
+ r.customs &&
+ r.status === "EXPIRED";
+
const columns = useMemo[]>(
() => [
{
@@ -877,6 +930,7 @@ function ShipmentBookingsTable({
cell: ({ row }) => {
const r = row.original;
const bookable = isBookable(r);
+ const rebookable = isRebookable(r);
return (
) : null}
+ {rebookable ? (
+ }
+ onClick={() => onRebook(r)}
+ >
+ Rebook
+
+ ) : null}
{composition.editable ? (
@@ -297,6 +314,12 @@ export default function TrainBuilderDetailPage() {
onClose={() => setLocoModalOpen(false)}
/>
+ setYardModalOpen(false)}
+ />
+
setDisbandOpen(false)}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
index 9799bf6e4..53bd1a818 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderListPage.tsx
@@ -183,7 +183,7 @@ export default function TrainBuilderListPage() {
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
- {row.original.wagonCount} wagons · {row.original.maxGrossTons}T ·{" "}
+ {row.original.wagonCount} wagons · {row.original.totalTareTons}T tare ·{" "}
{row.original.totalLengthMeters}m
),
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
index ae75debb1..0f80e423a 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/BatchScheduleDetailPage.tsx
@@ -838,6 +838,12 @@ export default function BatchScheduleDetailPage() {
}).format(new Date(data.scheduleDate)) + " EAT"
: "No date"}
+ {data.train ? (
+ }>
+ Train {data.train.code}
+ {data.train.trainName ? ` — ${data.train.trainName}` : ""}
+
+ ) : null}
{data.locomotive ? (
}>
Loco {data.locomotive.code} ·{" "}
diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
index f4cd3be02..c9c4a042a 100644
--- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx
@@ -757,13 +757,14 @@ export default function TrainScheduleV2DetailPage() {
1 ? "Locomotives" : "Locomotive",
value: locomotives.length
diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts
index af6263e58..d0e14d4a5 100644
--- a/apps/edr-freight-web/backoffice/src/services/api.ts
+++ b/apps/edr-freight-web/backoffice/src/services/api.ts
@@ -200,6 +200,7 @@ import {
type WagonMovementRecord,
type WagonTransferRequest,
type CreateTransferRequestPayload,
+ type TransferHistory,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
@@ -1701,6 +1702,21 @@ export const api = {
undefined,
() => [["wagonTransferRequests"]],
),
+
+ history: endpoint(
+ "wagonTransferRequests",
+ "history",
+ () => wagonTransferRequestService.myHistory().then((r) => r.data),
+ () => ["wagonTransferRequests", "history", "mine"],
+ ),
+
+ historyAll: endpoint<{ userId?: string }, TransferHistory>(
+ "wagonTransferRequests",
+ "historyAll",
+ ({ userId }) =>
+ wagonTransferRequestService.allHistory(userId).then((r) => r.data),
+ ({ userId }) => ["wagonTransferRequests", "history", "all", userId ?? ""],
+ ),
},
trains: {
@@ -1781,6 +1797,15 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
+ setYard: endpoint<{ id: string; currentYardId: string }, TrainComposition>(
+ "train-builder",
+ "setYard",
+ ({ id, currentYardId }) =>
+ trainBuilderService.setYard(id, currentYardId).then((r) => r.data),
+ undefined,
+ () => TRAIN_BUILDER_INVALIDATIONS,
+ ),
+
assignWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"assignWagons",
diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
index f94bd6ff7..e6c462992 100644
--- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
@@ -26,7 +26,7 @@ export interface BuiltTrainSummary {
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
wagonCount: number;
- maxGrossTons: number;
+ totalTareTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
}
@@ -63,12 +63,15 @@ export interface TrainCompositionWagon {
export interface TrainCompositionTotals {
wagonCount: number;
totalTareTons: number;
+ /** Informational only — building never checks against full capacity. */
totalCapacityTons: number;
- maxGrossTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
maxTrainLengthMeters: number;
- weightUtilizationPct: number | null;
+ /** Cargo the locomotives can still haul once pulling the empty consist. */
+ payloadCapacityTons: number;
+ /** Share of the haul limit consumed by the empty wagons alone. */
+ tareUtilizationPct: number | null;
lengthUtilizationPct: number | null;
}
@@ -126,7 +129,7 @@ export interface AvailableTrain {
currentYard: YardRefLite | null;
locomotives: Array<{ id: string; code: string; name: string | null }>;
wagonCount: number;
- maxGrossTons: number;
+ totalTareTons: number;
totalLengthMeters: number;
maxPullWeightTons: number;
atOriginYard: boolean;
@@ -157,6 +160,9 @@ export const trainBuilderService = {
build: (payload: BuildTrainPayload) => apiClient.post(BASE, payload),
setLocomotives: (id: string, locomotiveIds: string[]) =>
apiClient.put(`${BASE}/${id}/locomotives`, { locomotiveIds }),
+ /** Relocate the train — coupled locomotives and wagons move with it. */
+ setYard: (id: string, currentYardId: string) =>
+ apiClient.patch(`${BASE}/${id}/yard`, { currentYardId }),
assignWagons: (id: string, wagonIds: string[]) =>
apiClient.post(`${BASE}/${id}/wagons`, { wagonIds }),
removeWagon: (id: string, wagonId: string) =>
diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
index 6464b94f5..e818467f5 100644
--- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts
@@ -55,9 +55,12 @@ export interface WagonMovementRecord {
bookingId: string | null;
kind: Freight.WagonMovementKind;
movedByUserId: string | null;
+ /** The transfer request this move fulfilled, when one drove it. */
+ transferRequestId: string | null;
occurredAt: string;
note: string | null;
createdAt: string;
+ wagon?: { id: string; wagonNumber?: string } | null;
}
export const wagonService = {
@@ -120,11 +123,25 @@ export interface CreateTransferRequestPayload {
note?: string;
}
+/** Per-user activity: requests filed/fulfilled + the wagons physically moved. */
+export interface TransferHistory {
+ requests: WagonTransferRequest[];
+ movements: WagonMovementRecord[];
+}
+
export const wagonTransferRequestService = {
list: (status?: Freight.WagonTransferRequestStatus) =>
apiClient.get(
`/wagon-transfer-requests${status ? `?status=${status}` : ''}`,
),
+ /** The caller's own history (both roles: requests they filed and fulfilled). */
+ myHistory: () =>
+ apiClient.get('/wagon-transfer-requests/history'),
+ /** Admin: any/all staff's history (optional userId filter). */
+ allHistory: (userId?: string) =>
+ apiClient.get(
+ `/wagon-transfer-requests/history/all${userId ? `?userId=${userId}` : ''}`,
+ ),
getById: (id: string) =>
apiClient.get(`/wagon-transfer-requests/${id}`),
create: (data: CreateTransferRequestPayload) =>
diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
index 154db034c..ee301b970 100644
--- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
+++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts
@@ -309,6 +309,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;
@@ -417,6 +423,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"];
@@ -511,6 +519,12 @@ export interface TrainScheduleDetail {
deferredBookings?: DeferredBookingRow[];
freightType?: FreightType | null;
trainNumber?: string | null;
+ /** Built train (Train Builder) behind this departure, when scheduled by train. */
+ train?: {
+ id: string;
+ code: string;
+ trainName?: string | null;
+ } | null;
direction?: string | null;
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
requiresLoadingConfirmation?: boolean;
diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx
index ad2d5ace9..f9a3dd246 100644
--- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx
+++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/UpcomingWindowsSection.tsx
@@ -1,5 +1,5 @@
import { ActionIcon, Box, Group, Skeleton, Stack, Text } from "@mantine/core";
-import { memo, useMemo, useState } from "react";
+import { Fragment, memo, useMemo, useState } from "react";
import {
ArrowRight,
CalendarClock,
@@ -8,6 +8,7 @@ import {
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
+import { windowRouteStops } from "@/pages/contracts/booking-window";
import { Card } from "./Card";
const INK = "#10202F";
@@ -284,14 +285,21 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
}}
>
-
-
- {w.origin ?? "—"}
-
-
-
- {w.destination ?? "—"}
-
+
+ {windowRouteStops(w).map((stop, i) => (
+
+ {i > 0 ? (
+
+ ) : null}
+
+ {stop}
+
+
+ ))}
{w.reference && (
navigate("/support"),
}}
/>
@@ -181,14 +186,18 @@ export function ReadonlyBookingView({
: "This booking process has been terminated."
}
reason={booking.latestChangeRequestNote}
- onRebook={onRebook}
+ onRebook={canSelfRebook ? onRebook : undefined}
/>
) : isExpired ? (
) : isPendingConsolidation ? (
-
-
- {w.origin ?? "—"}
-
-
-
- {w.destination ?? "—"}
-
+
+ {windowRouteStops(w).map((stop, i) => (
+
+ {i > 0 ? (
+
+ ) : null}
+
+ {stop}
+
+
+ ))}
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
index c0bfbfc4c..0b5c1796c 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
+++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx
@@ -26,7 +26,12 @@ export default function NewShipmentRequestPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [scheduledDate, setScheduledDate] = useState("");
- const [quantity, setQuantity] = useState(1);
+ // Container contracts: one quantity per enabled size (e.g. 20ft + 40ft).
+ const [qtyBySize, setQtyBySize] = useState>(
+ {},
+ );
+ // Bulk contracts: a single amount — tons (PER_TON) or item count (PER_ITEM).
+ const [bulkAmount, setBulkAmount] = useState("");
const [notes, setNotes] = useState("");
const { data: contract, isLoading } = useQuery({
@@ -76,6 +81,22 @@ export default function NewShipmentRequestPage() {
contract.contractKind === "GENERAL" &&
(contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled);
+ // Only the container sizes the contract was scoped for (20ft, 40ft, or both).
+ const SIZE_ORDER = ["20ft", "40ft"];
+ const enabledSizes = SIZE_ORDER.filter((s) =>
+ contract.cargoScope?.some(
+ (l) => (l.containerSize ?? "").toLowerCase() === s,
+ ),
+ );
+ // A CONTAINER contract should always carry scope lines; fall back to both.
+ const sizes = enabledSizes.length ? enabledSizes : SIZE_ORDER;
+
+ // Bulk: pick the bulk scope line and read how it's measured.
+ const bulkScope =
+ contract.cargoScope?.find((l) => !l.containerSize) ??
+ contract.cargoScope?.[0];
+ const isPerItem = bulkScope?.cargoType?.unitOfMeasure === "PER_ITEM";
+
const handleSubmit = () => {
const dto: Freight.CreateBookingRequestDto = {
contractRouteId: route?.id,
@@ -84,17 +105,24 @@ export default function NewShipmentRequestPage() {
};
if (isContainer) {
- const size = contract.cargoScope?.[0]?.containerSize ?? "20FT";
- dto.containers = [
- {
- containerSize: size,
- quantity: Number(quantity) || 1,
- },
- ];
+ // One line per size the user filled; 0 (or blank) sizes are dropped.
+ const containers = sizes
+ .map((size) => ({ containerSize: size, quantity: Number(qtyBySize[size]) || 0 }))
+ .filter((line) => line.quantity > 0);
+ if (containers.length === 0) {
+ toast.error("Enter a quantity for at least one container size");
+ return;
+ }
+ dto.containers = containers;
} else {
+ const amount = Number(bulkAmount) || 0;
+ if (amount <= 0) {
+ toast.error(isPerItem ? "Enter the number of items" : "Enter the cargo weight");
+ return;
+ }
dto.bulk = {
- cargoTypeId: contract.cargoScope?.[0]?.cargoTypeId ?? null,
- cargoWeightTons: Number(quantity) || undefined,
+ cargoTypeId: bulkScope?.cargoTypeId ?? null,
+ ...(isPerItem ? { itemCount: amount } : { cargoWeightTons: amount }),
};
}
@@ -131,17 +159,47 @@ export default function NewShipmentRequestPage() {
/>
)}
-
+ {isContainer ? (
+
+ {sizes.map((size) => (
+
+ setQtyBySize((prev) => ({ ...prev, [size]: v }))
+ }
+ min={0}
+ allowDecimal={false}
+ />
+ ))}
+ {sizes.length > 1 ? (
+
+ Enter a quantity for each size you need — leave a size at 0 if
+ you don't need it.
+
+ ) : null}
+ {hasCustoms ? (
+
+ Global Logistics schedules the shipment date during customs
+ clearance — you only state the quantity.
+
+ ) : null}
+
+ ) : (
+
+ )}
{capacity?.length ? (
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts
index ab25284ba..1f0825f57 100644
--- a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts
+++ b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts
@@ -25,6 +25,16 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean {
return windows.some((w) => w.isOpenNow);
}
+/**
+ * Full ordered corridor for a window — every stop from origin through the
+ * intermediate milestones to the destination. Falls back to origin/destination
+ * when the backend sends no milestone chain (older schedules, routeless windows).
+ */
+export function windowRouteStops(w: MyBookingWindow): string[] {
+ if (w.routeStations && w.routeStations.length >= 2) return w.routeStations;
+ return [w.origin ?? "—", w.destination ?? "—"];
+}
+
/**
* The next upcoming (not-yet-open) window the customer should come back for —
* the one that OPENS soonest from now. Two guards matter here:
diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts
index 1128b0883..af2c8e4a5 100644
--- a/apps/edr-freight-web/portal/src/services/bookings.service.ts
+++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts
@@ -94,6 +94,12 @@ export interface MyBookingWindow {
departureDate: string;
origin: string | null;
destination: string | null;
+ /**
+ * Full ordered corridor for the window's route — origin, every intermediate
+ * milestone stop, then destination (e.g. Djibouti → Adama → Dire Dawa).
+ * Falls back to [origin, destination] when the route has no milestones.
+ */
+ routeStations: string[];
}
export interface GeneratePriceResponse {
diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts
index 9addde7dc..39e8985ee 100644
--- a/packages/types/src/freight/index.ts
+++ b/packages/types/src/freight/index.ts
@@ -327,6 +327,8 @@ export interface IWagonMovement extends BaseEntity {
bookingId?: string | null;
kind: WagonMovementKind;
movedByUserId?: string | null;
+ /** The transfer request this move fulfilled, when it came from one. */
+ transferRequestId?: string | null;
occurredAt: string;
note?: string | null;
}
From ae07b461f095323233d90ae1331f7ca696128fdb Mon Sep 17 00:00:00 2001
From: Yonas Tewabe
Date: Tue, 14 Jul 2026 16:30:23 +0300
Subject: [PATCH 13/29] Update docker-compose.yaml
---
docker-compose.yaml | 2 --
1 file changed, 2 deletions(-)
diff --git a/docker-compose.yaml b/docker-compose.yaml
index c0b58b44c..7637c500a 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -24,8 +24,6 @@ services:
dockerfile: apps/edr-gps-tracker/Dockerfile
secrets:
- npmrc
- depends_on:
- - freight-api
ports:
- "${GT06_TCP_PORT:-5023}:5023"
environment:
From 5cffe2860cd279c9c01d6efed8b28fd8422dc73b Mon Sep 17 00:00:00 2001
From: Marshal
Date: Tue, 14 Jul 2026 13:49:39 +0000
Subject: [PATCH 14/29] fix train builder and consolidation
---
...70000000000-ScheduleWagonAdjustmentLogs.ts | 50 +++
.../schedule-wagon-adjustment-log.entity.ts | 38 ++
.../dto/adjust-schedule-consist.dto.ts | 26 ++
.../train-scheduling.controller.ts | 29 ++
.../train-scheduling.service.ts | 315 +++++++++++++-
.../modules/trains/train-builder.service.ts | 71 +++-
.../trainScheduling/AdjustConsistModal.tsx | 396 ++++++++++++++++++
.../src/pages/bookings/NewBookingPage.tsx | 32 +-
.../trainBuilder/TrainBuilderDetailPage.tsx | 4 +-
.../BatchScheduleDetailPage.tsx | 19 +
.../TrainScheduleV2DetailPage.tsx | 20 +
.../backoffice/src/services/api.ts | 26 ++
.../src/services/trainBuilder.service.ts | 67 +++
.../new-booking-form/step5-cargo-details.tsx | 16 +-
14 files changed, 1092 insertions(+), 17 deletions(-)
create mode 100644 apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts
create mode 100644 apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts
create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts
create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx
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/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-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/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
index f8cc7e3d1..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,7 @@ 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";
@@ -166,6 +167,34 @@ export class TrainSchedulingController {
);
}
+ @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.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
index 7e0720238..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
@@ -25,6 +25,7 @@ import {
FindOptionsWhere,
ILike,
In,
+ IsNull,
Not,
QueryFailedError,
Raw,
@@ -48,6 +49,7 @@ 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';
@@ -61,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';
@@ -1331,11 +1334,25 @@ export class TrainSchedulingService {
limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0);
const lengthCapWithOverage =
limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0);
- // The locomotives pull GROSS weight: the customers' cargo plus the empty
- // weight of every planned wagon — cargo-only comparison understates the load.
- const planTareTons = roundTons(
- wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0),
- );
+ // 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(
@@ -4626,6 +4643,294 @@ export class TrainSchedulingService {
});
}
+ /**
+ * 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 →
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
index b92ab21d6..16ff29d38 100644
--- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
+++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts
@@ -10,6 +10,7 @@ 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';
@@ -511,9 +512,13 @@ export class TrainBuilderService {
startCount: number,
): Promise {
const uniqueIds = [...new Set(wagonIds)];
- let sequence = startCount;
+ 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 manager.getRepository(Wagon).findOne({
+ const wagon = await wagonRepo.findOne({
where: { id: wagonId },
lock: { mode: 'pessimistic_write' },
});
@@ -530,8 +535,16 @@ export class TrainBuilderService {
`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 manager.getRepository(Wagon).update(wagon.id, {
+ await wagonRepo.update(wagon.id, {
trainId: train.id,
sequenceNumber: sequence,
status: WagonStatus.Assigned,
@@ -539,6 +552,58 @@ export class TrainBuilderService {
}
}
+ /**
+ * 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({
diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx
new file mode 100644
index 000000000..1ca91e01f
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/AdjustConsistModal.tsx
@@ -0,0 +1,396 @@
+import {
+ Alert,
+ Badge,
+ Button,
+ Checkbox,
+ Divider,
+ Grid,
+ Group,
+ Modal,
+ Progress,
+ ScrollArea,
+ Stack,
+ Text,
+} from "@mantine/core";
+import { useMutation, useQuery } from "@tanstack/react-query";
+import { isAxiosError } from "axios";
+import { AlertTriangle, History, Minus, Plus } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+
+import { api } from "@/services/api";
+import type { ConsistWagonRef } from "@/services/trainBuilder.service";
+import { useToast } from "@/hooks/use-toast";
+
+const parseError = (error: unknown, fallback: string) => {
+ if (isAxiosError(error)) {
+ const message = error.response?.data?.message;
+ if (Array.isArray(message)) return message.join(", ");
+ if (typeof message === "string") return message;
+ }
+ return fallback;
+};
+
+const tareOf = (w: ConsistWagonRef) => w.wagonType?.tareWeightTons ?? 0;
+const lengthOf = (w: ConsistWagonRef) => w.wagonType?.lengthMeters ?? 0;
+const round2 = (v: number) => Math.round(v * 100) / 100;
+
+/**
+ * Adjust the built train's consist from a schedule: trim free wagons (their
+ * tare no longer rides — the fix when gross weight beats the pull limit) or
+ * couple extra yard wagons while weight/length headroom remains. Changes are
+ * permanent on the train and logged on the schedule.
+ */
+export default function AdjustConsistModal({
+ scheduleId,
+ opened,
+ onClose,
+}: AdjustConsistModalProps) {
+ const { toast } = useToast();
+ const [removeIds, setRemoveIds] = useState([]);
+ const [addIds, setAddIds] = useState([]);
+
+ const consistQuery = useQuery(
+ api.trainScheduling.scheduleConsist.queryOptions({
+ input: { scheduleId },
+ enabled: opened && Boolean(scheduleId),
+ }),
+ );
+ const adjust = useMutation(api.trainScheduling.adjustConsist.mutationOptions());
+ const data = consistQuery.data;
+
+ useEffect(() => {
+ if (opened) {
+ setRemoveIds([]);
+ setAddIds([]);
+ }
+ }, [opened]);
+
+ // Live projection: gross = cargo + tare of (consist − trims + adds).
+ const projection = useMemo(() => {
+ if (!data) return null;
+ const removed = new Set(removeIds);
+ const keptTare = data.wagons
+ .filter((w) => !removed.has(w.id))
+ .reduce((s, w) => s + tareOf(w), 0);
+ const keptLength = data.wagons
+ .filter((w) => !removed.has(w.id))
+ .reduce((s, w) => s + lengthOf(w), 0);
+ const addedWagons = data.addableWagons.filter((w) => addIds.includes(w.id));
+ const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
+ const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
+ const gross = round2(data.totals.cargoTons + tare);
+ return {
+ wagonCount: data.totals.wagonCount - removeIds.length + addIds.length,
+ tare: round2(tare),
+ gross,
+ length: round2(length),
+ grossPct: data.limits.pullCapTons
+ ? Math.round((gross / data.limits.pullCapTons) * 100)
+ : null,
+ lengthPct: data.limits.lengthCapMeters
+ ? Math.round((length / data.limits.lengthCapMeters) * 100)
+ : null,
+ overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
+ overLength:
+ data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
+ };
+ }, [data, removeIds, addIds]);
+
+ const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
+ setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
+
+ const handleSubmit = async () => {
+ if (!removeIds.length && !addIds.length) return;
+ try {
+ await adjust.mutateAsync({
+ scheduleId,
+ payload: {
+ ...(addIds.length ? { addWagonIds: addIds } : {}),
+ ...(removeIds.length ? { removeWagonIds: removeIds } : {}),
+ },
+ });
+ toast({
+ title: `Consist updated — ${removeIds.length ? `${removeIds.length} trimmed` : ""}${
+ removeIds.length && addIds.length ? ", " : ""
+ }${addIds.length ? `${addIds.length} added` : ""}`,
+ });
+ setRemoveIds([]);
+ setAddIds([]);
+ } catch (err) {
+ toast({
+ title: "Adjustment failed",
+ description: parseError(err, "Could not adjust the consist"),
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+
+ Adjust consist{data ? ` — train ${data.train.code}` : ""}
+
+ }
+ radius="lg"
+ size={860}
+ centered
+ >
+ {consistQuery.isLoading || !data ? (
+
+ {consistQuery.isError
+ ? "This schedule has no built train to adjust."
+ : "Loading consist…"}
+
+ ) : (
+
+ {!data.editable ? (
+ }>
+ The consist is frozen once the train is dispatched.
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Trim coupled wagons ({data.totals.wagonCount})
+
+
+
+ Only free (unloaded, unpinned) wagons can be detached. Detaching is
+ permanent — the wagon returns to the yard as available.
+
+
+
+ {data.wagons.map((wagon) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Couple yard wagons ({data.addableWagons.length} available)
+
+
+
+ AVAILABLE wagons standing in the train's yard. Blocked when they push
+ gross weight or length past the locomotive limits incl. tolerance.
+
+
+
+ {data.addableWagons.length ? (
+ data.addableWagons.map((wagon) => (
+
+ ))
+ ) : (
+
+ No available wagons in this yard
+
+ )}
+
+
+
+
+
+
+ {data.adjustments.length ? (
+ <>
+
+
+
+
+
+ Adjustment history
+
+
+
+
+ {data.adjustments.map((log) => (
+
+
+ {log.action === "ADD" ? "Added" : "Trimmed"}
+
+
+ {log.wagonNumber}
+
+
+ {new Date(log.occurredAt).toLocaleString()}
+
+
+ ))}
+
+
+
+ >
+ ) : null}
+
+
+
+ Projected consist: {projection?.wagonCount} wagons
+
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+export interface AdjustConsistModalProps {
+ scheduleId: string;
+ opened: boolean;
+ onClose: () => void;
+}
+
+function LimitGauge({
+ label,
+ detail,
+ pct,
+ over,
+}: {
+ label: string;
+ detail: string;
+ pct: number | null;
+ over: boolean;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ {pct != null ? `${pct}%` : "—"}
+
+
+
+ );
+}
+
+function WagonRow({
+ wagon,
+ checked,
+ disabled,
+ badge,
+ onToggle,
+}: {
+ wagon: ConsistWagonRef;
+ checked: boolean;
+ disabled: boolean;
+ badge: string | null;
+ onToggle: (id: string, checked: boolean) => void;
+}) {
+ return (
+
+ onToggle(wagon.id, e.currentTarget.checked)}
+ aria-label={`Select wagon ${wagon.wagonNumber}`}
+ />
+
+
+ {wagon.wagonNumber}
+
+
+ {wagon.wagonType
+ ? `${wagon.wagonType.code} · ${wagon.wagonType.tareWeightTons}T tare · ${wagon.wagonType.lengthMeters}m`
+ : "Unknown type"}
+
+
+ {badge ? (
+
+ {badge}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
index 0344e99cb..07a6c72cd 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/NewBookingPage.tsx
@@ -1,5 +1,6 @@
import {
ActionIcon,
+ Alert,
Badge,
Box,
Button,
@@ -324,6 +325,21 @@ export default function NewBookingPage() {
const containerWeight = lines.reduce((s, l) => s + (l.quantity || 0) * (l.vgmPerUnitTons || 0), 0);
const cargoTotalWeightVgm = freightType === "CONTAINER" ? containerWeight : bulkWeight;
+ // 20ft containers ride two per wagon, so a booking must hold an even number
+ // of them — odd counts would leave half a wagon waiting on a co-loader
+ // (cross-booking consolidation is disabled for now).
+ const twentyFtCount = useMemo(() => {
+ const sizeById = new Map();
+ for (const group of refData?.containers ?? []) {
+ for (const type of group.types) sizeById.set(type.id, group.size);
+ }
+ return lines.reduce((sum, l) => {
+ const size = l.containerTypeId ? (sizeById.get(l.containerTypeId) ?? "") : "";
+ return String(size).includes("20") ? sum + (l.quantity || 0) : sum;
+ }, 0);
+ }, [refData?.containers, lines]);
+ const hasOdd20ft = freightType === "CONTAINER" && twentyFtCount % 2 === 1;
+
// ---- validation ----
const lineValid = (l: ContainerLine) =>
Boolean(l.containerTypeId) && l.quantity >= 1 && l.vgmPerUnitTons > 0;
@@ -344,7 +360,7 @@ export default function NewBookingPage() {
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
(freightType === "BULK"
? Boolean(cargoTypeId) && bulkWeight > 0
- : allLinesValid);
+ : allLinesValid && !hasOdd20ft);
const updateLine = (key: string, patch: Partial) =>
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)));
@@ -842,6 +858,20 @@ export default function NewBookingPage() {
) : null}
+ {hasOdd20ft ? (
+ } radius="md" mt="md">
+
+ Odd number of 20ft containers ({twentyFtCount})
+
+
+ 20ft containers travel two per wagon, so they must be booked in
+ even numbers. Add one more 20ft container or remove one — e.g.
+ book {twentyFtCount + 1} or {twentyFtCount - 1} instead of{" "}
+ {twentyFtCount}.
+
+
+ ) : null}
+
+ {data.train && ["DRAFT", "SCHEDULED"].includes(data.status) ? (
+ }
+ onClick={() => setAdjustConsistOpen(true)}
+ >
+ Adjust consist
+
+ ) : null}
{data.windowPhase === "DOC_REVIEW" ? (
) : null}
+ {schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
+ }
+ onClick={() => setAdjustConsistOpen(true)}
+ >
+ Adjust consist
+
+ ) : null}
{gatepassApplies ? (
gatepassSecured ? (
) : null}
+ setAdjustConsistOpen(false)}
+ />
+
QUERY_KEYS.TRAIN_SCHEDULING.availableTrains(routeId),
),
+ scheduleConsist: endpoint<{ scheduleId: string }, ScheduleConsist>(
+ "train-scheduling",
+ "schedule-consist",
+ ({ scheduleId }) =>
+ trainBuilderService.scheduleConsist(scheduleId).then((r) => r.data),
+ ({ scheduleId }) => [
+ ...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
+ "consist",
+ scheduleId,
+ ],
+ ),
+
+ adjustConsist: endpoint<
+ { scheduleId: string; payload: AdjustConsistPayload },
+ ScheduleConsist
+ >(
+ "train-scheduling",
+ "adjust-consist",
+ ({ scheduleId, payload }) =>
+ trainBuilderService.adjustConsist(scheduleId, payload).then((r) => r.data),
+ undefined,
+ () => TRAIN_BUILDER_INVALIDATIONS,
+ ),
+
bookableSchedules: endpoint<
{ originYardId?: string | null; destinationYardId?: string | null },
BookableSchedule[]
diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
index e6c462992..d88cd3ed6 100644
--- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts
@@ -153,6 +153,64 @@ const toQuery = (filters: BuiltTrainListFilters = {}) => {
return qs ? `?${qs}` : "";
};
+// ---------------------------------------------------------------------------
+// Schedule consist adjustment (train-bound schedules)
+// ---------------------------------------------------------------------------
+
+export interface ConsistWagonRef {
+ id: string;
+ wagonNumber: string;
+ sequenceNumber: number | null;
+ wagonType: {
+ id: string;
+ code: string;
+ tareWeightTons: number;
+ capacityTons: number;
+ lengthMeters: number;
+ } | null;
+}
+
+export interface ScheduleConsist {
+ schedule: { id: string; reference: string | null; status: string };
+ train: {
+ id: string;
+ code: string;
+ trainName: string | null;
+ currentYardId: string | null;
+ };
+ limits: {
+ maxPullWeightTons: number;
+ overageToleranceTons: number;
+ pullCapTons: number;
+ maxTrainLengthMeters: number;
+ overageToleranceMeters: number;
+ lengthCapMeters: number;
+ };
+ totals: {
+ wagonCount: number;
+ cargoTons: number;
+ consistTareTons: number;
+ grossTons: number;
+ consistLengthMeters: number;
+ };
+ wagons: Array;
+ addableWagons: ConsistWagonRef[];
+ adjustments: Array<{
+ id: string;
+ action: "ADD" | "REMOVE";
+ wagonId: string;
+ wagonNumber: string;
+ adjustedByUserId: string | null;
+ occurredAt: string;
+ }>;
+ editable: boolean;
+}
+
+export interface AdjustConsistPayload {
+ addWagonIds?: string[];
+ removeWagonIds?: string[];
+}
+
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get(`${BASE}${toQuery(filters)}`),
@@ -175,4 +233,13 @@ export const trainBuilderService = {
apiClient.get(`/train-scheduling/available-trains`, {
params: { routeId },
}),
+ /** Consist snapshot for a train-bound schedule (adjust-consist UI). */
+ scheduleConsist: (scheduleId: string) =>
+ apiClient.get(`/train-scheduling/schedules/${scheduleId}/consist`),
+ /** Permanently trim/add wagons on the schedule's built train. */
+ adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
+ apiClient.post(
+ `/train-scheduling/schedules/${scheduleId}/adjust-consist`,
+ payload,
+ ),
};
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
index b582468cf..b9cf743fa 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step5-cargo-details.tsx
@@ -787,13 +787,17 @@ export function Step5CargoDetails({
const result = calcWagons(containers ?? []);
if (result.hasOddUnit) {
return (
-
-
Unpaired 20ft Container
+
+
+ Odd number of 20ft containers ({result.ft20Wagons})
+
- One 20ft container occupies only half a wagon. The wagon will
- depart once a co-loader is found to fill the remaining slot,
- which may delay departure beyond the standard
- lead time.
+ 20ft containers travel two per wagon, so they must be booked in
+ even numbers. Please add one more 20ft container{" "}
+ or remove one (e.g. book{" "}
+ {result.ft20Wagons + 1} or {result.ft20Wagons - 1} instead of{" "}
+ {result.ft20Wagons}) — the booking cannot be submitted with an
+ unpaired 20ft container.
);
From b102a31fc1a658077e94ee141f6eed07073c6169 Mon Sep 17 00:00:00 2001
From: Marshal
Date: Tue, 14 Jul 2026 13:51:31 +0000
Subject: [PATCH 15/29] fix train builder and consolidation
---
.../new-booking-form/step8-review.tsx | 36 +++++++++++++++----
1 file changed, 30 insertions(+), 6 deletions(-)
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
index a789943c2..501a04a5d 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx
@@ -25,10 +25,11 @@ import {
} from "lucide-react";
import type { Freight } from "@/types";
import {
+ calcWagons,
type BookingFormInputValues,
type BookingFormValues,
} from "./schema";
-import { StepHeader } from "./shared";
+import { AlertBox, StepHeader } from "./shared";
export const REVIEW_STEP_TARGETS = {
contract: 1,
@@ -160,6 +161,12 @@ export function Step8Review({
.join(", ")
: "";
+ // 20ft containers must pair up (two per wagon) — an odd total blocks submit.
+ const { hasOddUnit: hasOdd20ft, ft20Wagons: twentyFtCount } =
+ values.cargoType === "container"
+ ? calcWagons(values.containers ?? [])
+ : { hasOddUnit: false, ft20Wagons: 0 };
+
const isGeneralContract = values.bookingType === "general_contract";
// Both one-time and general contracts take the bulk amount from the cargo step
// (cargoWeight); general contracts no longer collect a per-route quantity.
@@ -529,10 +536,27 @@ export function Step8Review({
-
- Ready to submit. You'll review the unit rates before final
- submission.
-
+ {hasOdd20ft ? (
+
+
+
+ Odd number of 20ft containers ({twentyFtCount})
+
+
+ 20ft containers travel two per wagon, so they must be booked
+ in even numbers. Go back to the cargo step and{" "}
+ add one more 20ft container or{" "}
+ remove one (e.g. {twentyFtCount + 1} or{" "}
+ {twentyFtCount - 1} instead of {twentyFtCount}).
+
+
+
+ ) : (
+
+ Ready to submit. You'll review the unit rates before final
+ submission.
+
+ )}
}
onClick={onSubmit}
loading={submitPending}
- disabled={submitPending}
+ disabled={submitPending || hasOdd20ft}
>
Submit
From 12ab3f33b109f4803f39fbbebba15138618cbbb3 Mon Sep 17 00:00:00 2001
From: Roba Boru
Date: Tue, 14 Jul 2026 16:54:24 +0300
Subject: [PATCH 16/29] Fix stop route
---
.../src/modules/bookings/bookings.service.ts | 52 ++++++++++++++++---
1 file changed, 46 insertions(+), 6 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
index 7f09ffa49..a4b3617c9 100644
--- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
+++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts
@@ -1728,13 +1728,40 @@ export class BookingsService {
);
}
+ // Resolves the passenger's actual boarding/alighting stations for one leg from
+ // originStationId/destinationStationId (set when the booking covers only part of a
+ // longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via
+ // the schedule's stopTimes, falling back to the schedule's own full-route endpoints
+ // when there's no segment override (older records, or a booking that covers the
+ // whole run). Mirrors notifications.service.ts's resolveSegmentStations — that's
+ // already applied to SMS/email; this brings the booking API (voucher, detail page,
+ // confirmation) to the same behavior instead of always showing the train's full route.
+ private resolveSegmentStations(
+ schedule: any,
+ originStationId: string | null | undefined,
+ destinationStationId: string | null | undefined,
+ ): { origin: any; destination: any } {
+ const stopTimes: any[] = schedule?.stopTimes ?? [];
+ const findStation = (stationId: string | null | undefined, fallback: any) => {
+ if (stationId && stopTimes.length > 0) {
+ const stop = stopTimes.find((st: any) => st.stationId === stationId);
+ if (stop?.station) return stop.station;
+ }
+ return fallback ?? null;
+ };
+ return {
+ origin: findStation(originStationId, schedule?.originStation),
+ destination: findStation(destinationStationId, schedule?.destinationStation),
+ };
+ }
+
async getByRef(bookingRefOrId: string) {
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId);
const booking = await this.prisma.booking.findUnique({
where: isUuid ? { id: bookingRefOrId } : { bookingRef: bookingRefOrId },
include: {
- schedule: { include: { originStation: true, destinationStation: true, train: true } },
- returnSchedule: { include: { originStation: true, destinationStation: true, train: true } },
+ schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
+ returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
priceTier: { select: { priceMinor: true } },
@@ -1803,6 +1830,19 @@ export class BookingsService {
};
}
+ const outboundSegment = this.resolveSegmentStations(
+ (booking as any).schedule,
+ (booking as any).originStationId,
+ (booking as any).destinationStationId,
+ );
+ const returnSegment = (booking as any).returnSchedule
+ ? this.resolveSegmentStations(
+ (booking as any).returnSchedule,
+ (booking as any).returnOriginStationId,
+ (booking as any).returnDestinationStationId,
+ )
+ : null;
+
return {
id: booking.id, bookingRef: booking.bookingRef, status: booking.status,
totalMinor: resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: 'ETB',
@@ -1819,8 +1859,8 @@ export class BookingsService {
id: (booking as any).schedule.id,
trainNumber: (booking as any).schedule.train.number,
trainName: (booking as any).schedule.train.name,
- origin: { id: (booking as any).schedule.originStation.id, name: (booking as any).schedule.originStation.name, code: (booking as any).schedule.originStation.code, city: (booking as any).schedule.originStation.city },
- destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city },
+ origin: { id: outboundSegment.origin.id, name: outboundSegment.origin.name, code: outboundSegment.origin.code, city: outboundSegment.origin.city },
+ destination: { id: outboundSegment.destination.id, name: outboundSegment.destination.name, code: outboundSegment.destination.code, city: outboundSegment.destination.city },
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
},
returnSchedule: (booking as any).returnSchedule
@@ -1828,8 +1868,8 @@ export class BookingsService {
id: (booking as any).returnSchedule.id,
trainNumber: (booking as any).returnSchedule.train.number,
trainName: (booking as any).returnSchedule.train.name,
- origin: { id: (booking as any).returnSchedule.originStation.id, name: (booking as any).returnSchedule.originStation.name, code: (booking as any).returnSchedule.originStation.code, city: (booking as any).returnSchedule.originStation.city },
- destination: { id: (booking as any).returnSchedule.destinationStation.id, name: (booking as any).returnSchedule.destinationStation.name, code: (booking as any).returnSchedule.destinationStation.code, city: (booking as any).returnSchedule.destinationStation.city },
+ origin: { id: returnSegment!.origin.id, name: returnSegment!.origin.name, code: returnSegment!.origin.code, city: returnSegment!.origin.city },
+ destination: { id: returnSegment!.destination.id, name: returnSegment!.destination.name, code: returnSegment!.destination.code, city: returnSegment!.destination.city },
departureAt: (booking as any).returnSchedule.departureAt, arrivalAt: (booking as any).returnSchedule.arrivalAt,
}
: null,
From 7e934b34338e65b26a1e574c7c0e6e4d75c8ab4f Mon Sep 17 00:00:00 2001
From: Hagernesh
Date: Tue, 14 Jul 2026 14:14:02 +0000
Subject: [PATCH 17/29] feat(warehouse,last-mile): truck load size rule, dedup
last-mile, driver-required + arrival prefill
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Truck loading:
- loadTruck enforces max 2 containers / one 40ft (two 20ft) and auto-marks an
assigned truck arrived on load; container-items payload + modal expose
container size with a client-side selection cap.
- Show "#x containers pending assignment" in the portal truck card and the
backoffice container modal.
Last-mile:
- create() is idempotent — return the existing record for a booking instead of
inserting a duplicate delivery row (fixed the same booking showing twice in
Assign-Mile).
- setVehicles/update reject a truck with no assigned driver; the Assign toast
now surfaces the reason.
- New GET /last-mile/booking/:id/arrival-trucks returns assigned EDR trucks with
driver details; ReleaseOrderModal fetches and auto-fills them so an assigned
EDR truck no longer reads as "not assigned yet".
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../bookings/customer-truck.service.ts | 34 ++++--
.../bookings/dto/load-customer-truck.dto.ts | 4 +-
.../modules/last-mile/last-mile.controller.ts | 6 +
.../modules/last-mile/last-mile.service.ts | 110 ++++++++++++++++++
.../warehouses/warehouse-inventory.service.ts | 4 +
.../warehouses/ContainerItemsModal.tsx | 60 ++++++++--
.../warehouses/ReleaseOrderModal.tsx | 36 ++++++
.../src/pages/operations/LastMilePage.tsx | 8 +-
.../src/services/warehouse.service.ts | 20 ++++
.../CustomerTruckAssignmentCard.tsx | 21 +++-
10 files changed, 278 insertions(+), 25 deletions(-)
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/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index 1c1be66ec..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
@@ -2830,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;
@@ -2846,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;
@@ -2860,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",
@@ -2896,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.
diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx
index 57be67d68..0a2dd5b7f 100644
--- a/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ContainerItemsModal.tsx
@@ -80,14 +80,17 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
() => (tab === 'ALL' ? items : items.filter((i) => i.stage === tab)),
[items, tab],
);
- // Only arrived, not-yet-departed trucks can be loaded.
+ // Any assigned, not-yet-departed truck can be loaded here — loading a truck at
+ // the warehouse auto-marks it arrived on the backend, so assigned-but-not-yet-
+ // arrived trucks are selectable too (labelled "assigned" until they arrive).
const truckOptions = trucks
- .filter(
- (t) =>
- Boolean((t as { arrivedAt?: string }).arrivedAt) &&
- !(t as { departedAt?: string }).departedAt,
- )
- .map((t) => ({ value: t.id, label: `${t.plateNumber} · ${t.driverName}` }));
+ .filter((t) => !(t as { departedAt?: string }).departedAt)
+ .map((t) => ({
+ value: t.id,
+ label: `${t.plateNumber} · ${t.driverName}${
+ (t as { arrivedAt?: string }).arrivedAt ? '' : ' (assigned)'
+ }`,
+ }));
const loadMutation = useMutation({
mutationFn: () => warehouseService.loadTruck(bookingId as string, truckId as string, selected),
@@ -125,7 +128,28 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
}
};
- const toggle = (n: string) => setSelected((s) => (s.includes(n) ? s.filter((x) => x !== n) : [...s, n]));
+ const is40 = (n: string) =>
+ (items.find((i) => i.containerNumber === n)?.containerSize ?? '').includes('40');
+
+ // A truck carries at most 2 containers, and a 40ft fills the truck (max 1).
+ const toggle = (n: string) =>
+ setSelected((s) => {
+ if (s.includes(n)) return s.filter((x) => x !== n);
+ const next = [...s, n];
+ if (next.length > 2) {
+ toast({ variant: 'destructive', title: 'A truck carries at most 2 containers' });
+ return s;
+ }
+ if (next.length > 1 && next.some(is40)) {
+ toast({
+ variant: 'destructive',
+ title: 'A 40ft container fills the truck',
+ description: 'Load only one 40ft container per truck.',
+ });
+ return s;
+ }
+ return next;
+ });
return (
Container
+ SizeGoodsStageTruck
@@ -182,6 +207,15 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
/>
{i.containerNumber}
+
+ {i.containerSize ? (
+
+ {i.containerSize}
+
+ ) : (
+ bulk
+ )}
+ {i.goods ?? '—'}{i.stage}{i.truckPlate ?? '—'}
@@ -221,11 +255,17 @@ export function ContainerItemsModal({ opened, onClose, bookingId, bookingReferen
{/* Multiselect → load onto a truck */}
- {selected.length} selected
+
+ {selected.length} selected
+ {(() => {
+ const pending = items.filter((i) => !i.truckAssignmentId).length;
+ return pending > 0 ? ` · ${pending} container${pending === 1 ? '' : 's'} pending assignment` : '';
+ })()}
+ warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
+ // EDR last-mile trucks assigned to this booking — surfaced even when the modal
+ // is opened from the warehouse flow (which passes no truckPrefill prop), so an
+ // assigned EDR truck no longer shows as "not assigned yet".
+ const { data: lastMileTrucks = [] } = useQuery({
+ queryKey: ['release-last-mile-trucks', bookingId],
+ queryFn: () => warehouseService.getLastMileTrucks(bookingId as string),
+ enabled: opened && Boolean(bookingId),
+ });
// Per-container cargo weights — the truck's net (gross − tare) must equal the
// total cargo weight of the containers selected as loaded on it.
const { data: containerWeights = [] } = useQuery({
@@ -176,6 +184,24 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
+ // Opened from the warehouse flow (no truckPrefill prop): once the last-mile
+ // truck query resolves, auto-fill the first assigned EDR truck — without
+ // overwriting anything the operator typed or the locked exit-step values.
+ useEffect(() => {
+ if (!opened || truckPrefill || isExitStep) return;
+ const first = lastMileTrucks[0];
+ if (!first) return;
+ setTruckPlateNumber((p) => p || first.truckPlateNumber || '');
+ setTrailerPlateNumber((p) => p || first.trailerPlateNumber || '');
+ setDriverName((p) => p || first.driverName || '');
+ setDriverLicense((p) => p || first.driverLicense || '');
+ setDriverPhone((p) => p || first.driverPhone || '');
+ setTruckType((p) => p || first.truckType || '');
+ setContainerNumbers((prev) =>
+ prev.length === 1 && !prev[0] && first.containerNumber ? [first.containerNumber] : prev,
+ );
+ }, [opened, truckPrefill, isExitStep, lastMileTrucks]);
+
// Registered trucks for THIS booking, from both sources: EDR last-mile
// (truckPrefill) and the customer portal (customer_truck_assignments).
const assignedTruckOptions = [
@@ -199,6 +225,16 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
driverPhone: '',
truckType: t.truckType,
})),
+ ...lastMileTrucks
+ .filter((t) => t.truckPlateNumber || t.vehicleId)
+ .map((t) => ({
+ value: (t.truckPlateNumber || t.vehicleId) as string,
+ label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
+ trailerPlate: t.trailerPlateNumber ?? '',
+ driverName: t.driverName ?? '',
+ driverPhone: t.driverPhone ?? '',
+ truckType: t.truckType ?? '',
+ })),
];
// Only trucks actually assigned to THIS booking (last-mile prefill or customer
// portal) are selectable. No global fleet list — if nothing is assigned, the
diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
index 062332107..e5de54880 100644
--- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx
@@ -666,8 +666,12 @@ const LastMilePage = () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: ["vehicles"] });
},
- onError: () => {
- toast({ title: "Assign failed", variant: "destructive" });
+ onError: (e: unknown) => {
+ // Surface the backend reason (e.g. "Truck … has no assigned driver …").
+ const raw = (e as { response?: { data?: { message?: string | string[] } } })?.response?.data
+ ?.message;
+ const description = Array.isArray(raw) ? raw.join(", ") : raw;
+ toast({ title: "Assign failed", description, variant: "destructive" });
},
});
diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
index febde7f85..53ba263a5 100644
--- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
+++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts
@@ -71,6 +71,8 @@ export type ContainerItemStage = 'PENDING' | 'RECEIVED' | 'GRN' | 'ASSIGNED' | '
export interface ContainerItem {
containerNumber: string;
goods: string | null;
+ /** Container size, e.g. "20ft" / "40ft"; null for bulk. */
+ containerSize: string | null;
stage: ContainerItemStage;
grnNumber: string | null;
truckAssignmentId: string | null;
@@ -126,6 +128,18 @@ const cleanParams = (params: object) =>
Object.entries(params).filter(([, value]) => value !== undefined && value !== '' && value !== null),
);
+/** An assigned EDR last-mile truck, shaped for the arrival/exit weighing prefill. */
+export interface LastMileArrivalTruck {
+ vehicleId: string;
+ truckPlateNumber: string | null;
+ trailerPlateNumber: string | null;
+ driverName: string | null;
+ driverLicense: string | null;
+ driverPhone: string | null;
+ truckType: string | null;
+ containerNumber: string | null;
+}
+
export const warehouseService = {
/** Customer self-haul trucks assigned to a booking (portal multi-truck). */
getCustomerTrucks: async (bookingId: string): Promise => {
@@ -133,6 +147,12 @@ export const warehouseService = {
return data?.data ?? data ?? [];
},
+ /** Assigned EDR last-mile trucks for a booking (arrival/exit weighing prefill). */
+ getLastMileTrucks: async (bookingId: string): Promise => {
+ const { data } = await apiClient.get(`/last-mile/booking/${bookingId}/arrival-trucks`);
+ return data?.data ?? data ?? [];
+ },
+
/** Per-container/bulk items of a booking with lifecycle stage + refs. */
getContainerItems: async (bookingId: string): Promise => {
const { data } = await apiClient.get(
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
index a73057fcd..7c9cbeb0e 100644
--- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
+++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx
@@ -77,6 +77,10 @@ export function CustomerTruckAssignmentCard({
const availableContainers = (booking.containerNumbers ?? []).filter(
(n) => !assignedNumbers.has(n) || editingOwn.has(n),
);
+ // Containers on the booking not yet assigned to any truck (independent of edit).
+ const pendingAssignmentCount = (booking.containerNumbers ?? []).filter(
+ (n) => !assignedNumbers.has(n),
+ ).length;
// Both import and export specify the containers each truck carries.
const resetForm = () => {
@@ -158,11 +162,18 @@ export function CustomerTruckAssignmentCard({
External Truck Assignment
- {trucks.length > 0 && (
-
- {trucks.length} truck{trucks.length !== 1 ? "s" : ""}
-
- )}
+
+ {pendingAssignmentCount > 0 && (
+
+ {pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment
+
+ )}
+ {trucks.length > 0 && (
+
+ {trucks.length} truck{trucks.length !== 1 ? "s" : ""}
+
+ )}
+
{/* Assigned trucks */}
From fc684a0f65c139129434ac61558a3d000f77220e Mon Sep 17 00:00:00 2001
From: Abubeker Yasin
Date: Tue, 14 Jul 2026 20:04:42 +0300
Subject: [PATCH 18/29] feat: ( permissions ) add permssions for master data
---
.../src/modules/fleet/fleet.controller.ts | 15 ++-
.../modules/packages/packages.controller.ts | 24 ++--
.../modules/payments/payments.controller.ts | 15 ++-
.../modules/schedules/routes.controller.ts | 14 +--
.../modules/schedules/schedules.controller.ts | 29 ++---
.../seat-classes/seat-classes.controller.ts | 10 +-
.../src/modules/seats/seats.controller.ts | 15 +--
.../modules/stations/stations.controller.ts | 14 +--
.../src/seed/edr-passenger.seed.ts | 15 +++
.../seed/passenger-permissions.registry.ts | 107 ++++++++++++++++++
.../src/app/payment-methods/page.tsx | 9 +-
.../src/components/layout/Sidebar.tsx | 26 ++---
.../backoffice/src/lib/permissions.ts | 69 +++++++++--
13 files changed, 282 insertions(+), 80 deletions(-)
diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts
index d3864e2b2..ee2a048f0 100644
--- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts
+++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts
@@ -3,7 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR
import { FleetService } from './fleet.service';
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Fleet')
@Controller('fleet')
@@ -21,6 +22,7 @@ export class FleetController {
}
@Post('coach-types')
+ @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a coach type' })
@ApiBody({ type: CreateCoachTypeDto })
@ApiResponse({ status: 201, description: 'Coach type created' })
@@ -29,6 +31,7 @@ export class FleetController {
}
@Patch('coach-types/:id')
+ @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a coach type' })
@ApiParam({ name: 'id', description: 'Coach Type UUID' })
@ApiBody({ type: UpdateCoachTypeDto })
@@ -59,6 +62,7 @@ export class FleetController {
}
@Post('classes')
+ @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a class' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
@@ -67,6 +71,7 @@ export class FleetController {
}
@Patch('classes/:id')
+ @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a class' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@@ -98,6 +103,7 @@ export class FleetController {
}
@Post('seat-classes')
+ @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a class (DEPRECATED - use /fleet/classes)' })
@ApiBody({ type: CreateClassDto })
@ApiResponse({ status: 201, description: 'Class created' })
@@ -106,6 +112,7 @@ export class FleetController {
}
@Patch('seat-classes/:id')
+ @PassengerStaff([PASSENGER_PERMS.classes.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a class (DEPRECATED - use /fleet/classes)' })
@ApiParam({ name: 'id', description: 'Class UUID' })
@ApiBody({ type: UpdateClassDto })
@@ -136,6 +143,7 @@ export class FleetController {
}
@Post('trains')
+ @PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a train service' })
@ApiBody({ type: CreateTrainDto })
@ApiResponse({ status: 201, description: 'Train created' })
@@ -144,6 +152,7 @@ export class FleetController {
}
@Patch('trains/:id')
+ @PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update a train service' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiBody({ type: CreateTrainDto })
@@ -166,6 +175,7 @@ export class FleetController {
}
@Patch('trains/:id/restore')
+ @PassengerStaff([PASSENGER_PERMS.trains.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Restore (reactivate) a deactivated train' })
@ApiParam({ name: 'id', description: 'Train UUID' })
@ApiResponse({ status: 200, description: 'Train restored' })
@@ -268,6 +278,7 @@ export class FleetController {
}
@Post('coaches')
+ @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Create a coach with auto-generated seat numbers' })
@ApiBody({ type: CreateCoachDto })
@ApiResponse({
@@ -293,6 +304,7 @@ export class FleetController {
}
@Patch('coaches/:id')
+ @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Update coach properties' })
@ApiParam({ name: 'id', description: 'Coach UUID' })
@ApiBody({ type: UpdateCoachDto })
@@ -331,6 +343,7 @@ export class FleetController {
}
@Post('assignments')
+ @PassengerStaff([PASSENGER_PERMS.coaches.manage, PASSENGER_PERMS.admin])
@ApiOperation({ summary: 'Assign a coach to a schedule' })
@ApiBody({ type: AssignCoachDto })
@ApiResponse({ status: 201, description: 'Coach assigned' })
diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts
index 314f8aeb4..79f04f9d9 100644
--- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts
+++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts
@@ -3,10 +3,10 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { PackagesService } from './packages.service';
import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto, PackageBookingContextDto } from './packages.dto';
-import { IamGuard } from '../../common/iam-adapter';
import { JwtGuard } from '../../common/jwt.guard';
import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Packages')
@Controller('packages')
@@ -21,7 +21,7 @@ export class PackagesController {
}
@Get('inquiries')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.inquiries.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all inquiries (backoffice)' })
listInquiries(
@@ -34,7 +34,7 @@ export class PackagesController {
}
@Patch('inquiries/:id/status')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.inquiries.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update inquiry status (backoffice)' })
updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) {
@@ -57,7 +57,7 @@ export class PackagesController {
}
@Get('all')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all packages (backoffice)' })
listAll(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
@@ -65,7 +65,7 @@ export class PackagesController {
}
@Get('bookings')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'List all package bookings (backoffice)' })
listBookings(
@@ -124,7 +124,7 @@ export class PackagesController {
}
@Post()
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create package (admin)' })
create(@Body() dto: CreatePackageDto) {
@@ -132,7 +132,7 @@ export class PackagesController {
}
@Patch(':id')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update package (admin)' })
update(@Param('id') id: string, @Body() dto: Partial) {
@@ -149,7 +149,7 @@ export class PackagesController {
}
@Patch(':id/activate')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Activate package (admin)' })
activate(@Param('id') id: string) {
@@ -157,7 +157,7 @@ export class PackagesController {
}
@Patch(':id/deactivate')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Deactivate package (admin)' })
deactivate(@Param('id') id: string) {
@@ -165,7 +165,7 @@ export class PackagesController {
}
@Post(':id/tiers')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add price tier to package (admin)' })
addTier(@Param('id') id: string, @Body() dto: CreatePriceTierDto) {
@@ -173,7 +173,7 @@ export class PackagesController {
}
@Patch('tiers/:tierId')
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.packages.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update price tier (admin)' })
updateTier(@Param('tierId') tierId: string, @Body() dto: UpdatePriceTierDto) {
diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
index 8917c75d3..f917a9590 100644
--- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
+++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts
@@ -55,7 +55,11 @@ export class PaymentsController {
}
@Get("all")
- @PassengerStaff([PASSENGER_PERMS.payments.viewAll, PASSENGER_PERMS.admin])
+ @PassengerStaff([
+ PASSENGER_PERMS.payments.view,
+ PASSENGER_PERMS.payments.viewAll,
+ PASSENGER_PERMS.admin,
+ ])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Get all payments with filters (staff/admin only)" })
@ApiQuery({ name: "search", required: false })
@@ -146,7 +150,11 @@ export class PaymentsController {
}
@Post("refund")
- @PassengerStaff([PASSENGER_PERMS.payments.refund, PASSENGER_PERMS.admin])
+ @PassengerStaff([
+ PASSENGER_PERMS.payments.manage,
+ PASSENGER_PERMS.payments.refund,
+ PASSENGER_PERMS.admin,
+ ])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Refund a confirmed booking (staff/agent only)" })
refund(@Body() dto: RefundDto) {
@@ -155,6 +163,7 @@ export class PaymentsController {
@Post(":bookingId/force-confirm")
@PassengerStaff([
+ PASSENGER_PERMS.payments.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
])
@@ -174,6 +183,7 @@ export class PaymentsController {
@Post("methods")
@PassengerStaff([
+ PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
])
@@ -187,6 +197,7 @@ export class PaymentsController {
@Patch("methods/:id")
@PassengerStaff([
+ PASSENGER_PERMS.paymentMethods.manage,
PASSENGER_PERMS.payments.manageMethods,
PASSENGER_PERMS.admin,
])
diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts
index e751278ce..d468bba7c 100644
--- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts
@@ -1,9 +1,9 @@
-import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { RoutesService } from './routes.service';
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto';
-import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Routes')
@Controller('routes')
@@ -13,7 +13,7 @@ export class RoutesController {
// ── Routes ─────────────────────────────────────────────────────────────────
@Post()
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Create a reusable route with its ordered stops',
description: `Define the physical corridor once (e.g. ADD→ADM→AWS→DDW→AYS→DJI).
@@ -41,7 +41,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getRoute(@Param('id') id: string) { return this.service.getRoute(id); }
@Patch(':id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 200, description: 'Route updated' })
@@ -68,7 +68,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Post(':id/stops')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Add a stop to an existing route' })
@ApiParam({ name: 'id', description: 'Route UUID' })
@ApiResponse({ status: 201, description: 'Stop added' })
@@ -108,7 +108,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`,
getCoachTemplate(@Param('id') id: string) { return this.service.getRouteCoachTemplate(id); }
@Put(':id/coaches')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Set the default coach lineup for this route',
description: 'Replaces the entire coach template. Coaches are auto-assigned in this order when a new schedule is created for this route.',
diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
index 9fc2ba851..a2d62d5aa 100644
--- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
+++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts
@@ -1,10 +1,10 @@
-import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe, UseGuards } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SchedulesService } from './schedules.service';
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
-import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Schedule')
@Controller('schedules')
@@ -12,14 +12,14 @@ export class SchedulesController {
constructor(private service: SchedulesService) {}
@Post('bulk-generate')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Bulk generate repetitive schedules' })
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
return this.service.bulkGenerateSchedules(dto);
}
@Post()
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a train schedule from a route template' })
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
@@ -42,13 +42,13 @@ export class SchedulesController {
// ===== SPECIFIC ROUTES (must come BEFORE generic :id routes) =====
@Post('fares')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a fare rule scoped to a schedule or route code' })
@ApiResponse({ status: 201, description: 'Fare rule created' })
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
@Patch('fares/:id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a fare rule' })
@ApiParam({ name: 'id', description: 'FareRule UUID' })
@ApiResponse({ status: 200, description: 'Fare rule updated' })
@@ -65,7 +65,7 @@ export class SchedulesController {
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
@Post('segment-fares')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a segment fare rule' })
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
@@ -76,7 +76,7 @@ export class SchedulesController {
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
@Patch('segment-fares/:id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a segment fare rule' })
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
@@ -97,7 +97,7 @@ export class SchedulesController {
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
@Patch(':id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a schedule (partial)' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
@@ -105,7 +105,7 @@ export class SchedulesController {
}
@Patch(':id/status')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update schedule status' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
@@ -127,7 +127,7 @@ export class SchedulesController {
getStops(@Param('id') id: string) { return this.service.getStops(id); }
@Patch(':id/stops/:sequence')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a stop time' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
@@ -138,7 +138,7 @@ export class SchedulesController {
) { return this.service.updateStop(id, sequence, dto); }
@Put(':scheduleId/fares/:seatClassId')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({
summary: 'Override fare for a specific seat class on a schedule',
description: 'Upserts a schedule-scoped FareRule. Expires any existing active rule for the same schedule+seatClass and creates a new one.',
@@ -186,12 +186,13 @@ export class SchedulesController {
}
@Post(':id/fares/sync')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Sync fares from fare engine' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
@Post(':id/coaches')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Assign coaches to a schedule' })
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
assignCoaches(
diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts
index 4eb6c212b..453256d7e 100644
--- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts
+++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts
@@ -1,10 +1,10 @@
-import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiResponse, ApiBody } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { SeatClassesService } from './seat-classes.service';
import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto';
-import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Seat Classes')
@Controller('seat-classes')
@@ -26,7 +26,7 @@ export class SeatClassesController {
getSeatClass(@Param('id') id: string) { return this.service.getSeatClass(id); }
@Post()
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create a seat class' })
@ApiBody({ type: CreateSeatClassDto })
@ApiResponse({ status: 201, description: 'Seat class created' })
@@ -34,7 +34,7 @@ export class SeatClassesController {
createSeatClass(@Body() dto: CreateSeatClassDto) { return this.service.createSeatClass(dto); }
@Patch(':id')
- @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.tariffRates.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update a seat class' })
@ApiParam({ name: 'id', description: 'Seat class UUID' })
@ApiBody({ type: UpdateSeatClassDto })
diff --git a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts
index fa233f45c..752b66390 100644
--- a/apps/edr-passenger-api/src/modules/seats/seats.controller.ts
+++ b/apps/edr-passenger-api/src/modules/seats/seats.controller.ts
@@ -21,7 +21,8 @@ import {
import { SeatsService } from "./seats.service";
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
import { JwtGuard } from "../../common/jwt.guard";
-import { IamGuard } from "../../common/iam-adapter";
+import { PassengerStaff } from "../../common/passenger-guards";
+import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags("Seats")
@Controller("seats")
@@ -205,7 +206,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
// ── Seat Block / Unblock ───────────────────────────────────────────────────
@Post(":seatId/block")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Block a seat (e.g., maintenance, damage)" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -215,7 +216,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
}
@Delete(":seatId/block")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Unblock a seat" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -226,7 +227,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
// ── Maintenance ───────────────────────────────────────────────────────────
@Post(":seatId/maintenance")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Set seat status to Under Maintenance" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -236,7 +237,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
}
@Delete(":seatId/maintenance")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({ summary: "Clear seat maintenance status" })
@ApiParam({ name: "seatId", description: "Seat UUID" })
@@ -247,7 +248,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
// ── Remove Seat ────────────────────────────────────────────────────────────
@Patch(":seatId/remove")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Remove a seat by marking with negative seatNumber",
@@ -263,7 +264,7 @@ This makes it clear which segment of the route each seat is held for, enabling s
}
@Patch(":seatId/undo-remove")
- @UseGuards(IamGuard)
+ @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
@ApiBearerAuth("IAM-auth")
@ApiOperation({
summary: "Undo seat removal by restoring original seatNumber",
diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts
index 0f367043b..5ddd64383 100644
--- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts
+++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts
@@ -1,10 +1,10 @@
-import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Query } from '@nestjs/common';
+import { Body, Controller, Get, Param, Post, Patch, Delete, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
import { StationsService } from './stations.service';
import { CreateStationDto } from './stations.dto';
-import { JwtGuard } from '../../common/jwt.guard';
-import { PassengerAdmin } from '../../common/passenger-guards';
+import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
+import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
@ApiTags('Stations')
@Controller('stations')
@@ -79,8 +79,8 @@ export class StationsController {
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
- @UseGuards(JwtGuard)
- @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
+ @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Create new station' })
@ApiResponse({
status: 201,
@@ -105,8 +105,8 @@ export class StationsController {
create(@Body() dto: CreateStationDto) { return this.service.create(dto); }
@Patch(':id')
- @UseGuards(JwtGuard)
- @ApiBearerAuth('JWT-auth')
+ @PassengerStaff([PASSENGER_PERMS.stations.manage, PASSENGER_PERMS.admin])
+ @ApiBearerAuth('IAM-auth')
@ApiOperation({ summary: 'Update station' })
@ApiResponse({
status: 200,
diff --git a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts
index 578cf66ff..4313d5426 100644
--- a/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts
+++ b/apps/edr-passenger-api/src/seed/edr-passenger.seed.ts
@@ -54,4 +54,19 @@ export const EDR_PASSENGER_ROLES: PassengerSeedRole[] = [
name: { en: 'EDR Passenger Finance' },
permissionKeys: [...ROLE_PERMISSION_PRESETS.finance],
},
+ {
+ key: 'edr_passenger_operations_manager',
+ name: { en: 'EDR Passenger Operations Manager' },
+ permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsManager],
+ },
+ {
+ key: 'edr_passenger_marketing_manager',
+ name: { en: 'EDR Passenger Marketing Manager' },
+ permissionKeys: [...ROLE_PERMISSION_PRESETS.marketingManager],
+ },
+ {
+ key: 'edr_passenger_finance_manager',
+ name: { en: 'EDR Passenger Finance Manager' },
+ permissionKeys: [...ROLE_PERMISSION_PRESETS.financeManager],
+ },
];
diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
index bab7787b9..d62d5a261 100644
--- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
+++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts
@@ -34,6 +34,38 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [
perm('75b5ff62-a8e4-4331-b6e6-d53e1456d10e', 'edr_passenger_app:currencies:manage', 'Manage currencies'),
perm('4a47da9b-cf6e-4240-aff8-aadf01641c54', 'edr_passenger_app:notifications:send', 'Send notifications'),
perm('bfe3428f-8b85-4a36-87c6-33063b084bf3', 'edr_passenger_app:dashboard:view', 'View dashboard'),
+
+ // ── Master Data ────────────────────────────────────────────────────────────
+ perm('102969bc-13a2-4f4a-aa7f-ce4b2599ce82', 'edr_passenger_app:stations:view', 'View stations'),
+ perm('931cf6fc-8f41-46b6-82b8-b242f75d296e', 'edr_passenger_app:stations:manage', 'Manage stations'),
+ perm('82a801b3-2451-409b-99e8-9f4d3515d329', 'edr_passenger_app:trains:view', 'View trains'),
+ perm('e2a7f842-7691-4007-bd71-fd91c467044d', 'edr_passenger_app:trains:manage', 'Manage trains'),
+ perm('71de593c-ae4a-4d15-9f0a-b889eeb4910c', 'edr_passenger_app:coaches:view', 'View coaches'),
+ perm('39b3de2e-c779-4010-8ef3-d478f00a15c6', 'edr_passenger_app:coaches:manage', 'Manage coaches'),
+ perm('ba0fdd0b-6580-48a8-b31f-eb5ef76a2453', 'edr_passenger_app:seats:view', 'View seats'),
+ perm('6fb9affb-1885-446e-a8e4-962af9fae33f', 'edr_passenger_app:seats:manage', 'Manage seats'),
+ perm('b7b659d9-f453-41d3-a6db-72e671446214', 'edr_passenger_app:classes:view', 'View classes'),
+ perm('b2d06665-33f5-46d7-a895-785b5fb1896a', 'edr_passenger_app:classes:manage', 'Manage classes'),
+ perm('8731ee98-24c2-4f8b-9c06-cf3fa900a95a', 'edr_passenger_app:routes:view', 'View routes'),
+ perm('5851233c-78de-45b9-9d3f-63816d068622', 'edr_passenger_app:routes:manage', 'Manage routes'),
+ perm('c453bdf9-496a-4ac8-b733-8eb5dd5d591a', 'edr_passenger_app:schedules:view', 'View schedules'),
+ perm('d3f3cfd0-c7ce-47ab-be7f-bf3d6b40e488', 'edr_passenger_app:schedules:manage', 'Manage schedules'),
+
+ // ── Tourism ────────────────────────────────────────────────────────────────
+ perm('d78d810b-3003-4d81-92d5-41c437f3cc42', 'edr_passenger_app:packages:view', 'View packages'),
+ perm('dcfab0d9-1f80-4822-892a-e2851b549297', 'edr_passenger_app:packages:manage', 'Manage packages'),
+ perm('6d7ab68c-1b88-405d-9f92-b130055eece6', 'edr_passenger_app:inquiries:view', 'View package inquiries'),
+ perm('dbe5a07a-d12f-4a36-b191-0bb4f980054e', 'edr_passenger_app:inquiries:manage', 'Manage package inquiries'),
+
+ // ── Finance ────────────────────────────────────────────────────────────────
+ perm('4b6efd87-f230-4109-abe1-593e53cb0c10', 'edr_passenger_app:tariff_rates:view', 'View tariff rates'),
+ perm('94f17a59-397c-4c9c-a424-38a9c66c9e50', 'edr_passenger_app:tariff_rates:manage', 'Manage tariff rates'),
+ perm('2dc4eb75-5b28-4ead-a2d4-82ac95cd290c', 'edr_passenger_app:payments:view', 'View payments'),
+ perm('418b5f64-656b-4d44-a543-930ada9ec1a7', 'edr_passenger_app:payments:manage', 'Manage payments'),
+ perm('3f3d5479-af33-4883-867e-aae9e2aeeeca', 'edr_passenger_app:currencies:view', 'View currencies'),
+ perm('b4e63290-cc3a-4df8-9f2a-9ff726e86e36', 'edr_passenger_app:payment_methods:view', 'View payment methods'),
+ perm('f9fb6af2-e869-4e6e-938c-259643393315', 'edr_passenger_app:payment_methods:manage', 'Manage payment methods'),
+
perm('49fd28cd-5b58-4403-8e53-1df4b93cbbd2', 'edr_passenger_app:admin', 'Full admin access'),
];
@@ -54,10 +86,57 @@ export const PASSENGER_PERMS = {
manage: 'edr_passenger_app:tickets:manage',
},
payments: {
+ view: 'edr_passenger_app:payments:view',
+ manage: 'edr_passenger_app:payments:manage',
+ // legacy keys — retained as aliases for backward compatibility
viewAll: 'edr_passenger_app:payments:view_all',
refund: 'edr_passenger_app:payments:refund',
manageMethods: 'edr_passenger_app:payments:manage_methods',
},
+ paymentMethods: {
+ view: 'edr_passenger_app:payment_methods:view',
+ manage: 'edr_passenger_app:payment_methods:manage',
+ },
+ stations: {
+ view: 'edr_passenger_app:stations:view',
+ manage: 'edr_passenger_app:stations:manage',
+ },
+ trains: {
+ view: 'edr_passenger_app:trains:view',
+ manage: 'edr_passenger_app:trains:manage',
+ },
+ coaches: {
+ view: 'edr_passenger_app:coaches:view',
+ manage: 'edr_passenger_app:coaches:manage',
+ },
+ seats: {
+ view: 'edr_passenger_app:seats:view',
+ manage: 'edr_passenger_app:seats:manage',
+ },
+ classes: {
+ view: 'edr_passenger_app:classes:view',
+ manage: 'edr_passenger_app:classes:manage',
+ },
+ routes: {
+ view: 'edr_passenger_app:routes:view',
+ manage: 'edr_passenger_app:routes:manage',
+ },
+ schedules: {
+ view: 'edr_passenger_app:schedules:view',
+ manage: 'edr_passenger_app:schedules:manage',
+ },
+ packages: {
+ view: 'edr_passenger_app:packages:view',
+ manage: 'edr_passenger_app:packages:manage',
+ },
+ inquiries: {
+ view: 'edr_passenger_app:inquiries:view',
+ manage: 'edr_passenger_app:inquiries:manage',
+ },
+ tariffRates: {
+ view: 'edr_passenger_app:tariff_rates:view',
+ manage: 'edr_passenger_app:tariff_rates:manage',
+ },
reports: {
view: 'edr_passenger_app:reports:view',
},
@@ -73,6 +152,7 @@ export const PASSENGER_PERMS = {
manage: 'edr_passenger_app:agents:manage',
},
currencies: {
+ view: 'edr_passenger_app:currencies:view',
manage: 'edr_passenger_app:currencies:manage',
},
notifications: {
@@ -126,9 +206,36 @@ export const ROLE_PERMISSION_PRESETS = {
],
finance: [
+ PASSENGER_PERMS.payments.view,
PASSENGER_PERMS.payments.viewAll,
PASSENGER_PERMS.payments.refund,
PASSENGER_PERMS.reports.view,
PASSENGER_PERMS.dashboard.view,
],
+
+ operationsManager: [
+ PASSENGER_PERMS.stations.view, PASSENGER_PERMS.stations.manage,
+ PASSENGER_PERMS.trains.view, PASSENGER_PERMS.trains.manage,
+ PASSENGER_PERMS.coaches.view, PASSENGER_PERMS.coaches.manage,
+ PASSENGER_PERMS.seats.view, PASSENGER_PERMS.seats.manage,
+ PASSENGER_PERMS.classes.view, PASSENGER_PERMS.classes.manage,
+ PASSENGER_PERMS.routes.view, PASSENGER_PERMS.routes.manage,
+ PASSENGER_PERMS.schedules.view, PASSENGER_PERMS.schedules.manage,
+ PASSENGER_PERMS.dashboard.view,
+ ],
+
+ marketingManager: [
+ PASSENGER_PERMS.packages.view, PASSENGER_PERMS.packages.manage,
+ PASSENGER_PERMS.inquiries.view, PASSENGER_PERMS.inquiries.manage,
+ PASSENGER_PERMS.dashboard.view,
+ ],
+
+ financeManager: [
+ PASSENGER_PERMS.tariffRates.view, PASSENGER_PERMS.tariffRates.manage,
+ PASSENGER_PERMS.payments.view, PASSENGER_PERMS.payments.manage,
+ PASSENGER_PERMS.currencies.view, PASSENGER_PERMS.currencies.manage,
+ PASSENGER_PERMS.paymentMethods.view, PASSENGER_PERMS.paymentMethods.manage,
+ PASSENGER_PERMS.reports.view,
+ PASSENGER_PERMS.dashboard.view,
+ ],
} as const;
diff --git a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx
index 8937497c6..4e6a71950 100644
--- a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx
+++ b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx
@@ -9,14 +9,13 @@ import ActionButton from '@/components/ui/ActionButton';
import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { apiClient, paymentsApi } from '@/lib/api';
-import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { usePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions';
export default function PaymentMethodsPage() {
- const canManagePayments = usePermission(PERMS.payments.manage);
+ const canManageMethods = usePermission(PERMS.paymentMethods.manage);
const canManageAdmin = usePermission(PERMS.admin);
- const canManage = canManagePayments || canManageAdmin;
+ const canManage = canManageMethods || canManageAdmin;
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -228,11 +227,11 @@ export default function PaymentMethodsPage() {
+ Segment overrides set a fixed total price for a specific origin→destination stop pair, bypassing per-km calculation.
+ Precedence: Segment Override → Route Override → Seat Class Tariff.
+