Merge remote-tracking branch 'origin/dev' into dj-franc

This commit is contained in:
ghost2023
2026-09-06 21:57:27 +03:00
149 changed files with 10387 additions and 566 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ node_modules/
# build output
**/dist/
.next/
**/out/
coverage/
*.tsbuildinfo
**/*.tsbuildinfo

View File

@@ -32,6 +32,7 @@ copying a pattern across:
| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
| `edr-landing` | `@edr/landing` | **Next.js** static export | 5163 |
Those are the **fallbacks compiled into the code**, not what you will be running. Every
port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
@@ -43,8 +44,16 @@ the whole team and the low ports are contested — see the workspace root `CLAUD
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
packages (see `pnpm-workspace.yaml`).
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
package and is not built, linted, or type-checked. Leave it alone unless asked.
`apps/edr-landing/` is the public front door at `edrsc.com`: one static page that routes
visitors to the passenger or freight app. It is a Next.js **static export**
(`output: 'export'`), so its build artifact is `out/`, not `.next/`. It depends on no
workspace package — not even `@edr/ui-common`, whose Tailwind 4 tokens do not fit its
Tailwind 3 setup.
Its two destinations come from `NEXT_PUBLIC_PASSENGER_URL` and `NEXT_PUBLIC_FREIGHT_URL`
(each an origin; the entry path is appended in `src/lib/apps.ts`). A static export inlines
those at **build** time, so they must be passed as Docker build args — setting them in the
runtime environment does nothing.
`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.

View File

@@ -109,6 +109,7 @@ import { ExportsModule } from "./modules/exports/exports.module";
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
import { DriversModule } from "./modules/drivers/drivers.module";
import { TrainCrewModule } from "./modules/train-crew/train-crew.module";
import { FuelModule } from "./modules/fuel/fuel.module";
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
import { ComplianceModule } from "./modules/compliance/compliance.module";
@@ -252,6 +253,7 @@ if (!process.env.APPLICATION_NAME) {
UserTradeAccessModule,
VehiclesModule,
DriversModule,
TrainCrewModule,
FuelModule,
MaintenanceModule,
ComplianceModule,

View File

@@ -17,9 +17,9 @@ export default registerAs("app", () => ({
portalBaseUrl: (
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
).replace(/\/+$/, ""),
// Train weight/length are not env-configured: they come from locomotive
// configuration (see TrainSchedulingService.resolveTrainLimitConfig).
trainScheduling: {
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
},
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).

View File

@@ -124,6 +124,8 @@ export class ContractDocumentViewModelBuilder {
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
contract.serviceType?.includesEthiopianCustomsOnly,
// An empty-equipment contract resolves to the carriage-only paper.
contract.cargoCondition,
);
dynamicTemplate = dynamicSource
? {

View File

@@ -96,3 +96,55 @@ describe('ContractRateScheduleBuilder', () => {
expect(s.isEmpty).toBe(true);
});
});
/**
* Empty and laden freight are separate tariffs on the same lanes. Each
* contract's schedule must show only its own, or the printed paper quotes a
* price the customer is not being charged.
*/
describe('ContractRateScheduleBuilder — empty container contracts', () => {
const ladenImport = rate({
appliesTo: 'CONTAINER',
tradeDirection: 'IMPORT',
rateType: 'CONTAINER_IMPORT',
rateValue: 900,
originYard: { label: 'Negad' } as never,
destinationYard: { label: 'Mojo Dry Port' } as never,
containerType: { label: '40ft GP' } as never,
});
const emptyImport = rate({
appliesTo: 'EMPTY_CONTAINER',
tradeDirection: 'IMPORT',
rateType: 'EMPTY_CONTAINER_IMPORT',
rateValue: 250,
originYard: { label: 'Negad' } as never,
destinationYard: { label: 'Mojo Dry Port' } as never,
containerType: { label: '40ft GP' } as never,
});
const builder = new ContractRateScheduleBuilder({
findLiveRatesDetailed: jest.fn().mockResolvedValue([ladenImport, emptyImport]),
} as never);
it('shows only the empty lane on an empty contract', async () => {
const schedule = await builder.build('IMP', 'CON', 'EMPTY');
expect(schedule.freightLanes).toHaveLength(1);
expect(schedule.freightLanes[0].amount).toBe('250');
});
it('shows only the laden lane on a laden contract', async () => {
const schedule = await builder.build('IMP', 'CON', 'LADEN');
expect(schedule.freightLanes).toHaveLength(1);
expect(schedule.freightLanes[0].amount).toBe('900');
});
it('treats a contract with no condition as laden', async () => {
const schedule = await builder.build('IMP', 'CON');
expect(schedule.freightLanes).toHaveLength(1);
expect(schedule.freightLanes[0].amount).toBe('900');
});
});

View File

@@ -84,7 +84,9 @@ export class ContractRateScheduleBuilder {
async build(
direction: ContractDirection,
freight: ContractFreight,
cargoCondition?: string | null,
): Promise<RateSchedule> {
const isEmpty = cargoCondition === 'EMPTY';
const rates = await this.ratesService.findLiveRatesDetailed();
const freightLanes: RateScheduleRow[] = [];
@@ -93,7 +95,7 @@ export class ContractRateScheduleBuilder {
for (const rate of rates) {
if (this.isBaseFreight(rate)) {
if (this.baseFreightMatches(rate, direction, freight)) {
if (this.baseFreightMatches(rate, direction, freight, isEmpty)) {
freightLanes.push(this.laneRow(rate));
}
continue;
@@ -140,6 +142,7 @@ export class ContractRateScheduleBuilder {
rate.trigger === 'ALWAYS' &&
(rate.appliesTo === 'BULK' ||
rate.appliesTo === 'CONTAINER' ||
rate.appliesTo === 'EMPTY_CONTAINER' ||
rate.appliesTo === 'INTERCITY')
);
}
@@ -148,7 +151,19 @@ export class ContractRateScheduleBuilder {
rate: Rate,
direction: ContractDirection,
freight: ContractFreight,
isEmpty = false,
): boolean {
// Empty and laden are separate tariffs on the same lanes, so each contract
// shows only its own. Without this an empty contract would print the laden
// lane prices it is not being charged.
if (isEmpty) {
return (
rate.appliesTo === 'EMPTY_CONTAINER' &&
rate.tradeDirection === (direction === 'EXP' ? 'EXPORT' : 'IMPORT')
);
}
if (rate.appliesTo === 'EMPTY_CONTAINER') return false;
// Domestic contracts price off intercity rates; the freight kind is carried
// in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER).
if (direction === 'DOM') {

View File

@@ -140,6 +140,8 @@ export class ContractViewModelBuilder {
const rateSchedule = await this.rateScheduleBuilder.build(
template.direction,
template.freight,
// Empty bookings print the empty tariff, never the laden lane prices.
booking.cargoCondition,
);
const signatures = await this.loadSignatures(bookingId);
const logoImageUrl = await this.logoSettings.getLogoImageUrl();

View File

@@ -0,0 +1,68 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Roster of people assignable to a train (ITLMS Rolling Stock §1.2 crew
* composition). Separate from freight.drivers, which registers road/last-mile
* truck drivers and shares none of these fields.
*
* Role and nationality are stored as varchar rather than PG enums so adding a
* crew role later is an application change, not a type migration. The partial
* unique index keys on name + role — the roster has no employee number yet, so
* that is the only identity available to block an accidental re-entry; it is
* scoped to live rows so a soft-deleted member does not hold the name hostage.
*/
export class TrainCrewMembers3850000000000 implements MigrationInterface {
name = 'TrainCrewMembers3850000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_crew_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
first_name varchar(100) NOT NULL,
last_name varchar(100) NOT NULL,
role varchar(32) NOT NULL,
nationality varchar(16) NOT NULL,
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_train_crew_role CHECK (role IN (
'TRAIN_DRIVER','FEDERAL_POLICE','TECHNICIAN','REEFER_TECHNICIAN',
'HAZMAT_ESCORT','LASHING_INSPECTOR','LIVESTOCK_HANDLER'
)),
CONSTRAINT chk_train_crew_nationality CHECK (nationality IN (
'ETHIOPIAN','DJIBOUTIAN'
)),
CONSTRAINT chk_train_crew_status CHECK (status IN (
'ACTIVE','INACTIVE','SUSPENDED','ON_LEAVE'
))
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_crew_members_role
ON freight.train_crew_members (role)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_crew_members_nationality
ON freight.train_crew_members (nationality)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_crew_members_status
ON freight.train_crew_members (status)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_crew_members_is_active
ON freight.train_crew_members (is_active)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_crew_members_name_role
ON freight.train_crew_members (lower(first_name), lower(last_name), role)
WHERE deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_members`);
}
}

View File

@@ -0,0 +1,88 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Crew assigned to a train schedule (ITLMS Rolling Stock §1.2, §2).
*
* `segment` and `duty_role` are per-assignment, not per-roster-member: Case 1
* splits four drivers across the Dire Dawa boundary, and a driver who is
* Primary on one run is Assistant on the next. `role` is snapshotted so a later
* roster edit cannot rewrite the crew of a run that already departed.
*
* The partial unique index on (schedule, segment, duty_role) applies to drivers
* only — one segment cannot have two Primaries, while four federal police on
* the same run carry no segment or duty role and are unconstrained by it.
*/
export class TrainCrewAssignments3860000000000 implements MigrationInterface {
name = 'TrainCrewAssignments3860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.train_crew_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
train_schedule_id uuid NOT NULL,
crew_member_id uuid NOT NULL
REFERENCES freight.train_crew_members(id) ON DELETE RESTRICT,
role varchar(32) NOT NULL,
duty_role varchar(16),
segment varchar(24),
crewing_case varchar(8),
layover_start_at timestamptz,
layover_end_at timestamptz,
duty_start_at timestamptz,
duty_end_at timestamptz,
status varchar(16) NOT NULL DEFAULT 'PLANNED',
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT chk_crew_assignment_duty_role CHECK (
duty_role IS NULL OR duty_role IN ('PRIMARY','ASSISTANT','BENCH_RELIEF')
),
CONSTRAINT chk_crew_assignment_segment CHECK (
segment IS NULL OR segment IN
('INDODE_DIRE_DAWA','DIRE_DAWA_NAGAD','FULL_CORRIDOR')
),
CONSTRAINT chk_crew_assignment_case CHECK (
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
),
CONSTRAINT chk_crew_assignment_status CHECK (
status IN ('PLANNED','CONFIRMED','COMPLETED','REMOVED')
)
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_schedule
ON freight.train_crew_assignments (train_schedule_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_member
ON freight.train_crew_assignments (crew_member_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_status
ON freight.train_crew_assignments (status)
`);
// Nobody holds two seats on one run.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_schedule_member
ON freight.train_crew_assignments (train_schedule_id, crew_member_id)
WHERE deleted_at IS NULL
`);
// One Primary (and one Assistant) per segment — drivers only.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
`);
// Monthly overtime rollups scan a member's duty spans (§3.1).
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_duty_window
ON freight.train_crew_assignments (crew_member_id, duty_start_at)
WHERE duty_start_at IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_assignments`);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Drop `crewing_case` from train crew assignments.
*
* The column encoded the two fixed driver pairing cases of ITLMS Rolling Stock
* §2 (2+2 Ethiopian/Djiboutian, or 3 Ethiopian). Operations crew each run to
* its own need instead — any number of drivers, each carrying their own segment
* and duty role — so the case has nothing left to select and the column no
* longer has a meaning. The §1.1 territorial boundary is unaffected: it is a
* per-driver rule and still enforced.
*/
export class DropCrewingCase3870000000000 implements MigrationInterface {
name = 'DropCrewingCase3870000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
DROP CONSTRAINT IF EXISTS chk_crew_assignment_case
`);
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
DROP COLUMN IF EXISTS crewing_case
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
ADD COLUMN IF NOT EXISTS crewing_case varchar(8)
`);
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
ADD CONSTRAINT chk_crew_assignment_case CHECK (
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
)
`);
}
}

View File

@@ -0,0 +1,91 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Driver legs become yard-to-yard instead of three fixed corridor segments.
*
* The old `segment` enum could only express IndodeDire Dawa, Dire DawaNagad
* or the full corridor. Operations hand over at other yards too (Feto, Meiso,
* Sebet/Sibra — the very points ITLMS Rolling Stock §2 names as rotation
* places), so a leg is now any two yards on the schedule's route.
*
* `segment` is kept, nullable, so rows written before this still read back; it
* is never populated again. The §1.1 territorial boundary is unaffected — it
* now derives from yard position rather than the segment name, so a Djibouti
* driver is still confined to Dire Dawa and eastward.
*
* The driver-slot unique index moves with it: one Primary per leg, where a leg
* is the (from, to) pair rather than a segment name.
*/
export class CrewLegYards3880000000000 implements MigrationInterface {
name = 'CrewLegYards3880000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
ADD COLUMN IF NOT EXISTS from_yard_id uuid REFERENCES freight.yards(id),
ADD COLUMN IF NOT EXISTS to_yard_id uuid REFERENCES freight.yards(id)
`);
// Backfill the three legacy segments onto real yards so historic rows keep
// a usable leg. Matched by code; a deployment missing one simply leaves
// those rows with a null leg, which the validator reports as incomplete.
await queryRunner.query(`
UPDATE freight.train_crew_assignments a
SET from_yard_id = f.id, to_yard_id = t.id
FROM freight.yards f, freight.yards t
WHERE a.segment = 'INDODE_DIRE_DAWA'
AND a.from_yard_id IS NULL
AND f.code = 'KALITY' AND t.code = 'DIRE_DAWA'
`);
await queryRunner.query(`
UPDATE freight.train_crew_assignments a
SET from_yard_id = f.id, to_yard_id = t.id
FROM freight.yards f, freight.yards t
WHERE a.segment = 'DIRE_DAWA_NAGAD'
AND a.from_yard_id IS NULL
AND f.code = 'DIRE_DAWA' AND t.code = 'NAGAD'
`);
await queryRunner.query(`
UPDATE freight.train_crew_assignments a
SET from_yard_id = f.id, to_yard_id = t.id
FROM freight.yards f, freight.yards t
WHERE a.segment = 'FULL_CORRIDOR'
AND a.from_yard_id IS NULL
AND f.code = 'KALITY' AND t.code = 'NAGAD'
`);
// One Primary per leg replaces one Primary per segment.
await queryRunner.query(`
DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_slot
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_leg
ON freight.train_crew_assignments
(train_schedule_id, from_yard_id, to_yard_id, duty_role)
WHERE deleted_at IS NULL
AND from_yard_id IS NOT NULL
AND to_yard_id IS NOT NULL
AND duty_role IS NOT NULL
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_crew_assignments_leg
ON freight.train_crew_assignments (from_yard_id, to_yard_id)
WHERE from_yard_id IS NOT NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_crew_assignments_leg`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_leg`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.train_crew_assignments
DROP COLUMN IF EXISTS from_yard_id,
DROP COLUMN IF EXISTS to_yard_id
`);
}
}

View File

@@ -0,0 +1,48 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Empty container import is base rail freight for equipment carrying no cargo,
* so it is sold per lane exactly like laden container freight.
*
* CK_rates_yard_scope gains EMPTY_CONTAINER in its yard-carrying branch: an
* empty rate prices a leg (Djibouti -> Modjo), so both yards stay required.
* Drop-and-recreate is the established shape for this constraint — see
* 3430000000000-FuelSurcharge and 3640000000000-EthiopianCustomsClearance.
*/
export class EmptyContainerRateScope3890000000000 implements MigrationInterface {
name = 'EmptyContainerRateScope3890000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
);
await queryRunner.query(`
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
CASE
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
)
`);
}
}

View File

@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Whether a booking moves cargo or bare equipment.
*
* EMPTY is container freight carrying nothing — the box itself is the shipment,
* priced per size and lane off an EMPTY_CONTAINER_IMPORT rate. Deliberately a
* separate column rather than a third `freight_type`: an empty booking is still
* CONTAINER freight for wagon footprint, yard and warehouse allocation, train
* scheduling, marshalling and gate passes, and `freight_type` is read in ~880
* places whose else-arm means "container".
*
* Every existing row is LADEN, which the default supplies — no backfill needed.
*/
export class BookingCargoCondition3900000000000 implements MigrationInterface {
name = 'BookingCargoCondition3900000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"
`);
// Bulk carries no equipment of its own, so EMPTY only ever rides CONTAINER
// freight. Enforced here so no API path can file the combination.
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD CONSTRAINT "CK_bookings_cargo_condition" CHECK (
cargo_condition IN ('LADEN', 'EMPTY')
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
)
`);
// The booking queues filter empties out of (and into) the laden lists.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_cargo_condition
ON freight.bookings (cargo_condition)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_bookings_cargo_condition`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cargo_condition`,
);
}
}

View File

@@ -0,0 +1,76 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Contract paper for empty container import.
*
* - contracts.cargo_condition mirrors bookings.cargo_condition, so a general
* contract can commit to moving bare equipment.
* - Seeds IMPORT_EMPTY_CONTAINER, the system template the document renderer
* resolves for those contracts. It carries no customs variant: an empty box
* has no declaration to clear, the same reason intercity is unsuffixed.
*/
const SEEDED_CODES = ['IMPORT_EMPTY_CONTAINER'] as const;
export class EmptyContainerContractTemplate3910000000000 implements MigrationInterface {
name = 'EmptyContainerContractTemplate3910000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD CONSTRAINT "CK_contracts_cargo_condition" CHECK (
cargo_condition IN ('LADEN', 'EMPTY')
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
)
`);
for (const code of SEEDED_CODES) {
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
if (!seed) throw new Error(`Missing contract template default for ${code}`);
await queryRunner.query(
`INSERT INTO freight.contract_templates
(id, code, name, description, document_title, whereas_clauses, articles,
is_active, is_system, created_at, updated_at)
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
true, true, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.contract_templates
WHERE code = $1::varchar AND deleted_at IS NULL
)`,
[
seed.code,
seed.name,
seed.description,
seed.documentTitle,
JSON.stringify(seed.whereasClauses),
JSON.stringify(
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
),
],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.contract_templates WHERE code = ANY($1::varchar[]) AND is_system = true`,
[[...SEEDED_CODES]],
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS cargo_condition`,
);
}
}

View File

@@ -0,0 +1,99 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Customer-requested validity extension of an EXPIRED contract.
*
* Flow: the customer asks from the portal (`extension_requested_at` is stamped,
* the reason lands in contract_review_notes as EXTENSION_REQUESTED), then staff
* add days on the backoffice detail page and the contract returns to the status
* it held before it lapsed. Both expiry paths (nightly sweep + lazy flip on
* read) now stash that status in `status_before_expiry`, mirroring
* `status_before_suspension`; rows expired before this column existed fall
* back to the kind's resting status on extension.
*
* Also seeds `edr_freight_app:contracts:extend`. `FreightPositionsSeeder`
* resolves every registry key against `iam.permissions` at boot and throws on
* a missing row, so the catalog row must exist wherever the registry ships.
* The grant is copied from whoever already holds `contracts:suspend` — the
* registry places both keys on the same desk (marketing), and the position
* seeder only re-syncs presets when SEED_EDR_ORG is set.
*/
export class ContractExtensionRequest3920000000000 implements MigrationInterface {
name = 'ContractExtensionRequest3920000000000';
private static readonly KEY = 'edr_freight_app:contracts:extend';
private static readonly ID = 'a3000001-0001-4000-8000-00000000001d';
private static readonly SIBLING_KEY = 'edr_freight_app:contracts:suspend';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS extension_requested_at timestamptz NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contracts
ADD COLUMN IF NOT EXISTS status_before_expiry varchar(40) NULL
`);
await queryRunner.query(
`INSERT INTO iam.permissions (id, key, name, application_id)
SELECT $2::uuid,
$1::varchar,
'{"am": "Extend an expired contract", "en": "Extend an expired contract"}'::jsonb,
a.id
FROM iam.application a
WHERE a.key = 'edr_freight_app'
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.ID],
);
// Grant wherever suspend is already granted (positions and roles alike).
await queryRunner.query(
`INSERT INTO iam.position_permissions (position_id, permission_id)
SELECT pp.position_id, np.id
FROM iam.position_permissions pp
JOIN iam.permissions sp ON sp.id = pp.permission_id AND sp.key = $2::varchar
JOIN iam.permissions np ON np.key = $1::varchar
WHERE NOT EXISTS (
SELECT 1 FROM iam.position_permissions x
WHERE x.position_id = pp.position_id AND x.permission_id = np.id
)`,
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
);
await queryRunner.query(
`INSERT INTO iam.role_permissions (role_id, permission_id)
SELECT rp.role_id, np.id
FROM iam.role_permissions rp
JOIN iam.permissions sp ON sp.id = rp.permission_id AND sp.key = $2::varchar
JOIN iam.permissions np ON np.key = $1::varchar
WHERE NOT EXISTS (
SELECT 1 FROM iam.role_permissions x
WHERE x.role_id = rp.role_id AND x.permission_id = np.id
)`,
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
);
}
/** Grants go first, or the delete trips the permission foreign keys. */
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM iam.position_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[ContractExtensionRequest3920000000000.KEY],
);
await queryRunner.query(
`DELETE FROM iam.role_permissions
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
[ContractExtensionRequest3920000000000.KEY],
);
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
ContractExtensionRequest3920000000000.KEY,
]);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_expiry`,
);
await queryRunner.query(
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS extension_requested_at`,
);
}
}

View File

@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
import { CARGO_CONDITIONS, CargoCondition, FREIGHT_TYPES, FreightType } from './entities/booking.entity';
import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
/** Normalize and validate booking freight shape (used on create and after update merge). */
@@ -12,10 +12,25 @@ export function assertFreightShape(input: BookingFreightShapeInput): void {
}
//
const condition = input.cargoCondition ?? 'LADEN';
if (!CARGO_CONDITIONS.includes(condition as CargoCondition)) {
throw new BadRequestException(
`cargoCondition must be one of: ${CARGO_CONDITIONS.join(', ')}`,
);
}
const containers = input.containers ?? [];
const hasContainers = containers.length > 0;
const hasCargoType = Boolean(input.cargoTypeId);
// Empty means bare equipment: there is no commodity to name, and bulk has no
// equipment of its own to move, so EMPTY only ever rides CONTAINER freight.
if (condition === 'EMPTY' && input.freightType !== 'CONTAINER') {
throw new BadRequestException(
'An empty booking must be CONTAINER freight — bulk carries no equipment',
);
}
if (input.freightType === 'BULK') {
if (hasContainers) {
throw new BadRequestException(

View File

@@ -298,6 +298,42 @@ export class BookingLifecycleNotifierService {
this.inApp(b, 'Operation request needs changes', msg);
}
/**
* Operations moved the shipment day (and possibly the train) themselves
* instead of asking the customer to. The booking stays under review, so the
* customer only needs to know the new day — nothing to resubmit.
*/
operationRescheduled(b: Booking, previousDay: string | null, note?: string): void {
const newDay = b.scheduledDate
? b.scheduledDate.toLocaleDateString('en-GB', { timeZone: 'Africa/Addis_Ababa' })
: 'a new day';
const msg =
`Operations moved the shipment day of booking ${b.reference} ` +
`${previousDay ? `from ${previousDay} ` : ''}to ${newDay}.` +
(note ? ` Note from Operations: ${note}` : '') +
' The request stays under review — no action is needed on your side.';
if (b.customsClearingEnabled) {
this.logger.log(`OPERATION RESCHEDULED (to GL) — ${this.ref(b)}`);
void this.inbox.notify({
recipients:
b.createdByRole === 'GL_ET' && b.createdByUserId
? { userIds: [b.createdByUserId] }
: CLEARANCE_DESK,
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.BOOKING_STATUS,
title: `Booking ${b.reference} shipment day changed`,
body: msg,
link: b.contractId
? `/dashboard/contracts/clearance/${b.contractId}`
: `/dashboard/bookings/${b.id}/clearance`,
data: { bookingId: b.id, reference: b.reference, note: note ?? null },
});
return;
}
void this.notifyContact(b, msg, 'OPERATION RESCHEDULED');
this.inApp(b, 'Shipment day changed by Operations', msg);
}
/** Operation accepted → invoice ready; await payment / booking window. */
operationAccepted(b: Booking): void {
// No invoice and no pay window for a shipping line — the charge sits on

View File

@@ -779,3 +779,124 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
expect(line.amount).toBe(3 * 1690);
});
});
/**
* Empty container import is bare equipment moved as freight in its own right.
* It has to price off EMPTY_CONTAINER_IMPORT, never the laden CONTAINER_IMPORT
* rate for the same lane and box — the two are separate tariffs, and
* UQ_rates_pattern only lets both exist because the rateType differs.
*/
describe('BookingPricingService — empty container import', () => {
const DJIBOUTI = 'yard-djibouti';
const CT40 = 'ct-40ft';
const ladenImport40: Rate = {
id: 'rate-container-import-40',
rateType: 'CONTAINER_IMPORT',
currency: 'USD',
rateValue: 900,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: CT40,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
} as Rate;
const emptyImport40: Rate = {
id: 'rate-empty-container-import-40',
rateType: 'EMPTY_CONTAINER_IMPORT',
currency: 'USD',
rateValue: 250,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: CT40,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
} as Rate;
let service: BookingPricingService;
const priceLines = (booking: Booking) =>
(
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: Array<{ containerTypeId: string; quantity: number; wagonsPerUnit: number }> },
) => Promise<{
lineItems: Array<{ code: string; amount: number; description: string }>;
blocked: string[];
}>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [{ containerTypeId: CT40, quantity: 4, wagonsPerUnit: 1 }],
});
const bookingWith = (cargoCondition: string) =>
({
id: 'b-empty-1',
freightType: 'CONTAINER',
cargoCondition,
tradeDirection: 'IMPORT',
paymentCurrency: 'USD',
// Bare equipment declares no VGM — the service zeroes it at create.
cargoTotalWeightVgm: 0,
originYardId: DJIBOUTI,
destinationYardId: MOJO,
bookingContainers: [],
}) as unknown as Booking;
beforeEach(() => {
const exchangeService = {
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: 1, DJF: 1 }),
};
service = new BookingPricingService(
{ calculateWagonCount: jest.fn().mockResolvedValue(4) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ sizeFt: 40, label: '40ft' }) } as never,
{ findLiveRates: jest.fn().mockResolvedValue([ladenImport40, emptyImport40]) } as never,
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
{} as never,
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
);
});
it('prices an empty booking off the empty tariff, not the laden one', async () => {
const result = await priceLines(bookingWith('EMPTY'));
expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].code).toBe('EMPTY_CONTAINER_IMPORT');
expect(result.lineItems[0].amount).toBe(250 * 4);
expect(result.lineItems[0].description).toContain('empty');
});
it('leaves laden bookings on the laden tariff', async () => {
const result = await priceLines(bookingWith('LADEN'));
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
expect(result.lineItems[0].amount).toBe(900 * 4);
});
it('treats a booking with no condition set as laden', async () => {
const booking = bookingWith('LADEN');
delete (booking as unknown as Record<string, unknown>).cargoCondition;
const result = await priceLines(booking);
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
});
it('hard-blocks an empty booking on a lane with no empty rate configured', async () => {
(
service as unknown as { ratesService: { findLiveRates: jest.Mock } }
).ratesService.findLiveRates.mockResolvedValue([ladenImport40]);
const result = await priceLines(bookingWith('EMPTY'));
// Never silently fall through to the laden rate — that would bill an empty
// repositioning move at 900/box instead of 250.
expect(result.lineItems).toHaveLength(0);
expect(result.blocked[0]).toContain('EMPTY_CONTAINER_IMPORT');
});
});

View File

@@ -249,8 +249,10 @@ export class BookingPricingService {
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
// wagon). Frozen contract snapshots win over live rates; a customs booking
// with nothing configured hard-blocks — clearance never ships for free.
// An empty box carries no declaration and no duty, so there is no clearance
// to sell even if a customs-bundled service type was somehow selected.
const clearanceBlocked: string[] = [];
if (booking.customsClearingEnabled) {
if (booking.customsClearingEnabled && booking.cargoCondition !== 'EMPTY') {
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
for (const line of clearance.lineItems) {
lineItems.push(line);
@@ -575,9 +577,17 @@ export class BookingPricingService {
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
const usdToEtb = fx['USD'];
const isBulk = booking.freightType === 'BULK';
// Bare equipment prices off its own tariff. It has to be a distinct
// rateType, not a cheaper CONTAINER_IMPORT row: UQ_rates_pattern keys on
// rate_type without applies_to, so an empty 40ft rate on a lane would
// collide with the laden 40ft rate for that same lane.
const isEmpty = booking.cargoCondition === 'EMPTY';
const rateType =
booking.tradeDirection === 'IMPORT'
const rateType = isEmpty
? booking.tradeDirection === 'EXPORT'
? 'EMPTY_CONTAINER_EXPORT'
: 'EMPTY_CONTAINER_IMPORT'
: booking.tradeDirection === 'IMPORT'
? isBulk
? 'BULK_IMPORT'
: 'CONTAINER_IMPORT'
@@ -651,7 +661,7 @@ export class BookingPricingService {
if (rate) usedRatesMap.set(rate.id, rate);
lines.push({
code: rateType,
description: `${label} rail freight`,
description: isEmpty ? `${label} empty rail freight` : `${label} rail freight`,
amount,
unitAmount,
unit: rateUnit,

View File

@@ -217,3 +217,170 @@ describe('BookingTransitionService — requestOperation export space gate', () =
);
});
});
/**
* Staff reschedule of an operation request: instead of returning the booking
* to the customer, Operations sets the new shipment day (and the export train)
* themselves. Same day-pool / export gates as the customer request; the booking
* lands (back) at OPERATION_REQUEST_PENDING and the customer is told.
*/
describe('BookingTransitionService — staff reschedule of an operation request', () => {
function makeService(over: {
status?: string;
tradeDirection?: 'EXPORT' | 'IMPORT';
hasDeparture?: boolean;
} = {}) {
const booking = {
id: 'b-1',
reference: 'BKG-1',
status: over.status ?? 'OPERATION_REQUEST_PENDING',
tradeDirection: over.tradeDirection ?? 'IMPORT',
originYardId: 'o-1',
destinationYardId: 'd-1',
totalAmount: 1000,
contractId: null,
scheduledDate: new Date('2026-07-01T00:00:00.000Z'),
requestedTrainScheduleId: null,
serviceType: { code: 'RAIL_CONTAINER' },
};
const bookingsRepository = {
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
createReviewNote: jest.fn().mockResolvedValue(undefined),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
checkDayCompatibilityForBooking: jest.fn().mockResolvedValue({
hasDeparture: over.hasDeparture ?? true,
hasCompatible: true,
}),
};
const bookingBatchService = {
pickExportSchedule: jest.fn().mockResolvedValue('sched-1'),
};
const notifier = { operationRescheduled: jest.fn() };
const clearanceEvents = { record: jest.fn() };
const service = new BookingTransitionService(
bookingsRepository as never,
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
bookingBatchService as never,
bookingsService as never,
{ isPhasedCustomsBooking: () => false } as never,
{} as never, // workflowService
{} as never, // invoiceService
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
notifier as never,
clearanceEvents as never,
{ emit: jest.fn() } as never, // events
);
return { service, bookingsRepository, bookingBatchService, notifier, clearanceEvents };
}
it('refuses a booking that has not requested operation', async () => {
const { service, bookingsRepository } = makeService({ status: 'CLEARANCE_READY' });
await expect(
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
).rejects.toBeInstanceOf(ConflictException);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('refuses a day with no departure on the route and changes nothing', async () => {
const { service, bookingsRepository } = makeService({ hasDeparture: false });
await expect(
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
).rejects.toBeInstanceOf(BadRequestException);
expect(bookingsRepository.update).not.toHaveBeenCalled();
});
it('moves an import request to the new day, keeps it pending, logs it and tells the customer', async () => {
const { service, bookingsRepository, notifier, clearanceEvents } = makeService();
await service.rescheduleOperationRequest(
'b-1',
'2026-07-20T00:00:00.000Z',
'sched-9', // ignored for import — the batch engine assigns the train
'staff-1',
);
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
status: 'OPERATION_REQUEST_PENDING',
scheduledDate: new Date('2026-07-20T00:00:00.000Z'),
requestedTrainScheduleId: null,
});
expect(bookingsRepository.createReviewNote).not.toHaveBeenCalled();
expect(clearanceEvents.record).toHaveBeenCalledWith(
expect.objectContaining({
bookingId: 'b-1',
action: 'OPERATION_RESCHEDULED',
actorType: 'STAFF',
actorId: 'staff-1',
metadata: expect.objectContaining({
previousScheduledDate: '2026-07-01',
scheduledDate: '2026-07-20',
}),
}),
);
expect(notifier.operationRescheduled).toHaveBeenCalledWith(
expect.objectContaining({ id: 'b-1' }),
'2026-07-01',
undefined,
);
});
it('resolves a change request staff had raised: back to pending, note kept as a staff note', async () => {
const { service, bookingsRepository, notifier } = makeService({
status: 'OPERATION_CHANGES_REQUESTED',
});
await service.rescheduleOperationRequest(
'b-1',
'2026-07-20T00:00:00.000Z',
null,
'staff-1',
{ note: ' Moved to the Monday train ' },
);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }),
);
expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith(
'b-1',
'Moved to the Monday train',
'STAFF_NOTE',
'staff-1',
);
expect(notifier.operationRescheduled).toHaveBeenCalledWith(
expect.anything(),
'2026-07-01',
'Moved to the Monday train',
);
});
it('export rail: requires the train and persists the pick after the space gate', async () => {
const { service, bookingsRepository, bookingBatchService } = makeService({
tradeDirection: 'EXPORT',
});
await expect(
service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'),
).rejects.toThrow(/select a train/i);
expect(bookingsRepository.update).not.toHaveBeenCalled();
await service.rescheduleOperationRequest(
'b-1',
'2026-07-20T00:00:00.000Z',
'sched-9',
'staff-1',
);
expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledWith(
expect.objectContaining({ requestedTrainScheduleId: 'sched-9' }),
);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({
status: 'OPERATION_REQUEST_PENDING',
requestedTrainScheduleId: 'sched-9',
}),
);
});
});

View File

@@ -1311,6 +1311,45 @@ export class BookingTransitionService {
);
}
const { date, requestedId } = await this.resolveOperationDay(
booking,
scheduledDate,
requestedTrainScheduleId,
opts?.bypassDayPool,
);
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'OPERATION_REQUESTED',
label: `Requested operation for shipment day ${scheduledDate}`,
actorType: 'CUSTOMER',
actorId: opts?.userId ?? null,
metadata: { scheduledDate },
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
return fresh;
}
/**
* Validate a shipment day (and, for export rail, the picked train) for a
* booking the way the customer's operation request does, and resolve what
* gets persisted: the binding `scheduledDate` and the `requestedTrainScheduleId`
* (export rail / shipping-line only — import and domestic trains are assigned
* by the batch engine, so their pick is dropped). Shared by the customer
* request and the staff reschedule so both enforce the same gates.
*/
private async resolveOperationDay(
booking: Booking,
scheduledDate: string,
requestedTrainScheduleId?: string | null,
bypassDayPool?: boolean,
): Promise<{ date: Date; requestedId: string | null }> {
const date = new Date(scheduledDate);
if (Number.isNaN(date.getTime())) {
throw new BadRequestException("A valid schedule date is required");
@@ -1322,7 +1361,7 @@ export class BookingTransitionService {
// gate; quantity never blocks — oversized bookings get a partial split
// offer). The batch engine assigns the specific train within that
// (route, day) pool later.
if (!opts?.bypassDayPool) {
if (!bypassDayPool) {
const { hasDeparture, hasCompatible } =
await this.bookingsService.checkDayCompatibilityForBooking(
booking,
@@ -1359,7 +1398,7 @@ export class BookingTransitionService {
// persisted here the same way an export pick is. Customer import/domestic
// bookings still never carry one (the batch engine assigns their train).
const requestedId =
isExportTrain || opts?.bypassDayPool
isExportTrain || bypassDayPool
? (requestedTrainScheduleId ?? null)
: null;
// Export rail rides the exact train the customer picked — never an
@@ -1403,21 +1442,76 @@ export class BookingTransitionService {
}
}
return { date, requestedId };
}
/**
* Operations changes the shipment day and/or train of a booking the customer
* has already requested operation on — instead of bouncing it back to the
* customer with a change request, staff set the new day (and, for export
* rail, the train) themselves. The same day-pool / export-space gates as the
* customer's own request apply, so staff cannot park a booking on a day with
* no departure or a train with no room.
*
* Allowed at OPERATION_REQUEST_PENDING (staff review) and at
* OPERATION_CHANGES_REQUESTED (staff resolve their own change request); either
* way the booking lands back at OPERATION_REQUEST_PENDING for the normal
* accept. The customer is told the new day, with the staff note when given.
*/
async rescheduleOperationRequest(
bookingId: string,
scheduledDate: string,
requestedTrainScheduleId: string | null | undefined,
actorId: string,
options: { note?: string } = {},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
"OPERATION_REQUEST_PENDING",
"OPERATION_CHANGES_REQUESTED",
]);
const { date, requestedId } = await this.resolveOperationDay(
booking,
scheduledDate,
requestedTrainScheduleId,
);
const previousDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null;
const previousTrainId = booking.requestedTrainScheduleId ?? null;
const note = options.note?.trim() || undefined;
await this.bookingsRepository.update(bookingId, {
status: "OPERATION_REQUEST_PENDING",
scheduledDate: date,
requestedTrainScheduleId: requestedId,
} as never);
if (note) {
await this.bookingsRepository.createReviewNote(
bookingId,
note,
"STAFF_NOTE",
actorId,
);
}
await this.clearanceEvents.record({
bookingId,
action: 'OPERATION_REQUESTED',
label: `Requested operation for shipment day ${scheduledDate}`,
actorType: 'CUSTOMER',
actorId: opts?.userId ?? null,
metadata: { scheduledDate },
action: "OPERATION_RESCHEDULED",
label:
`Operations moved the shipment day ` +
`${previousDay ? `from ${previousDay} ` : ""}to ${eatDay(date)}` +
(requestedId && requestedId !== previousTrainId ? " and changed the train" : ""),
actorType: "STAFF",
actorId,
metadata: {
previousScheduledDate: previousDay,
scheduledDate: eatDay(date),
previousTrainScheduleId: previousTrainId,
trainScheduleId: requestedId,
note: note ?? null,
},
});
const fresh = await this.bookingsService.findById(bookingId);
this.notifier.operationRequestedToStaff(fresh);
this.notifier.operationRescheduled(fresh, previousDay, note);
return fresh;
}

View File

@@ -82,6 +82,7 @@ import {
ReviewDocumentDto,
RequestOperationDto,
OperationReviewDto,
RescheduleOperationDto,
StaffRejectDto,
} from "./dto/request-changes.dto";
import { ContractViewDto } from "./dto/contract-view.dto";
@@ -1344,6 +1345,29 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/operation/reschedule")
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary:
"Operations changes a pending operation request's shipment day and/or " +
"train on the customer's behalf (OPERATION_REQUEST_PENDING | " +
"OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
})
async rescheduleOperationRequest(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: RescheduleOperationDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.rescheduleOperationRequest(
id,
dto.scheduledDate,
dto.trainScheduleId ?? null,
resolveAuthUserId(user),
{ note: dto.note },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/clearance/review")
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
@ApiOperation({

View File

@@ -1106,11 +1106,13 @@ ${footer}
const containers = await Promise.all(
containerLines.map(async (c) => {
const ct = await this.containerTypesService.findById(c.containerTypeId);
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
// Optional on the DTO — an empty booking states no VGM at all.
const vgmPerUnitTons = Number(c.vgmPerUnitTons ?? 0);
const totalVgmTons = c.quantity * vgmPerUnitTons;
return {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
@@ -1375,13 +1377,25 @@ ${footer}
}
}
const containers = dto.containers ?? [];
const cargoCondition = dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN';
const isEmpty = cargoCondition === 'EMPTY';
assertFreightShape({
freightType: dto.freightType,
cargoCondition,
cargoTypeId: dto.cargoTypeId,
containers,
containers: dto.containers ?? [],
});
// Bare equipment declares no VGM. Zero the lines HERE, before the rule
// engine sees them, so weight-limit and overweight evaluation, the wagon
// estimate, the persisted rows and every tonnage aggregate downstream all
// read the same figure — a stray VGM on an empty line would otherwise price
// an overweight surcharge on a box with nothing in it.
const containers = (dto.containers ?? []).map((c) => ({
...c,
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
}));
const tradeDirection = await this.resolveTradeDirectionForBooking(
dto.originYardId,
dto.destinationYardId,
@@ -1506,10 +1520,11 @@ ${footer}
destinationYardId: dto.destinationYardId,
tradeDirection,
freightType: dto.freightType,
cargoCondition,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
cargoTotalWeightVgm: isEmpty ? 0 : dto.cargoTotalWeightVgm,
// Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK.
bulkTotalWeightTons:
dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null,
@@ -1647,6 +1662,11 @@ ${footer}
const warnings: string[] = [];
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
// A draft may be switched between laden and empty; an untouched draft keeps
// whatever it was created as.
const cargoCondition =
(dto.cargoCondition ?? existing.cargoCondition) === 'EMPTY' ? 'EMPTY' : 'LADEN';
const isEmpty = cargoCondition === 'EMPTY';
let containers =
dto.containers ??
(existing.bookingContainers ?? [])
@@ -1672,7 +1692,14 @@ ${footer}
}
}
assertFreightShape({ freightType, cargoTypeId, containers });
// Same normalisation as create: zero the VGM of an empty booking before the
// rule engine, the wagon estimate or the persisted rows ever read it.
containers = containers.map((c) => ({
...c,
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
}));
assertFreightShape({ freightType, cargoCondition, cargoTypeId, containers });
const originYardId = dto.originYardId ?? existing.originYardId;
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
@@ -1719,6 +1746,9 @@ ${footer}
const updates: Record<string, unknown> = {
...dto,
freightType,
cargoCondition,
// Bare equipment declares no VGM, whichever way the draft was edited.
cargoTotalWeightVgm: isEmpty ? 0 : cargoAmount,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
// Break-bulk actual tonnage; cleared when the booking leaves BULK.
bulkTotalWeightTons:
@@ -1825,10 +1855,12 @@ ${footer}
await this.bookingsRepository.deleteContainers(id);
await this.bookingsRepository.createContainers(
id,
// Index-aligned with ruleResult, which evaluated these same lines.
dto.containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
// Bare equipment declares no VGM — same normalisation the rule engine saw.
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
weightResult: ruleResult.containerWeightResults[i],

View File

@@ -18,7 +18,12 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
import {
BOOKING_STATUSES,
BOOKING_TYPES,
CARGO_CONDITIONS,
FREIGHT_TYPES,
} from '../entities/booking.entity';
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
@@ -47,11 +52,20 @@ export class CreateBookingContainerDto {
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
/**
* Omitted on an empty booking — bare equipment has no verified gross mass to
* declare, and the service zeroes the line rather than trusting a stray value.
*/
@ApiPropertyOptional({
description: 'VGM per container in tons. Omit for an EMPTY booking',
minimum: 0,
default: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmPerUnitTons!: number;
@Transform(({ value }) => Number(value ?? 0))
vgmPerUnitTons?: number;
@ApiPropertyOptional({
description: 'How many of this line are hazardous (0..quantity)',
@@ -312,6 +326,20 @@ export class CreateBookingDto {
@IsIn([...FREIGHT_TYPES])
freightType!: string;
/**
* LADEN (default) or EMPTY. EMPTY is container freight carrying nothing —
* the box itself is the shipment, priced per size and lane off an
* EMPTY_CONTAINER_IMPORT rate.
*/
@ApiPropertyOptional({
enum: CARGO_CONDITIONS,
default: 'LADEN',
description: 'EMPTY moves bare equipment; requires CONTAINER freight',
})
@IsOptional()
@IsIn([...CARGO_CONDITIONS])
cargoCondition?: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Required for BULK; must be omitted for CONTAINER',
@@ -330,10 +358,14 @@ export class CreateBookingDto {
@IsUUID()
shippingLineId?: string;
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
@ApiProperty({
description: 'Total cargo weight VGM in tons. Omit for an EMPTY booking',
minimum: 0,
})
@ValidateIf((o) => o.cargoCondition !== 'EMPTY')
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
@Transform(({ value }) => Number(value ?? 0))
cargoTotalWeightVgm!: number;
/**

View File

@@ -107,6 +107,38 @@ export class RequestOperationDto {
trainScheduleId?: string;
}
/**
* Operations changes a pending operation request's shipment day and/or train
* on the customer's behalf (instead of returning it for changes).
*/
export class RescheduleOperationDto {
@ApiProperty({
description:
'The new shipment day (train departure day). ISO date — must have an ' +
'open departure on the booking route that can carry the cargo.',
example: '2026-07-15',
})
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({
description:
'EXPORT rail only: the train (schedule id) to ride, from ' +
'GET /bookings/:id/export-trains for the new day. Required for export ' +
'rail; ignored for import/domestic/road bookings.',
})
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiPropertyOptional({
description: 'Optional note to the customer explaining the change.',
})
@IsOptional()
@IsString()
note?: string;
}
export class OperationReviewDto {
@ApiProperty({
description:

View File

@@ -8,6 +8,8 @@ import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity';
export interface BookingFreightShapeInput {
freightType?: string;
/** LADEN (default) or EMPTY — see CARGO_CONDITIONS on the Booking entity. */
cargoCondition?: string | null;
cargoTypeId?: string | null;
containers?: Array<{ containerTypeId?: string }> | null;
}
@@ -20,6 +22,13 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa
return true;
}
// Bulk carries no equipment of its own, so an empty booking is always
// container freight. Rejected here as well as in assertFreightShape so the
// 400 names the field instead of surfacing from the service layer.
if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') {
return false;
}
const containers = dto.containers ?? [];
const hasContainers = containers.length > 0;
const hasCargoType =
@@ -49,6 +58,9 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa
defaultMessage(args: ValidationArguments): string {
const dto = args.object as BookingFreightShapeInput;
if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') {
return 'An empty booking must be CONTAINER freight — bulk carries no equipment';
}
if (dto.freightType === 'BULK') {
return 'BULK freight requires cargoTypeId and must not include container lines';
}

View File

@@ -83,6 +83,20 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
export type FreightType = (typeof FREIGHT_TYPES)[number];
/**
* Whether the booking moves cargo or bare equipment. EMPTY is container
* freight with nothing inside: the box IS the shipment, priced per size and
* lane off an EMPTY_CONTAINER_IMPORT rate.
*
* This is deliberately NOT a third `freightType`. An empty booking is still
* CONTAINER freight everywhere it matters physically — wagon footprint, yard
* and warehouse allocation, train scheduling, marshalling, gate passes — and
* `freightType` is read in ~880 places whose else-arm means "container". Only
* pricing, documents, customs and the contract template branch on condition.
*/
export const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const;
export type CargoCondition = (typeof CARGO_CONDITIONS)[number];
export const SCHEDULING_STATUSES = [
SchedulingStatus.NotScheduled,
SchedulingStatus.Holding,
@@ -388,6 +402,13 @@ export class Booking extends BaseEntity {
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
freightType!: string;
/**
* LADEN (the default, and every pre-existing row) or EMPTY. Only ever EMPTY
* on CONTAINER freight — bulk has no equipment to move on its own.
*/
@Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' })
cargoCondition!: string;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
cargoTypeId?: string | null;

View File

@@ -53,24 +53,60 @@ describe('contractTemplateCodeFor', () => {
it('only ever resolves to a code that exists', () => {
const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null];
const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null];
const conditions = ['LADEN', 'EMPTY', null, undefined];
for (const d of directions) {
for (const f of freights) {
for (const c of [true, false]) {
for (const e of [true, false, undefined]) {
expect(CONTRACT_TEMPLATE_CODES).toContain(
contractTemplateCodeFor(d, f, c, e),
);
for (const cond of conditions) {
expect(CONTRACT_TEMPLATE_CODES).toContain(
contractTemplateCodeFor(d, f, c, e, cond),
);
}
}
}
}
}
});
// Empty equipment is a carriage agreement, not a cargo contract: no cargo
// liability, no VGM declaration, no commercial documents, no customs leg.
it('gives empty container import its own customs-free paper', () => {
for (const customs of [true, false]) {
for (const ethiopian of [true, false, undefined]) {
expect(
contractTemplateCodeFor('IMPORT', 'CONTAINER', customs, ethiopian, 'EMPTY'),
).toBe('IMPORT_EMPTY_CONTAINER');
}
}
});
it('leaves laden contracts on the laden codes', () => {
expect(
contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false, 'LADEN'),
).toBe('IMPORT_CONTAINER_NO_CUSTOMS');
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false)).toBe(
'IMPORT_CONTAINER_NO_CUSTOMS',
);
});
// Empty rates and empty bookings are import-only, so a stray EMPTY on any
// other direction must fall through rather than resolve a template that
// describes a Djibouti-to-Ethiopia movement.
it('ignores the empty condition outside import', () => {
expect(
contractTemplateCodeFor('EXPORT', 'CONTAINER', false, false, 'EMPTY'),
).toBe('EXPORT_CONTAINER_NO_CUSTOMS');
expect(
contractTemplateCodeFor('DOMESTIC', 'CONTAINER', false, false, 'EMPTY'),
).toBe('INTERCITY_CONTAINER');
});
});
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
it('seeds exactly the fourteen declared codes, once each', () => {
it('seeds exactly the fifteen declared codes, once each', () => {
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
expect(seeded).toHaveLength(14);
expect(seeded).toHaveLength(15);
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
});

View File

@@ -54,6 +54,9 @@ const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
// Carriage of the equipment itself — no cargo, no clearing, so it previews
// against the transport-only scope like every other non-customs code.
IMPORT_EMPTY_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
};
@Injectable()
@@ -216,6 +219,7 @@ export class ContractTemplatesService {
customsClearingEnabled?: boolean | null,
cargoTypeId?: string | null,
ethiopianCustomsOnly?: boolean | null,
cargoCondition?: string | null,
): Promise<ContractTemplate | null> {
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
if (isBulk) {
@@ -235,6 +239,7 @@ export class ContractTemplatesService {
freightType,
customsClearingEnabled,
ethiopianCustomsOnly,
cargoCondition,
);
const template = await this.repository.findByCode(code);
return template?.isActive ? template : null;

View File

@@ -41,6 +41,14 @@ export const CONTRACT_TEMPLATE_CODES = [
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
"EXPORT_CONTAINER_NO_CUSTOMS",
"INTERCITY_CONTAINER",
/**
* Empty container import — bare equipment railed north from Djibouti. No
* customs split: an empty box carries no declaration to clear, the same
* reason intercity has a single unsuffixed code. Import-only, matching the
* rate rule (southbound empties are served by the WITH_RETURN surcharge and
* empty_return_requests instead).
*/
"IMPORT_EMPTY_CONTAINER",
] as const;
export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number];
@@ -74,7 +82,14 @@ export function contractTemplateCodeFor(
freightType?: string | null,
customsClearingEnabled?: boolean | null,
ethiopianCustomsOnly?: boolean | null,
cargoCondition?: string | null,
): ContractTemplateCode {
// Empty equipment is its own paper: a straight carriage agreement with no
// cargo liability, no VGM declaration and no customs leg. Import-only, so
// anything else falls through to the laden codes below.
if (cargoCondition === "EMPTY" && tradeDirection === "IMPORT") {
return "IMPORT_EMPTY_CONTAINER";
}
const direction =
tradeDirection === "IMPORT"
? "IMPORT"

View File

@@ -0,0 +1,170 @@
import { ContractTransitionService } from './contract-transition.service';
import type { Contract } from './entities/contract.entity';
/**
* A lapsed contract comes back only on the customer's say-so: they ask once,
* staff add days, and the contract lands back where it was before it expired.
* Those three rules are the feature.
*/
describe('ContractTransitionService — extension request / extend', () => {
const contract = (over: Partial<Contract> = {}): Contract =>
({
id: 'c-1',
reference: 'CTR-2026-00042',
companyId: 'co-1',
contractKind: 'GENERAL',
status: 'EXPIRED',
freightType: 'CONTAINER',
contractValidUntil: new Date('2026-01-31T21:00:00Z'),
statusBeforeExpiry: 'CONTRACT_ACTIVE',
extensionRequestedAt: null,
...over,
}) as Contract;
let current: Contract;
let repo: { update: jest.Mock; createReviewNote: jest.Mock };
let notifier: { extended: jest.Mock; extensionRequestedToStaff: jest.Mock };
let service: ContractTransitionService;
/** A staff user holding the extend key — authorization is tested elsewhere. */
const staff = {
permissions: [{ key: 'edr_freight_app:contracts:extend' }],
};
beforeEach(() => {
current = contract();
repo = {
update: jest.fn().mockImplementation((_id: string, patch: object) => {
current = { ...current, ...patch } as Contract;
return Promise.resolve(current);
}),
createReviewNote: jest.fn().mockResolvedValue(undefined),
};
notifier = { extended: jest.fn(), extensionRequestedToStaff: jest.fn() };
service = Object.create(
ContractTransitionService.prototype,
) as ContractTransitionService;
Object.assign(service, {
contractsRepository: repo,
contractsService: { findById: () => Promise.resolve(current) },
notifier,
});
});
it('records the customer request and tells the contract desk', async () => {
await service.requestExtension('c-1', ' Two more shipments due ', 'user-1');
expect(repo.createReviewNote).toHaveBeenCalledWith(
'c-1',
'Two more shipments due',
'EXTENSION_REQUESTED',
'user-1',
'CUSTOMER',
);
expect(repo.update).toHaveBeenCalledWith('c-1', {
extensionRequestedAt: expect.any(Date),
});
expect(notifier.extensionRequestedToStaff).toHaveBeenCalledWith(
expect.objectContaining({ id: 'c-1' }),
'Two more shipments due',
);
});
it('refuses a request on a contract that has not expired', async () => {
current = contract({ status: 'CONTRACT_ACTIVE' });
await expect(
service.requestExtension('c-1', undefined, 'user-1'),
).rejects.toThrow(/CONTRACT_ACTIVE/);
expect(repo.update).not.toHaveBeenCalled();
});
it('allows one pending request at a time', async () => {
current = contract({ extensionRequestedAt: new Date() });
await expect(
service.requestExtension('c-1', undefined, 'user-1'),
).rejects.toThrow(/already awaiting/);
expect(repo.update).not.toHaveBeenCalled();
});
it('refuses to extend before the customer has asked', async () => {
await expect(
service.extend('c-1', 30, undefined, 'staff-1', staff as never),
).rejects.toThrow(/not requested/);
expect(repo.update).not.toHaveBeenCalled();
});
it('adds days from today on a lapsed contract and restores the pre-expiry status', async () => {
current = contract({
extensionRequestedAt: new Date(),
statusBeforeExpiry: 'ACTIVE_SHIPMENT_IN_PROGRESS',
});
const before = Date.now();
await service.extend('c-1', 10, 'Approved by desk', 'staff-1', staff as never);
const patch = repo.update.mock.calls[0][1] as {
status: string;
statusBeforeExpiry: null;
extensionRequestedAt: null;
contractValidUntil: Date;
};
expect(patch.status).toBe('ACTIVE_SHIPMENT_IN_PROGRESS');
expect(patch.statusBeforeExpiry).toBeNull();
expect(patch.extensionRequestedAt).toBeNull();
// The old end (Jan 2026) is in the past, so the ten days count from now.
const tenDays = 10 * 86_400_000;
expect(patch.contractValidUntil.getTime()).toBeGreaterThanOrEqual(before + tenDays - 1000);
expect(patch.contractValidUntil.getTime()).toBeLessThanOrEqual(Date.now() + tenDays + 3_600_000);
expect(repo.createReviewNote).toHaveBeenCalledWith(
'c-1',
expect.stringMatching(/^Extended by 10 days to .*\. Approved by desk$/),
'EXTENDED',
'staff-1',
'STAFF',
);
expect(notifier.extended).toHaveBeenCalledWith(
expect.objectContaining({ id: 'c-1' }),
10,
patch.contractValidUntil,
'Approved by desk',
);
});
it('extends from the current end date when it is still in the future', async () => {
const future = new Date(Date.now() + 5 * 86_400_000);
current = contract({ extensionRequestedAt: new Date(), contractValidUntil: future });
await service.extend('c-1', 7, undefined, 'staff-1', staff as never);
const patch = repo.update.mock.calls[0][1] as { contractValidUntil: Date };
const expected = new Date(future);
expected.setDate(expected.getDate() + 7);
expect(patch.contractValidUntil.getTime()).toBe(expected.getTime());
});
it('falls back to the resting status for rows expired before it was tracked', async () => {
current = contract({
extensionRequestedAt: new Date(),
statusBeforeExpiry: null,
contractKind: 'ONE_TIME',
});
await service.extend('c-1', 1, undefined, 'staff-1', staff as never);
expect(repo.update).toHaveBeenCalledWith(
'c-1',
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
);
});
it('refuses to extend without the extend permission', async () => {
current = contract({ extensionRequestedAt: new Date() });
await expect(
service.extend('c-1', 30, undefined, 'staff-1', { permissions: [] } as never),
).rejects.toThrow();
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -188,6 +188,26 @@ export class ContractNotifierService {
this.inApp(c, 'Contract cancelled', msg);
}
/** Staff extended the validity of a lapsed contract — it is live again. */
extended(c: Contract, days: number, validUntil: Date, note?: string | null): void {
const msg =
`Your contract ${c.reference} has been extended by ${days} day${days === 1 ? '' : 's'} ` +
`and is now valid until ${validUntil.toLocaleDateString('en-GB')}. ` +
`You can book shipments under it again.${note ? ` Note: ${note}` : ''}`;
void this.notifyContact(c, msg, 'EXTENDED');
this.inApp(c, 'Contract extended', msg);
}
/** Customer asked for their expired contract to be extended — staff-side record. */
extensionRequestedToStaff(c: Contract, note: string | null): void {
this.inAppStaff(
c,
'Contract extension requested',
`The customer asked to extend expired contract ${this.ref(c)}.` +
`${note ? ` Reason: ${note}` : ''} Open the contract to add validity days.`,
);
}
/** Customer cancelled their own contract — staff-side record. */
cancelledByCustomer(c: Contract, reason: string): void {
this.inAppStaff(

View File

@@ -430,6 +430,8 @@ export class ContractTransitionService {
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
contract.serviceType?.includesEthiopianCustomsOnly,
// An empty-equipment contract resolves to the carriage-only paper.
contract.cargoCondition,
);
if (!active) return null;
return {
@@ -1500,6 +1502,105 @@ export class ContractTransitionService {
return updated;
}
/**
* Customer asks EDR to extend the validity of their EXPIRED contract. Only
* stamps the request and tells the contract desk — nothing on the contract
* moves until staff {@link extend} it. One pending request at a time.
*/
async requestExtension(
contractId: string,
note: string | undefined,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertContractStatus(contract, ['EXPIRED']);
if (contract.extensionRequestedAt) {
throw new ConflictException(
'An extension request for this contract is already awaiting EDR.',
);
}
const reason = note?.trim() || null;
await this.contractsRepository.createReviewNote(
contractId,
reason ?? 'Customer requested a validity extension.',
'EXTENSION_REQUESTED',
userId,
'CUSTOMER',
);
await this.contractsRepository.update(contractId, {
extensionRequestedAt: new Date(),
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.extensionRequestedToStaff(updated, reason);
return updated;
}
/**
* Staff add validity days to an EXPIRED contract the customer asked to
* extend, and the contract returns to the status it held before it lapsed
* (stashed in statusBeforeExpiry by both expiry paths). Days count from
* today once the contract has lapsed — adding to a date already in the past
* could leave it expired — and from the current end date otherwise.
*
* Gated on the customer's request: the portal button is the only way to set
* extensionRequestedAt, so staff cannot silently revive a contract nobody
* asked about.
*/
async extend(
contractId: string,
days: number,
note: string | undefined,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(user, FREIGHT_PERMS.contracts.extend);
assertContractStatus(contract, ['EXPIRED']);
if (!contract.extensionRequestedAt) {
throw new ConflictException(
'The customer has not requested an extension for this contract. A contract is only extended on customer request.',
);
}
if (!Number.isInteger(days) || days < 1) {
throw new BadRequestException('An extension must add at least one day.');
}
const now = new Date();
const currentEnd = contract.contractValidUntil
? new Date(contract.contractValidUntil)
: null;
const base = currentEnd && currentEnd.getTime() > now.getTime() ? currentEnd : now;
const validUntil = new Date(base);
validUntil.setDate(validUntil.getDate() + days);
// Rows that lapsed before statusBeforeExpiry existed have nothing to
// restore — fall back to the kind's post-signature resting status, the
// same default resume() uses.
const restored =
contract.statusBeforeExpiry ??
(contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED');
const trimmed = note?.trim() || null;
await this.contractsRepository.createReviewNote(
contractId,
`Extended by ${days} day${days === 1 ? '' : 's'} to ${validUntil.toLocaleDateString('en-GB')}.` +
(trimmed ? ` ${trimmed}` : ''),
'EXTENDED',
actorId,
'STAFF',
);
await this.contractsRepository.update(contractId, {
status: restored,
statusBeforeExpiry: null,
extensionRequestedAt: null,
contractValidUntil: validUntil,
} as never);
const updated = await this.contractsService.findById(contractId);
this.notifier.extended(updated, days, validUntil, trimmed);
return updated;
}
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);

View File

@@ -78,6 +78,10 @@ import {
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
import { RenewContractDto } from './dto/renew-contract.dto';
import {
ExtendContractDto,
RequestContractExtensionDto,
} from './dto/extend-contract.dto';
import {
CompleteConsolidatedPairDto,
CreateBookingUnderContractDto,
@@ -528,6 +532,52 @@ export class ContractsController {
);
}
@Post(':id/extension-request')
@PortalCustomer()
@ApiOperation({
summary: 'Customer asks EDR to extend the validity of their expired contract',
})
async requestExtension(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestContractExtensionDto,
@CurrentUser() user: TCurrentUser,
) {
// Same ownership rule as cancel/renew: staff with bookings.view/contracts.view
// pass through, everyone else must own the contract's company.
const contract = await this.contractsService.findById(id);
if (
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
return this.transitionService.requestExtension(
id,
dto.note,
resolveAuthUserId(user),
);
}
@Post(':id/extend')
@BookingStaff(FREIGHT_PERMS.contracts.extend)
@ApiOperation({
summary:
'Staff extend an expired contract the customer asked to extend — it returns to its pre-expiry status',
})
extend(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ExtendContractDto,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.extend(
id,
dto.days,
dto.note,
resolveAuthUserId(user),
user,
);
}
@Post(':id/cancel')
@PortalCustomer()
@ApiOperation({

View File

@@ -135,7 +135,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
const result = await this.repository
.createQueryBuilder()
.update(Contract)
.set({ status: 'EXPIRED' })
// SET reads the pre-update row, so status_before_expiry gets the status
// being replaced — the value ContractTransitionService.extend restores.
.set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' })
.where('deleted_at IS NULL')
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
@@ -155,7 +157,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
const result = await this.repository
.createQueryBuilder()
.update(Contract)
.set({ status: 'EXPIRED' })
.set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' })
.where('id = :id', { id })
.andWhere('deleted_at IS NULL')
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })

View File

@@ -433,6 +433,7 @@ export class ContractsService {
renewalOfId: dto.renewalOfId ?? null,
tradeDirection: dto.tradeDirection,
freightType: dto.freightType,
cargoCondition: dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN',
serviceTypeId: dto.serviceTypeId,
// A contract is always QUOTED in USD — the billing currency is chosen per
// booking (or on the shipment request when GL books for the customer), so
@@ -952,6 +953,20 @@ export class ContractsService {
}
}
// Why the customer wants more time — shown on the staff detail page while
// the extension request is pending.
if (contract.status === 'EXPIRED' && contract.extensionRequestedAt) {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'EXTENSION_REQUESTED',
);
contract.latestExtensionRequestNote = note?.body ?? null;
} catch {
contract.latestExtensionRequestNote = null;
}
}
// Lets the portal disable "Cancel contract" instead of letting the customer
// click it and read a 400. The API re-checks on cancel regardless.
contract.activeBookingCount =

View File

@@ -23,6 +23,7 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const;
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
// Canonical UPPERCASE — everything downstream (booking gating, pricing
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
@@ -154,6 +155,15 @@ export class CreateContractDto {
@IsIn([...FREIGHT_TYPES])
freightType!: string;
/**
* LADEN (default) or EMPTY. EMPTY commits to moving bare equipment and is
* container freight only.
*/
@ApiPropertyOptional({ enum: CARGO_CONDITIONS, default: 'LADEN' })
@IsOptional()
@IsIn([...CARGO_CONDITIONS])
cargoCondition?: string;
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
@IsUUID()
serviceTypeId!: string;

View File

@@ -0,0 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
/** Customer asks EDR to extend the validity of their EXPIRED contract. */
export class RequestContractExtensionDto {
@ApiPropertyOptional({ description: 'Why the customer needs the contract extended' })
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}
/** Staff extend an EXPIRED contract that the customer asked to extend. */
export class ExtendContractDto {
@ApiProperty({
description:
'Days to add. Counted from today when the contract has already lapsed, otherwise from its current end date.',
minimum: 1,
maximum: 3650,
})
@IsInt()
@Min(1)
@Max(3650)
days!: number;
@ApiPropertyOptional({ description: 'Optional note recorded with the extension and shown to the customer' })
@IsOptional()
@IsString()
@MaxLength(2000)
note?: string;
}

View File

@@ -19,6 +19,10 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
'SUSPENSION_LIFTED',
/** Customer cancelled their own contract; body is their reason. */
'CANCELLATION',
/** Customer asked for an EXPIRED contract's validity to be extended. */
'EXTENSION_REQUESTED',
/** Staff extended the validity; body records the days added and the new end. */
'EXTENDED',
] as const;
export type ContractReviewNoteType =
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];

View File

@@ -150,6 +150,14 @@ export class Contract extends BaseEntity {
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
freightType!: string;
/**
* LADEN (the default, and every pre-existing row) or EMPTY. An EMPTY contract
* commits to moving bare equipment and resolves the IMPORT_EMPTY_CONTAINER
* template — a straight carriage agreement with no cargo or customs articles.
*/
@Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' })
cargoCondition!: string;
@Column({ name: 'service_type_id', type: 'uuid' })
serviceTypeId!: string;
@@ -231,6 +239,23 @@ export class Contract extends BaseEntity {
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
statusBeforeSuspension?: string | null;
/**
* Status the contract held when it lapsed to EXPIRED (stamped by both the
* nightly sweep and the lazy flip on read), restored when staff extend the
* validity. Null on rows that expired before the column existed — extension
* then falls back to the kind's post-signature resting status.
*/
@Column({ name: 'status_before_expiry', type: 'varchar', length: 40, nullable: true })
statusBeforeExpiry?: string | null;
/**
* When the customer asked for the validity of this EXPIRED contract to be
* extended. Set by the portal request, cleared when staff extend. Staff
* cannot extend a contract the customer has not asked about.
*/
@Column({ name: 'extension_requested_at', type: 'timestamptz', nullable: true })
extensionRequestedAt?: Date | null;
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
clearanceStatus!: string;
@@ -365,6 +390,13 @@ export class Contract extends BaseEntity {
*/
latestSuspensionNote?: string | null;
/**
* Body of the most recent EXTENSION_REQUESTED review note, attached by
* ContractsService.findById while an extension request is pending so staff
* see why the customer wants the contract extended. Not a column.
*/
latestExtensionRequestNote?: string | null;
/**
* Count of this contract's non-terminal bookings, attached by
* ContractsService.findById. The portal disables customer cancellation while

View File

@@ -27,3 +27,29 @@ describe('deriveRateType — surcharge triggers', () => {
);
});
});
describe('deriveRateType — empty container freight', () => {
it('splits empty freight from laden freight by direction', () => {
expect(deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS' })).toBe(
'EMPTY_CONTAINER_IMPORT',
);
expect(
deriveRateType({
appliesTo: 'EMPTY_CONTAINER',
trigger: 'ALWAYS',
tradeDirection: 'EXPORT',
}),
).toBe('EMPTY_CONTAINER_EXPORT');
});
// UQ_rates_pattern keys on rate_type but not on applies_to, so an empty rate
// sharing CONTAINER_IMPORT would collide with the laden rate for the same
// lane and container type. The distinct rateType is what keeps both fileable.
it('never resolves to the laden container rate type', () => {
for (const tradeDirection of ['IMPORT', 'EXPORT']) {
expect(
deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS', tradeDirection }),
).not.toBe(tradeDirection === 'EXPORT' ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT');
}
});
});

View File

@@ -58,6 +58,8 @@ export function deriveRateType(input: {
switch (appliesTo) {
case 'CONTAINER':
return isExport ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT';
case 'EMPTY_CONTAINER':
return isExport ? 'EMPTY_CONTAINER_EXPORT' : 'EMPTY_CONTAINER_IMPORT';
case 'BULK':
return isExport ? 'BULK_EXPORT' : 'BULK_IMPORT';
case 'INTERCITY':

View File

@@ -84,3 +84,25 @@ describe("allowedRateUnits — bulk unit of measure", () => {
expect(isBulkQuantityUnit("FLAT")).toBe(false);
});
});
/**
* Empty equipment carries no cargo, so no weighed unit applies — only the box
* and the wagon it rides on.
*/
describe("allowedRateUnits — empty container freight", () => {
it("offers per-container and per-wagon only", () => {
expect(
allowedRateUnits({ appliesTo: "EMPTY_CONTAINER", trigger: "ALWAYS" }),
).toEqual(["PER_CONTAINER", "PER_WAGON"]);
});
it("never offers a weighed unit, even for a per-item commodity scope", () => {
expect(
allowedRateUnits({
appliesTo: "EMPTY_CONTAINER",
trigger: "ALWAYS",
cargoUnitOfMeasure: "PER_ITEM",
}),
).not.toContain("PER_ITEM");
});
});

View File

@@ -98,6 +98,10 @@ function unitsForShape(input: {
switch (appliesTo) {
case 'CONTAINER':
return ['PER_CONTAINER', 'PER_WAGON'];
case 'EMPTY_CONTAINER':
// Empty equipment carries no cargo to weigh, so the only bases that mean
// anything are the box itself and the wagon it rides on.
return ['PER_CONTAINER', 'PER_WAGON'];
case 'BULK':
return ['PER_TON', 'PER_WAGON'];
case 'INTERCITY':

View File

@@ -8,6 +8,12 @@ import { Yard } from './yard.entity';
export const RATE_TYPES = [
'CONTAINER_IMPORT',
'CONTAINER_EXPORT',
// Empty equipment moved as freight in its own right — no cargo, priced per
// box by size. Distinct from CONTAINER_IMPORT because UQ_rates_pattern keys
// on rate_type: an empty 40ft Djibouti->Modjo rate filed as CONTAINER_IMPORT
// would collide with the laden 40ft rate for the same lane.
'EMPTY_CONTAINER_IMPORT',
'EMPTY_CONTAINER_EXPORT',
'BULK_IMPORT',
'BULK_EXPORT',
'INTERCITY_BULK',
@@ -59,12 +65,14 @@ export type RateUnit = typeof RATE_UNITS[number];
* lookup and snapshots).
*
* - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS)
* - EMPTY_CONTAINER : base rail freight for empty equipment
* - FIRST_MILE / LAST_MILE : pickup / delivery legs
* - OTHER : trigger-based surcharges (hazard, reefer …)
*/
export const RATE_APPLIES_TO = [
'BULK',
'CONTAINER',
'EMPTY_CONTAINER',
'INTERCITY',
'FIRST_MILE',
'LAST_MILE',

View File

@@ -24,7 +24,12 @@ import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.reposito
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = [
'BULK',
'CONTAINER',
'EMPTY_CONTAINER',
'INTERCITY',
];
/**
* Surcharges sold per cargo kind: the admin says container or bulk, a
* container fee then names its container type and a bulk fee its commodity.
@@ -381,6 +386,30 @@ export class RatesService {
return;
}
if (appliesTo === 'EMPTY_CONTAINER') {
// Northbound repositioning only. Southbound empties are already sold by
// the WITH_RETURN surcharge and empty_return_requests; a second path to
// the same movement would let the business double-sell it.
if (tradeDirection !== 'IMPORT') {
throw new BadRequestException(
'An empty container rate is import-only for now.',
);
}
// Size is the entire scope of an empty rate — there is no cargo to narrow
// by, so the box type must be named and a commodity must not be.
if (!containerTypeId) {
throw new BadRequestException(
'An empty container rate must name the container type it covers.',
);
}
if (cargoTypeId) {
throw new BadRequestException(
'An empty container rate cannot be scoped to a bulk cargo type.',
);
}
return;
}
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException(
`${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,

View File

@@ -0,0 +1,346 @@
import { CrewDutyRole } from './entities/train-crew-assignment.entity';
import { TrainCrewRole } from './entities/train-crew-member.entity';
import {
AssignmentFacts,
CorridorContext,
CorridorYard,
CrewDemandInput,
legAllowsNationality,
overtimeHours,
specializedRequirements,
technicianRequirement,
validateCrewComposition,
} from './crew-composition.rules';
/**
* A slice of the real corridor, using the production display_order values:
* GMP 3, Feto 7, Meiso 10, Dire Dawa 12, Nagad 19.
*/
const YARD: Record<string, CorridorYard> = {
GMP: { id: 'y-gmp', label: 'GMP', country: 'Ethiopia', displayOrder: 3 },
FETO: { id: 'y-feto', label: 'Feto', country: 'Ethiopia', displayOrder: 7 },
MEISO: { id: 'y-meiso', label: 'Meiso', country: 'Ethiopia', displayOrder: 10 },
DIRE_DAWA: { id: 'y-dd', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 12 },
NAGAD: { id: 'y-nagad', label: 'Nagad', country: 'Djibouti', displayOrder: 19 },
};
const CORRIDOR: CorridorContext = {
yards: new Map(Object.values(YARD).map((y) => [y.id, y])),
originOrder: YARD.GMP.displayOrder,
destinationOrder: YARD.NAGAD.displayOrder,
direDawaOrder: YARD.DIRE_DAWA.displayOrder,
};
const NO_DEMAND: CrewDemandInput = {
hasBadOrderWagon: false,
badOrderWagonLabels: [],
hasReeferCargo: false,
reeferSources: [],
hasHazmatCargo: false,
hazmatSources: [],
hasBreakBulkCargo: false,
breakBulkSources: [],
hasLivestockCargo: false,
livestockSources: [],
};
let seq = 0;
const driver = (
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN',
from: CorridorYard,
to: CorridorYard,
dutyRole: CrewDutyRole,
): AssignmentFacts => ({
crewMemberId: `driver-${++seq}`,
role: TrainCrewRole.TRAIN_DRIVER,
dutyRole,
fromYardId: from.id,
toYardId: to.id,
nationality,
memberName: `Driver ${seq}`,
});
const crewOfRole = (role: TrainCrewRole, count: number): AssignmentFacts[] =>
Array.from({ length: count }, () => ({
crewMemberId: `member-${++seq}`,
role,
nationality: 'ETHIOPIAN',
memberName: `Member ${seq}`,
}));
const codes = (result: { issues: Array<{ code: string }> }) =>
result.issues.map((i) => i.code);
describe('crew composition rules (ITLMS Rolling Stock)', () => {
const validate = (
assignments: AssignmentFacts[],
demand: CrewDemandInput = NO_DEMAND,
) => validateCrewComposition(assignments, demand, CORRIDOR);
/** One Ethiopian Primary over the whole route — the minimum viable crew. */
const soloPrimary = () =>
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY);
describe('free-form crew sizing', () => {
it('accepts a single driver working the whole corridor', () => {
const result = validate([soloPrimary()]);
expect(result.issues).toEqual([]);
expect(result.complete).toBe(true);
});
it.each([1, 3, 4, 6, 8])('accepts a crew of %i drivers on one leg', (count) => {
const drivers = [
soloPrimary(),
...Array.from({ length: count - 1 }, () =>
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
),
];
expect(validate(drivers).complete).toBe(true);
});
it('accepts any number of federal police, including none', () => {
for (const count of [0, 1, 4, 9]) {
const result = validate([
soloPrimary(),
...crewOfRole(TrainCrewRole.FEDERAL_POLICE, count),
]);
expect(result.complete).toBe(true);
}
});
it('requires at least one driver', () => {
const result = validate(crewOfRole(TrainCrewRole.FEDERAL_POLICE, 4));
expect(codes(result)).toContain('DRIVER_COUNT');
});
});
describe('yard-to-yard legs', () => {
it('lets staff hand over at any intermediate yard', () => {
// Three legs the old fixed segments could not express: GMPFeto,
// FetoMeiso, MeisoNagad.
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.FETO, YARD.MEISO, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.MEISO, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.issues).toEqual([]);
expect(result.complete).toBe(true);
});
it('rejects a leg with the same yard at both ends', () => {
const result = validate([
driver('ETHIOPIAN', YARD.FETO, YARD.FETO, CrewDutyRole.PRIMARY),
]);
expect(codes(result)).toContain('DRIVER_LEG_EMPTY');
});
it('rejects a yard outside the schedule route', () => {
const outside: CorridorYard = {
id: 'y-sebeta',
label: 'Sebeta',
country: 'Ethiopia',
displayOrder: 1, // before the GMP origin
};
const corridor: CorridorContext = {
...CORRIDOR,
yards: new Map([...(CORRIDOR.yards ?? []), [outside.id, outside]]),
};
const result = validateCrewComposition(
[driver('ETHIOPIAN', outside, YARD.NAGAD, CrewDutyRole.PRIMARY)],
NO_DEMAND,
corridor,
);
expect(codes(result)).toContain('LEG_OUTSIDE_ROUTE');
});
it('requires a from-yard, to-yard and duty role on every driver', () => {
const result = validate([
{
crewMemberId: 'd1',
role: TrainCrewRole.TRAIN_DRIVER,
nationality: 'ETHIOPIAN',
memberName: 'Unslotted Driver',
},
]);
expect(codes(result)).toContain('DRIVER_SLOT_INCOMPLETE');
});
});
describe('§1.1 territorial boundary', () => {
const dd = YARD.DIRE_DAWA.displayOrder;
it('lets a Djibouti driver work at or beyond Dire Dawa', () => {
expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'DJIBOUTIAN', dd)).toBe(true);
});
it('bars a Djibouti driver from any leg west of Dire Dawa', () => {
expect(legAllowsNationality(YARD.GMP, YARD.DIRE_DAWA, 'DJIBOUTIAN', dd)).toBe(false);
expect(legAllowsNationality(YARD.FETO, YARD.MEISO, 'DJIBOUTIAN', dd)).toBe(false);
});
it('leaves Ethiopian drivers unrestricted', () => {
expect(legAllowsNationality(YARD.GMP, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true);
expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true);
});
it('flags a Djibouti driver placed on a western leg', () => {
const result = validate([
driver('DJIBOUTIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY),
]);
expect(codes(result)).toContain('TERRITORIAL_BOUNDARY');
});
it('accepts the documented split: Ethiopians west, Djiboutians east', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.ASSISTANT),
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.ASSISTANT),
]);
expect(result.issues).toEqual([]);
expect(result.runType).toBe('LONG_RUN');
});
});
describe('one Primary per leg', () => {
it('rejects two Primaries on the same leg', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(codes(result)).toContain('DUPLICATE_PRIMARY');
});
it('allows a Primary on each of two different legs', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.complete).toBe(true);
});
it('allows many Assistants alongside one Primary', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.BENCH_RELIEF),
]);
expect(result.complete).toBe(true);
});
it('requires a Primary on every covered leg', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
]);
expect(codes(result)).toContain('PRIMARY_MISSING');
});
});
describe('§1.1 run type', () => {
it('is a long run when the legs span the whole route', () => {
const result = validate([
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.runType).toBe('LONG_RUN');
});
it('is a short run when the legs cover only part of the route', () => {
const result = validate([
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
]);
expect(result.runType).toBe('SHORT_RUN');
});
});
describe('§1.2 technical maintenance crew', () => {
it('requires no technician when no bad-order wagon is attached', () => {
expect(technicianRequirement(NO_DEMAND).min).toBe(0);
});
it('forces one technician when a bad-order wagon is attached', () => {
const demand = {
...NO_DEMAND,
hasBadOrderWagon: true,
badOrderWagonLabels: ['WG-1042'],
};
expect(technicianRequirement(demand).min).toBe(1);
const result = validate([soloPrimary()], demand);
expect(codes(result)).toContain('TECHNICIAN_REQUIRED');
// The wagon that forced it is named, so the demand is explicable.
expect(result.issues.find((i) => i.code === 'TECHNICIAN_REQUIRED')?.message)
.toContain('WG-1042');
});
});
describe('§1.2 specialized cargo crew', () => {
it('asks for nothing when no specialized cargo is aboard', () => {
expect(specializedRequirements(NO_DEMAND)).toEqual([]);
});
it('requires a reefer technician only when reefer cargo is aboard', () => {
const demand = { ...NO_DEMAND, hasReeferCargo: true, reeferSources: ['BK-1'] };
const rules = specializedRequirements(demand);
expect(rules).toHaveLength(1);
expect(rules[0].role).toBe(TrainCrewRole.REEFER_TECHNICIAN);
expect(rules[0].min).toBe(1);
});
it('blocks a hazmat run with no escort assigned', () => {
const result = validate([soloPrimary()], {
...NO_DEMAND,
hasHazmatCargo: true,
hazmatSources: ['BK-2024-0891'],
});
expect(codes(result)).toContain('SPECIALIZED_REQUIRED');
expect(result.complete).toBe(false);
});
it('passes once the escort is assigned, at any count', () => {
for (const escorts of [1, 2, 5]) {
const result = validate(
[soloPrimary(), ...crewOfRole(TrainCrewRole.HAZMAT_ESCORT, escorts)],
{ ...NO_DEMAND, hasHazmatCargo: true, hazmatSources: ['BK-2024-0891'] },
);
expect(result.complete).toBe(true);
}
});
});
describe('duplicate seats', () => {
it('flags a member assigned twice on one run', () => {
const twice = crewOfRole(TrainCrewRole.FEDERAL_POLICE, 1)[0];
const result = validate([soloPrimary(), twice, twice]);
expect(codes(result)).toContain('DUPLICATE_MEMBER');
});
});
describe('§3.2 overtime hours', () => {
it('reproduces the documented worked example', () => {
// PDF: 500h worked against a 240h standard => 260h variance,
// split 156h at the 1.5x tier and 104h at the 1.75x tier.
expect(overtimeHours(500)).toEqual({
variance: 260,
tier1Hours: 156,
tier2Hours: 104,
});
});
it('reports no overtime below the monthly standard', () => {
expect(overtimeHours(200)).toEqual({
variance: 0,
tier1Hours: 0,
tier2Hours: 0,
});
});
it('splits the variance 60/40 as a flat convention', () => {
const { tier1Hours, tier2Hours, variance } = overtimeHours(340);
expect(variance).toBe(100);
expect(tier1Hours).toBe(60);
expect(tier2Hours).toBe(40);
});
});
});

View File

@@ -0,0 +1,437 @@
import { TrainCrewRole } from './entities/train-crew-member.entity';
import {
CrewDutyRole,
CrewSegment,
} from './entities/train-crew-assignment.entity';
/**
* ITLMS Rolling Stock §1.2 / §2 composition rules.
*
* One module, used by BOTH the assignment API and the dispatch guard, so the
* wizard and the departure gate can never disagree about whether a crew is
* complete. Pure functions over plain data — no repository access — so the
* caller decides what to load and this stays unit-testable.
*/
/**
* Security-detail size (§1.2 names 4 federal police).
*
* Operations asked for free-form crewing, so the document's numbers are treated
* as the usual shape rather than a hard limit — any count is accepted and the
* typical value is surfaced as a hint in the UI.
*/
export const FEDERAL_POLICE_TYPICAL = 4;
/** Government monthly working-hour baseline (§3.1). */
export const MONTHLY_STANDARD_HOURS = 240;
/**
* §3.2 tier split. The document fixes the day/night division as a flat 60/40 of
* the variance regardless of when the hours fell, and that is implemented as
* written rather than derived from real clock hours.
*/
export const OT_TIER_1_SHARE = 0.6;
export const OT_TIER_2_SHARE = 0.4;
export const OT_TIER_1_FACTOR = 1.5;
export const OT_TIER_2_FACTOR = 1.75;
/**
* Driving-crew size (§1.2 "3 or 4 Drivers").
*
* Operations asked for a free-form crew rather than the two fixed pairing cases
* of §2, so the document's 3-or-4 is treated as the usual shape, not a limit:
* any count within these bounds is accepted and each driver carries their own
* segment and duty role. MIN stays at 1 so a partially built crew still saves.
*/
export const DRIVER_COUNT_MIN = 1;
/** Typical driving-crew size per §1.2 — a hint in the UI, never enforced. */
export const DRIVER_COUNT_TYPICAL = [3, 4];
/**
* A corridor yard as the rules see it.
*
* `displayOrder` is the yard's place along the corridor (Sebeta 1 … DCT/SGTD
* 22), which is what makes "is this leg inside the schedule's span" and "does
* this leg cross into Djibouti" answerable without hard-coding station names.
*/
export interface CorridorYard {
id: string;
label: string;
country: string;
displayOrder: number;
}
/** Dire Dawa is the handover point §1.1 draws the territorial line at. */
export const DIRE_DAWA_CODE = 'DIRE_DAWA';
/**
* The corridor a schedule runs on, as the rules need to see it: every yard by
* id, where the schedule starts and ends, and where Dire Dawa sits. Supplied by
* the caller so these functions stay pure and unit-testable.
*/
export interface CorridorContext {
yards?: Map<string, CorridorYard>;
originOrder?: number;
destinationOrder?: number;
direDawaOrder?: number;
}
/**
* §1.1 territorial boundary: Djiboutian drivers work the Dire Dawa Nagad
* corridor segment exclusively.
*
* Expressed against yards rather than a fixed segment name: a leg is open to a
* Djiboutian driver when it stays at or beyond Dire Dawa, so any handover point
* east of it works without naming the pair in code. The restriction is
* asymmetric on purpose — the document confines Djiboutian drivers but never
* bars Ethiopians from that stretch.
*/
export const legAllowsNationality = (
from: CorridorYard | undefined,
to: CorridorYard | undefined,
nationality: string,
direDawaOrder: number,
): boolean => {
if (nationality !== 'DJIBOUTIAN') return true;
if (!from || !to) return true; // Incomplete leg — a separate rule reports it.
// Both ends must sit at or beyond Dire Dawa, whichever way the train runs.
return Math.min(from.displayOrder, to.displayOrder) >= direDawaOrder;
};
/** Specialized-crew rules (§1.2), each keyed to what the train is carrying. */
export interface SpecializedRequirement {
role: TrainCrewRole;
/** Hard floor — 0 unless the cargo or consist forces someone aboard. */
min: number;
/** The count §1.2 suggests. A hint for the UI; nothing enforces it. */
typical: number;
/** Why this is required — surfaced verbatim so the demand is explicable. */
reason: string;
}
/** What the consist and its cargo demand, as detected from the schedule. */
export interface CrewDemandInput {
/** A defective / bad-order wagon is attached (§1.2 forces 1 technician). */
hasBadOrderWagon: boolean;
badOrderWagonLabels: string[];
hasReeferCargo: boolean;
reeferSources: string[];
hasHazmatCargo: boolean;
hazmatSources: string[];
hasBreakBulkCargo: boolean;
breakBulkSources: string[];
hasLivestockCargo: boolean;
livestockSources: string[];
}
const listSources = (sources: string[]): string =>
sources.length ? ` (${sources.slice(0, 3).join(', ')}${sources.length > 3 ? '…' : ''})` : '';
/**
* Turn detected cargo/consist facts into the crew the run must carry.
* Only triggered rows appear, so staff are never asked about cargo not aboard.
*/
export const specializedRequirements = (
demand: CrewDemandInput,
): SpecializedRequirement[] => {
const required: SpecializedRequirement[] = [];
if (demand.hasReeferCargo) {
required.push({
role: TrainCrewRole.REEFER_TECHNICIAN,
min: 1,
typical: 2,
reason: `Reefer cargo on board${listSources(demand.reeferSources)}`,
});
}
if (demand.hasHazmatCargo) {
required.push({
role: TrainCrewRole.HAZMAT_ESCORT,
min: 1,
typical: 2,
reason: `Dangerous / flammable cargo on board${listSources(demand.hazmatSources)}`,
});
}
if (demand.hasBreakBulkCargo) {
required.push({
role: TrainCrewRole.LASHING_INSPECTOR,
min: 1,
typical: 2,
reason: `Break-bulk cargo requiring lashing inspection${listSources(demand.breakBulkSources)}`,
});
}
if (demand.hasLivestockCargo) {
required.push({
role: TrainCrewRole.LIVESTOCK_HANDLER,
min: 1,
typical: 3,
reason: `Livestock shipment on board${listSources(demand.livestockSources)}`,
});
}
return required;
};
/** Technician floor: 1 is mandatory only when a bad-order wagon is attached. */
export const technicianRequirement = (
demand: CrewDemandInput,
): SpecializedRequirement => ({
role: TrainCrewRole.TECHNICIAN,
min: demand.hasBadOrderWagon ? 1 : 0,
typical: 3,
reason: demand.hasBadOrderWagon
? `Defective / bad-order wagon attached${listSources(demand.badOrderWagonLabels)}`
: 'Optional technical maintenance crew',
});
/** One assignment, reduced to what the rules actually read. */
export interface AssignmentFacts {
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole | null;
/** The leg this driver works, as two corridor yards. */
fromYardId?: string | null;
toYardId?: string | null;
nationality: string;
memberName: string;
}
export interface CrewValidationIssue {
code: string;
message: string;
}
export interface CrewValidationResult {
/** True when every mandatory rule passes — the dispatch gate reads this. */
complete: boolean;
issues: CrewValidationIssue[];
/** Derived, never entered: one segment covered = short run, both = long run (§1.1). */
runType: 'SHORT_RUN' | 'LONG_RUN' | null;
}
/**
* Validate a schedule's crew against §1.1 and §1.2.
*
* Returns issues rather than throwing: the wizard renders them as a live
* checklist while a partial crew is still being built, and only the dispatch
* guard treats a non-empty list as fatal.
*/
export const validateCrewComposition = (
assignments: AssignmentFacts[],
demand: CrewDemandInput,
corridor: CorridorContext = {},
): CrewValidationResult => {
const yards = corridor.yards ?? new Map<string, CorridorYard>();
const direDawaOrder = corridor.direDawaOrder ?? Number.POSITIVE_INFINITY;
const issues: CrewValidationIssue[] = [];
const drivers = assignments.filter((a) => a.role === TrainCrewRole.TRAIN_DRIVER);
if (drivers.length < DRIVER_COUNT_MIN) {
issues.push({
code: 'DRIVER_COUNT',
message: 'At least one driver must be assigned',
});
}
// Every driver needs a leg and a duty role — without them the run has no
// record of who drove which part of the corridor.
for (const driver of drivers) {
if (!driver.fromYardId || !driver.toYardId || !driver.dutyRole) {
issues.push({
code: 'DRIVER_SLOT_INCOMPLETE',
message: `${driver.memberName} needs a from-yard, a to-yard and a duty role`,
});
continue;
}
if (driver.fromYardId === driver.toYardId) {
issues.push({
code: 'DRIVER_LEG_EMPTY',
message: `${driver.memberName} has the same yard at both ends of their leg`,
});
}
// A leg outside the schedule's own span would put a driver on track this
// train never runs.
if (corridor.originOrder !== undefined && corridor.destinationOrder !== undefined) {
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
const from = yards.get(driver.fromYardId);
const to = yards.get(driver.toYardId);
for (const yard of [from, to]) {
if (yard && (yard.displayOrder < low || yard.displayOrder > high)) {
issues.push({
code: 'LEG_OUTSIDE_ROUTE',
message: `${yard.label} is outside this schedule's route — ${driver.memberName}'s leg must stay between the origin and destination`,
});
}
}
}
}
// A leg cannot have two Primaries — someone must be in charge of each stretch
// and only one person can be. Assistants and relief drivers are unconstrained.
const legKey = (d: AssignmentFacts) => `${d.fromYardId}>${d.toYardId}`;
const legLabel = (d: AssignmentFacts) => {
const from = d.fromYardId ? yards.get(d.fromYardId)?.label : undefined;
const to = d.toYardId ? yards.get(d.toYardId)?.label : undefined;
return from && to ? `${from} ${to}` : 'this leg';
};
const primariesByLeg = new Map<string, { names: string[]; label: string }>();
for (const driver of drivers) {
if (driver.dutyRole === CrewDutyRole.PRIMARY && driver.fromYardId && driver.toYardId) {
const key = legKey(driver);
const entry = primariesByLeg.get(key) ?? { names: [], label: legLabel(driver) };
entry.names.push(driver.memberName);
primariesByLeg.set(key, entry);
}
}
for (const [, entry] of primariesByLeg) {
if (entry.names.length > 1) {
issues.push({
code: 'DUPLICATE_PRIMARY',
message: `${entry.label} has more than one Primary Driver (${entry.names.join(', ')})`,
});
}
}
// Each covered leg needs a Primary — an Assistant alone cannot run it.
const coveredLegs = new Map<string, string>();
for (const driver of drivers) {
if (driver.fromYardId && driver.toYardId) {
coveredLegs.set(legKey(driver), legLabel(driver));
}
}
for (const [key, label] of coveredLegs) {
if (!primariesByLeg.has(key)) {
issues.push({
code: 'PRIMARY_MISSING',
message: `${label} has no Primary Driver assigned`,
});
}
}
// §1.1 territorial boundary — Djibouti drivers stay at or beyond Dire Dawa.
for (const driver of drivers) {
const from = driver.fromYardId ? yards.get(driver.fromYardId) : undefined;
const to = driver.toYardId ? yards.get(driver.toYardId) : undefined;
if (!legAllowsNationality(from, to, driver.nationality, direDawaOrder)) {
issues.push({
code: 'TERRITORIAL_BOUNDARY',
message: `${driver.memberName} is a Djibouti driver and may only work legs from Dire Dawa eastward`,
});
}
}
// §1.2 names 4 federal police, 1-3 technicians and so on. Those counts are
// no longer enforced: operations crew each run to its own need, so any number
// of any role is accepted. What still holds is what makes a run coherent —
// a driver with a segment and duty role, one Primary per segment, and the
// specialized crew the cargo actually demands.
// §1.2 technical maintenance crew: a bad-order wagon still forces at least
// one technician — that rule is about safety, not crew sizing, so it stays.
const technicianRule = technicianRequirement(demand);
const technicians = assignments.filter((a) => a.role === TrainCrewRole.TECHNICIAN).length;
if (technicians < technicianRule.min) {
issues.push({
code: 'TECHNICIAN_REQUIRED',
message: `At least ${technicianRule.min} technician required — ${technicianRule.reason}`,
});
}
// §1.2 specialized cargo crew: the floor stays (hazmat aboard means an escort
// rides along) but the upper bound is gone — how many is operations' call.
for (const rule of specializedRequirements(demand)) {
const count = assignments.filter((a) => a.role === rule.role).length;
if (count < rule.min) {
issues.push({
code: 'SPECIALIZED_REQUIRED',
message: `At least ${rule.min} ${labelRole(rule.role)} required — ${rule.reason}`,
});
}
}
// Nobody may hold two seats on the same run.
const seen = new Set<string>();
for (const a of assignments) {
if (seen.has(a.crewMemberId)) {
issues.push({
code: 'DUPLICATE_MEMBER',
message: `${a.memberName} is assigned more than once on this run`,
});
}
seen.add(a.crewMemberId);
}
return {
complete: issues.length === 0,
issues,
runType: deriveRunType(drivers, corridor, yards),
};
};
/**
* §1.1 run type. A crew whose legs together span the schedule's whole route is
* a long run; anything shorter is a short run.
*/
const deriveRunType = (
drivers: AssignmentFacts[],
corridor: CorridorContext,
yards: Map<string, CorridorYard>,
): 'SHORT_RUN' | 'LONG_RUN' | null => {
const orders = drivers
.flatMap((d) => [d.fromYardId, d.toYardId])
.map((id) => (id ? yards.get(id)?.displayOrder : undefined))
.filter((o): o is number => o !== undefined);
if (!orders.length) return null;
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
return 'SHORT_RUN';
}
const routeLow = Math.min(corridor.originOrder, corridor.destinationOrder);
const routeHigh = Math.max(corridor.originOrder, corridor.destinationOrder);
const covered = Math.min(...orders) <= routeLow && Math.max(...orders) >= routeHigh;
return covered ? 'LONG_RUN' : 'SHORT_RUN';
};
export const labelSegment = (segment: CrewSegment): string =>
({
[CrewSegment.INDODE_DIRE_DAWA]: 'Indode/GMP Dire Dawa',
[CrewSegment.DIRE_DAWA_NAGAD]: 'Dire Dawa Nagad',
[CrewSegment.FULL_CORRIDOR]: 'Full corridor',
})[segment];
export const labelDutyRole = (dutyRole: CrewDutyRole): string =>
({
[CrewDutyRole.PRIMARY]: 'Primary Driver',
[CrewDutyRole.ASSISTANT]: 'Assistant Driver',
[CrewDutyRole.BENCH_RELIEF]: 'Bench/Relief Driver',
})[dutyRole];
export const labelRole = (role: TrainCrewRole): string =>
({
[TrainCrewRole.TRAIN_DRIVER]: 'train driver',
[TrainCrewRole.FEDERAL_POLICE]: 'federal police',
[TrainCrewRole.TECHNICIAN]: 'technician',
[TrainCrewRole.REEFER_TECHNICIAN]: 'reefer technician',
[TrainCrewRole.HAZMAT_ESCORT]: 'HAZMAT escort',
[TrainCrewRole.LASHING_INSPECTOR]: 'lashing inspector',
[TrainCrewRole.LIVESTOCK_HANDLER]: 'livestock handler',
})[role];
/**
* §3.2 overtime hours for one driver's month.
*
* Hours only, by design: no salary is stored anywhere in the platform, so the
* output stops at the two tier totals and finance applies the rates.
*/
export const overtimeHours = (
workedHours: number,
standardHours: number = MONTHLY_STANDARD_HOURS,
): { variance: number; tier1Hours: number; tier2Hours: number } => {
const variance = Math.max(0, workedHours - standardHours);
return {
variance,
tier1Hours: variance * OT_TIER_1_SHARE,
tier2Hours: variance * OT_TIER_2_SHARE,
};
};

View File

@@ -0,0 +1,32 @@
import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import {
TrainCrewNationality,
TrainCrewRole,
TrainCrewStatus,
} from '../entities/train-crew-member.entity';
export class CreateTrainCrewMemberDto {
@IsString()
@MinLength(1)
@MaxLength(100)
firstName!: string;
@IsString()
@MinLength(1)
@MaxLength(100)
lastName!: string;
@IsEnum(TrainCrewRole)
role!: TrainCrewRole;
@IsEnum(TrainCrewNationality)
nationality!: TrainCrewNationality;
@IsOptional()
@IsEnum(TrainCrewStatus)
status?: TrainCrewStatus;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,63 @@
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsEnum, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import {
TrainCrewNationality,
TrainCrewRole,
TrainCrewStatus,
} from '../entities/train-crew-member.entity';
/** Sortable columns. Whitelisted: the value is interpolated into ORDER BY. */
export const TRAIN_CREW_SORT_FIELDS = [
'firstName',
'lastName',
'role',
'nationality',
'status',
'createdAt',
'updatedAt',
] as const;
export class QueryTrainCrewMemberDto {
/** Matched against first and last name. */
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@IsEnum(TrainCrewRole)
role?: TrainCrewRole;
@IsOptional()
@IsEnum(TrainCrewNationality)
nationality?: TrainCrewNationality;
@IsOptional()
@IsEnum(TrainCrewStatus)
status?: TrainCrewStatus;
@IsOptional()
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
@IsBoolean()
isActive?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
limit?: number;
@IsOptional()
@IsIn(TRAIN_CREW_SORT_FIELDS as unknown as string[])
sortBy?: (typeof TRAIN_CREW_SORT_FIELDS)[number];
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';
}

View File

@@ -0,0 +1,45 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { CrewDutyRole } from '../entities/train-crew-assignment.entity';
import { TrainCrewRole } from '../entities/train-crew-member.entity';
export class CrewAssignmentRowDto {
@IsUUID()
crewMemberId!: string;
@IsEnum(TrainCrewRole)
role!: TrainCrewRole;
/** Required for drivers, rejected as incomplete without it. */
@IsOptional()
@IsEnum(CrewDutyRole)
dutyRole?: CrewDutyRole;
/** The leg this driver works — any two yards on the schedule's route. */
@IsOptional()
@IsUUID()
fromYardId?: string;
@IsOptional()
@IsUUID()
toYardId?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class SaveCrewAssignmentsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => CrewAssignmentRowDto)
assignments!: CrewAssignmentRowDto[];
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTrainCrewMemberDto } from './create-train-crew-member.dto';
export class UpdateTrainCrewMemberDto extends PartialType(CreateTrainCrewMemberDto) {}

View File

@@ -0,0 +1,114 @@
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
import { TrainCrewMember, TrainCrewRole } from './train-crew-member.entity';
/**
* Legacy fixed corridor segments.
*
* Kept only so historic rows written before segments became yard-to-yard still
* read back. New assignments carry `fromYardId`/`toYardId` instead: staff pick
* any two yards on the corridor, so a leg is no longer limited to the three
* spans the original design hard-coded.
*/
export enum CrewSegment {
INDODE_DIRE_DAWA = 'INDODE_DIRE_DAWA',
DIRE_DAWA_NAGAD = 'DIRE_DAWA_NAGAD',
FULL_CORRIDOR = 'FULL_CORRIDOR',
}
/** Driver duty role for one run (§2). Null for non-driving crew. */
export enum CrewDutyRole {
PRIMARY = 'PRIMARY',
ASSISTANT = 'ASSISTANT',
BENCH_RELIEF = 'BENCH_RELIEF',
}
export enum CrewAssignmentStatus {
PLANNED = 'PLANNED',
CONFIRMED = 'CONFIRMED',
COMPLETED = 'COMPLETED',
REMOVED = 'REMOVED',
}
/**
* One roster member assigned to one train schedule.
*
* `role` is snapshotted from the roster at assignment time: a member who later
* changes role must not silently rewrite the crew of a run that already
* departed. `dutyRole` and `segment` live here rather than on the roster
* because they are properties of THIS run — a driver who is Primary on one
* trip is Assistant on the next. Crew sizes are free-form: operations size each
* run to its own need rather than to a fixed pairing case.
*
* Duty stamps feed the §3 monthly overtime totals. Per the agreed scope the
* platform reports OT hours only; no salary is stored anywhere, and the payroll
* conversion stays with finance.
*/
@Entity({ schema: 'freight', name: 'train_crew_assignments' })
@Index(['trainScheduleId'])
@Index(['crewMemberId'])
@Index(['status'])
export class TrainCrewAssignment extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@Column({ name: 'crew_member_id', type: 'uuid' })
crewMemberId!: string;
@ManyToOne(() => TrainCrewMember, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'crew_member_id' })
crewMember?: TrainCrewMember;
@Column({ name: 'role', type: 'varchar', length: 32 })
role!: TrainCrewRole;
@Column({ name: 'duty_role', type: 'varchar', length: 16, nullable: true })
dutyRole?: CrewDutyRole | null;
/** Legacy fixed segment — null on every assignment written since yard legs. */
@Column({ name: 'segment', type: 'varchar', length: 24, nullable: true })
segment?: CrewSegment | null;
/**
* The leg this driver works, as two yards on the corridor.
*
* Free-form on purpose: operations pick any yard as a handover point, so a
* crew change at Meiso or Feto is expressible without a code change. The
* schedule's own origin and destination bound what staff may choose.
*/
@Column({ name: 'from_yard_id', type: 'uuid', nullable: true })
fromYardId?: string | null;
@Column({ name: 'to_yard_id', type: 'uuid', nullable: true })
toYardId?: string | null;
/**
* Mandatory off-duty layover at Dire Dawa (§1.3). The document gives ~5 hours
* as a typical duration, not a rule, so nothing here enforces a length — the
* stamps are recorded and reported.
*/
@Column({ name: 'layover_start_at', type: 'timestamptz', nullable: true })
layoverStartAt?: Date | null;
@Column({ name: 'layover_end_at', type: 'timestamptz', nullable: true })
layoverEndAt?: Date | null;
/** Worked span for this run — accumulated monthly for the §3 OT calculation. */
@Column({ name: 'duty_start_at', type: 'timestamptz', nullable: true })
dutyStartAt?: Date | null;
@Column({ name: 'duty_end_at', type: 'timestamptz', nullable: true })
dutyEndAt?: Date | null;
@Column({
name: 'status',
type: 'varchar',
length: 16,
default: CrewAssignmentStatus.PLANNED,
})
status!: CrewAssignmentStatus;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,69 @@
import { Column, Entity, Index } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
/**
* On-board role a crew member is rostered for. Mirrors the crew composition
* rules in ITLMS Rolling Stock §1.2: driving crew, the federal police security
* detail, technical maintenance, and the four specialized cargo roles.
*/
export enum TrainCrewRole {
TRAIN_DRIVER = 'TRAIN_DRIVER',
FEDERAL_POLICE = 'FEDERAL_POLICE',
TECHNICIAN = 'TECHNICIAN',
REEFER_TECHNICIAN = 'REEFER_TECHNICIAN',
HAZMAT_ESCORT = 'HAZMAT_ESCORT',
LASHING_INSPECTOR = 'LASHING_INSPECTOR',
LIVESTOCK_HANDLER = 'LIVESTOCK_HANDLER',
}
/**
* Employing country. Drives the territorial boundary in §1.1 — Djibouti train
* drivers operate only on the Dire Dawa Nagad segment — and the crewing
* cases in §2 (Case 1 pairs 2 Ethiopian with 2 Djiboutian drivers).
*/
export enum TrainCrewNationality {
ETHIOPIAN = 'ETHIOPIAN',
DJIBOUTIAN = 'DJIBOUTIAN',
}
export enum TrainCrewStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
SUSPENDED = 'SUSPENDED',
ON_LEAVE = 'ON_LEAVE',
}
/**
* Roster of people assignable to a train. Distinct from `freight.drivers`,
* which is the road/last-mile truck driver register (licences, vehicle types,
* trip counts) — a train driver shares none of those fields.
*/
@Entity({ schema: 'freight', name: 'train_crew_members' })
@Index(['role'])
@Index(['nationality'])
@Index(['status'])
@Index(['isActive'])
export class TrainCrewMember extends BaseEntity {
@Column({ name: 'first_name', type: 'varchar', length: 100 })
firstName!: string;
@Column({ name: 'last_name', type: 'varchar', length: 100 })
lastName!: string;
@Column({ name: 'role', type: 'varchar', length: 32 })
role!: TrainCrewRole;
@Column({ name: 'nationality', type: 'varchar', length: 16 })
nationality!: TrainCrewNationality;
@Column({
name: 'status',
type: 'varchar',
length: 16,
default: TrainCrewStatus.ACTIVE,
})
status!: TrainCrewStatus;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,53 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto';
import { TrainCrewAssignmentService } from './train-crew-assignment.service';
@ApiTags('train-crew-assignments')
@ApiBearerAuth()
@Controller('train-schedules/:scheduleId/crew')
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([FREIGHT_PERMS.trainCrew.view, FREIGHT_PERMS.trainCrew.assign])
export class TrainCrewAssignmentController {
constructor(private readonly service: TrainCrewAssignmentService) {}
@Get()
@ApiOperation({
summary: "A schedule's crew, the cargo-driven requirements, and rule validation",
})
getCrew(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.service.getScheduleCrew(scheduleId);
}
@Get('eligible-drivers')
@ApiOperation({ summary: 'Roster drivers eligible for a leg between two yards' })
eligibleDrivers(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Query('fromYardId') fromYardId?: string,
@Query('toYardId') toYardId?: string,
) {
return this.service.eligibleDrivers(scheduleId, fromYardId, toYardId);
}
@Get('corridor-yards')
@ApiOperation({ summary: "Yards a driver leg may use on this schedule's route" })
corridorYards(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.service.corridorYards(scheduleId);
}
@Put()
@BookingStaff(FREIGHT_PERMS.trainCrew.assign)
@ApiOperation({
summary: "Replace a schedule's crew (an incomplete crew saves; dispatch is what blocks)",
})
save(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: SaveCrewAssignmentsDto,
) {
return this.service.saveAssignments(scheduleId, dto);
}
}

View File

@@ -0,0 +1,389 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import {
CrewAssignmentStatus,
TrainCrewAssignment,
} from './entities/train-crew-assignment.entity';
import {
TrainCrewMember,
TrainCrewRole,
TrainCrewStatus,
} from './entities/train-crew-member.entity';
import {
AssignmentFacts,
CorridorContext,
CorridorYard,
CrewDemandInput,
CrewValidationResult,
DIRE_DAWA_CODE,
labelRole,
legAllowsNationality,
specializedRequirements,
technicianRequirement,
validateCrewComposition,
} from './crew-composition.rules';
import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto';
/** Wagon statuses that mean "defective / bad order" for §1.2. */
const BAD_ORDER_WAGON_STATUSES = ['MAINTENANCE', 'DETAINED', 'OUT_OF_SERVICE'];
/**
* Cargo-type name fragments that mark a livestock shipment. Matched on the
* cargo type's name because no boolean flag for livestock exists yet — unlike
* reefer and hazardous, which bookings carry explicitly.
*/
const LIVESTOCK_NAME_HINTS = ['livestock', 'cattle', 'animal', 'poultry'];
@Injectable()
export class TrainCrewAssignmentService {
constructor(
@InjectRepository(TrainCrewAssignment)
private readonly assignmentRepo: Repository<TrainCrewAssignment>,
@InjectRepository(TrainCrewMember)
private readonly memberRepo: Repository<TrainCrewMember>,
private readonly dataSource: DataSource,
) {}
/** Every assignment on a schedule, with the roster member joined. */
async listForSchedule(scheduleId: string): Promise<TrainCrewAssignment[]> {
return this.assignmentRepo.find({
where: {
trainScheduleId: scheduleId,
status: In([
CrewAssignmentStatus.PLANNED,
CrewAssignmentStatus.CONFIRMED,
CrewAssignmentStatus.COMPLETED,
]),
},
relations: { crewMember: true },
order: { createdAt: 'ASC' },
});
}
/**
* What this schedule's consist and cargo demand (§1.2).
*
* Read straight from the train set and its allocations rather than asked of
* the user: the wagons and bookings already say whether a bad-order wagon is
* attached and whether reefer, hazardous, break-bulk or livestock cargo is
* aboard, so the requirement is derived and every row can name its trigger.
*/
async detectDemand(scheduleId: string): Promise<CrewDemandInput> {
const badOrder: Array<{ label: string }> = await this.dataSource.query(
`
SELECT COALESCE(w.wagon_number, tsw.id::text) AS label
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
WHERE ts.id = $1
AND w.status = ANY($2)
`,
[scheduleId, BAD_ORDER_WAGON_STATUSES],
);
const cargo: Array<{
reference: string | null;
is_reefer: boolean;
is_hazardous: boolean;
load_type: string | null;
cargo_type_name: string | null;
}> = await this.dataSource.query(
`
SELECT DISTINCT
b.reference,
b.is_reefer,
b.is_hazardous,
wba.load_type,
ct.cargo_type_name
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id
JOIN freight.wagon_booking_allocations wba ON wba.train_set_wagon_id = tsw.id
JOIN freight.bookings b ON b.id = wba.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
WHERE ts.id = $1
`,
[scheduleId],
);
const label = (row: { reference: string | null }) => row.reference ?? 'a booking';
const isLivestock = (name: string | null) =>
Boolean(name) &&
LIVESTOCK_NAME_HINTS.some((hint) => name!.toLowerCase().includes(hint));
const reefer = cargo.filter((c) => c.is_reefer);
const hazmat = cargo.filter((c) => c.is_hazardous);
// Break-bulk rides as a bulk allocation rather than a container.
const breakBulk = cargo.filter((c) => c.load_type === 'BULK');
const livestock = cargo.filter((c) => isLivestock(c.cargo_type_name));
return {
hasBadOrderWagon: badOrder.length > 0,
badOrderWagonLabels: badOrder.map((w) => w.label),
hasReeferCargo: reefer.length > 0,
reeferSources: reefer.map(label),
hasHazmatCargo: hazmat.length > 0,
hazmatSources: hazmat.map(label),
hasBreakBulkCargo: breakBulk.length > 0,
breakBulkSources: breakBulk.map(label),
hasLivestockCargo: livestock.length > 0,
livestockSources: livestock.map(label),
};
}
/**
* The corridor this schedule runs on: every active yard by id, plus where the
* schedule starts, ends, and where Dire Dawa sits. `display_order` is the
* yard's place along the line, which is what lets the rules answer "is this
* leg inside the route" and "does it cross the territorial boundary" without
* hard-coding station names.
*/
async loadCorridor(scheduleId: string): Promise<CorridorContext> {
const rows: Array<{
id: string;
code: string;
label: string;
country: string;
display_order: number;
}> = await this.dataSource.query(
`SELECT id, code, label, country, display_order
FROM freight.yards
WHERE is_active = true
ORDER BY display_order ASC`,
);
const yards = new Map<string, CorridorYard>(
rows.map((r) => [
r.id,
{
id: r.id,
label: r.label,
country: r.country,
displayOrder: Number(r.display_order),
},
]),
);
const [schedule]: Array<{
origin_station_id: string | null;
destination_station_id: string | null;
}> = await this.dataSource.query(
`SELECT origin_station_id, destination_station_id
FROM freight.train_schedules WHERE id = $1`,
[scheduleId],
);
const orderOf = (id: string | null | undefined) =>
id ? yards.get(id)?.displayOrder : undefined;
return {
yards,
originOrder: orderOf(schedule?.origin_station_id),
destinationOrder: orderOf(schedule?.destination_station_id),
direDawaOrder: rows.find((r) => r.code === DIRE_DAWA_CODE)
? Number(rows.find((r) => r.code === DIRE_DAWA_CODE)!.display_order)
: undefined,
};
}
/** Yards a driver leg may use — every yard between origin and destination. */
async corridorYards(scheduleId: string): Promise<CorridorYard[]> {
const corridor = await this.loadCorridor(scheduleId);
const all = [...(corridor.yards?.values() ?? [])].sort(
(a, b) => a.displayOrder - b.displayOrder,
);
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
return all;
}
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
return all.filter((y) => y.displayOrder >= low && y.displayOrder <= high);
}
/**
* Full picture for one schedule: who is assigned, what the cargo demands, and
* which composition rules currently fail. The wizard renders this directly.
*/
async getScheduleCrew(scheduleId: string) {
const [assignments, demand, corridor] = await Promise.all([
this.listForSchedule(scheduleId),
this.detectDemand(scheduleId),
this.loadCorridor(scheduleId),
]);
const validation = validateCrewComposition(
assignments.map(toFacts),
demand,
corridor,
);
return {
scheduleId,
assignments,
corridorYards: [...(corridor.yards?.values() ?? [])]
.filter((y) => {
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
return true;
}
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
return y.displayOrder >= low && y.displayOrder <= high;
})
.sort((a, b) => a.displayOrder - b.displayOrder),
demand,
requirements: {
technician: technicianRequirement(demand),
specialized: specializedRequirements(demand),
},
validation,
};
}
/**
* Replace a schedule's crew in one transaction.
*
* A whole-set replace rather than per-row edits: the wizard submits the
* finished crew, and composition rules are only meaningful over the complete
* set. Saving an INCOMPLETE crew is allowed on purpose — ops build a roster
* over days, and §1.2 places the hard gate at departure, not at save time.
* Only structural errors (unknown member, wrong role, territorial breach)
* reject here; the rest surface as issues and block dispatch.
*/
async saveAssignments(
scheduleId: string,
dto: SaveCrewAssignmentsDto,
): Promise<CrewValidationResult> {
const rows = dto.assignments ?? [];
const memberIds = rows.map((r) => r.crewMemberId);
const corridor = await this.loadCorridor(scheduleId);
const members = memberIds.length
? await this.memberRepo.find({ where: { id: In(memberIds) } })
: [];
const byId = new Map(members.map((m) => [m.id, m]));
for (const row of rows) {
const member = byId.get(row.crewMemberId);
if (!member) {
throw new NotFoundException(`Crew member ${row.crewMemberId} not found`);
}
if (member.status !== TrainCrewStatus.ACTIVE || !member.isActive) {
throw new BadRequestException(
`${member.firstName} ${member.lastName} is ${member.status} and cannot be assigned`,
);
}
if (row.role !== member.role) {
throw new BadRequestException(
`${member.firstName} ${member.lastName} is a ${labelRole(member.role)}, not a ${labelRole(row.role)}`,
);
}
if (member.role === TrainCrewRole.TRAIN_DRIVER) {
if (!row.fromYardId || !row.toYardId || !row.dutyRole) {
throw new BadRequestException(
`Driver ${member.firstName} ${member.lastName} needs a from-yard, a to-yard and a duty role`,
);
}
// §1.1 territorial boundary is structural — never persist a breach.
const from = corridor.yards?.get(row.fromYardId);
const to = corridor.yards?.get(row.toYardId);
if (
!legAllowsNationality(
from,
to,
member.nationality,
corridor.direDawaOrder ?? Number.POSITIVE_INFINITY,
)
) {
throw new BadRequestException(
`${member.firstName} ${member.lastName} is a Djibouti driver and may only work legs from Dire Dawa eastward`,
);
}
}
}
await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(TrainCrewAssignment);
await repo.delete({ trainScheduleId: scheduleId });
if (rows.length) {
await repo.insert(
rows.map((row) => ({
trainScheduleId: scheduleId,
crewMemberId: row.crewMemberId,
role: row.role,
dutyRole: row.dutyRole ?? null,
fromYardId: row.fromYardId ?? null,
toYardId: row.toYardId ?? null,
status: CrewAssignmentStatus.PLANNED,
notes: row.notes ?? null,
})),
);
}
});
const demand = await this.detectDemand(scheduleId);
const saved = await this.listForSchedule(scheduleId);
return validateCrewComposition(saved.map(toFacts), demand, corridor);
}
/**
* Dispatch gate (§1.2 "prior to departure"). Throws with every unmet rule
* listed, so staff see the whole gap at once rather than one error per retry.
*/
async assertCrewReadyForDispatch(scheduleId: string): Promise<void> {
const { validation } = await this.getScheduleCrew(scheduleId);
if (!validation.complete) {
throw new BadRequestException(
`Train crew is incomplete: ${validation.issues.map((i) => i.message).join('; ')}`,
);
}
}
/** Roster drivers eligible for a leg between two yards (§1.1). */
async eligibleDrivers(
scheduleId: string,
fromYardId?: string,
toYardId?: string,
): Promise<TrainCrewMember[]> {
const drivers = await this.memberRepo.find({
where: {
role: TrainCrewRole.TRAIN_DRIVER,
status: TrainCrewStatus.ACTIVE,
isActive: true,
},
order: { firstName: 'ASC' },
});
if (!fromYardId || !toYardId) return drivers;
const corridor = await this.loadCorridor(scheduleId);
const from = corridor.yards?.get(fromYardId);
const to = corridor.yards?.get(toYardId);
return drivers.filter((d) =>
legAllowsNationality(
from,
to,
d.nationality,
corridor.direDawaOrder ?? Number.POSITIVE_INFINITY,
),
);
}
}
/** Reduce a persisted assignment to the facts the rules read. */
const toFacts = (a: TrainCrewAssignment): AssignmentFacts => ({
crewMemberId: a.crewMemberId,
role: a.role,
dutyRole: a.dutyRole,
fromYardId: a.fromYardId,
toYardId: a.toYardId,
nationality: a.crewMember?.nationality ?? '',
memberName: a.crewMember
? `${a.crewMember.firstName} ${a.crewMember.lastName}`
: 'A crew member',
});

View File

@@ -0,0 +1,70 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto';
import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto';
import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto';
import { TrainCrewService } from './train-crew.service';
@ApiTags('train-crew')
@ApiBearerAuth()
@Controller('train-crew')
// Class gate lists every key its routes use: Nest runs class AND method
// guards, so a key missing here would deny before the route's own key runs.
@BookingStaff([
FREIGHT_PERMS.trainCrew.view,
FREIGHT_PERMS.trainCrew.create,
FREIGHT_PERMS.trainCrew.update,
FREIGHT_PERMS.trainCrew.delete,
])
export class TrainCrewController {
constructor(private readonly trainCrewService: TrainCrewService) {}
@Post()
@BookingStaff(FREIGHT_PERMS.trainCrew.create)
@ApiOperation({ summary: 'Create a train crew member' })
create(@Body() dto: CreateTrainCrewMemberDto) {
return this.trainCrewService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List train crew members with filters' })
findAll(@Query() query: QueryTrainCrewMemberDto) {
return this.trainCrewService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'Get a train crew member by id' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.trainCrewService.findById(id);
}
@Patch(':id')
@BookingStaff(FREIGHT_PERMS.trainCrew.update)
@ApiOperation({ summary: 'Update a train crew member' })
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrainCrewMemberDto,
) {
return this.trainCrewService.update(id, dto);
}
@Delete(':id')
@BookingStaff(FREIGHT_PERMS.trainCrew.delete)
@ApiOperation({ summary: 'Delete a train crew member' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.trainCrewService.remove(id);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainCrewAssignment } from './entities/train-crew-assignment.entity';
import { TrainCrewMember } from './entities/train-crew-member.entity';
import { TrainCrewAssignmentController } from './train-crew-assignment.controller';
import { TrainCrewAssignmentService } from './train-crew-assignment.service';
import { TrainCrewController } from './train-crew.controller';
import { TrainCrewService } from './train-crew.service';
@Module({
imports: [TypeOrmModule.forFeature([TrainCrewMember, TrainCrewAssignment])],
providers: [TrainCrewService, TrainCrewAssignmentService],
controllers: [TrainCrewController, TrainCrewAssignmentController],
exports: [TrainCrewService, TrainCrewAssignmentService],
})
export class TrainCrewModule {}

View File

@@ -0,0 +1,111 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ILike, Repository } from 'typeorm';
import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto';
import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto';
import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto';
import { TrainCrewMember } from './entities/train-crew-member.entity';
const DEFAULT_LIMIT = 25;
@Injectable()
export class TrainCrewService {
constructor(
@InjectRepository(TrainCrewMember)
private readonly crewRepo: Repository<TrainCrewMember>,
) {}
async create(dto: CreateTrainCrewMemberDto): Promise<TrainCrewMember> {
await this.assertNoDuplicate(dto.firstName, dto.lastName, dto.role);
const member = this.crewRepo.create(dto);
return this.crewRepo.save(member);
}
async findAll(query: QueryTrainCrewMemberDto = {}): Promise<{
data: TrainCrewMember[];
total: number;
page: number;
limit: number;
}> {
const page = query.page ?? 1;
const limit = query.limit ?? DEFAULT_LIMIT;
const qb = this.crewRepo.createQueryBuilder('c');
if (query.search) {
qb.andWhere('(c.firstName ILIKE :search OR c.lastName ILIKE :search)', {
search: `%${query.search}%`,
});
}
if (query.role) qb.andWhere('c.role = :role', { role: query.role });
if (query.nationality) {
qb.andWhere('c.nationality = :nationality', { nationality: query.nationality });
}
if (query.status) qb.andWhere('c.status = :status', { status: query.status });
if (query.isActive !== undefined) {
qb.andWhere('c.isActive = :isActive', { isActive: query.isActive });
}
// sortBy is whitelisted by QueryTrainCrewMemberDto's @IsIn before it lands here.
const [data, total] = await qb
.orderBy(`c.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'DESC')
.skip((page - 1) * limit)
.take(limit)
.getManyAndCount();
return { data, total, page, limit };
}
async findById(id: string): Promise<TrainCrewMember> {
const member = await this.crewRepo.findOne({ where: { id } });
if (!member) {
throw new NotFoundException(`Train crew member ${id} not found`);
}
return member;
}
async update(id: string, dto: UpdateTrainCrewMemberDto): Promise<TrainCrewMember> {
const member = await this.findById(id);
const firstName = dto.firstName ?? member.firstName;
const lastName = dto.lastName ?? member.lastName;
const role = dto.role ?? member.role;
const identityChanged =
firstName !== member.firstName ||
lastName !== member.lastName ||
role !== member.role;
if (identityChanged) {
await this.assertNoDuplicate(firstName, lastName, role, id);
}
Object.assign(member, dto);
return this.crewRepo.save(member);
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.crewRepo.softDelete(id);
}
/**
* The roster carries no employee number yet, so name + role is the only
* identity available to catch an accidental re-entry of the same person.
* Case-insensitive; `exceptId` skips the row being updated.
*/
private async assertNoDuplicate(
firstName: string,
lastName: string,
role: string,
exceptId?: string,
): Promise<void> {
const existing = await this.crewRepo.findOne({
where: { firstName: ILike(firstName), lastName: ILike(lastName), role: role as never },
});
if (existing && existing.id !== exceptId) {
throw new ConflictException(
`Train crew member ${firstName} ${lastName} (${role}) already exists`,
);
}
}
}

View File

@@ -289,6 +289,99 @@ export function bookingCloseCutoff(
return new Date(departure.getTime() - offsetMinutes * 60_000);
}
/** The schedule fields the close-offset reopen guard reads. */
export interface CloseOffsetReopenSchedule {
status: string;
direction?: string | null;
windowPhase?: string | null;
bookingWindowStatus?: string | null;
scheduledDepartureDate?: Date | null;
}
export interface CloseOffsetReopenCheck {
/** True when shortening the close offset is the one thing that reopens booking. */
eligible: boolean;
/** Why the schedule is not eligible; null when it is. */
reason: string | null;
/** Minutes before departure this schedule currently stops taking bookings. */
offsetMinutes: number | null;
/** The cutoff that shut booking (departure offset); null without an offset. */
cutoffAt: Date | null;
}
/**
* Is this schedule's booking shut ONLY because of its close offset? That is the
* one case staff may fix from the board by shortening the offset (3 days → 1
* day, 2 hours, …) so the desk reopens before departure. Every other way a
* window ends stays closed: the train departed, it is full, it never had an
* offset (booking ran until departure), or the window is still mid-cycle.
*
* The last guard — "a cycle would fit before departure with no offset at all" —
* is what makes the offset the ONLY problem: when the desk's next opening lands
* after the train leaves, no offset change can help.
*/
export function closeOffsetReopenCheck(
schedule: CloseOffsetReopenSchedule,
cfg: {
importCloseOffsetMinutes?: number | null;
exportCloseOffsetMinutes?: number | null;
windowOpenHour: number;
windowCloseHour: number;
},
now: Date,
): CloseOffsetReopenCheck {
const departure = schedule.scheduledDepartureDate ?? null;
const offsetRaw =
schedule.direction === 'EXPORT'
? cfg.exportCloseOffsetMinutes
: cfg.importCloseOffsetMinutes;
const offsetMinutes = offsetRaw != null && offsetRaw > 0 ? offsetRaw : null;
const cutoffAt =
departure && offsetMinutes != null
? bookingCloseCutoff(departure, schedule.direction, cfg)
: null;
const no = (reason: string): CloseOffsetReopenCheck => ({
eligible: false,
reason,
offsetMinutes,
cutoffAt,
});
if (schedule.status !== 'DRAFT' && schedule.status !== 'SCHEDULED') {
return no(`A ${schedule.status.toLowerCase()} train cannot reopen booking.`);
}
if (!departure || departure.getTime() <= now.getTime()) {
return no('This train has already departed (or has no departure date).');
}
if (offsetMinutes == null) {
return no(
'This train has no close offset — booking ran until departure, so there is nothing to shorten.',
);
}
if (schedule.windowPhase !== 'DONE') {
return no(
schedule.windowPhase == null
? 'This train does not run a managed booking window.'
: `Booking is not closed yet — the window is in its ${schedule.windowPhase} phase.`,
);
}
if (schedule.bookingWindowStatus === 'FULL') {
return no(
'Booking closed because the train is full, not because of the close offset.',
);
}
const hours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
if (nextCycleOpensAt(now, hours, departure) == null) {
return no(
'The desk would not reopen before departure even with no close offset — the offset is not what is blocking booking.',
);
}
return { eligible: true, reason: null, offsetMinutes, cutoffAt };
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;

View File

@@ -66,6 +66,7 @@ import { AvailableDaysQueryDto } from "../dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
import { ReduceScheduleCloseOffsetDto } from "../dto/reduce-schedule-close-offset.dto";
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto";
import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto";
@@ -871,7 +872,7 @@ export class TrainSchedulingController {
@TrainSchedulingView()
@ApiOperation({
summary:
"Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)",
"Download the schedule's wagon list as an Excel workbook (containers grouped by customer: wagon, container, size, route, company, transitor)",
})
async scheduleWagonListExport(
@Param("id", ParseUUIDPipe) id: string,
@@ -985,6 +986,20 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/close-offset")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Shorten the booking-close offset of a schedule whose booking shut only because of that offset, so its window reopens before departure",
})
async reduceScheduleCloseOffset(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ReduceScheduleCloseOffsetDto,
) {
await this.trainSchedulingService.reduceScheduleCloseOffset(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/schedule-date")
@TrainSchedulingUpdate()
@ApiOperation({

View File

@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
/**
* Shorten the booking-close offset of ONE schedule whose booking shut only
* because of that offset (staff action on the ops board). The value replaces the
* schedule's frozen offset; 0 means "close at departure".
*/
export class ReduceScheduleCloseOffsetDto {
@ApiProperty({
example: 120,
description:
'New minutes-before-departure at which booking closes. Must be shorter than the current offset; 0 = close at departure.',
})
@Type(() => Number)
@IsInt()
@Min(0)
closeOffsetMinutes!: number;
}

View File

@@ -3,6 +3,13 @@ import { Column, Entity } from 'typeorm';
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
export class TrainSchedulingGlobalRules extends BaseEntity {
/**
* LEGACY — `max_train_length_meters`, `max_train_weight_tons` and
* `max_20ft_container_weight_tons` are no longer read by planning: train
* weight/length come from locomotive configuration and per-box ceilings from
* the rule engine's weight limit rules (`max_capacity_tons`). Kept only so
* existing rows keep loading.
*/
@Column({
name: 'max_train_length_meters',
type: 'numeric',

View File

@@ -0,0 +1,266 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { closeOffsetReopenCheck } from './batch-window.util';
import { TrainSchedulingService } from './services/train-scheduling.service';
/**
* "Reopen booking by shortening the close offset": a train whose booking shut
* ONLY because of its close offset (3 days → cut to 1 day / 2 hours) gets its
* window re-armed. Every other closed state is refused. Pure guard first, then
* the service against stub repositories.
*/
describe('close-offset reopen', () => {
// Wednesday 2026-09-09 10:00 EAT (07:00Z). A 3-day offset closes Sunday 10:00 EAT.
const DEPARTURE = new Date('2026-09-09T07:00:00.000Z');
// Monday 2026-09-07 09:00 EAT — inside the desk day, past the 3-day cutoff.
const NOW = new Date('2026-09-07T06:00:00.000Z');
const THREE_DAYS = 3 * 1_440;
const cfg = {
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
exportPaymentWindowMinutes: 60,
importCloseOffsetMinutes: THREE_DAYS,
exportCloseOffsetMinutes: THREE_DAYS,
};
const closedByOffset = (over: Record<string, unknown> = {}) => ({
id: 'S1',
reference: 'S-2026-00001',
status: 'SCHEDULED',
direction: 'IMPORT',
windowPhase: 'DONE',
bookingWindowStatus: 'CLOSED',
scheduledDepartureDate: DEPARTURE,
originStationId: 'Y-ADD',
destinationStationId: 'Y-DJ',
ruleWindowOpenHour: 8,
ruleWindowCloseHour: 17,
ruleWindowDurationHours: 3,
ruleImportWindowLeadDays: 3,
ruleExportBookingLeadHours: 24,
ruleImportCloseOffsetMinutes: THREE_DAYS,
ruleExportCloseOffsetMinutes: THREE_DAYS,
...over,
});
describe('closeOffsetReopenCheck', () => {
it('is eligible when DONE, not full, departure ahead, and an offset shut it', () => {
const check = closeOffsetReopenCheck(closedByOffset(), cfg, NOW);
expect(check.eligible).toBe(true);
expect(check.offsetMinutes).toBe(THREE_DAYS);
expect(check.cutoffAt?.toISOString()).toBe('2026-09-06T07:00:00.000Z');
});
it.each([
['dispatched train', { status: 'DISPATCHED' }, /dispatched/i],
['already departed', { scheduledDepartureDate: new Date('2026-09-01T07:00:00.000Z') }, /departed/i],
['full train', { bookingWindowStatus: 'FULL' }, /full/i],
['window still open', { windowPhase: 'OPEN' }, /not closed yet/i],
['legacy row with no window', { windowPhase: null }, /managed booking window/i],
])('refuses a %s', (_label, over, reason) => {
const check = closeOffsetReopenCheck(closedByOffset(over), cfg, NOW);
expect(check.eligible).toBe(false);
expect(check.reason).toMatch(reason);
});
it('refuses when the schedule never had an offset (booking ran to departure)', () => {
const check = closeOffsetReopenCheck(
closedByOffset(),
{ ...cfg, importCloseOffsetMinutes: null },
NOW,
);
expect(check.eligible).toBe(false);
expect(check.reason).toMatch(/no close offset/i);
expect(check.cutoffAt).toBeNull();
});
it('refuses when the desk could not reopen before departure even with no offset', () => {
// Tuesday 18:00 EAT, desk 817: next opening is Wednesday 08:00, but the
// train departs Wednesday 07:00 EAT — the offset is not the blocker.
const lateNow = new Date('2026-09-08T15:00:00.000Z');
const earlyDeparture = new Date('2026-09-09T04:00:00.000Z');
const check = closeOffsetReopenCheck(
closedByOffset({ scheduledDepartureDate: earlyDeparture }),
cfg,
lateNow,
);
expect(check.eligible).toBe(false);
expect(check.reason).toMatch(/would not reopen before departure/i);
});
it('reads the export offset for an EXPORT schedule', () => {
const check = closeOffsetReopenCheck(
closedByOffset({ direction: 'EXPORT' }),
{ ...cfg, importCloseOffsetMinutes: null, exportCloseOffsetMinutes: 120 },
NOW,
);
expect(check.eligible).toBe(true);
expect(check.offsetMinutes).toBe(120);
});
});
describe('TrainSchedulingService.reduceScheduleCloseOffset', () => {
type Fixture = {
schedule: Record<string, unknown> | null;
siblings?: Record<string, unknown>[];
now?: Date;
};
const makeService = (fx: Fixture) => {
const updates: Array<{ id: string; patch: Record<string, unknown> }> = [];
const repo = {
update: jest.fn().mockImplementation(async (id: string, patch: Record<string, unknown>) => {
updates.push({ id, patch });
}),
};
const siblingsQb = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(fx.siblings ?? []),
};
const dataSource = {
getRepository: jest.fn().mockReturnValue(repo),
manager: {
getRepository: jest
.fn()
.mockReturnValue({ createQueryBuilder: () => siblingsQb }),
},
};
const service = Object.create(
TrainSchedulingService.prototype,
) as TrainSchedulingService;
const emitted: string[] = [];
Object.assign(service, {
dataSource,
trainSchedulesRepository: {
findById: jest.fn().mockResolvedValue(fx.schedule),
},
getWindowConfig: jest.fn().mockResolvedValue(cfg),
emitWindowState: jest.fn().mockImplementation(async (id: string) => {
emitted.push(id);
}),
logger: { log: jest.fn(), warn: jest.fn() },
});
jest.useFakeTimers().setSystemTime(fx.now ?? NOW);
return { service, updates, emitted };
};
afterEach(() => jest.useRealTimers());
it('404s on an unknown schedule', async () => {
const { service } = makeService({ schedule: null });
await expect(
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('refuses a train whose window is not shut by its offset', async () => {
const { service, updates } = makeService({
schedule: closedByOffset({ bookingWindowStatus: 'FULL' }),
});
await expect(
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }),
).rejects.toThrow(/full/i);
expect(updates).toHaveLength(0);
});
it('refuses an offset that is not shorter than the current one', async () => {
const { service, updates } = makeService({ schedule: closedByOffset() });
await expect(
service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: THREE_DAYS }),
).rejects.toThrow(/shorter than the current 3 days/i);
expect(updates).toHaveLength(0);
});
it('refuses an offset whose new cutoff is still before the next desk opening', async () => {
// 2 days before departure = Monday 10:00 EAT; now is Monday 09:00 so a
// cycle fits… but 2 days 1 hour (Mon 09:00) does not.
const { service } = makeService({ schedule: closedByOffset() });
await expect(
service.reduceScheduleCloseOffset('S1', {
closeOffsetMinutes: 2 * 1_440 + 60,
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('shortens the offset, re-arms the window at now (desk open) and caps it at the new cutoff', async () => {
const { service, updates, emitted } = makeService({ schedule: closedByOffset() });
// 1 day before departure → new cutoff Tuesday 10:00 EAT.
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 });
expect(updates).toHaveLength(1);
const [{ id, patch }] = updates;
expect(id).toBe('S1');
expect(patch).toMatchObject({
ruleImportCloseOffsetMinutes: 1_440,
windowRuleCustom: true,
windowPhase: 'PRE_WINDOW',
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
// Desk is open at 09:00 → reopens now; 3h cycle → 12:00 EAT (09:00Z).
expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString());
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-07T09:00:00.000Z');
expect(emitted).toEqual(['S1']);
});
it('stores 0 as null (booking runs to departure) and caps the cycle at departure', async () => {
// Tuesday 16:00 EAT: 3h cycle would run past the 17:00 desk close.
const tueAfternoon = new Date('2026-09-08T13:00:00.000Z');
const { service, updates } = makeService({
schedule: closedByOffset(),
now: tueAfternoon,
});
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 0 });
const [{ patch }] = updates;
expect(patch.ruleImportCloseOffsetMinutes).toBeNull();
expect((patch.windowOpensAt as Date).toISOString()).toBe(tueAfternoon.toISOString());
// Desk close (17:00 EAT = 14:00Z) ends the cycle before departure.
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-08T14:00:00.000Z');
});
it('reopens route+day siblings shut by the same offset and leaves the rest alone', async () => {
const { service, updates } = makeService({
schedule: closedByOffset(),
siblings: [
closedByOffset({ id: 'S2' }),
// Already full — booking did not close because of the offset.
closedByOffset({ id: 'S3', bookingWindowStatus: 'FULL' }),
// Still mid-cycle — must keep the state its customers see.
closedByOffset({ id: 'S4', windowPhase: 'PAYMENT' }),
],
});
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 });
expect(updates.map((u) => u.id)).toEqual(['S1', 'S2']);
expect(updates[1].patch).toMatchObject({
ruleImportCloseOffsetMinutes: 1_440,
windowPhase: 'PRE_WINDOW',
});
});
it('an EXPORT reopen is a single FCFS window to the new cutoff and touches no sibling', async () => {
const { service, updates } = makeService({
schedule: closedByOffset({ direction: 'EXPORT' }),
siblings: [closedByOffset({ id: 'S2', direction: 'EXPORT' })],
});
await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 120 });
expect(updates).toHaveLength(1);
const [{ patch }] = updates;
expect(patch).toMatchObject({
ruleExportCloseOffsetMinutes: 120,
windowPhase: 'PRE_WINDOW',
});
expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString());
// Departure 07:00Z 2h.
expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-09T05:00:00.000Z');
});
});
});

View File

@@ -5,6 +5,7 @@ import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulingService } from './train-scheduling.service';
@@ -2211,4 +2212,46 @@ describe('TrainSchedulingService', () => {
expect(written.windowPhase).toBeUndefined();
});
});
describe('containerCapacityCeilingsByLine — weight limit rule capacity', () => {
const ceilings = (bookings: unknown[]) =>
(
service as never as {
containerCapacityCeilingsByLine: (b: unknown[]) => Promise<Record<string, number>>;
}
).containerCapacityCeilingsByLine(bookings);
it('maps each container line to its rule capacity, exact direction winning over BOTH', async () => {
const find = jest.fn().mockResolvedValue([
{ containerTypeId: 'ct-20', tradeDirection: 'BOTH', maxCapacityTons: '28.000' },
{ containerTypeId: 'ct-20', tradeDirection: 'EXPORT', maxCapacityTons: '26.000' },
{ containerTypeId: 'ct-40', tradeDirection: 'IMPORT', maxCapacityTons: null },
]);
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === WeightLimitRule) return { find };
throw new Error('unexpected repository');
});
const result = await ceilings([
{
tradeDirection: 'EXPORT',
bookingContainers: [
{ id: 'line-a', containerTypeId: 'ct-20' },
{ id: 'line-b', containerTypeId: 'ct-40' },
],
},
{ tradeDirection: 'IMPORT', bookingContainers: [{ id: 'line-c', containerTypeId: 'ct-20' }] },
]);
expect(result).toEqual({ 'line-a': 26, 'line-c': 28 });
expect(find).toHaveBeenCalledTimes(1);
});
it('queries nothing when the bookings carry no container lines', async () => {
dataSource.getRepository.mockImplementation(() => {
throw new Error('should not be called');
});
await expect(ceilings([{ tradeDirection: 'EXPORT', bookingContainers: [] }])).resolves.toEqual({});
});
});
});

View File

@@ -66,6 +66,7 @@ import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-boo
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
import { TrainCrewAssignmentService } from '../../train-crew/train-crew-assignment.service';
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
@@ -74,25 +75,13 @@ 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 { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
import { TabularExportService } from '../../exports/tabular-export.service';
import {
buildWagonListWorkbook,
groupWagonListLines,
WagonListLine,
} from '../utils/wagon-list-workbook.util';
/** One line of the schedule wagon-list export (raw SQL projection). */
interface ScheduleWagonListRow {
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
containerNumber: string | null;
containerSizeFt: number | null;
loadType: string | null;
status: string | null;
bulkCargoDescription: string | null;
/** numeric columns arrive as strings from pg. */
vgmTons: string | null;
originLabel: string | null;
destinationLabel: string | null;
bookingReference: string | null;
customerName: string | null;
}
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
@@ -111,6 +100,7 @@ import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
import {
ImportDjiboutiOperation,
@@ -122,6 +112,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from '../dto/import-djibouti-operation.dto';
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
import { ReduceScheduleCloseOffsetDto } from '../dto/reduce-schedule-close-offset.dto';
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto';
import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto';
@@ -159,6 +150,7 @@ import {
validateMixedTrainLimitsPerEdge,
MAX_TEU_SLOTS_PER_WAGON,
type ContainerPlacementInput,
type ContainerPlacementRules,
type WagonPlanSlot,
} from '../utils/wagon-plan.util';
import {
@@ -189,6 +181,8 @@ import {
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
MAX_FALLBACK_LENGTH,
MAX_FALLBACK_WEIGHT,
WagonTypeDimensions,
} from '../train-capacity.util';
import {
@@ -204,12 +198,15 @@ import { orderConsistWagons } from '../consist-order.util';
import {
bookingCloseCutoff,
clampCloseToOfficeHours,
closeOffsetReopenCheck,
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
eatDay,
eatDayToUtc,
nextCycleOpensAt,
shiftEatDay,
type OfficeHours,
} from '../batch-window.util';
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service';
@@ -246,6 +243,19 @@ const HANDLING_FIELDS = [
type HandlingField = (typeof HANDLING_FIELDS)[number][0];
/** "3 days" / "2 hours" / "45 minutes" for an error message. */
function describeMinutes(minutes: number): string {
if (minutes % 1_440 === 0) {
const d = minutes / 1_440;
return `${d} day${d === 1 ? '' : 's'}`;
}
if (minutes % 60 === 0) {
const h = minutes / 60;
return `${h} hour${h === 1 ? '' : 's'}`;
}
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
}
/** Drops the keys a partial override left undefined, so `...` merges keep the base value. */
function pickDefined<T extends object>(source: T): Partial<T> {
return Object.fromEntries(
@@ -273,6 +283,16 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) {
};
}
/** Wire shape of a close-offset reopen check (dates as ISO strings). */
function toCloseOffsetReopenInfo(check: ReturnType<typeof closeOffsetReopenCheck>) {
return {
eligible: check.eligible,
reason: check.reason,
offsetMinutes: check.offsetMinutes,
cutoffAt: check.cutoffAt ? check.cutoffAt.toISOString() : null,
};
}
/**
* The booking-window config a specific schedule runs under: its frozen rule
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
@@ -378,13 +398,13 @@ export interface UnassignedBookingsResponse {
bookings: CompositionUnassignedBookingRow[];
}
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonsPerTrain: Math.floor(760 / 14),
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
};
/**
* Train weight/length come from locomotive configuration (the assigned set, or
* the strongest in-service locomotive when none is assigned yet); per-box
* container ceilings come from the rule engine's weight limit rules. Only the
* 20ft pair-imbalance tolerance is a static default.
*/
const DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS = 10;
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
interface BookingWindowRow {
@@ -446,9 +466,10 @@ export class TrainSchedulingService {
// Per-wagon history ledger (global module). @Optional keeps the positional
// spec constructors working; production always has it.
@Optional() private readonly wagonHistory?: WagonHistoryService,
// Crew composition gate (ITLMS Rolling Stock §1.2 "prior to departure").
// Trailing + @Optional so the positional constructors in the existing specs
// keep working; production always resolves it from ExportsModule.
@Optional() private readonly tabularExport?: TabularExportService,
// keep working; production always resolves it.
@Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService,
) {}
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
@@ -809,9 +830,9 @@ export class TrainSchedulingService {
}
/**
* Train length/weight and 20ft weight caps are engine-internal (wagon
* planning still reads them off the row); they are no longer exposed or
* editable through the global-rules endpoints.
* Train length/weight and the 20ft weight cap columns are legacy: planning
* now takes weight/length from locomotive configuration and per-box ceilings
* from weight limit rules. They are neither read nor exposed here.
*/
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
if (!row) return row;
@@ -1030,6 +1051,152 @@ export class TrainSchedulingService {
return fresh ?? schedule;
}
/**
* Shorten the booking-close offset of ONE schedule whose booking shut ONLY
* because of that offset, and re-arm its window so the desk reopens. A 3-day
* offset that closed booking with the train still days away can be cut to a
* day or a couple of hours; the window then opens at the next desk opening
* (now, if the desk is open) and runs its normal cycles until the new cutoff.
*
* Refused for every other kind of closed window (departed, full, no offset,
* still mid-cycle) — see `closeOffsetReopenCheck`. The new offset must be
* shorter than the current one and must leave room for a cycle before the
* new cutoff. The offset is frozen onto the schedule (the global value is
* untouched) and the row is marked custom so a later global-rules save does
* not re-stamp it.
*
* IMPORT/DOMESTIC: the same shorter offset is applied to every route+day
* sibling that is likewise shut only by its offset, so the group keeps its
* single shared timeline (each capped at its own new cutoff). EXPORT windows
* are per-train, so an export change touches only this schedule.
*/
async reduceScheduleCloseOffset(
id: string,
dto: ReduceScheduleCloseOffsetDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
const now = new Date();
const liveCfg = await this.getWindowConfig();
const cfg = effectiveWindowConfig(schedule, liveCfg);
const check = closeOffsetReopenCheck(schedule, cfg, now);
if (!check.eligible || check.offsetMinutes == null) {
throw new BadRequestException(
check.reason ?? 'This schedule cannot reopen by shortening its close offset.',
);
}
const newOffset = dto.closeOffsetMinutes;
if (newOffset >= check.offsetMinutes) {
throw new BadRequestException(
`The new close offset must be shorter than the current ${describeMinutes(
check.offsetMinutes,
)} before departure.`,
);
}
const isExport = schedule.direction === 'EXPORT';
// 0 is stored as null so "no offset" keeps its single canonical value.
const offsetPatch = isExport
? { ruleExportCloseOffsetMinutes: newOffset || null }
: { ruleImportCloseOffsetMinutes: newOffset || null };
const merged: BookingWindowConfig = {
...cfg,
...(isExport
? { exportCloseOffsetMinutes: newOffset || null }
: { importCloseOffsetMinutes: newOffset || null }),
};
const hours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
const cutoff = bookingCloseCutoff(
schedule.scheduledDepartureDate,
schedule.direction,
merged,
);
// The desk reopens at the next office-hours opening (now, when it is open),
// exactly as a reopen cycle would — and only if that lands before the cutoff.
const opensAt = nextCycleOpensAt(now, hours, cutoff);
if (opensAt == null) {
throw new BadRequestException(
'Even with this offset the desk would not reopen before booking closes again ' +
`(new cutoff ${cutoff.toISOString()}) — shorten the offset further.`,
);
}
let closesAt: Date;
if (isExport) {
// Export runs one FCFS window: from the reopen until the cutoff.
closesAt = cutoff;
} else {
closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
closesAt = clampCloseToOfficeHours(opensAt, closesAt, hours);
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
}
const cap = (d: Date, bound: Date): Date =>
d.getTime() > bound.getTime() ? bound : d;
const targets: Array<{ id: string; cutoff: Date }> = [{ id, cutoff }];
if (!isExport) {
const siblings = await this.findGroupSiblings(
this.dataSource.manager,
schedule.originStationId,
schedule.destinationStationId,
schedule.scheduledDepartureDate,
id,
);
for (const sib of siblings) {
const sibCfg = effectiveWindowConfig(sib, liveCfg);
const sibCheck = closeOffsetReopenCheck(sib, sibCfg, now);
// Only a sibling that is ALSO shut purely by an offset longer than the
// new one joins in; anything else keeps the state its customers saw.
if (
!sibCheck.eligible ||
sibCheck.offsetMinutes == null ||
sibCheck.offsetMinutes <= newOffset ||
!sib.scheduledDepartureDate
) {
continue;
}
const sibCutoff = bookingCloseCutoff(sib.scheduledDepartureDate, sib.direction, {
...sibCfg,
importCloseOffsetMinutes: newOffset || null,
});
if (opensAt.getTime() >= sibCutoff.getTime()) continue;
targets.push({ id: sib.id, cutoff: sibCutoff });
}
}
const repo = this.dataSource.getRepository(TrainSchedule);
for (const t of targets) {
await repo.update(t.id, {
...offsetPatch,
// Staff-set — exempt from the global re-stamp.
windowRuleCustom: true,
// Back to PRE_WINDOW: the window tick opens it at windowOpensAt and runs
// the normal cycle from there (bookingWindowStatus flips OPEN then).
windowPhase: 'PRE_WINDOW',
windowOpensAt: cap(opensAt, t.cutoff),
windowClosesAt: cap(closesAt, t.cutoff),
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
}
this.logger.log(
`Close offset of schedule ${schedule.reference ?? id} shortened ` +
`${check.offsetMinutes}${newOffset} min before departure` +
` (+${targets.length - 1} route+day sibling(s)) — booking reopens ` +
`${opensAt.toISOString()}, closes ${closesAt.toISOString()}`,
);
for (const t of targets) void this.emitWindowState(t.id);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Correct a departure's operational run identifiers — the train number and
* voyage number yards and customs quote.
@@ -3027,6 +3194,11 @@ export class TrainSchedulingService {
'End the loading window at the origin station before dispatching',
);
}
// On-board crew must be complete before the train leaves — ITLMS Rolling
// Stock §1.2 enforces composition "prior to departure", so an incomplete
// crew saves freely on the assignment page but cannot depart. Optional
// dependency: the positional spec constructors omit it.
await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId);
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');
@@ -3770,15 +3942,15 @@ export class TrainSchedulingService {
}
/**
* The schedule detail page's wagon-list Excel export.
*
* One row per container (a wagon carrying two boxes yields two rows, repeating
* the wagon number) so each container's own VGM is present and totals footable.
* Bulk wagons, having no containers, yield a single row carrying the bulk
* description and the allocated tonnage as the VGM figure.
* The schedule detail page's wagon-list Excel export, laid out like the
* wagon sheet the yard circulates by hand: containers grouped by customer,
* one line per container (a two-box wagon repeats its wagon number under one
* "No."), a blank line between customers, and the wagon count / company /
* transitor merged down each group. See buildWagonListWorkbook.
*
* Only wagon slots that actually carry an allocation are listed — empty slots
* on the consist are omitted.
* on the consist are omitted. A bulk wagon yields one line carrying the cargo
* description in place of a container number.
*/
async scheduleWagonListWorkbook(
scheduleId: string,
@@ -3787,56 +3959,37 @@ export class TrainSchedulingService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.tabularExport) {
throw new BadRequestException('Tabular export service is unavailable');
}
// Row grain is the container item; the LEFT JOIN keeps bulk (and any
// container-less) allocation as one row. `booking_container_units` is joined
// on BOTH container number and its booking_container line — container
// numbers repeat across bookings, so number alone would multiply rows.
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
// Row grain is the container item; the LEFT JOIN keeps a bulk (or any
// container-less) allocation as one row. The transitor is the customs
// clearing agent the customer named on the booking.
const lines: WagonListLine[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
ci.container_number AS "containerNumber",
cit.size_ft AS "containerSizeFt",
a.load_type AS "loadType",
a.status AS "status",
bl.cargo_description AS "bulkCargoDescription",
COALESCE(
ci.gross_weight_tons,
bcu.vgm_tons,
bc.vgm_per_unit_tons,
a.allocated_weight_tons
) AS "vgmTons",
COALESCE(by_.label, so.label) AS "originLabel",
COALESCE(ay.label, sd.label) AS "destinationLabel",
b.reference AS "bookingReference",
COALESCE(
slc.name,
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
c.name
) AS "customerName"
) AS "customerName",
NULLIF(TRIM(b.customs_clearing_agent), '') AS "transitor"
FROM freight.train_schedules s
JOIN freight.train_set_wagons tsw
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
JOIN freight.wagon_booking_allocations a
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.bookings b ON b.id = a.booking_id
LEFT JOIN freight.companies c ON c.id = b.company_id
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
LEFT JOIN freight.booking_container bc
ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL
LEFT JOIN freight.booking_container_units bcu
ON bcu.container_number = ci.container_number
AND bcu.booking_container_id = bc.id
AND bcu.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
@@ -3848,47 +4001,13 @@ export class TrainSchedulingService {
[scheduleId],
);
// "number" is the printed line number of the sheet, not the wagon sequence —
// a two-container wagon occupies two lines, and the reader counts lines.
const sheetRows = rows.map((row, index) => ({
number: index + 1,
wagonNumber: row.wagonNumber ?? '—',
containerNumber:
row.containerNumber ??
(row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'),
vgmTons: row.vgmTons === null ? null : Number(row.vgmTons),
originLabel: row.originLabel ?? '—',
destinationLabel: row.destinationLabel ?? '—',
customerName: row.customerName ?? '—',
}));
const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0);
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
const buffer = await this.tabularExport.toXlsx({
title: `Wagons ${reference}`.slice(0, 31),
description: `Wagon list for train ${reference}`,
label: 'train-schedule:wagon-list',
kpis: [
{ label: 'Lines', value: sheetRows.length },
{
label: 'Wagons',
value: new Set(rows.map((r) => r.sequenceNo)).size,
},
{ label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' },
],
columns: [
{ key: 'number', label: 'No.', type: 'number' },
{ key: 'wagonNumber', label: 'Wagon', type: 'string' },
{ key: 'containerNumber', label: 'Container number', type: 'string' },
{ key: 'vgmTons', label: 'VGM', type: 'tons' },
{ key: 'originLabel', label: 'Origin', type: 'string' },
{ key: 'destinationLabel', label: 'Destination', type: 'string' },
{ key: 'customerName', label: 'Customer', type: 'string' },
],
rows: sheetRows,
const { groups, totalWagons } = groupWagonListLines(lines);
const buffer = await buildWagonListWorkbook({
trainLabel: schedule.trainNumber ?? schedule.reference ?? schedule.id,
groups,
totalWagons,
});
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
return {
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
buffer,
@@ -6367,8 +6486,11 @@ export class TrainSchedulingService {
skip,
take,
});
// Live window config: each row's frozen rule overlays it to decide whether
// the "shorten close offset" action applies (see closeOffsetReopenCheck).
const liveCfg = await this.getWindowConfig();
return {
items: schedules.map((s) => this.mapScheduleListItem(s)),
items: schedules.map((s) => this.mapScheduleListItem(s, liveCfg)),
meta: buildPaginationMeta(total, page, pageSize),
};
}
@@ -6708,11 +6830,6 @@ export class TrainSchedulingService {
)),
);
const placementRules = {
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
// With forceAssign, capacity-shaped rules (train limits, total weight,
// locomotive capability) become warnings — staff owns the override. Physical
// impossibilities (no wagon of the required type at the yard, wrong route,
@@ -6745,6 +6862,11 @@ export class TrainSchedulingService {
);
if (requireContainerPlacements && resolvedMode !== 'BULK') {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
const placementRules: ContainerPlacementRules = {
maxContainerWeightTonsByLineId:
await this.containerCapacityCeilingsByLine(containerBookings),
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
violations.push(
...validateContainerPlacements(
containerBookings,
@@ -6892,6 +7014,70 @@ export class TrainSchedulingService {
}
}
/**
* Hard per-box ceiling for every container line of the given bookings, from
* the rule engine's weight limit rule (`max_capacity_tons`) matching the
* line's container type and the booking's trade direction (a `BOTH` rule
* applies to either direction; an exact-direction rule wins over it). Lines
* whose rule has no capacity set get no entry — capacity is optional.
*/
private async containerCapacityCeilingsByLine(
bookings: Booking[],
): Promise<Record<string, number>> {
const lines: Array<{ lineId: string; containerTypeId: string; tradeDirection: string }> = [];
for (const booking of bookings) {
const direction = String(booking.tradeDirection ?? '').toUpperCase();
for (const line of booking.bookingContainers ?? []) {
if (!line.containerTypeId) continue;
lines.push({ lineId: line.id, containerTypeId: line.containerTypeId, tradeDirection: direction });
}
}
if (!lines.length) return {};
const typeIds = [...new Set(lines.map((l) => l.containerTypeId))];
const rules = await this.dataSource
.getRepository(WeightLimitRule)
.find({ where: { containerTypeId: In(typeIds) } });
const ceilings: Record<string, number> = {};
for (const { lineId, containerTypeId, tradeDirection } of lines) {
const candidates = rules.filter(
(r) => r.containerTypeId === containerTypeId && r.maxCapacityTons != null,
);
const rule =
candidates.find((r) => r.tradeDirection === tradeDirection) ??
candidates.find((r) => r.tradeDirection === 'BOTH');
const cap = Number(rule?.maxCapacityTons);
if (Number.isFinite(cap) && cap > 0) ceilings[lineId] = cap;
}
return ceilings;
}
/**
* Limits for a train that has no locomotive assigned yet: the strongest
* in-service locomotive on each axis, so planning assumes the most capable
* power that could be coupled. Null when no locomotive is configured at all.
*/
private async strongestFleetLocomotiveLimits(): Promise<LocomotiveLimits | null> {
const fleet = await this.locomotivesRepository.findAll({
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
});
const pulls = fleet.map((l) => Number(l.maxPullWeightTons)).filter((v) => v > 0);
const lengths = fleet.map((l) => Number(l.maxTrainLengthMeters)).filter((v) => v > 0);
if (!pulls.length && !lengths.length) return null;
const strongest = (axis: number[], pick: (l: Locomotive) => number) =>
fleet.find((l) => pick(l) === Math.max(...axis));
return {
maxPullWeightTons: pulls.length ? Math.max(...pulls) : Infinity,
maxTrainLengthMeters: lengths.length ? Math.max(...lengths) : Infinity,
overageToleranceTons:
Number(strongest(pulls, (l) => Number(l.maxPullWeightTons))?.overageToleranceTons) || 0,
overageToleranceMeters:
Number(strongest(lengths, (l) => Number(l.maxTrainLengthMeters))?.overageToleranceMeters) ||
0,
};
}
private async resolveTrainLimitConfig(
dto?: {
maxTrainWeightTons?: number;
@@ -6902,24 +7088,14 @@ export class TrainSchedulingService {
builtWagonCount?: number,
): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}>('app.trainScheduling');
const ruleWeightCap =
dto?.maxTrainWeightTons ??
(row?.maxTrainWeightTons != null
? Number(row.maxTrainWeightTons)
: configured?.maxTrainWeightTons);
const ruleLengthCap =
dto?.maxTrainLengthMeters ??
(row?.maxTrainLengthMeters != null
? Number(row.maxTrainLengthMeters)
: configured?.maxTrainLengthMeters);
const configured = this.configService?.get<{ maxWagonsPerTrain?: number }>(
'app.trainScheduling',
);
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
const max20ftPairWeightDiffTons = this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) || DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS,
);
if (locomotive) {
// With a locomotive assigned its own limits are the single source of
@@ -6954,52 +7130,40 @@ export class TrainSchedulingService {
: builtWagonCount && builtWagonCount > 0
? builtWagonCount
: derived.maxWagonSlots,
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) ||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
max20ftPairWeightDiffTons,
};
}
const maxWeightTons = this.positiveNumber(
dto?.maxTrainWeightTons,
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
);
const maxLengthMeters = this.positiveNumber(
dto?.maxTrainLengthMeters,
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
);
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
// No locomotive on the set yet: plan against the strongest in-service
// locomotive's configuration. An explicit dto override still narrows it.
const fleet = await this.strongestFleetLocomotiveLimits();
if (!fleet) {
this.logger.warn(
'No in-service locomotive is configured — train weight/length limits fall back to ' +
`${MAX_FALLBACK_WEIGHT}T / ${MAX_FALLBACK_LENGTH}m until a locomotive is added`,
);
}
const derived = deriveTrainCapacityFromLocomotive(
fleet ?? { maxPullWeightTons: MAX_FALLBACK_WEIGHT, maxTrainLengthMeters: MAX_FALLBACK_LENGTH },
wagonTypes,
{
maxTrainWeightTons: dto?.maxTrainWeightTons,
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
},
);
return {
maxWeightTons,
maxLengthMeters,
maxWeightTons: derived.maxWeightTons,
maxLengthMeters: derived.maxLengthMeters,
maxWagonsPerTrain: Math.floor(
this.positiveNumber(
dto?.maxWagonsPerTrain,
row?.maxWagonsPerTrain != null
? Number(row.maxWagonsPerTrain)
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
: configured?.maxWagonsPerTrain ?? derived.maxWagonSlots,
),
),
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
max20ftPairWeightDiffTons,
};
}
@@ -8869,7 +9033,11 @@ export class TrainSchedulingService {
throw new ConflictException('Could not allocate a unique schedule reference');
}
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
private mapScheduleListItem(
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
/** Live window config; when given, the row carries its close-offset reopen state. */
liveCfg?: BookingWindowConfig,
) {
// Wagon figures must match the detail page's wagon plan (WagonPlanGrid) —
// see computeScheduleWagonUsage for why the stored counter cannot be used.
const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } =
@@ -8933,6 +9101,18 @@ export class TrainSchedulingService {
freightType: this.resolveScheduleFreightType(schedule),
status: schedule.status,
bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN',
windowPhase: schedule.windowPhase ?? null,
// Whether booking shut ONLY because of the close offset — the board offers
// "shorten close offset" on exactly these rows.
closeOffsetReopen: liveCfg
? toCloseOffsetReopenInfo(
closeOffsetReopenCheck(
schedule,
effectiveWindowConfig(schedule, liveCfg),
new Date(),
),
)
: null,
cancellationReason: schedule.cancellationReason ?? null,
cancelledAt: schedule.cancelledAt ?? null,
maxWagons: schedule.maxWagons ?? 0,
@@ -10977,6 +11157,14 @@ export class TrainSchedulingService {
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as the frozen reopen gap), so the editor prefills them from live config.
// Shut only by its close offset? Drives the "shorten close offset" action.
closeOffsetReopen: toCloseOffsetReopenInfo(
closeOffsetReopenCheck(
schedule,
effectiveWindowConfig(schedule, windowCfg),
new Date(),
),
),
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
@@ -10986,6 +11174,12 @@ export class TrainSchedulingService {
: null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
// The offsets this train actually runs under (its frozen snapshot, or
// the live global for a legacy row) — null = booking runs to departure.
importCloseOffsetMinutes:
effectiveWindowConfig(schedule, windowCfg).importCloseOffsetMinutes ?? null,
exportCloseOffsetMinutes:
effectiveWindowConfig(schedule, windowCfg).exportCloseOffsetMinutes ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
// Editor prefill: this schedule's own override when staff set one,
// else the live global for the schedule's direction (import/export
@@ -13049,3 +13243,4 @@ export class TrainSchedulingService {
return fresh ?? schedule;
}
}
//

View File

@@ -6,7 +6,6 @@ import { BillingModule } from '../billing/billing.module';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { ExportsModule } from '../exports/exports.module';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FacilityHandlingService } from './facility-handling.service';
@@ -17,6 +16,7 @@ import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
import { TrainCrewModule } from '../train-crew/train-crew.module';
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
@@ -68,7 +68,6 @@ import { ContractsModule } from '../contracts/contracts.module';
UserTradeAccessModule,
NotificationsModule,
NotificationInboxModule,
ExportsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
@@ -76,6 +75,7 @@ import { ContractsModule } from '../contracts/contracts.module';
forwardRef(() => WarehousesModule),
RuleEngineModule,
forwardRef(() => ContractsModule),
TrainCrewModule,
],
controllers: [TrainSchedulingController],
providers: [

View File

@@ -0,0 +1,169 @@
import ExcelJS from 'exceljs';
import {
buildWagonListWorkbook,
groupWagonListLines,
WAGON_LIST_HEADERS,
WagonListLine,
wagonListSheetName,
} from './wagon-list-workbook.util';
const line = (overrides: Partial<WagonListLine>): WagonListLine => ({
sequenceNo: 1,
wagonNumber: 'ER0001',
containerNumber: 'CONT0000001',
containerSizeFt: 40,
loadType: 'CONTAINER',
bulkCargoDescription: null,
originLabel: 'DCT',
destinationLabel: 'GMP',
customerName: 'ABC transit',
transitor: null,
...overrides,
});
// Mirrors the reference sheet: a 40ft wagon, a wagon carrying two 20ft boxes,
// then a second customer's single wagon, and a bulk wagon for a third.
const fixture: WagonListLine[] = [
line({ sequenceNo: 1, wagonNumber: 'ER0691', containerNumber: 'TLLU4855720' }),
line({
sequenceNo: 2,
wagonNumber: 'ER0693',
containerNumber: 'CXDU1833620',
containerSizeFt: 20,
transitor: 'Semuzu Transit',
}),
line({
sequenceNo: 2,
wagonNumber: 'ER0693',
containerNumber: 'TTNU1328287',
containerSizeFt: 20,
transitor: 'Semuzu Transit',
}),
line({
sequenceNo: 3,
wagonNumber: 'ER0444',
containerNumber: 'ESLU0720200',
containerSizeFt: 20,
customerName: 'SYNTRANS LOGISTICS PLC',
}),
line({
sequenceNo: 4,
wagonNumber: 'ER0716',
containerNumber: null,
containerSizeFt: null,
loadType: 'BULK',
bulkCargoDescription: 'Wheat',
customerName: 'Baili food processing',
}),
];
describe('groupWagonListLines', () => {
it('groups by customer in first-appearance order and counts wagons, not containers', () => {
const { groups, totalWagons } = groupWagonListLines(fixture);
expect(groups.map((g) => g.companyName)).toEqual([
'ABC transit',
'SYNTRANS LOGISTICS PLC',
'Baili food processing',
]);
expect(groups.map((g) => g.wagonCount)).toEqual([2, 1, 1]);
expect(totalWagons).toBe(4);
});
it('numbers wagons across the whole sheet, repeating the ordinal for a second container', () => {
const { groups } = groupWagonListLines(fixture);
expect(groups[0].lines.map((l) => l.wagonOrdinal)).toEqual([1, 2, 2]);
expect(groups[1].lines.map((l) => l.wagonOrdinal)).toEqual([3]);
expect(groups[2].lines.map((l) => l.wagonOrdinal)).toEqual([4]);
});
it('renders container size as "NNft", bulk loads by cargo description, and the transitor once per group', () => {
const { groups } = groupWagonListLines(fixture);
expect(groups[0].lines.map((l) => l.containerType)).toEqual(['40ft', '20ft', '20ft']);
expect(groups[0].transitor).toBe('Semuzu Transit');
expect(groups[2].lines[0]).toMatchObject({
containerNumber: 'Wheat',
containerType: 'Bulk',
});
expect(groups[2].transitor).toBe('');
});
it('files lines with no customer under a placeholder group', () => {
const { groups } = groupWagonListLines([line({ customerName: null })]);
expect(groups[0].companyName).toBe('—');
});
});
describe('wagonListSheetName', () => {
it('strips characters Excel forbids and caps at 31 characters', () => {
expect(wagonListSheetName('V138U/8502')).toBe('V138U 8502');
expect(wagonListSheetName('a'.repeat(40))).toHaveLength(31);
expect(wagonListSheetName('///')).toBe('Wagons');
});
});
describe('buildWagonListWorkbook', () => {
let sheet: ExcelJS.Worksheet;
beforeAll(async () => {
const grouped = groupWagonListLines(fixture);
const buffer = await buildWagonListWorkbook({ trainLabel: 'V138U/8502', ...grouped });
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
sheet = workbook.worksheets[0];
});
const cell = (address: string) => sheet.getCell(address).value;
const merged = (address: string) => sheet.getCell(address).isMerged;
it('opens with the banner (train + total wagons) merged across every column, then the headers', () => {
expect(sheet.name).toBe('V138U 8502');
expect(String(cell('A1'))).toMatch(/^V138U\/8502\s+Total wagons= 4$/);
expect(merged('I1')).toBe(true);
expect(sheet.getRow(2).values).toEqual([undefined, ...WAGON_LIST_HEADERS]);
expect(sheet.getCell('A2').font?.bold).toBe(true);
});
it('lays each customer out as a contiguous block separated by a blank row', () => {
// Rows 3-5: ABC transit; row 6 blank; row 7: SYNTRANS; row 8 blank; row 9: Baili.
expect([cell('B3'), cell('B4'), cell('B5')]).toEqual(['ER0691', 'ER0693', 'ER0693']);
expect(sheet.getRow(6).values).toEqual([]);
expect(cell('B7')).toBe('ER0444');
expect(sheet.getRow(8).values).toEqual([]);
expect(cell('B9')).toBe('ER0716');
expect(cell('C9')).toBe('Wheat');
expect(cell('G9')).toBe('Bulk');
});
it('prints "No." once per wagon, merged down a two-container wagon', () => {
expect([cell('A3'), cell('A4'), cell('A5')]).toEqual([1, 2, 2]);
expect(merged('A4')).toBe(true);
expect(merged('A5')).toBe(true);
expect(merged('A3')).toBe(false);
expect(cell('A7')).toBe(3);
expect(cell('A9')).toBe(4);
});
it('merges wagon count, company and transitor down the whole customer block', () => {
expect(cell('D3')).toBe(2);
expect(cell('H3')).toBe('ABC transit');
expect(cell('I3')).toBe('Semuzu Transit');
for (const col of ['D', 'H', 'I']) {
expect(merged(`${col}3`)).toBe(true);
expect(merged(`${col}5`)).toBe(true);
}
expect(sheet.getCell('H3').font?.bold).toBe(true);
// A single-line block has nothing to merge.
expect(merged('H7')).toBe(false);
expect(cell('D7')).toBe(1);
expect(cell('I7')).toBeNull();
});
it('carries the route and container size on every line', () => {
expect([cell('E3'), cell('F3'), cell('G3')]).toEqual(['DCT', 'GMP', '40ft']);
expect([cell('E5'), cell('F5'), cell('G5')]).toEqual(['DCT', 'GMP', '20ft']);
});
});

View File

@@ -0,0 +1,219 @@
import ExcelJS from 'exceljs';
/**
* One loaded container (or one bulk load) on a wagon of the schedule — the
* input grain of the wagon-list workbook. A wagon carrying two boxes arrives
* as two lines sharing `sequenceNo`.
*/
export interface WagonListLine {
sequenceNo: number | null;
wagonNumber: string | null;
containerNumber: string | null;
/** 20 / 40 / 45 …; null for bulk or unknown. */
containerSizeFt: number | null;
loadType: string | null;
bulkCargoDescription: string | null;
originLabel: string | null;
destinationLabel: string | null;
customerName: string | null;
/** The customs clearing / transit agent named on the booking. */
transitor: string | null;
}
export interface WagonListGroupLine {
/** Sheet-wide wagon counter — printed once per wagon, not once per container. */
wagonOrdinal: number;
sequenceNo: number | null;
wagonNumber: string;
containerNumber: string;
containerType: string;
origin: string;
destination: string;
}
/** All lines of one customer, contiguous on the sheet. */
export interface WagonListGroup {
companyName: string;
transitor: string;
/** Distinct wagons in the group — the "Number of Wagons" cell. */
wagonCount: number;
lines: WagonListGroupLine[];
}
export interface WagonListWorkbookInput {
/** Train number (falls back to the schedule reference) — the banner text. */
trainLabel: string;
groups: WagonListGroup[];
totalWagons: number;
}
const BLANK = '—';
/**
* Groups the container-grain lines by customer, in order of first appearance,
* keeping consist order inside each group. Wagon ordinals run across the whole
* sheet so the reader can count wagons down the "No." column.
*/
export function groupWagonListLines(lines: WagonListLine[]): {
groups: WagonListGroup[];
totalWagons: number;
} {
const groups = new Map<
string,
WagonListGroup & { transitors: Set<string>; wagons: Set<string> }
>();
const ordinalByGroupWagon = new Map<string, number>();
let nextOrdinal = 1;
for (const line of lines) {
const companyName = line.customerName?.trim() || BLANK;
let group = groups.get(companyName);
if (!group) {
group = {
companyName,
transitor: '',
wagonCount: 0,
lines: [],
transitors: new Set(),
wagons: new Set(),
};
groups.set(companyName, group);
}
const wagonKey = `${line.sequenceNo ?? ''}|${line.wagonNumber ?? ''}`;
const ordinalKey = `${companyName} ${wagonKey}`;
let wagonOrdinal = ordinalByGroupWagon.get(ordinalKey);
if (wagonOrdinal === undefined) {
wagonOrdinal = nextOrdinal++;
ordinalByGroupWagon.set(ordinalKey, wagonOrdinal);
group.wagons.add(wagonKey);
}
const transitor = line.transitor?.trim();
if (transitor) group.transitors.add(transitor);
const isBulk = line.loadType === 'BULK' && !line.containerNumber;
group.lines.push({
wagonOrdinal,
sequenceNo: line.sequenceNo,
wagonNumber: line.wagonNumber ?? BLANK,
containerNumber:
line.containerNumber ?? (isBulk ? (line.bulkCargoDescription ?? 'Bulk') : BLANK),
containerType: isBulk ? 'Bulk' : line.containerSizeFt ? `${line.containerSizeFt}ft` : BLANK,
origin: line.originLabel ?? BLANK,
destination: line.destinationLabel ?? BLANK,
});
}
const result = [...groups.values()].map(({ transitors, wagons, ...group }) => ({
...group,
transitor: [...transitors].join(', '),
wagonCount: wagons.size,
}));
return {
groups: result,
totalWagons: result.reduce((sum, g) => sum + g.wagonCount, 0),
};
}
const COLUMN_WIDTHS = [3.7, 14.9, 14.9, 17.3, 15, 12.8, 16.2, 27.5, 29.9];
export const WAGON_LIST_HEADERS = [
'No.',
'Wagon',
'Container No.',
'Number of Wagons',
'Origin',
'Destination',
'Type of Container',
'Company Name',
'Transitor',
];
const LAST_COLUMN = WAGON_LIST_HEADERS.length;
/** Excel's "Blue-Gray, Text 2, Lighter 60%" — the banner fill of the reference sheet. */
const BANNER_FILL: ExcelJS.Fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFACB9CA' },
};
const CENTERED: Partial<ExcelJS.Alignment> = { horizontal: 'center', vertical: 'middle' };
/** Excel forbids `[]:*?/\` in sheet names and caps them at 31 characters. */
export function wagonListSheetName(trainLabel: string): string {
const cleaned = trainLabel.replace(/[[\]:*?/\\]+/g, ' ').trim();
return (cleaned || 'Wagons').slice(0, 31);
}
/**
* The operations wagon-list sheet, laid out like the hand-made one the yard
* circulates: a banner row (train number + total wagons), one header row, then
* the containers grouped by customer with a blank row between customers.
* Inside a group the wagon number repeats per container while "No." is merged
* down the wagon; "Number of Wagons", "Company Name" and "Transitor" are merged
* down the whole group.
*/
export async function buildWagonListWorkbook(input: WagonListWorkbookInput): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(wagonListSheetName(input.trainLabel), {
views: [{ zoomScale: 85 }],
});
COLUMN_WIDTHS.forEach((width, i) => {
sheet.getColumn(i + 1).width = width;
});
const banner = sheet.addRow([
`${input.trainLabel}${' '.repeat(40)}Total wagons= ${input.totalWagons}`,
]);
sheet.mergeCells(1, 1, 1, LAST_COLUMN);
banner.height = 28;
const bannerCell = banner.getCell(1);
bannerCell.font = { name: 'Calibri', size: 12, bold: true };
bannerCell.alignment = CENTERED;
bannerCell.fill = BANNER_FILL;
const header = sheet.addRow(WAGON_LIST_HEADERS);
header.eachCell((cell) => {
cell.font = { name: 'Calibri', size: 11, bold: true };
cell.alignment = CENTERED;
});
input.groups.forEach((group, groupIndex) => {
if (groupIndex > 0) sheet.addRow([]);
const firstRow = sheet.rowCount + 1;
let wagonStartRow = firstRow;
group.lines.forEach((line, lineIndex) => {
const isFirstLine = lineIndex === 0;
const newWagon = isFirstLine || group.lines[lineIndex - 1].wagonOrdinal !== line.wagonOrdinal;
const row = sheet.addRow([
newWagon ? line.wagonOrdinal : null,
line.wagonNumber,
line.containerNumber,
isFirstLine ? group.wagonCount : null,
line.origin,
line.destination,
line.containerType,
isFirstLine ? group.companyName : null,
isFirstLine ? group.transitor || null : null,
]);
for (let col = 1; col <= LAST_COLUMN; col++) {
const cell = row.getCell(col);
cell.font = { name: 'Calibri', size: 11, bold: col === 8 };
if (col === 8) cell.alignment = { ...CENTERED, wrapText: true };
else if (col !== 2 && col !== 3) cell.alignment = CENTERED;
}
row.getCell(1).numFmt = '#,##0';
if (newWagon && !isFirstLine) {
if (row.number - 1 > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, row.number - 1, 1);
wagonStartRow = row.number;
}
});
const lastRow = sheet.rowCount;
if (lastRow > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, lastRow, 1);
if (lastRow > firstRow) {
for (const col of [4, 8, 9]) sheet.mergeCells(firstRow, col, lastRow, col);
}
});
return Buffer.from(await workbook.xlsx.writeBuffer());
}

View File

@@ -171,7 +171,7 @@ describe('wagon-plan.util', () => {
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
});
it('rejects 20ft container over max individual weight', () => {
it('rejects a container over its line weight-limit-rule capacity', () => {
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
@@ -182,11 +182,29 @@ describe('wagon-plan.util', () => {
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 },
max20ftPairWeightDiffTons: 10,
});
expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true);
expect(violations.filter((v) => v.includes('weight limit rule capacity of 30T'))).toHaveLength(2);
});
it('applies no per-box ceiling to a line without a weight-limit-rule capacity', () => {
const booking = makeContainerBooking('c20b', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: 1,
containerNumber: `CNTR-${index + 1}`,
}));
const violations = validate20ftContainerRules(units, placements, {
maxContainerWeightTonsByLineId: {},
max20ftPairWeightDiffTons: 10,
});
expect(violations).toEqual([]);
});
it('rejects 20ft pair when weight difference exceeds limit', () => {
@@ -204,7 +222,6 @@ describe('wagon-plan.util', () => {
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
});

View File

@@ -20,12 +20,17 @@ export type TrainLimitConfig = {
maxWeightTons?: number;
maxLengthMeters?: number;
maxWagonsPerTrain?: number;
max20ftContainerWeightTons?: number;
max20ftPairWeightDiffTons?: number;
};
export type ContainerPlacementRules = {
max20ftContainerWeightTons?: number;
/**
* Hard per-box weight ceiling keyed by booking container LINE id, resolved
* from the rule engine's weight limit rule (`max_capacity_tons`) for the
* line's container type and the booking's trade direction. A line with no
* entry has no ceiling — the rule's capacity is optional.
*/
maxContainerWeightTonsByLineId?: Record<string, number>;
max20ftPairWeightDiffTons?: number;
};
@@ -820,15 +825,21 @@ export function perEdgeConsistUsage(
);
}
/**
* Per-box weight rules for a container plan:
* - every unit is checked against its line's weight-limit-rule capacity
* ceiling (`maxContainerWeightTonsByLineId`, any size);
* - 20ft pairs sharing a wagon are checked for weight imbalance.
*/
export function validate20ftContainerRules(
units: ContainerUnitRow[],
placements: ContainerPlacementInput[],
rules?: ContainerPlacementRules,
): string[] {
const violations: string[] = [];
const maxEach = rules?.max20ftContainerWeightTons;
const capacityByLine = rules?.maxContainerWeightTonsByLineId;
const maxDiff = rules?.max20ftPairWeightDiffTons;
if (maxEach == null && maxDiff == null) return violations;
if (capacityByLine == null && maxDiff == null) return violations;
const placementByUnit = new Map(
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
@@ -837,15 +848,16 @@ export function validate20ftContainerRules(
const weightsBySlot = new Map<number, number[]>();
for (const unit of units) {
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
if (sizeFt >= 40) continue;
const maxEach = capacityByLine?.[unit.bookingContainerId];
if (maxEach != null && unit.grossWeightTons > maxEach) {
violations.push(
`${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`,
`${unit.label} weight ${unit.grossWeightTons}T exceeds the weight limit rule capacity of ${maxEach}T for ${unit.containerTypeCode} containers`,
);
}
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
if (sizeFt >= 40) continue;
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
if (!placement?.sequenceNo) continue;

View File

@@ -912,6 +912,129 @@ Settle assessed duties and taxes within the period notified by the Service Provi
),
];
/* ────────────────────────── IMPORT / EMPTY CONTAINER ─────────────────────── */
/**
* Empty container import — bare equipment railed north from Djibouti for
* repositioning inland. Not a variant of the laden import pack: there is no
* cargo to describe, no VGM to declare, no commercial documents to lodge and no
* customs leg to sell, so the paper is a straight equipment-carriage agreement.
* Priced per box by size (20ft / 40ft) and lane, off an EMPTY_CONTAINER_IMPORT
* rate.
*/
const IMPORT_EMPTY_CONTAINER_BASE: ContractTemplateBase = {
name: "Empty Container Import Contract",
description:
"Railway transport of empty containers from Djibouti (DMP/Nagad) to the agreed Ethiopian terminal for repositioning. Priced per container by size; no cargo, no customs clearing.",
documentTitle: "Empty Container Transportation Service by Railway",
whereasClauses: [
"The Client has requested and agreed to the transportation of empty containers from the Djibouti railway terminals (DMP or Nagad) to the agreed Ethiopian destination terminal using the Addis Ababa\u2013Djibouti railway line.",
"The containers covered by this Agreement carry no cargo, and the Service Provider is engaged for the carriage of the equipment itself.",
"The Service Provider has agreed to transport the empty containers as per the terms of this contract.",
],
articles: [
a(
"objective",
"Objective and Scope of the Services",
`To provide railway transportation services for empty 20ft and/or 40ft containers from the agreed Djibouti loading terminal (DMP or Nagad Railway Station) to the agreed Ethiopian destination terminal.
The scope of the services comprises:
- Terminal handling and loading of the empty containers onto flat wagons at the Djibouti loading terminal.
- Railway transport between the agreed origin and destination terminals.
- Unloading of the empty containers at the destination terminal.
The containers covered by this Agreement carry no cargo. Any container found to be laden at loading falls outside this Agreement and shall be handled and priced as a laden shipment.`,
),
a(
"client-obligations",
"Obligations of the Client",
`Give written/email/electronic shipment instructions to the Service Provider stating the number of empty containers by size (20ft and/or 40ft), the loading terminal and the destination terminal.
Provide the container release order or equivalent instruction from the container owner or its agent, together with the container numbers, before loading.
Warrant that every container tendered is empty, free of residue, and holds no cargo, dunnage or personal effects.
Ensure the containers are presented at the loading terminal, in a condition fit for rail carriage, one day before the planned loading date.
One flat wagon carries either one 40ft container or two 20ft containers.
Book wagons at least five (5) days in advance.
Assign representatives at both ends to oversee container handover.
Collect the empty containers from the destination terminal within three (3) calendar days from the day following the arrival notice.
If the Client fails to collect the containers within the specified period, the Client shall be liable to pay the applicable demurrage, storage and double handling charges of the destination terminal.
Settle all charges due under this Agreement in accordance with the agreed payment terms.`,
),
a(
"provider-obligations",
"Obligations of the Service Provider",
`Provide the agreed number of flat wagons on the agreed loading date, subject to wagon availability and the allocation priority applicable to the booking.
Handle and load the empty containers at the Djibouti loading terminal and unload them at the destination terminal.
Transport the empty containers to the agreed destination terminal and issue an arrival notice to the Client.
Record the condition of each container at handover, and hand over the containers at destination in the condition in which they were received, fair wear and tear from carriage excepted.
Issue the consignment note and the interchange documentation for each shipment.
Notify the Client without delay of any incident affecting the containers in the Service Provider's custody.`,
),
a(
"liability",
"Liability for the Equipment",
`The Service Provider's liability under this Agreement is limited to loss of, or physical damage to, the containers while in its custody between loading at the origin terminal and handover at the destination terminal.
Because the containers carry no cargo, no cargo liability, cargo insurance obligation or cargo declaration arises under this Agreement.
The Service Provider shall not be liable for pre-existing damage recorded at loading, nor for damage arising from a defect in the container itself.
The Client shall indemnify the Service Provider against any claim arising from a container tendered as empty that is later found to contain cargo, residue or prohibited goods.`,
),
a(
"force-majeure",
"Force Majeure",
`Neither party shall be liable for failure to perform its obligations under this Agreement where such failure results from an event beyond its reasonable control, including natural disaster, war, civil unrest, government action, or closure of the railway line or terminals.
The affected party shall notify the other in writing within five (5) calendar days of the occurrence and shall resume performance as soon as the event ceases.`,
),
a(
"pricing",
"Contract Price and Terms of Payment",
`The price is charged per empty container carried, at the agreed rate for each container size (20ft and 40ft) on the agreed origin\u2013destination lane, as set out in the rate schedule to this Agreement.
The price covers terminal handling, loading, railway carriage and unloading as described in the Scope of the Services. It excludes any charge levied by the destination terminal after the free period, and any first-mile or last-mile road leg unless separately agreed.
Payment shall be made in accordance with the payment terms stated in this Agreement; where the price is quoted in USD and settled in Birr, conversion applies the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment.
The Service Provider may revise the rates on prior written notice to the Client.`,
),
a(
"contract-documents",
"Contract Documents",
`The following form an integral part of this Agreement:
- This Agreement and its rate schedule.
- The container release order or equivalent instruction from the container owner or its agent.
- The shipment instruction given by the Client for each consignment.
- The consignment note and interchange documents issued for each shipment.`,
),
a(
"consignment-notes",
"Consignment Notes",
`A consignment note shall be issued for each shipment, stating the container numbers, sizes, the origin and destination terminals and the recorded condition of each container.
The consignment note is evidence of the containers received for carriage and of their condition at handover.`,
),
a(
"amendment",
"Amendment",
`Any amendment to this Agreement shall be valid only if made in writing and signed by the authorised representatives of both parties.`,
),
a(
"termination",
"Termination of Contract",
`Either party may terminate this Agreement by giving thirty (30) calendar days' prior written notice to the other party.
Either party may terminate this Agreement with immediate effect where the other party commits a material breach and fails to remedy it within fifteen (15) calendar days of written notice.
Termination does not affect any obligation accrued before the effective date of termination, including payment for shipments already performed or in transit.`,
),
a(
"effectiveness",
"Contract Effectiveness",
`This Agreement becomes effective on the date it is signed by the authorised representatives of both parties.`,
),
a(
"duration",
"Contract Period",
`This Agreement shall remain in force for the period stated in the Agreement, unless terminated earlier in accordance with the Termination article.`,
),
a(
"disputes",
"Settlement of Disputes",
`The parties shall attempt to settle any dispute arising out of or in connection with this Agreement amicably.
Failing amicable settlement, the dispute shall be resolved in accordance with the laws of the Federal Democratic Republic of Ethiopia before the competent courts of Ethiopia.`,
),
],
};
/** Build the stored `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio for one base pack. */
function splitByCustoms(
base: ContractTemplateBase,
@@ -944,10 +1067,11 @@ function splitByCustoms(
}
/**
* Fourteen templates: import and export each split by customs clearing option
* Fifteen templates: import and export each split by customs clearing option
* (full, Ethiopian-only, none), intercity
* not split at all — it is a domestic Ethiopian movement that crosses no
* border, so there is no customs leg to contract for.
* border, so there is no customs leg to contract for. Empty container import
* is unsplit for the same reason: bare equipment carries no declaration.
*/
export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
...splitByCustoms(IMPORT_BULK_BASE, "IMPORT_BULK"),
@@ -956,4 +1080,5 @@ export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
...splitByCustoms(IMPORT_CONTAINER_BASE, "IMPORT_CONTAINER"),
...splitByCustoms(EXPORT_CONTAINER_BASE, "EXPORT_CONTAINER"),
{ ...INTERCITY_CONTAINER_BASE, code: "INTERCITY_CONTAINER" },
{ ...IMPORT_EMPTY_CONTAINER_BASE, code: "IMPORT_EMPTY_CONTAINER" },
];

View File

@@ -472,6 +472,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:contracts:cancel",
"Cancel a contract (terminal)",
),
// Add validity days to an EXPIRED contract the customer asked to extend and
// put it back where it was. Sits on the same desk as suspend/cancel.
perm(
"a3000001-0001-4000-8000-00000000001d",
"edr_freight_app:contracts:extend",
"Extend an expired contract",
),
];
// Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and
@@ -1506,6 +1513,38 @@ export const EMPTY_RETURN_REQUEST_PERMISSIONS: FreightPermissionSeed[] = [
),
];
// E'''. Train crew roster — the people assignable to a train (drivers, federal
// police, technicians, specialized cargo crew) per ITLMS Rolling Stock 1.2.
// Distinct from the `drivers` keys above, which gate the road/last-mile truck
// driver register.
export const TRAIN_CREW_PERMISSIONS: FreightPermissionSeed[] = [
perm(
"f5a00001-0001-4000-8000-000000000001",
"edr_freight_app:train_crew:view",
"View train crew members",
),
perm(
"f5a00001-0001-4000-8000-000000000002",
"edr_freight_app:train_crew:create",
"Create train crew member",
),
perm(
"f5a00001-0001-4000-8000-000000000003",
"edr_freight_app:train_crew:update",
"Update train crew member",
),
perm(
"f5a00001-0001-4000-8000-000000000004",
"edr_freight_app:train_crew:delete",
"Delete train crew member",
),
perm(
"f5a00001-0001-4000-8000-000000000005",
"edr_freight_app:train_crew:assign",
"Assign train crew to a schedule",
),
];
// E'. Train-scheduling finer actions (augment existing view/manage)
export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
perm(
@@ -1954,6 +1993,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...PORT_TERMINAL_PERMISSIONS,
...ADDITIONAL_CHARGE_PERMISSIONS,
...EMPTY_RETURN_REQUEST_PERMISSIONS,
...TRAIN_CREW_PERMISSIONS,
...SCHEDULING_EXTRA_PERMISSIONS,
...CONFIG_SETTINGS_PERMISSIONS,
...STAFF_IAM_PERMISSIONS,
@@ -2091,6 +2131,7 @@ export const FREIGHT_PERMS = {
clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise",
suspend: "edr_freight_app:contracts:suspend",
cancel: "edr_freight_app:contracts:cancel",
extend: "edr_freight_app:contracts:extend",
editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
@@ -2331,6 +2372,13 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:drivers:update",
delete: "edr_freight_app:drivers:delete",
},
trainCrew: {
view: "edr_freight_app:train_crew:view",
create: "edr_freight_app:train_crew:create",
update: "edr_freight_app:train_crew:update",
delete: "edr_freight_app:train_crew:delete",
assign: "edr_freight_app:train_crew:assign",
},
tracking: {
view: "edr_freight_app:tracking:view",
manage: "edr_freight_app:tracking:manage",
@@ -2918,6 +2966,8 @@ export const ROLE_PERMISSION_PRESETS = {
// Terminal kill switch, granted alongside suspend on the same desk that
// already rejects contracts and cancels bookings.
FREIGHT_PERMS.contracts.cancel,
// Validity extension of an expired contract, on customer request.
FREIGHT_PERMS.contracts.extend,
FREIGHT_PERMS.contracts.editDocument,
...BOOKING_DESK_NOTIFICATION_KEYS,
// Marketing follows up with the customer when a reviewer sends profile

View File

@@ -68,6 +68,7 @@ import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerforman
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
import VehicleDetailPage from "./pages/fleet/VehicleDetailPage";
import DriverDetailPage from "./pages/fleet/DriverDetailPage";
import TrainCrewPage from "./pages/train-crew/TrainCrewPage";
import RoutesPage from "./pages/fleet/RoutesPage";
import FuelPurchasePage from "./pages/fleet/FuelPurchasePage";
import FuelStatsPage from "./pages/fleet/FuelStatsPage";
@@ -87,6 +88,7 @@ import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
import ScheduleCrewPage from "./pages/trainScheduling/ScheduleCrewPage";
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage";
@@ -900,6 +902,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/crew"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<ScheduleCrewPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
@@ -1017,6 +1027,14 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="train-crew"
element={
<RequirePermission permission={FREIGHT_PERMS.trainCrew.view}>
<TrainCrewPage />
</RequirePermission>
}
/>
<Route
path="fuel-purchases"
element={

View File

@@ -1,8 +1,10 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react";
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { OperationRescheduleModal } from "./OperationRescheduleModal";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
@@ -10,6 +12,7 @@ import {
isAllocateAction,
isClearanceNavAction,
isContractNavAction,
isRescheduleAction,
listRowHasActions,
type BookingActionContext,
} from "@/features/bookings/booking-actions.config";
@@ -44,6 +47,8 @@ export function BookingActionsMenu({
const flow = useBookingActionDialog(row.id, context);
const { actions, pendingAction, mutations } = flow;
// Day / train reschedule has its own modal (date + export train picker).
const [rescheduleOpen, setRescheduleOpen] = useState(false);
const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`);
@@ -67,6 +72,8 @@ export function BookingActionsMenu({
goToClearanceTab();
} else if (isAllocateAction(action.id)) {
onAllocateBooking?.();
} else if (isRescheduleAction(action.id)) {
setRescheduleOpen(true);
} else {
flow.openAction(action);
}
@@ -109,6 +116,14 @@ export function BookingActionsMenu({
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
<OperationRescheduleModal
bookingId={row.id}
opened={rescheduleOpen}
onClose={() => {
onSuppressRowClick?.();
setRescheduleOpen(false);
}}
/>
</>
);
}
@@ -183,6 +198,14 @@ export function BookingActionsMenu({
consolidationPartnerId={row.consolidationPartnerId}
consolidationPartnerReference={row.consolidationPartnerReference}
/>
<OperationRescheduleModal
bookingId={row.id}
opened={rescheduleOpen}
onClose={() => {
onSuppressRowClick?.();
setRescheduleOpen(false);
}}
/>
</Group>
);
}

View File

@@ -0,0 +1,255 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Modal,
Select,
Stack,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { CalendarClock, Info } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import {
useBookingDetail,
useBookingMutations,
} from "@/hooks/bookings/useBookings";
export interface OperationRescheduleModalProps {
bookingId: string;
opened: boolean;
onClose: () => void;
}
/**
* Operations moves a pending operation request to another shipment day and,
* for export rail, another train — instead of returning it to the customer.
* The server re-runs the customer's own gates (open departure that day, wagon
* that can carry the cargo, export train with room) and refuses with the
* reason if the new day does not work.
*/
export function OperationRescheduleModal({
bookingId,
opened,
onClose,
}: OperationRescheduleModalProps) {
const detailQuery = useBookingDetail(opened ? bookingId : undefined);
const booking = detailQuery.data;
const mutations = useBookingMutations(bookingId);
const isExportRail = booking ? isExportRailBooking(booking) : false;
const [day, setDay] = useState<Date | null>(null);
const [trainId, setTrainId] = useState<string | null>(null);
const [note, setNote] = useState("");
// Seed from the booking each time the modal opens: the current day and, for
// export, the train the customer picked (the detail's requested/allocated train).
useEffect(() => {
if (!opened || !booking) return;
setDay(booking.scheduledDate ? new Date(booking.scheduledDate) : null);
setTrainId(booking.trainScheduleSummary?.id ?? null);
setNote("");
}, [opened, booking]);
const dayKey = day ? eatDay(day) : null;
const currentDayKey = booking?.scheduledDate
? eatDay(booking.scheduledDate)
: null;
// Days with an open departure on the booking's route — a planning hint; the
// server still validates the pick.
const daysQuery = useQuery({
...api.trainScheduling.availableDays.queryOptions({
input: {
originYardId: booking?.originYard?.id ?? null,
destinationYardId: booking?.destinationYard?.id ?? null,
},
}),
enabled:
opened &&
Boolean(booking?.originYard?.id && booking?.destinationYard?.id),
});
const availableDays = useMemo(
() => new Set((daysQuery.data ?? []).map((d) => eatDay(d))),
[daysQuery.data],
);
const dayHasDeparture = dayKey ? availableDays.has(dayKey) : false;
// Export rail: the day's export trains with free space, so staff pick one.
const trainsQuery = useQuery({
...api.trainScheduling.exportTrains.queryOptions({
input: { bookingId, date: day ? day.toISOString() : "" },
}),
enabled: opened && isExportRail && Boolean(day),
});
const trainOptions = useMemo(
() => (trainsQuery.data ?? []).map(exportTrainOption),
[trainsQuery.data],
);
// A train belongs to one day: changing the day drops a pick from another day.
useEffect(() => {
if (!isExportRail || !trainsQuery.data) return;
if (trainId && !trainsQuery.data.some((t) => t.scheduleId === trainId)) {
setTrainId(null);
}
}, [isExportRail, trainsQuery.data, trainId]);
const unchanged =
dayKey != null &&
dayKey === currentDayKey &&
(!isExportRail || trainId === (booking?.trainScheduleSummary?.id ?? null));
const canSave =
Boolean(day) && !unchanged && (!isExportRail || Boolean(trainId));
const handleSave = () => {
if (!day || !canSave) return;
mutations.rescheduleOperation.mutate(
{
scheduledDate: day.toISOString(),
...(isExportRail && trainId ? { trainScheduleId: trainId } : {}),
...(note.trim() ? { note: note.trim() } : {}),
},
{ onSuccess: () => onClose() },
);
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="md"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<CalendarClock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Change train / shipment day
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{booking?.reference ?? "Booking"}
</Text>
</Box>
</Group>
}
>
{detailQuery.isLoading || !booking ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Sets the shipment day
{isExportRail ? " and the export train " : " "}
for the customer, so nothing has to go back to them. The request
stays under review for the normal accept, and the customer is told
the new day.
{!isExportRail
? " Import and domestic trains are assigned by the batch engine on the chosen day."
: ""}
</Alert>
<Group gap="xs" wrap="wrap">
<Badge variant="light" color="gray">
Currently {currentDayKey ?? "no day"}
</Badge>
{booking.trainScheduleSummary ? (
<Badge variant="light" color="gray">
{booking.trainScheduleSummary.trainNumber ??
booking.trainScheduleSummary.reference ??
"train"}
{booking.trainScheduleSummary.isRequested ? " (requested)" : ""}
</Badge>
) : null}
</Group>
<DateInput
label="New shipment day"
description={
daysQuery.data && daysQuery.data.length
? "Days with an open departure on this route are selectable."
: "Pick the train departure day."
}
value={day}
onChange={(v) => setDay(v ? new Date(v) : null)}
minDate={new Date()}
excludeDate={
daysQuery.data && daysQuery.data.length
? (d) => !availableDays.has(eatDay(d))
: undefined
}
popoverProps={{ withinPortal: true }}
/>
{day &&
daysQuery.data &&
daysQuery.data.length &&
!dayHasDeparture ? (
<Text size="xs" c="red">
No open departure on this route for {dayKey}.
</Text>
) : null}
{isExportRail ? (
<Select
label="Export train"
placeholder={
!day
? "Pick a day first"
: trainsQuery.isLoading
? "Loading trains…"
: "Select a train with room"
}
data={trainOptions}
value={trainId}
onChange={setTrainId}
disabled={!day || trainsQuery.isLoading}
nothingFoundMessage="No export train on this day"
comboboxProps={{ withinPortal: true }}
searchable
/>
) : null}
<Textarea
label="Note to customer (optional)"
placeholder="Why the day is changing…"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
radius="md"
color="edr-green"
leftSection={<CalendarClock size={16} />}
loading={mutations.rescheduleOperation.isPending}
disabled={!canSave}
onClick={handleSave}
>
Save new day
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
export default OperationRescheduleModal;

View File

@@ -1,11 +1,27 @@
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import {
Alert,
Button,
Group,
Paper,
Select,
Stack,
Text,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle, Pencil, Send } from "lucide-react";
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import toast from "react-hot-toast";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import {
eatDay,
exportTrainOption,
formatEatDay,
isExportRailBooking,
} from "@/features/bookings/shipmentDay";
export interface BookingChangesRequestedAlertProps {
bookingId: string;
@@ -27,8 +43,11 @@ export interface BookingChangesRequestedAlertProps {
*
* The customer cannot act on this — GL created the booking on their behalf — so
* the note and the way out both live here, on the page GL works from. Resubmit
* re-requests operation on the chosen shipment day; the server re-checks the day
* has a departure that can carry the cargo and refuses with the reason if not.
* re-requests operation on the chosen shipment day: only days with an open
* departure on the booking's route are selectable, and an export rail booking
* also picks the train it rides (the API refuses an export resubmit without
* one). The server re-checks the day and train and refuses with the reason if
* they no longer work.
*/
export function BookingChangesRequestedAlert({
bookingId,
@@ -39,16 +58,82 @@ export function BookingChangesRequestedAlert({
editHref,
onResubmitted,
}: BookingChangesRequestedAlertProps) {
const [day, setDay] = useState<Date | null>(
scheduledDate ? new Date(scheduledDate) : null,
// The chosen departure day, as an EAT day key (YYYY-MM-DD). Only days that
// actually have an open departure on the booking's route are offered.
const [dayKey, setDayKey] = useState<string | null>(
scheduledDate ? eatDay(scheduledDate) : null,
);
const [trainId, setTrainId] = useState<string | null>(null);
const [sending, setSending] = useState(false);
// The booking's route and direction decide which days are offered and
// whether a train has to be picked — fetched only when this user can resubmit.
const { data: booking } = useBookingDetail(
canResubmit ? bookingId : undefined,
);
const isExportRail = booking ? isExportRailBooking(booking) : false;
// Seed the train from the customer's / previous pick once the booking loads.
useEffect(() => {
if (booking?.trainScheduleSummary?.id) {
setTrainId((current) => current ?? booking.trainScheduleSummary!.id);
}
}, [booking]);
const daysQuery = useQuery({
...api.trainScheduling.availableDays.queryOptions({
input: {
originYardId: booking?.originYard?.id ?? null,
destinationYardId: booking?.destinationYard?.id ?? null,
},
}),
enabled:
canResubmit &&
Boolean(booking?.originYard?.id && booking?.destinationYard?.id),
});
const dayOptions = useMemo(
() =>
Array.from(new Set((daysQuery.data ?? []).map((d) => eatDay(d))))
.sort()
.map((key) => ({ value: key, label: formatEatDay(key) })),
[daysQuery.data],
);
// A previously held day that no longer has a departure is not offered — the
// select shows nothing until GL picks a real one.
const dayHasDeparture =
dayKey != null && dayOptions.some((o) => o.value === dayKey);
// Any instant inside the chosen EAT day; the API keys on the day.
const dayIso = dayKey ? `${dayKey}T12:00:00.000Z` : "";
const trainsQuery = useQuery({
...api.trainScheduling.exportTrains.queryOptions({
input: { bookingId, date: dayIso },
}),
enabled: canResubmit && isExportRail && dayHasDeparture,
});
const trainOptions = useMemo(
() => (trainsQuery.data ?? []).map(exportTrainOption),
[trainsQuery.data],
);
// A train belongs to one day: changing the day drops a pick from another day.
useEffect(() => {
if (!isExportRail || !trainsQuery.data) return;
if (trainId && !trainsQuery.data.some((t) => t.scheduleId === trainId)) {
setTrainId(null);
}
}, [isExportRail, trainsQuery.data, trainId]);
const canSend = dayHasDeparture && (!isExportRail || Boolean(trainId));
const resubmit = async () => {
if (!day) return;
if (!canSend) return;
setSending(true);
try {
await bookingsService.proceedToOperation(bookingId, day.toISOString());
await bookingsService.proceedToOperation(
bookingId,
dayIso,
isExportRail && trainId ? trainId : undefined,
);
toast.success("Sent back to Operations for review");
onResubmitted?.();
} catch {
@@ -91,8 +176,9 @@ export function BookingChangesRequestedAlert({
)}
<Text size="sm">
This booking was created by GL Ethiopia, so the customer cannot fix it.
Make the correction Operations asked for, then send it back for review.{" "}
This booking was created by GL Ethiopia, so the customer cannot fix
it. Make the correction Operations asked for, then send it back for
review.{" "}
<Text
component={Link}
to={`/dashboard/bookings/${bookingId}/clearance`}
@@ -106,21 +192,54 @@ export function BookingChangesRequestedAlert({
{canResubmit ? (
<Group gap="sm" align="flex-end" wrap="wrap">
<DateInput
label="Shipment day"
description="Keep the day or pick another with an open departure"
value={day}
onChange={(v) => setDay(v ? new Date(v) : null)}
minDate={new Date()}
<Select
label="Departure day"
description="Existing departures on this route"
placeholder={
daysQuery.isLoading
? "Loading departures…"
: dayOptions.length
? "Select a departure day"
: "No open departure on this route"
}
data={dayOptions}
value={dayHasDeparture ? dayKey : null}
onChange={setDayKey}
disabled={daysQuery.isLoading || !dayOptions.length}
nothingFoundMessage="No open departure on this route"
comboboxProps={{ withinPortal: true }}
searchable
size="sm"
w={230}
/>
{isExportRail ? (
<Select
label="Export train"
description="The train this shipment rides"
placeholder={
!dayHasDeparture
? "Pick a day first"
: trainsQuery.isLoading
? "Loading trains…"
: "Select a train with room"
}
data={trainOptions}
value={trainId}
onChange={setTrainId}
disabled={!dayHasDeparture || trainsQuery.isLoading}
nothingFoundMessage="No export train on this day"
comboboxProps={{ withinPortal: true }}
searchable
size="sm"
w={340}
/>
) : null}
<Button
color="red"
radius="md"
size="sm"
loading={sending}
disabled={!day}
disabled={!canSend}
leftSection={<Send size={15} />}
onClick={() => void resubmit()}
>

View File

@@ -1,9 +1,19 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Button,
Group,
Modal,
NumberInput,
Stack,
Text,
Textarea,
} from "@mantine/core";
import {
Ban,
CalendarClock,
CalendarPlus,
Check,
Eye,
// FilePen, // ponytail: back with the "Edit contract articles" button
@@ -84,6 +94,9 @@ export function ContractActionsToolbar({
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
// Cancel is its own key — it is terminal, so it is NOT implied by suspend.
const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel);
// Revive an EXPIRED contract by adding validity days — only after the
// customer asked for it from the portal (the API enforces the same).
const mayExtend = hasPermission(user, FREIGHT_PERMS.contracts.extend);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
@@ -98,6 +111,9 @@ export function ContractActionsToolbar({
const [resumeNote, setResumeNote] = useState("");
const [cancelOpen, setCancelOpen] = useState(false);
const [cancelReason, setCancelReason] = useState("");
const [extendOpen, setExtendOpen] = useState(false);
const [extendDays, setExtendDays] = useState<number>(30);
const [extendNote, setExtendNote] = useState("");
// Shared by the suspended branch and the normal toolbar — both can cancel.
const cancelModal = (
@@ -183,7 +199,132 @@ export function ContractActionsToolbar({
[validitySetting],
);
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
// Lapsed: nothing to do until the customer asks for more time from the
// portal. Once they have, staff add days and the contract returns to the
// status it held before it expired.
if (status === "EXPIRED") {
const requestedAt = contract.extensionRequestedAt
? new Date(contract.extensionRequestedAt)
: null;
const currentEnd = contract.contractValidUntil
? new Date(contract.contractValidUntil)
: null;
// Mirrors ContractTransitionService.extend: days count from today once the
// contract has lapsed, from the current end date otherwise.
const base =
currentEnd && currentEnd.getTime() > Date.now() ? currentEnd : new Date();
const newEnd = new Date(base);
newEnd.setDate(newEnd.getDate() + Math.max(0, Math.floor(extendDays || 0)));
const restoredStatus =
contract.statusBeforeExpiry ??
(contract.contractKind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED");
const daysValid = Number.isInteger(extendDays) && extendDays >= 1;
return (
<SectionCard icon={CalendarClock} title="Contract expired">
<Stack gap="sm">
<Text size="sm" c="dimmed">
This contract's validity ended
{currentEnd ? ` on ${currentEnd.toLocaleDateString()}` : ""}. New
bookings are blocked until it is extended.
</Text>
{requestedAt ? (
<>
<Text size="sm">
<b>Extension requested</b> by the customer on{" "}
{requestedAt.toLocaleDateString()}.
</Text>
{contract.latestExtensionRequestNote && (
<Text size="sm">
<b>Reason:</b> {contract.latestExtensionRequestNote}
</Text>
)}
{mayExtend ? (
<Button
fullWidth
color="edr-green"
leftSection={<CalendarPlus size={16} />}
onClick={() => setExtendOpen(true)}
>
Extend contract
</Button>
) : (
<Text size="sm" c="dimmed">
You do not have permission to extend a contract.
</Text>
)}
</>
) : (
<Text size="sm" c="dimmed">
The customer has not requested an extension. A contract can only
be extended once they ask for it from the portal.
</Text>
)}
</Stack>
<Modal
opened={extendOpen}
onClose={() => setExtendOpen(false)}
title="Extend this contract?"
centered
>
<Stack gap="md">
<Text size="sm">
Contract <b>{contract.reference}</b> gets the days below added to
its validity, returns to <b>{restoredStatus}</b>, and the customer
is notified. Bookings under it are possible again immediately.
</Text>
<NumberInput
label="Days to add"
min={1}
max={3650}
step={1}
allowDecimal={false}
value={extendDays}
onChange={(v) => setExtendDays(typeof v === "number" ? v : Number(v) || 0)}
/>
<Text size="sm" c="dimmed">
New validity end:{" "}
<b>{daysValid ? newEnd.toLocaleDateString() : "—"}</b>
</Text>
<Textarea
label="Note (optional)"
placeholder="Shown to the customer with the extension…"
autosize
minRows={2}
value={extendNote}
onChange={(e) => setExtendNote(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setExtendOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
disabled={!daysValid}
loading={mutations.extend.isPending}
onClick={() =>
mutations.extend.mutate(
{ days: extendDays, note: extendNote.trim() || undefined },
{
onSuccess: () => {
setExtendOpen(false);
setExtendNote("");
},
},
)
}
>
Extend contract
</Button>
</Group>
</Stack>
</Modal>
</SectionCard>
);
}
if (["REJECTED", "CANCELLED", "CONTRACT_CLOSED"].includes(status)) {
return null;
}

View File

@@ -582,13 +582,39 @@ export default function GlCreateBookingForm() {
}
}, [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.)
// Rebook seed: copy the source booking's real cargo once — container lines
// (with their per-unit details) or the bulk weight / item count / wagons.
useEffect(() => {
if (!copyFromBooking || prefilled) return;
const lines = copyFromBooking.bookingContainers ?? [];
if (!lines.length) return;
if (!lines.length) {
// Bulk booking: seed the quantity fields from what was actually booked.
// A break-bulk (per-item) booking stores the real tons in
// bulkTotalWeightTons and the item count in cargoTotalWeightVgm.
const perItem = copyFromBooking.bulkTotalWeightTons != null;
const tons = perItem
? copyFromBooking.bulkTotalWeightTons
: copyFromBooking.cargoTotalWeightVgm;
const items = perItem
? copyFromBooking.cargoTotalWeightVgm
: copyFromBooking.bulkItemCount;
if (!(Number(tons) > 0) && !(Number(items) > 0)) return;
setPrefilled(true);
if (copyFromBooking.cargoFreeText) {
setCargoDescription(copyFromBooking.cargoFreeText);
}
setBulk((b) => ({
...b,
cargoWeightTons: Number(tons) > 0 ? String(tons) : "",
itemCount: Number(items) > 0 ? String(items) : "",
requestedWagons:
copyFromBooking.bulkRequestedWagons != null &&
copyFromBooking.bulkRequestedWagons > 0
? String(copyFromBooking.bulkRequestedWagons)
: b.requestedWagons,
}));
return;
}
// The booking stores a numeric sizeFt (20) but the contract scope — and the
// create payload the server validates — uses its own size strings ("20ft").
// Seed with the scope's string so the rebook payload matches what a fresh
@@ -636,13 +662,23 @@ export default function GlCreateBookingForm() {
// Seed one shipment line per contracted size exactly once — same seeding the
// portal form does. Subsequent renders reuse the lines.
//
// Functional update on purpose: when the page is reached by an in-app click
// the contract AND the rebook source are both already cached, so this effect
// and the copyFrom seed above fire in the SAME commit. Reading
// `containerLines` from the closure here saw the pre-seed empty array and
// overwrote the copied lines with blank 0 × 20ft / 0 × 40ft rows (a hard
// refresh loaded them in sequence and looked fine). The updater sees the
// copied lines already queued and leaves them alone.
useEffect(() => {
if (!contract || prefilled || seededRef.current) return;
seededRef.current = true;
if (isContainer && containerSizes.length > 0 && containerLines.length === 0) {
setContainerLines(containerSizes.map(emptyLine));
if (isContainer && containerSizes.length > 0) {
setContainerLines((prev) =>
prev.length === 0 ? containerSizes.map(emptyLine) : prev,
);
}
}, [contract, prefilled, isContainer, containerSizes, containerLines.length]);
}, [contract, prefilled, isContainer, containerSizes]);
const quantities: GlShipmentQuantities = useMemo(
() => ({

View File

@@ -29,6 +29,13 @@ const rulesRouteMeta = RULE_ENGINE_RESOURCES.filter((r) => r.category === "rules
);
const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
{
prefix: "/dashboard/train-crew",
meta: {
title: "Train Crew",
subtitle: "Roster of on-board personnel assignable to a train",
},
},
{
prefix: "/dashboard/overview",
meta: {

View File

@@ -523,6 +523,18 @@ export const buildSidebarSections = (
},
],
},
{
title: "Rolling stock",
mutedTitle: true,
items: [
{
label: "Train Crew",
href: "/dashboard/train-crew",
icon: <Users />,
permission: FREIGHT_PERMS.trainCrew.view,
},
],
},
{
title: "Freight configuration",
mutedTitle: true,

View File

@@ -0,0 +1,259 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Divider,
Group,
Loader,
Modal,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { isAxiosError } from "axios";
import { Info, Unlock } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import DurationField from "@/components/trainScheduling/DurationField";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
const EAT = "Africa/Addis_Ababa";
/** "3 days" / "2 hr" / "45 min" for a minute count. */
function describeMinutes(minutes: number): string {
if (minutes <= 0) return "at departure";
if (minutes % 1440 === 0) {
const d = minutes / 1440;
return `${d} day${d === 1 ? "" : "s"}`;
}
if (minutes % 60 === 0) {
const h = minutes / 60;
return `${h} hr`;
}
return `${minutes} min`;
}
function formatEat(value: string | Date | null | undefined): string {
if (!value) return "—";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("en-GB", {
timeZone: EAT,
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
function parseError(error: unknown, fallback: string): 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;
}
export interface ReduceCloseOffsetModalProps {
scheduleId: string | null;
opened: boolean;
onClose: () => void;
/** Called after a successful save (e.g. to refetch a list). */
onSaved?: () => void;
}
/**
* Reopen a schedule whose booking shut ONLY because of its close offset, by
* shortening that offset (3 days → 1 day, 2 hours, …). The API decides
* eligibility (`closeOffsetReopen`); every other kind of closed window is
* explained and left alone.
*/
export default function ReduceCloseOffsetModal({
scheduleId,
opened,
onClose,
onSaved,
}: ReduceCloseOffsetModalProps) {
const { toast } = useToast();
const detailQuery = useQuery({
...api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "" },
}),
enabled: opened && Boolean(scheduleId),
});
const schedule = detailQuery.data;
const reopen = schedule?.closeOffsetReopen ?? null;
const currentOffset = reopen?.offsetMinutes ?? null;
const save = useMutation(
api.trainScheduling.reduceScheduleCloseOffset.mutationOptions(),
);
// New offset, in minutes (what the API stores). Seeded to the current offset
// so the field reads as "shorten this", never as an empty box.
const [offsetMinutes, setOffsetMinutes] = useState<number | "">("");
useEffect(() => {
if (!opened) return;
setOffsetMinutes(currentOffset ?? "");
}, [opened, currentOffset]);
const departure = schedule?.scheduledDepartureDate
? new Date(schedule.scheduledDepartureDate)
: null;
const newCutoff = useMemo(() => {
if (!departure || offsetMinutes === "") return null;
const n = Number(offsetMinutes);
if (!Number.isFinite(n) || n < 0) return null;
return new Date(departure.getTime() - n * 60_000);
}, [departure, offsetMinutes]);
const value = offsetMinutes === "" ? NaN : Number(offsetMinutes);
const isShorter =
Number.isFinite(value) && currentOffset != null && value < currentOffset;
const cutoffInPast = newCutoff != null && newCutoff.getTime() <= Date.now();
const canSave =
reopen?.eligible === true && isShorter && value >= 0 && !cutoffInPast;
const handleSave = async () => {
if (!scheduleId || !canSave) return;
try {
await save.mutateAsync({
id: scheduleId,
payload: { closeOffsetMinutes: Math.round(value) },
});
toast({
title: "Booking window reopened",
description: `Booking now closes ${describeMinutes(Math.round(value))} before departure.`,
});
onSaved?.();
onClose();
} catch (err) {
toast({
title: "Could not reopen booking",
description: parseError(err, "The close offset was not changed."),
variant: "destructive",
});
}
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
size="md"
title={
<Group gap="sm">
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Unlock size={18} />
</ThemeIcon>
<Box>
<Text fw={600} lh={1.2}>
Reopen booking shorten close offset
</Text>
<Text size="xs" c="dimmed" lh={1.2}>
{schedule?.route?.name ?? "This schedule"}
</Text>
</Box>
</Group>
}
>
{detailQuery.isLoading || !schedule ? (
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
) : !reopen?.eligible ? (
<Alert
variant="light"
color="yellow"
icon={<Info size={16} />}
title="The close offset is not what closed this train"
>
{reopen?.reason ??
"This schedule cannot be reopened by shortening its close offset."}
</Alert>
) : (
<Stack gap="lg">
<Alert variant="light" color="blue" icon={<Info size={16} />}>
Booking on this train closed early because of its close offset the
train itself has not left. Shorten the offset and the desk reopens
at its next opening (right away if it is open now) until the new
cutoff.
{schedule.direction !== "EXPORT"
? " Every train on this route departing the same day that is closed for the same reason reopens with it."
: ""}
</Alert>
<Box>
<Text size="sm" fw={600} mb={6}>
Current close
</Text>
<Group gap="xs" wrap="wrap">
<Badge variant="light" color="red">
Closes {describeMinutes(currentOffset ?? 0)} before departure
</Badge>
<Text size="xs" c="dimmed">
closed {formatEat(reopen.cutoffAt)} EAT · departs{" "}
{formatEat(departure)} EAT
</Text>
</Group>
</Box>
<Divider />
<DurationField
label="New close offset (before departure)"
description="Must be shorter than the current offset. 0 = booking stays open until the train departs."
value={offsetMinutes}
nativeUnit="minutes"
min={0}
onChange={setOffsetMinutes}
/>
{newCutoff ? (
<Text size="sm">
Booking would now close{" "}
<Text span fw={600}>
{formatEat(newCutoff)} EAT
</Text>
{cutoffInPast ? (
<Text span c="red">
{" "}
that is already in the past; shorten it further.
</Text>
) : !isShorter ? (
<Text span c="red">
{" "}
not shorter than the current offset.
</Text>
) : null}
</Text>
) : null}
<Group justify="flex-end" mt="xs">
<Button variant="default" radius="md" onClick={onClose}>
Cancel
</Button>
<Button
radius="md"
color="edr-green"
leftSection={<Unlock size={16} />}
loading={save.isPending}
disabled={!canSave}
onClick={() => void handleSave()}
>
Reopen booking
</Button>
</Group>
</Stack>
)}
</Modal>
);
}

View File

@@ -194,6 +194,13 @@ export const QUERY_KEYS = {
byId: (id: string) => ["vehicles", "detail", id] as const,
},
TRAIN_CREW: {
ROOT: ["train-crew"] as const,
list: (filter?: Record<string, unknown>) =>
["train-crew", "list", filter ?? {}] as const,
byId: (id: string) => ["train-crew", "detail", id] as const,
},
FIRST_MILE: {
ROOT: ["first-mile"] as const,
list: (filter?: Record<string, unknown>) =>

View File

@@ -264,6 +264,8 @@ export const URL_CONSTANTS = {
`/bookings/${id}/clearance/export-release`,
// Re-request operation after Operations sent the booking back for changes.
CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`,
// Operations changes a pending request's shipment day / train themselves.
OPERATION_RESCHEDULE: (id: string) => `/bookings/${id}/operation/reschedule`,
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
},
@@ -286,6 +288,7 @@ export const URL_CONSTANTS = {
STAFF_CANCEL: (id: string) => `/contracts/${id}/staff/cancel`,
SUSPEND: (id: string) => `/contracts/${id}/suspend`,
RESUME: (id: string) => `/contracts/${id}/resume`,
EXTEND: (id: string) => `/contracts/${id}/extend`,
APPROVE_STEP: (id: string, stepId: string) =>
`/contracts/${id}/approval-steps/${stepId}/approve`,
REJECT_STEP: (id: string, stepId: string) =>
@@ -435,6 +438,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/booking-window`,
WINDOW_RULE: (id: string) =>
`/train-scheduling/schedules/${id}/window-rule`,
CLOSE_OFFSET: (id: string) =>
`/train-scheduling/schedules/${id}/close-offset`,
SCHEDULE_DATE: (id: string) =>
`/train-scheduling/schedules/${id}/schedule-date`,
MERGE_PREVIEW: (id: string, targetTrainId: string) =>
@@ -871,4 +876,9 @@ export const URL_CONSTANTS = {
BASE: "/drivers",
BY_ID: (id: string) => `/drivers/${id}`,
},
TRAIN_CREW: {
BASE: "/train-crew",
BY_ID: (id: string) => `/train-crew/${id}`,
},
};

View File

@@ -1,6 +1,7 @@
import type { LucideIcon } from "lucide-react";
import {
Ban,
CalendarClock,
Check,
MessageSquareWarning,
Play,
@@ -28,6 +29,7 @@ export type BookingActionId =
| "complete"
| "operationAccept"
| "operationRequestChanges"
| "operationReschedule"
| "cancel";
export type BookingActionInputKind =
@@ -128,6 +130,22 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [
},
];
/**
* Operations changes the shipment day / train themselves, instead of returning
* the request to the customer. Opens its own modal (day + export train picker),
* not the generic confirm dialog — see isRescheduleAction.
*/
const OPERATION_RESCHEDULE_ACTION: BookingActionDef = {
id: "operationReschedule",
label: "Change train / shipment day",
shortLabel: "Reschedule",
description: "Move the request to another shipment day or train yourself",
confirmTitle: "",
confirmDescription: "",
variant: "outline",
icon: CalendarClock,
};
// Marketing/operations review of a drawdown order's operation request.
const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
{
@@ -156,6 +174,7 @@ const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
inputLabel: "Message to customer",
inputPlaceholder: "Describe what needs to change…",
},
OPERATION_RESCHEDULE_ACTION,
];
const CANCEL_ACTION: BookingActionDef = {
@@ -202,6 +221,7 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
operationReschedule: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.update,
cancel: FREIGHT_PERMS.bookings.cancel,
};
@@ -253,6 +273,11 @@ export function getBookingActions(
case "OPERATION_REQUEST_PENDING":
actions = withCancel(OPERATION_REVIEW_ACTIONS);
break;
case "OPERATION_CHANGES_REQUESTED":
// Waiting on the customer — but Operations may also resolve their own
// change request by setting the day / train directly.
actions = [OPERATION_RESCHEDULE_ACTION];
break;
case "PAID":
// Allocate is handled by the Operations "Ready to allocate" queue, not the
// per-booking action menu. Start transit was removed entirely. No per-row
@@ -300,6 +325,11 @@ export function isAllocateAction(id: BookingActionId): boolean {
return id === "allocateBooking";
}
/** Opens the day / train reschedule modal instead of the generic confirm dialog. */
export function isRescheduleAction(id: BookingActionId): boolean {
return id === "operationReschedule";
}
/** Opens the booking detail on the Clearance tab without a confirm dialog. */
export function isClearanceNavAction(id: BookingActionId): boolean {
return id === "reviewClearance";

View File

@@ -0,0 +1,88 @@
/**
* Shared helpers for the staff shipment-day / export-train pickers (the
* operation reschedule modal and the GL "returned for changes" resubmit).
*/
export const EAT_TIMEZONE = "Africa/Addis_Ababa";
/** YYYY-MM-DD of an instant in East Africa Time — the booking day key. */
export function eatDay(value: string | Date): string {
const date = typeof value === "string" ? new Date(value) : value;
return new Intl.DateTimeFormat("en-CA", {
timeZone: EAT_TIMEZONE,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
/** "Mon, 07 Sep, 09:00" in EAT; "—" for a missing or invalid value. */
export function formatEat(value: string | Date | null | undefined): string {
if (!value) return "—";
const date = typeof value === "string" ? new Date(value) : value;
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TIMEZONE,
weekday: "short",
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
/** "Wed, 09 Sep 2026" for a YYYY-MM-DD EAT day key. */
export function formatEatDay(dayKey: string): string {
const date = new Date(`${dayKey}T12:00:00.000Z`);
if (Number.isNaN(date.getTime())) return dayKey;
return new Intl.DateTimeFormat("en-GB", {
timeZone: EAT_TIMEZONE,
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(date);
}
/** Mirrors the API road-service rule: ServiceType.code ROAD, TRUCK, ROAD_*, TRUCK_* */
export function isRoadServiceCode(code: string | null | undefined): boolean {
const c = (code ?? "").toUpperCase();
return (
c === "ROAD" ||
c === "TRUCK" ||
c.startsWith("ROAD_") ||
c.startsWith("TRUCK_")
);
}
/** Export rail bookings are the only ones that carry a train pick. */
export function isExportRailBooking(booking: {
tradeDirection?: string | null;
serviceType?: { code?: string | null } | null;
}): boolean {
return (
booking.tradeDirection === "EXPORT" &&
!isRoadServiceCode(booking.serviceType?.code)
);
}
/** Select option for one export train; closed or too-small trains are disabled. */
export function exportTrainOption(t: {
scheduleId: string;
trainNumber: string | null;
trainName: string | null;
departure: string;
isOpen: boolean;
fits: boolean;
freeWagons: number;
neededWagons: number;
}): { value: string; label: string; disabled: boolean } {
return {
value: t.scheduleId,
label:
`${t.trainNumber ?? t.trainName ?? "Train"} · departs ${formatEat(t.departure)} · ` +
`${t.freeWagons} free / needs ${t.neededWagons}` +
(!t.isOpen ? " · closed" : !t.fits ? " · no room" : ""),
disabled: !t.isOpen || !t.fits,
};
}

View File

@@ -64,6 +64,17 @@ export function useBookingMutations(bookingId: string) {
onError: (error) => toast.error(parseApiError(error, "Failed to reject booking")),
});
const rescheduleOperation = useMutation({
mutationFn: (payload: {
scheduledDate: string;
trainScheduleId?: string;
note?: string;
}) => api.bookings.rescheduleOperation.call({ id: bookingId, ...payload }),
onSuccess: (data) => onSuccess(data, "Shipment day updated"),
onError: (error) =>
toast.error(parseApiError(error, "Failed to change the shipment day")),
});
const reviewOperation = useMutation({
mutationFn: (payload: {
decision: "ACCEPT" | "REQUEST_CHANGES";
@@ -162,6 +173,7 @@ export function useBookingMutations(bookingId: string) {
startTransit.isPending ||
complete.isPending ||
reviewOperation.isPending ||
rescheduleOperation.isPending ||
cancel.isPending;
return {
@@ -170,6 +182,7 @@ export function useBookingMutations(bookingId: string) {
requestChanges,
staffReject,
reviewOperation,
rescheduleOperation,
generateContract,
signContract,
payBooking,

View File

@@ -172,6 +172,14 @@ export function useContractMutations(contractId: string) {
onError: (error) => toast.error(extractErrorMessage(error, "Failed to lift suspension")),
});
const extend = useMutation({
mutationFn: (payload: Freight.ExtendContractDto) =>
contractsService.extend(contractId, payload),
onSuccess: (data) =>
onSuccess(data, `Contract extended — it is back to ${data.status}`),
onError: (error) => toast.error(extractErrorMessage(error, "Failed to extend contract")),
});
const approveStep = useMutation({
// The server derives the required role from the step itself, so the client
// does not send one.
@@ -280,6 +288,7 @@ export function useContractMutations(contractId: string) {
cancelByStaff,
suspend,
resume,
extend,
approveStep,
rejectStep,
generateContract,

View File

@@ -97,6 +97,7 @@ export const FREIGHT_PERMS = {
clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions",
suspend: "edr_freight_app:contracts:suspend",
cancel: "edr_freight_app:contracts:cancel",
extend: "edr_freight_app:contracts:extend",
editDocument: "edr_freight_app:contracts:edit_document",
finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise",
finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm",
@@ -266,6 +267,13 @@ export const FREIGHT_PERMS = {
update: "edr_freight_app:drivers:update",
delete: "edr_freight_app:drivers:delete",
},
trainCrew: {
view: "edr_freight_app:train_crew:view",
create: "edr_freight_app:train_crew:create",
update: "edr_freight_app:train_crew:update",
delete: "edr_freight_app:train_crew:delete",
assign: "edr_freight_app:train_crew:assign",
},
tracking: {
view: "edr_freight_app:tracking:view",
manage: "edr_freight_app:tracking:manage",

View File

@@ -206,6 +206,10 @@ export const LEGACY_APPROVAL_ROLES = [
const RATE_APPLIES_TO = [
{ label: "Bulk (base freight)", value: "BULK" },
{ label: "Container (base freight)", value: "CONTAINER" },
{
label: "Empty container (base freight, import)",
value: "EMPTY_CONTAINER",
},
{ label: "Intercity (base freight)", value: "INTERCITY" },
{ label: "First mile", value: "FIRST_MILE" },
{ label: "Last mile", value: "LAST_MILE" },
@@ -290,7 +294,9 @@ const SHIPPING_LINE_CARGO_KINDS = [
/** True when the rate being edited is base rail freight, which is priced per leg. */
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
["BULK", "CONTAINER", "EMPTY_CONTAINER", "INTERCITY"].includes(
String(values.appliesTo ?? ""),
);
/** Surcharges sold per origin → destination leg (mirrors RatesService.isRouteScoped). */
export const ROUTE_SCOPED_TRIGGERS = [
@@ -388,6 +394,9 @@ const unitsForShape = (
switch (appliesTo) {
case "CONTAINER":
return ["PER_CONTAINER", "PER_WAGON"];
case "EMPTY_CONTAINER":
// No cargo to weigh — only the box and the wagon it rides on.
return ["PER_CONTAINER", "PER_WAGON"];
case "BULK":
return ["PER_TON", "PER_WAGON"];
case "INTERCITY":
@@ -1144,6 +1153,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
},
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } },
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } },
{
key: "empty-container",
label: "Empty container",
filters: { appliesTo: "EMPTY_CONTAINER", isShippingLineRate: "false" },
},
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } },
{
key: "trucking",
@@ -1301,8 +1315,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
optionsFromValues: (v: Record<string, unknown>) =>
String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "WITH_RETURN"
// Empty freight and the empty-return surcharge are both import-only.
String(v.appliesTo ?? "") === "EMPTY_CONTAINER" ||
(String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "WITH_RETURN")
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
: String(v.appliesTo ?? "") === "OTHER" &&
String(v.trigger ?? "") === "FUEL"
@@ -1310,7 +1326,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showIf: (v) =>
!isShippingLineRate(v) &&
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(["BULK", "CONTAINER", "EMPTY_CONTAINER"].includes(
String(v.appliesTo ?? ""),
) ||
(String(v.appliesTo ?? "") === "OTHER" &&
[
"CUSTOMS_CLEARANCE",
@@ -1513,6 +1531,19 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
},
// Empty freight has no cargo to narrow by, so the box size IS the scope —
// required here, unlike the laden catch-all above. The API rejects an
// unscoped empty rate for the same reason.
{
name: "containerTypeId",
label: "Container type",
type: "select",
required: true,
placeholder: "Which container type this rate covers",
description: "20ft and 40ft price differently — one rate per size per lane.",
showIf: (v) =>
!isShippingLineRate(v) && v.appliesTo === "EMPTY_CONTAINER",
},
// Container type for a shipping-line base-freight rate. Required here,
// unlike the customer form's optional catch-all: a line negotiates a
// price per box size, so an unscoped line rate has no meaning.

View File

@@ -0,0 +1,459 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Button,
Card,
Container,
Group,
Loader,
Modal,
Select,
Stack,
Switch,
Table,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Pencil, Plus, Trash2 } from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
// Generic list footer — shared by the fleet and train-scheduling lists despite
// the ruleEngine path.
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import {
TRAIN_CREW_NATIONALITY_OPTIONS,
TRAIN_CREW_ROLE_OPTIONS,
TRAIN_CREW_STATUS_OPTIONS,
trainCrewNationalityLabel,
trainCrewRoleLabel,
trainCrewService,
trainCrewStatusLabel,
type SaveTrainCrewMemberPayload,
type TrainCrewMember,
type TrainCrewNationality,
type TrainCrewRole,
type TrainCrewStatus,
} from "@/services/trainCrew.service";
const DEFAULT_PAGE_SIZE = 10;
const ALL = "__all__";
/** Mantine colour per status, so the roster reads at a glance. */
const STATUS_COLOR: Record<TrainCrewStatus, string> = {
ACTIVE: "green",
INACTIVE: "gray",
SUSPENDED: "red",
ON_LEAVE: "yellow",
};
type FormState = {
firstName: string;
lastName: string;
role: TrainCrewRole | "";
nationality: TrainCrewNationality | "";
status: TrainCrewStatus;
isActive: boolean;
};
const EMPTY_FORM: FormState = {
firstName: "",
lastName: "",
role: "",
nationality: "",
status: "ACTIVE",
isActive: true,
};
export default function TrainCrewPage() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canCreate = hasPermission(user, FREIGHT_PERMS.trainCrew.create);
const canUpdate = hasPermission(user, FREIGHT_PERMS.trainCrew.update);
const canDelete = hasPermission(user, FREIGHT_PERMS.trainCrew.delete);
// The footer owns page size as well as page, so both live here. `pageIndex`
// is 0-based to match the footer's PaginationState; the API is 1-based.
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: DEFAULT_PAGE_SIZE,
});
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState<string>(ALL);
const [nationalityFilter, setNationalityFilter] = useState<string>(ALL);
const [statusFilter, setStatusFilter] = useState<string>(ALL);
const [modalOpen, setModalOpen] = useState(false);
/** Row being edited; null means the modal is in create mode. */
const [editing, setEditing] = useState<TrainCrewMember | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [deleteTarget, setDeleteTarget] = useState<TrainCrewMember | null>(null);
// Filtering and paging are server-side, so the active filters are part of the
// query key — changing one refetches rather than slicing a stale page.
const filters = useMemo(
() => ({
page: pagination.pageIndex + 1,
limit: pagination.pageSize,
...(search.trim() ? { search: search.trim() } : {}),
...(roleFilter !== ALL ? { role: roleFilter as TrainCrewRole } : {}),
...(nationalityFilter !== ALL
? { nationality: nationalityFilter as TrainCrewNationality }
: {}),
...(statusFilter !== ALL ? { status: statusFilter as TrainCrewStatus } : {}),
}),
[pagination, search, roleFilter, nationalityFilter, statusFilter],
);
const { data, isLoading } = useQuery({
queryKey: QUERY_KEYS.TRAIN_CREW.list(filters),
queryFn: async () => {
const res = await trainCrewService.getAll(filters);
return res.data;
},
});
const members = data?.data ?? [];
const total = data?.total ?? 0;
const invalidate = () =>
qc.invalidateQueries({ queryKey: QUERY_KEYS.TRAIN_CREW.ROOT });
const describeError = (error: unknown, fallback: string): string => {
const message = (error as { response?: { data?: { message?: unknown } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
return typeof message === "string" ? message : fallback;
};
const saveMutation = useMutation({
mutationFn: async (values: FormState) => {
const payload: Partial<SaveTrainCrewMemberPayload> = {
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
role: values.role as TrainCrewRole,
nationality: values.nationality as TrainCrewNationality,
status: values.status,
isActive: values.isActive,
};
return editing
? trainCrewService.update(editing.id, payload)
: trainCrewService.create(payload);
},
onSuccess: () => {
toast({ title: editing ? "Crew member updated" : "Crew member added" });
closeModal();
invalidate();
},
onError: (error: unknown) => {
toast({
title: editing ? "Could not update crew member" : "Could not add crew member",
description: describeError(error, "The request failed. Please try again."),
variant: "destructive",
});
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => trainCrewService.delete(id),
onSuccess: () => {
toast({ title: "Crew member removed" });
setDeleteTarget(null);
invalidate();
},
onError: (error: unknown) => {
toast({
title: "Could not remove crew member",
description: describeError(error, "The request failed. Please try again."),
variant: "destructive",
});
},
});
const openCreate = () => {
setEditing(null);
setForm(EMPTY_FORM);
setModalOpen(true);
};
const openEdit = (member: TrainCrewMember) => {
setEditing(member);
setForm({
firstName: member.firstName,
lastName: member.lastName,
role: member.role,
nationality: member.nationality,
status: member.status,
isActive: member.isActive,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditing(null);
setForm(EMPTY_FORM);
};
/** Every column is NOT NULL server-side, so all four must be filled. */
const formValid =
form.firstName.trim().length > 0 &&
form.lastName.trim().length > 0 &&
form.role !== "" &&
form.nationality !== "";
// Filters narrow the result set, so a page beyond the new last page would
// render empty — reset to the first page whenever one changes.
const resetToFirstPage = () =>
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
const onFilterChange = (setter: (value: string) => void) => (value: string | null) => {
setter(value ?? ALL);
resetToFirstPage();
};
return (
<Container size="xl" py="xl" px="lg">
<Breadcrumbs items={[{ label: "Train Crew" }, { label: "Crew Members" }]} />
<Group justify="space-between" mb="lg">
<div>
<Title order={1}>Train Crew</Title>
<Text size="sm" c="dimmed">
Roster of on-board personnel assignable to a train
</Text>
</div>
{canCreate ? (
<Button leftSection={<Plus size={16} />} onClick={openCreate} color="edr-green">
Add Crew Member
</Button>
) : null}
</Group>
<Card withBorder>
<Group p="md" gap="sm" align="flex-end" wrap="wrap">
<TextInput
label="Search"
placeholder="Search by name…"
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
resetToFirstPage();
}}
style={{ flex: 1, minWidth: 220 }}
/>
<Select
label="Role"
data={[{ label: "All roles", value: ALL }, ...TRAIN_CREW_ROLE_OPTIONS]}
value={roleFilter}
onChange={onFilterChange(setRoleFilter)}
style={{ minWidth: 180 }}
/>
<Select
label="Nationality"
data={[
{ label: "All nationalities", value: ALL },
...TRAIN_CREW_NATIONALITY_OPTIONS,
]}
value={nationalityFilter}
onChange={onFilterChange(setNationalityFilter)}
style={{ minWidth: 170 }}
/>
<Select
label="Status"
data={[{ label: "All statuses", value: ALL }, ...TRAIN_CREW_STATUS_OPTIONS]}
value={statusFilter}
onChange={onFilterChange(setStatusFilter)}
style={{ minWidth: 160 }}
/>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>First Name</Table.Th>
<Table.Th>Last Name</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Nationality</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Active</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
</Table.Td>
</Table.Tr>
) : members.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={7}>
<Text c="dimmed" ta="center" py="md">
No crew members found.
</Text>
</Table.Td>
</Table.Tr>
) : null}
{members.map((member) => (
<Table.Tr key={member.id}>
<Table.Td>{member.firstName}</Table.Td>
<Table.Td>{member.lastName}</Table.Td>
<Table.Td>{trainCrewRoleLabel(member.role)}</Table.Td>
<Table.Td>{trainCrewNationalityLabel(member.nationality)}</Table.Td>
<Table.Td>
<Badge size="sm" color={STATUS_COLOR[member.status] ?? "gray"}>
{trainCrewStatusLabel(member.status)}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" color={member.isActive ? "green" : "gray"} variant="light">
{member.isActive ? "Yes" : "No"}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs">
{canUpdate ? (
<Button
size="xs"
variant="light"
leftSection={<Pencil size={14} />}
onClick={() => openEdit(member)}
>
Edit
</Button>
) : null}
{canDelete ? (
<Button
size="xs"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
onClick={() => setDeleteTarget(member)}
>
Delete
</Button>
) : null}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<RuleEngineListFooter
itemLabel="crew members"
pagination={pagination}
pageCount={Math.ceil(total / pagination.pageSize)}
totalCount={total}
onPaginationChange={setPagination}
/>
</Card>
<Modal
opened={modalOpen}
onClose={closeModal}
title={editing ? "Edit Crew Member" : "Add Crew Member"}
size="lg"
>
<Stack gap="md">
<TextInput
label="First Name"
placeholder="First name"
value={form.firstName}
onChange={(e) => setForm({ ...form, firstName: e.currentTarget.value })}
required
/>
<TextInput
label="Last Name"
placeholder="Last name"
value={form.lastName}
onChange={(e) => setForm({ ...form, lastName: e.currentTarget.value })}
required
/>
<Select
label="Role"
placeholder="Select role"
data={TRAIN_CREW_ROLE_OPTIONS}
value={form.role || null}
onChange={(val) => setForm({ ...form, role: (val as TrainCrewRole) ?? "" })}
required
/>
<Select
label="Nationality"
placeholder="Select nationality"
data={TRAIN_CREW_NATIONALITY_OPTIONS}
value={form.nationality || null}
onChange={(val) =>
setForm({ ...form, nationality: (val as TrainCrewNationality) ?? "" })
}
required
/>
<Select
label="Status"
data={TRAIN_CREW_STATUS_OPTIONS}
value={form.status}
onChange={(val) =>
setForm({ ...form, status: (val as TrainCrewStatus) ?? "ACTIVE" })
}
required
/>
<Switch
label="Active"
checked={form.isActive}
onChange={(e) => setForm({ ...form, isActive: e.currentTarget.checked })}
/>
<Group justify="flex-end">
<Button variant="light" onClick={closeModal}>
Cancel
</Button>
<Button
onClick={() => saveMutation.mutate(form)}
loading={saveMutation.isPending}
disabled={!formValid}
>
{editing ? "Save Changes" : "Add Crew Member"}
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
title="Remove Crew Member"
size="md"
>
<Stack gap="md">
<Text size="sm">
Remove {deleteTarget?.firstName} {deleteTarget?.lastName} from the train crew
roster?
</Text>
<Group justify="flex-end">
<Button variant="light" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
color="red"
loading={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
Remove
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,612 @@
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Group,
Loader,
Select,
Stack,
Stepper,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertTriangle,
CheckCircle2,
Plus,
ShieldCheck,
Train,
Trash2,
Users,
Wrench,
} from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
trainCrewService,
trainCrewRoleLabel,
type TrainCrewMember,
type TrainCrewRole,
} from "@/services/trainCrew.service";
import {
DUTY_ROLE_OPTIONS,
trainCrewAssignmentService,
type CorridorYard,
type CrewDutyRole,
} from "@/services/trainCrewAssignment.service";
/**
* One driver row being built. The leg (two yards) and the duty role are
* properties of THIS run, not of the person.
*/
interface DriverRow {
key: string;
crewMemberId: string | null;
fromYardId: string | null;
toYardId: string | null;
dutyRole: CrewDutyRole | null;
}
/**
* A new row pre-filled with the schedule's own endpoints — the common case is
* one driver over the whole route, and staff narrow it from there.
*/
const newDriverRow = (yards: CorridorYard[]): DriverRow => ({
key: `driver-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
crewMemberId: null,
fromYardId: yards[0]?.id ?? null,
toYardId: yards[yards.length - 1]?.id ?? null,
dutyRole: null,
});
/**
* Assign a train crew to one schedule — ITLMS Rolling Stock §1.1 and §1.2.
*
* Crew sizes are free-form: operations add as many drivers, police, technicians
* or specialists as a given run needs, rather than filling the fixed pairing
* cases of §2. What is still enforced is what makes a run coherent — every
* driver carries a leg and duty role, one Primary per leg, Djibouti drivers
* confined to Dire Dawa and eastward (§1.1), and the specialized crew the cargo
* actually demands (§1.2).
*
* A partial crew always saves: §1.2 puts the hard gate at departure, so this
* page and the dispatch guard call the same server-side validator.
*/
export default function ScheduleCrewPage() {
const { scheduleId = "" } = useParams();
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trainCrew.assign);
const [step, setStep] = useState(0);
const [drivers, setDrivers] = useState<DriverRow[]>([]);
/** Support and specialist picks, keyed by role. */
const [supportIds, setSupportIds] = useState<Record<string, Array<string | null>>>({});
const { data: crew, isLoading } = useQuery({
queryKey: ["schedule-crew", scheduleId],
queryFn: async () => (await trainCrewAssignmentService.get(scheduleId)).data,
enabled: Boolean(scheduleId),
});
const { data: roster = [] } = useQuery({
queryKey: ["train-crew", "roster-all"],
queryFn: async () => {
const res = await trainCrewService.getAll({ limit: 200, status: "ACTIVE" });
return res.data.data;
},
});
// Seed from what is already saved, so reopening resumes rather than restarts.
useEffect(() => {
if (!crew) return;
const driverRows: DriverRow[] = [];
const support: Record<string, Array<string | null>> = {};
for (const a of crew.assignments) {
if (a.role === "TRAIN_DRIVER") {
driverRows.push({
key: a.id,
crewMemberId: a.crewMemberId,
fromYardId: a.fromYardId ?? null,
toYardId: a.toYardId ?? null,
dutyRole: a.dutyRole ?? null,
});
} else {
support[a.role] = [...(support[a.role] ?? []), a.crewMemberId];
}
}
setDrivers(driverRows);
setSupportIds(support);
}, [crew]);
const corridorYards = crew?.corridorYards ?? [];
const yardOptions = useMemo(
() => corridorYards.map((y) => ({ value: y.id, label: y.label })),
[corridorYards],
);
/**
* §1.1 — a leg is open to a Djibouti driver only when both ends sit at or
* beyond Dire Dawa. Position along the corridor answers this without naming
* station pairs, so a handover anywhere east of Dire Dawa works.
*/
const legOpenToDjibouti = (leg: {
fromYardId: string | null;
toYardId: string | null;
}) => {
const boundary = corridorYards.find((y) => /dire dawa/i.test(y.label));
const from = corridorYards.find((y) => y.id === leg.fromYardId);
const to = corridorYards.find((y) => y.id === leg.toYardId);
// An unknown boundary or half-built leg is not a breach — the server-side
// validator reports the incomplete leg on its own.
if (!boundary || !from || !to) return true;
return Math.min(from.displayOrder, to.displayOrder) >= boundary.displayOrder;
};
const byRole = useMemo(() => {
const map = new Map<TrainCrewRole, TrainCrewMember[]>();
for (const m of roster) {
map.set(m.role, [...(map.get(m.role) ?? []), m]);
}
return map;
}, [roster]);
/** Everyone already picked — nobody may hold two seats on one run. */
const takenIds = useMemo(() => {
const ids = [
...drivers.map((d) => d.crewMemberId),
...Object.values(supportIds).flat(),
].filter(Boolean) as string[];
return new Set(ids);
}, [drivers, supportIds]);
const memberOptions = (
role: TrainCrewRole,
currentValue: string | null,
leg?: { fromYardId: string | null; toYardId: string | null },
) =>
(byRole.get(role) ?? [])
.filter((m) => {
// §1.1 territorial boundary: a Djibouti driver never appears on a leg
// they may not work. Enforced by making the invalid choice unavailable
// rather than by rejecting it afterwards.
if (leg && m.nationality === "DJIBOUTIAN" && !legOpenToDjibouti(leg)) {
return false;
}
return m.id === currentValue || !takenIds.has(m.id);
})
.map((m) => ({
value: m.id,
label: `${m.firstName} ${m.lastName} · ${m.nationality === "ETHIOPIAN" ? "ET" : "DJ"}`,
}));
const setDriver = (key: string, patch: Partial<DriverRow>) =>
setDrivers((prev) =>
prev.map((row) => {
if (row.key !== key) return row;
const next = { ...row, ...patch };
// Moving the leg can invalidate the person already chosen — clear
// rather than silently persist a territorial breach.
const legMoved = patch.fromYardId !== undefined || patch.toYardId !== undefined;
if (legMoved && next.crewMemberId) {
const member = roster.find((m) => m.id === next.crewMemberId);
if (member?.nationality === "DJIBOUTIAN" && !legOpenToDjibouti(next)) {
next.crewMemberId = null;
}
}
return next;
}),
);
const setSupportCount = (role: TrainCrewRole, count: number) =>
setSupportIds((prev) => ({
...prev,
[role]: Array.from({ length: count }, (_, i) => prev[role]?.[i] ?? null),
}));
const buildPayload = () => {
const assignments: Array<{
crewMemberId: string;
role: TrainCrewRole;
dutyRole?: CrewDutyRole;
fromYardId?: string;
toYardId?: string;
}> = [];
for (const row of drivers) {
if (row.crewMemberId) {
assignments.push({
crewMemberId: row.crewMemberId,
role: "TRAIN_DRIVER",
...(row.dutyRole ? { dutyRole: row.dutyRole } : {}),
...(row.fromYardId ? { fromYardId: row.fromYardId } : {}),
...(row.toYardId ? { toYardId: row.toYardId } : {}),
});
}
}
for (const [role, ids] of Object.entries(supportIds)) {
for (const id of ids) {
if (id) assignments.push({ crewMemberId: id, role: role as TrainCrewRole });
}
}
return { assignments };
};
const saveMutation = useMutation({
mutationFn: () => trainCrewAssignmentService.save(scheduleId, buildPayload()),
onSuccess: (res) => {
const validation = res.data;
toast({
title: validation.complete
? "Crew saved — composition complete"
: "Crew saved (still incomplete)",
description: validation.complete
? undefined
: "The train cannot be dispatched until every rule passes.",
});
qc.invalidateQueries({ queryKey: ["schedule-crew", scheduleId] });
},
onError: (error: unknown) => {
const message = (error as { response?: { data?: { message?: unknown } } })
?.response?.data?.message;
toast({
title: "Could not save crew",
description: Array.isArray(message)
? message.join(", ")
: typeof message === "string"
? message
: "The request failed. Please try again.",
variant: "destructive",
});
},
});
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" />
</Group>
</PageContainer>
);
}
const demand = crew?.demand;
const specialized = crew?.requirements.specialized ?? [];
const technicianRule = crew?.requirements.technician;
const validation = crew?.validation;
return (
<PageContainer>
<PageHeader
title="Assign Train Crew"
subtitle="Add as many drivers and crew as this run needs"
backTo={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
meta={
validation ? (
<Badge
variant="light"
color={validation.complete ? "green" : "orange"}
leftSection={
validation.complete ? <CheckCircle2 size={12} /> : <AlertTriangle size={12} />
}
>
{validation.complete ? "Ready to dispatch" : "Incomplete"}
</Badge>
) : null
}
action={
canAssign ? (
<Button
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
color="edr-green"
>
Save Crew
</Button>
) : null
}
/>
<Stepper active={step} onStepClick={setStep} mt="md" size="sm">
<Stepper.Step label="Drivers" description="Any number">
<Stack gap="md" mt="lg">
<Text size="sm" c="dimmed">
Add a row per driver and set the leg they work any two yards on this
schedule's route, so a handover at Feto or Meiso is as easy as one at
Dire Dawa. Djibouti drivers are offered only on legs from Dire Dawa
eastward.
</Text>
{drivers.length === 0 ? (
<Alert color="gray">No drivers added yet.</Alert>
) : (
drivers.map((row, index) => (
<Card key={row.key} withBorder padding="md">
<Group justify="space-between" mb="sm">
<Group gap="sm">
<ThemeIcon size={28} radius="md" variant="light" color="edr-green">
<Train size={14} />
</ThemeIcon>
<Text fw={600} size="sm">
Driver {index + 1}
</Text>
</Group>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove driver"
onClick={() =>
setDrivers((prev) => prev.filter((d) => d.key !== row.key))
}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
<Group grow align="flex-start" wrap="wrap">
<Select
label="From yard"
placeholder="Start of this leg"
searchable
data={yardOptions}
value={row.fromYardId}
onChange={(val) => setDriver(row.key, { fromYardId: val })}
/>
<Select
label="To yard"
placeholder="End of this leg"
searchable
data={yardOptions}
value={row.toYardId}
onChange={(val) => setDriver(row.key, { toYardId: val })}
/>
<Select
label="Duty role"
placeholder="Select a duty role"
data={DUTY_ROLE_OPTIONS}
value={row.dutyRole}
onChange={(val) =>
setDriver(row.key, { dutyRole: (val as CrewDutyRole) ?? null })
}
/>
<Select
label="Driver"
placeholder="Select a driver"
searchable
clearable
data={memberOptions("TRAIN_DRIVER", row.crewMemberId, row)}
value={row.crewMemberId}
onChange={(val) => setDriver(row.key, { crewMemberId: val })}
/>
</Group>
</Card>
))
)}
<Button
variant="light"
leftSection={<Plus size={16} />}
onClick={() => setDrivers((prev) => [...prev, newDriverRow(corridorYards)])}
>
Add Driver
</Button>
</Stack>
</Stepper.Step>
<Stepper.Step label="Support crew" description="Police, technical, cargo">
<Stack gap="lg" mt="lg">
<SupportSection
role="FEDERAL_POLICE"
icon={<ShieldCheck size={16} />}
color="blue"
title="Security detail"
hint="Add as many federal police as this run needs"
values={supportIds.FEDERAL_POLICE ?? []}
onCount={(n) => setSupportCount("FEDERAL_POLICE", n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
FEDERAL_POLICE: (prev.FEDERAL_POLICE ?? []).map((v, idx) =>
idx === i ? val : v,
),
}))
}
options={(value) => memberOptions("FEDERAL_POLICE", value)}
/>
<SupportSection
role="TECHNICIAN"
icon={<Wrench size={16} />}
color="orange"
title="Technical maintenance crew"
hint={technicianRule?.reason ?? "Optional technical maintenance crew"}
alert={
demand?.hasBadOrderWagon
? "A defective wagon is attached, so at least one technician is mandatory."
: undefined
}
values={supportIds.TECHNICIAN ?? []}
onCount={(n) => setSupportCount("TECHNICIAN", n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
TECHNICIAN: (prev.TECHNICIAN ?? []).map((v, idx) => (idx === i ? val : v)),
}))
}
options={(value) => memberOptions("TECHNICIAN", value)}
/>
{specialized.length ? (
specialized.map((rule) => (
<SupportSection
key={rule.role}
role={rule.role}
icon={<Users size={16} />}
color="grape"
title={trainCrewRoleLabel(rule.role)}
hint={rule.reason}
values={supportIds[rule.role] ?? []}
onCount={(n) => setSupportCount(rule.role, n)}
onPick={(i, val) =>
setSupportIds((prev) => ({
...prev,
[rule.role]: (prev[rule.role] ?? []).map((v, idx) =>
idx === i ? val : v,
),
}))
}
options={(value) => memberOptions(rule.role, value)}
/>
))
) : (
<Alert color="gray">
No specialized cargo detected on this train no reefer, HAZMAT, break-bulk
or livestock crew is required.
</Alert>
)}
</Stack>
</Stepper.Step>
<Stepper.Completed>
<Stack gap="md" mt="lg">
<Card withBorder padding="lg">
<Text fw={600} mb="sm">
Composition checklist
</Text>
{validation?.complete ? (
<Group gap="xs">
<ThemeIcon size={22} radius="xl" color="green" variant="light">
<CheckCircle2 size={14} />
</ThemeIcon>
<Text size="sm">Every rule passes this train may be dispatched.</Text>
</Group>
) : (
<Stack gap="xs">
{validation?.issues.map((issue) => (
<Group key={`${issue.code}-${issue.message}`} gap="xs" wrap="nowrap">
<ThemeIcon size={22} radius="xl" color="orange" variant="light">
<AlertTriangle size={14} />
</ThemeIcon>
<Text size="sm">{issue.message}</Text>
</Group>
))}
</Stack>
)}
</Card>
{validation?.runType ? (
<Text size="sm" c="dimmed">
Derived run type:{" "}
<Text span fw={600}>
{validation.runType === "LONG_RUN" ? "Long run" : "Short run"}
</Text>
</Text>
) : null}
</Stack>
</Stepper.Completed>
</Stepper>
<Group justify="space-between" mt="xl">
<Button variant="light" disabled={step === 0} onClick={() => setStep((s) => s - 1)}>
Back
</Button>
<Button variant="light" disabled={step > 1} onClick={() => setStep((s) => s + 1)}>
Next
</Button>
</Group>
</PageContainer>
);
}
/** A crew block: add/remove rows freely, each naming one person. */
function SupportSection({
role,
icon,
color,
title,
hint,
alert,
values,
onCount,
onPick,
options,
}: {
role: TrainCrewRole;
icon: React.ReactNode;
color: string;
title: string;
hint: string;
alert?: string;
values: Array<string | null>;
onCount: (count: number) => void;
onPick: (index: number, value: string | null) => void;
options: (currentValue: string | null) => Array<{ value: string; label: string }>;
}) {
return (
<Card withBorder padding="lg">
<Group gap="sm" mb="md">
<ThemeIcon size={32} radius="md" variant="light" color={color}>
{icon}
</ThemeIcon>
<div>
<Text fw={600}>{title}</Text>
<Text size="xs" c="dimmed">
{hint}
</Text>
</div>
</Group>
{alert ? (
<Alert color="orange" icon={<AlertTriangle size={16} />} mb="md">
{alert}
</Alert>
) : null}
<Stack gap="sm">
{values.map((value, index) => (
<Group key={index} align="flex-end" wrap="nowrap">
<Select
label={`${trainCrewRoleLabel(role)} ${index + 1}`}
placeholder="Select a crew member"
searchable
clearable
data={options(value)}
value={value}
onChange={(val) => onPick(index, val)}
style={{ flex: 1 }}
/>
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove"
onClick={() => {
const next = values.filter((_, i) => i !== index);
onCount(next.length);
next.forEach((v, i) => onPick(i, v));
}}
>
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => onCount(values.length + 1)}
style={{ alignSelf: "flex-start" }}
>
Add {trainCrewRoleLabel(role)}
</Button>
</Stack>
</Card>
);
}

View File

@@ -42,6 +42,7 @@ import {
Ruler,
Send,
Train,
Unlock,
Weight,
Workflow as WorkflowIcon,
Warehouse,
@@ -77,6 +78,7 @@ import { StationWorkControls } from "@/components/trainScheduling/StationWorkCon
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
import { SwitchGovernmentBookingModal } from "@/components/trainScheduling/SwitchGovernmentBookingModal";
@@ -135,6 +137,8 @@ export default function TrainScheduleV2DetailPage() {
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
// Reopen a train whose booking shut only because of its close offset.
const [closeOffsetOpen, setCloseOffsetOpen] = useState(false);
const [mergeModalOpen, setMergeModalOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
@@ -1325,6 +1329,19 @@ export default function TrainScheduleV2DetailPage() {
}
action={
<Group gap="sm" wrap="nowrap">
{/* Booking shut only by the close offset — the one closed state
staff can undo here, so it gets a visible button. */}
{schedule.closeOffsetReopen?.eligible ? (
<Button
variant="filled"
color="edr-green"
size="compact-sm"
leftSection={<Unlock size={14} />}
onClick={() => setCloseOffsetOpen(true)}
>
Reduce close offset
</Button>
) : null}
{/* Merging rewrites the consist, so it is offered only while
the departure can still be edited. */}
{canEditBookings ? (
@@ -1441,6 +1458,15 @@ export default function TrainScheduleV2DetailPage() {
Window settings
</Menu.Item>
) : null}
{schedule.closeOffsetReopen?.eligible ? (
<Menu.Item
color="edr-green"
leftSection={<Unlock size={15} />}
onClick={() => setCloseOffsetOpen(true)}
>
Reopen booking (shorten close offset)
</Menu.Item>
) : null}
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item onClick={() => setMaintenanceOpen(true)}>
Reschedule train
@@ -1820,6 +1846,13 @@ export default function TrainScheduleV2DetailPage() {
onSaved={() => void detailQuery.refetch()}
/>
<ReduceCloseOffsetModal
scheduleId={scheduleId ?? null}
opened={closeOffsetOpen}
onClose={() => setCloseOffsetOpen(false)}
onSaved={() => void detailQuery.refetch()}
/>
<LoadEmptyContainersModal
opened={loadEmptiesOpen}
onClose={() => setLoadEmptiesOpen(false)}

View File

@@ -37,6 +37,8 @@ import {
Send,
Table2,
Train,
Unlock,
Users,
Weight,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -57,6 +59,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { directionColor, directionRowStyle } from "@/components/trainBuilder/trainStatus";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
import ReduceCloseOffsetModal from "@/components/trainScheduling/ReduceCloseOffsetModal";
import CreateScheduleWindowFields, {
buildWindowRulePayload,
type WindowFormState,
@@ -144,6 +147,9 @@ export default function TrainScheduleV2ListPage() {
const { viewMode, setViewMode } = useFleetViewMode("train-scheduling-v2");
const [createOpen, setCreateOpen] = useState(false);
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
// "Shorten close offset": reopens a train whose booking shut only because
// of its close offset (the API flags exactly those rows).
const [closeOffsetId, setCloseOffsetId] = useState<string | null>(null);
// Dispatch is irreversible from this screen, so it goes through an explicit
// confirmation.
const [dispatchTarget, setDispatchTarget] = useState<TrainScheduleListItem | null>(null);
@@ -372,12 +378,40 @@ export default function TrainScheduleV2ListPage() {
},
{
id: "actions",
size: 32,
size: 330,
meta: { headerClassName, cellClassName: `${cellClassName} whitespace-nowrap` },
cell: ({ row }) => {
const schedule = row.original;
return (
<Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
<Group gap="xs" justify="flex-end" wrap="nowrap" onClick={(e) => e.stopPropagation()}>
{/* Booking shut only by the close offset: a visible button, since
this is the one closed state staff can fix from the board. */}
{schedule.closeOffsetReopen?.eligible ? (
<Button
variant="filled"
color="edr-green"
size="xs"
radius="md"
leftSection={<Unlock size={14} />}
onClick={() => setCloseOffsetId(schedule.id)}
>
Reduce offset
</Button>
) : null}
<Button
variant="light"
color="indigo"
size="xs"
radius="md"
leftSection={<Users size={14} />}
onClick={() =>
navigate(
`/dashboard/operations/train-scheduling-v2/${schedule.id}/crew`,
)
}
>
Assign Train Crew
</Button>
<Menu position="bottom-end" withinPortal shadow="md" width={190}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Row actions">
@@ -419,6 +453,15 @@ export default function TrainScheduleV2ListPage() {
Booking window settings
</Menu.Item>
) : null}
{schedule.closeOffsetReopen?.eligible ? (
<Menu.Item
color="edr-green"
leftSection={<Unlock size={15} />}
onClick={() => setCloseOffsetId(schedule.id)}
>
Reopen booking (shorten close offset)
</Menu.Item>
) : null}
{/* Start the run. Same transition as the detail page's
Dispatch button — that page also shows unassigned-wagon
and not-loaded warnings, so it stays the fuller surface. */}
@@ -639,6 +682,9 @@ export default function TrainScheduleV2ListPage() {
onTrack={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/track`)
}
onAssignCrew={() =>
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}/crew`)
}
/>
))}
</SimpleGrid>
@@ -787,6 +833,13 @@ export default function TrainScheduleV2ListPage() {
onSaved={() => void schedulesQuery.refetch()}
/>
<ReduceCloseOffsetModal
scheduleId={closeOffsetId}
opened={closeOffsetId != null}
onClose={() => setCloseOffsetId(null)}
onSaved={() => void schedulesQuery.refetch()}
/>
<EditScheduleDateModal
scheduleId={editDateSchedule?.id ?? null}
currentDate={editDateSchedule?.scheduleDate ?? null}
@@ -1053,10 +1106,12 @@ function ScheduleCard({
schedule,
onOpen,
onTrack,
onAssignCrew,
}: {
schedule: TrainScheduleListItem;
onOpen: () => void;
onTrack: () => void;
onAssignCrew: () => void;
}) {
const { day, time } = splitDate(schedule.scheduleDate);
const canTrack = ["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -1153,6 +1208,19 @@ function ScheduleCard({
Track
</Button>
) : null}
<Button
variant="light"
color="indigo"
size="sm"
radius="md"
leftSection={<Users size={15} />}
onClick={(e) => {
e.stopPropagation();
onAssignCrew();
}}
>
Assign Train Crew
</Button>
</Group>
</Stack>
</Card>

View File

@@ -29,9 +29,14 @@ import {
ArrowUp,
ChartColumn,
ChevronRight,
CircleCheck,
List,
MapPin,
PauseCircle,
Search,
TrainFront,
Wrench,
type LucideIcon,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
@@ -48,6 +53,17 @@ import {
} from "./wagonPerformance";
import { downloadSheet, downloadSheets } from "./exportSection";
import { SectionExportButton } from "./SectionExportButton";
import {
CardHeader,
ColumnChart,
LegendKey,
LegendRow,
SplitBar,
StackedBar,
formatCount,
toneVar,
} from "./chartKit";
import "./wagonPerformance.css";
const WINDOWS = [
{ value: "30", label: "30d" },
@@ -89,25 +105,78 @@ const IDLE_BUCKETS: Array<{
{ label: "46 d +", min: 46, max: Infinity, tone: "red" },
];
/**
* One headline figure.
*
* The tone rail down the left edge is the tile's status channel — it repeats
* what the value's colour already says, so severity survives for a reader who
* cannot separate the hues. `meter` is an optional share of the fleet, drawn
* on a track one step lighter than its own fill so the whole bar reads.
*/
const StatTile = ({
label,
value,
hint,
color,
tone = "gray",
icon: Icon,
meter,
}: {
label: string;
value: React.ReactNode;
hint: string;
color?: string;
tone?: string;
icon?: LucideIcon;
meter?: number;
}) => (
<Card withBorder radius="md" padding="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{label}
</Text>
<Text size="26px" fw={700} lh={1.1} mt={8} c={color}>
<Card
withBorder
radius="lg"
padding="md"
pl="lg"
className="wp-stat-tile"
style={{ position: "relative", overflow: "hidden" }}
>
<Box
style={{
position: "absolute",
insetBlock: 0,
left: 0,
width: 3,
background: toneVar(tone),
}}
/>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Text size="xs" c="dimmed" tt="uppercase" fw={600} lh={1.3}>
{label}
</Text>
{Icon ? <Icon size={15} strokeWidth={2} color={toneVar(tone)} /> : null}
</Group>
<Text size="30px" fw={700} lh={1.05} mt={10} c={color}>
{value}
</Text>
<Text size="xs" c="dimmed" mt={6} lh={1.35}>
{meter == null ? null : (
<Box
mt={12}
h={4}
style={{
borderRadius: 999,
background: toneVar(tone, 1),
overflow: "hidden",
}}
>
<Box
h="100%"
w={`${Math.min(100, Math.max(0, meter))}%`}
style={{ borderRadius: 999, background: toneVar(tone) }}
/>
</Box>
)}
<Text size="xs" c="dimmed" mt={meter == null ? 8 : 8} lh={1.35}>
{hint}
</Text>
</Card>
@@ -140,7 +209,13 @@ const SortHeader = ({
</UnstyledButton>
);
/** A short ranked list — the "best / worst" boards. */
/**
* A short ranked list — the "best / worst" boards.
*
* Each row carries a hairline bar scaled against the board's own leader, so
* the shape of the ranking (a runaway top wagon, or a flat field) is visible
* without reading every figure. Rows are buttons: they open the wagon.
*/
const Leaderboard = ({
title,
subtitle,
@@ -151,69 +226,114 @@ const Leaderboard = ({
title: string;
subtitle: string;
accent: string;
rows: Array<{ id: string; number: string; note: string; value: string }>;
rows: Array<{
id: string;
number: string;
note: string;
value: string;
weight?: number;
}>;
onOpen: (id: string) => void;
}) => (
<Card withBorder radius="md" padding={0}>
<Box
p="md"
pb="sm"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap={8} wrap="nowrap">
<Box
w={7}
h={7}
style={{
borderRadius: 2,
background: `var(--mantine-color-${accent}-6)`,
}}
/>
}) => {
const peak = Math.max(1, ...rows.map((r) => r.weight ?? 0));
return (
<Card withBorder radius="lg" padding={0} style={{ overflow: "hidden" }}>
<Box style={{ height: 3, background: toneVar(accent) }} />
<Box
p="md"
pb="sm"
style={{ borderBottom: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Text fw={600} size="sm">
{title}
</Text>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
</Box>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
Nothing to rank yet.
</Text>
) : (
<Stack gap={0} py={4}>
{rows.map((r, i) => (
<UnstyledButton
key={r.id}
onClick={() => onOpen(r.id)}
px="md"
py={9}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="xs" fw={600} c="dimmed" w={14}>
{i + 1}
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
</Box>
{rows.length === 0 ? (
<Text size="sm" c="dimmed" py="xl" ta="center">
Nothing to rank yet.
</Text>
) : (
<Stack gap={0} py={4}>
{rows.map((r, i) => (
<UnstyledButton
key={r.id}
onClick={() => onOpen(r.id)}
px="md"
py={10}
className="wp-rank-row"
>
<Group justify="space-between" wrap="nowrap" gap="sm">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
{/* Medallion: the top three carry the board's own tone. */}
<Center
w={20}
h={20}
style={{
flexShrink: 0,
borderRadius: 6,
background:
i < 3
? toneVar(accent, 0)
: "var(--mantine-color-edr-slate-soft-0)",
}}
>
<Text
size="10px"
fw={700}
c={i < 3 ? `${accent}.8` : "dimmed"}
lh={1}
>
{i + 1}
</Text>
</Center>
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{r.number}
</Text>
<Text size="xs" c="dimmed" truncate>
{r.note}
</Text>
</div>
</Group>
<Text
size="sm"
fw={700}
style={{
whiteSpace: "nowrap",
fontVariantNumeric: "tabular-nums",
}}
>
{r.value}
</Text>
<div style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{r.number}
</Text>
<Text size="xs" c="dimmed" truncate>
{r.note}
</Text>
</div>
</Group>
<Text size="sm" fw={700} style={{ whiteSpace: "nowrap" }}>
{r.value}
</Text>
</Group>
</UnstyledButton>
))}
</Stack>
)}
</Card>
);
{r.weight == null ? null : (
<Box
mt={8}
ml={30}
h={3}
style={{
borderRadius: 999,
background: "var(--mantine-color-edr-divider-0)",
}}
>
<Box
h="100%"
w={`${Math.max(2, (r.weight / peak) * 100)}%`}
style={{ borderRadius: 999, background: toneVar(accent) }}
/>
</Box>
)}
</UnstyledButton>
))}
</Stack>
)}
</Card>
);
};
/**
* Wagon performance — the executive report on how the wagon fleet is earning
@@ -355,6 +475,9 @@ const WagonPerformancePage = () => {
.sort((a, b) => b.wagons - a.wagons);
}, [wagons]);
/** Busiest yard — the scale every yard's share bar is drawn against. */
const yardPeak = Math.max(1, ...byYard.map((y) => y.wagons));
/** Which classes of stock earn, and which sit. */
const byType = useMemo(() => {
const rows = new Map<
@@ -425,6 +548,7 @@ const WagonPerformancePage = () => {
number: w.wagonNumber,
note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`,
value: `${w.loadsInWindow ?? 0} loads`,
weight: w.loadsInWindow ?? 0,
})),
stranded: withIdle
.sort((a, b) => b.idle - a.idle)
@@ -434,6 +558,7 @@ const WagonPerformancePage = () => {
number: w.wagonNumber,
note: `${w.wagonType?.code ?? "—"} · ${yardOf(w)}`,
value: `${idle} days`,
weight: idle,
})),
idle: [...wagons]
.filter((w) => (w.movesInWindow ?? 0) === 0)
@@ -721,12 +846,17 @@ const WagonPerformancePage = () => {
<SimpleGrid cols={{ base: 1, sm: 2, lg: 5 }} spacing="md">
<StatTile
label="Fleet size"
value={kpis.total}
value={formatCount(kpis.total)}
hint="Wagons on the register"
tone="edr-slate"
icon={TrainFront}
/>
<StatTile
label="In service"
value={kpis.inService}
value={formatCount(kpis.inService)}
tone="edr-green"
icon={CircleCheck}
meter={kpis.total > 0 ? (kpis.inService / kpis.total) * 100 : 0}
hint={
kpis.total > 0
? `${Math.round((kpis.inService / kpis.total) * 100)}% of the fleet`
@@ -735,20 +865,28 @@ const WagonPerformancePage = () => {
/>
<StatTile
label={`Idle over ${IDLE_THRESHOLD_DAYS}d`}
value={kpis.stranded}
value={formatCount(kpis.stranded)}
hint="No movement in the current yard"
color={kpis.stranded > 0 ? "red" : undefined}
tone={kpis.stranded > 0 ? "red" : "edr-slate"}
icon={PauseCircle}
meter={kpis.total > 0 ? (kpis.stranded / kpis.total) * 100 : 0}
/>
<StatTile
label="Off roster"
value={kpis.offRoster}
value={formatCount(kpis.offRoster)}
hint="Maintenance, detained or withdrawn"
color={kpis.offRoster > 0 ? "yellow.8" : undefined}
tone={kpis.offRoster > 0 ? "yellow" : "edr-slate"}
icon={Wrench}
meter={kpis.total > 0 ? (kpis.offRoster / kpis.total) * 100 : 0}
/>
<StatTile
label="Loads · moves"
value={kpis.loads}
hint={`${kpis.moves} moves · mean ${kpis.meanLoads} loads per wagon, ${windowLabel}`}
value={formatCount(kpis.loads)}
tone="edr-blue"
icon={ChartColumn}
hint={`${formatCount(kpis.moves)} moves · mean ${kpis.meanLoads} loads per wagon, ${windowLabel}`}
/>
</SimpleGrid>
@@ -776,136 +914,88 @@ const WagonPerformancePage = () => {
<Stack gap="lg">
{/* ── Status mix + idle distribution ───────────── */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Card withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>Status mix</Text>
<SectionExportButton
label="status mix"
onExport={exportStatusMix}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
{kpis.total} wagons on the register
</Text>
<Card withBorder radius="lg" padding="lg">
<CardHeader
title="Status mix"
subtitle={`${kpis.total} wagons on the register`}
action={
<SectionExportButton
label="status mix"
onExport={exportStatusMix}
/>
}
/>
{statusMix.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
<Text size="sm" c="dimmed" py="xl" ta="center">
No wagons registered.
</Text>
) : (
<>
<Progress.Root size="lg" radius="xl" mt="md" mb="md">
<Box mt="lg" mb="lg">
<StackedBar
height={14}
unit="wagons"
segments={statusMix.map((s) => ({
key: s.status,
label: s.label,
value: s.count,
pct: s.pct,
tone: s.color,
}))}
/>
</Box>
<Stack gap={11}>
{statusMix.map((s) => (
<Progress.Section
<LegendRow
key={s.status}
value={s.pct}
color={s.color}
tone={s.color}
label={s.label}
value={s.count}
pct={s.pct}
/>
))}
</Progress.Root>
<Stack gap={9}>
{statusMix.map((s) => (
<Group
key={s.status}
justify="space-between"
gap="sm"
>
<Group gap={9} wrap="nowrap">
<Box
w={9}
h={9}
style={{
borderRadius: 3,
background: `var(--mantine-color-${s.color}-6)`,
}}
/>
<Text size="sm">{s.label}</Text>
</Group>
<Group gap="sm">
<Text size="sm" fw={700}>
{s.count}
</Text>
<Text size="xs" c="dimmed" w={34} ta="right">
{s.pct}%
</Text>
</Group>
</Group>
))}
</Stack>
</>
)}
</Card>
<Card withBorder radius="md" padding="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>Idle-day distribution</Text>
<SectionExportButton
label="idle distribution"
onExport={exportIdleDistribution}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Wagons by days without movement in their current yard
</Text>
<Group
align="flex-end"
gap="md"
h={170}
mt="lg"
wrap="nowrap"
>
{idleDistribution.map((b) => (
<Stack
key={b.label}
gap={6}
align="center"
justify="flex-end"
h="100%"
style={{ flex: 1 }}
>
<Text
size="xs"
fw={700}
c={
b.tone === "red"
? "red"
: b.tone === "yellow"
? "yellow.8"
: undefined
}
>
{b.count}
</Text>
<Box
w="100%"
h={`${Math.max(3, b.pct)}%`}
style={{
background: `var(--mantine-color-${b.tone}-6)`,
borderRadius: "5px 5px 0 0",
minHeight: 3,
}}
/>
<Text
size="xs"
c="dimmed"
style={{ whiteSpace: "nowrap" }}
>
{b.label}
</Text>
</Stack>
))}
<Card withBorder radius="lg" padding="lg">
<CardHeader
title="Idle-day distribution"
subtitle="Wagons by days without movement in their current yard"
action={
<SectionExportButton
label="idle distribution"
onExport={exportIdleDistribution}
/>
}
/>
{/* The bar colours are a severity scale, not identity, so
the key names the bands rather than each bucket. */}
<Group gap="lg" mt="sm">
<LegendKey tone="edr-green" label="Healthy" />
<LegendKey tone="yellow" label="Watch" />
<LegendKey tone="red" label="Stranded" />
</Group>
<ColumnChart data={idleDistribution} />
<Text
size="xs"
c="dimmed"
mt="md"
mt="lg"
pt="sm"
style={{
borderTop:
"1px solid var(--mantine-color-edr-divider-0)",
}}
>
<strong>{kpis.stranded}</strong> wagons have sat over{" "}
{IDLE_THRESHOLD_DAYS} days
<Text
span
fw={700}
c={kpis.stranded > 0 ? "red" : undefined}
>
{kpis.stranded}
</Text>{" "}
wagons have sat over {IDLE_THRESHOLD_DAYS} days
{kpis.total > 0
? `${Math.round((kpis.stranded / kpis.total) * 100)}% of the fleet locked up`
: ""}
@@ -947,29 +1037,30 @@ const WagonPerformancePage = () => {
</SimpleGrid>
{/* ── By yard ──────────────────────────────────── */}
<Card withBorder radius="md" padding={0}>
<Box p="md">
<Group justify="space-between" wrap="nowrap">
<Text fw={600}>By yard</Text>
<SectionExportButton
label="by-yard"
onExport={exportByYard}
/>
</Group>
<Text size="xs" c="dimmed" mt={4}>
Where the fleet is parked and how long it stays
</Text>
<Card withBorder radius="lg" padding={0}>
<Box p="lg" pb="md">
<CardHeader
title="By yard"
subtitle="Where the fleet is parked and how long it stays"
action={
<SectionExportButton
label="by-yard"
onExport={exportByYard}
/>
}
/>
</Box>
{byYard.length === 0 ? (
<Text size="sm" c="dimmed" py="lg" ta="center">
No wagons to group.
</Text>
) : (
<Table.ScrollContainer minWidth={640}>
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="sm" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Yard</Table.Th>
<Table.Th w={200}>Share of fleet</Table.Th>
<Table.Th w={110} ta="right">
Wagons
</Table.Th>
@@ -985,12 +1076,60 @@ const WagonPerformancePage = () => {
{byYard.map((y) => (
<Table.Tr key={y.label}>
<Table.Td>
<Text size="sm" fw={600}>
{y.label}
</Text>
<Group gap={8} wrap="nowrap">
<MapPin
size={13}
color="var(--mantine-color-edr-muted-0)"
style={{ flexShrink: 0 }}
/>
<Text size="sm" fw={600}>
{y.label}
</Text>
</Group>
</Table.Td>
<Table.Td>
{/* Bar is scaled against the busiest yard, so
the biggest one always fills the track. */}
<Tooltip
withArrow
label={`${y.wagons} wagons · ${
kpis.total > 0
? Math.round(
(y.wagons / kpis.total) * 100,
)
: 0
}% of the fleet`}
>
<Box
h={6}
style={{
borderRadius: 999,
background:
"var(--mantine-color-edr-divider-0)",
}}
>
<Box
h="100%"
w={`${Math.max(
2,
(y.wagons / yardPeak) * 100,
)}%`}
style={{
borderRadius: 999,
background: toneVar("edr-blue"),
}}
/>
</Box>
</Tooltip>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={600}>
<Text
size="sm"
fw={600}
style={{
fontVariantNumeric: "tabular-nums",
}}
>
{y.wagons}
</Text>
</Table.Td>
@@ -1029,8 +1168,14 @@ const WagonPerformancePage = () => {
</Card>
{/* ── By wagon type ────────────────────────────── */}
<Card withBorder radius="md" padding={0}>
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
<Card withBorder radius="lg" padding={0}>
<Group
p="lg"
pb="md"
justify="space-between"
wrap="wrap"
gap="sm"
>
<div>
<Text fw={600}>By wagon type</Text>
<Text size="xs" c="dimmed" mt={4}>
@@ -1038,33 +1183,9 @@ const WagonPerformancePage = () => {
stuck
</Text>
</div>
<Group gap="md">
<Group gap={6}>
<Box
w={11}
h={5}
style={{
borderRadius: 2,
background: "var(--mantine-color-edr-green-6)",
}}
/>
<Text size="xs" c="dimmed">
Loaded
</Text>
</Group>
<Group gap={6}>
<Box
w={11}
h={5}
style={{
borderRadius: 2,
background: "var(--mantine-color-teal-4)",
}}
/>
<Text size="xs" c="dimmed">
Empty
</Text>
</Group>
<Group gap="lg">
<LegendKey tone="edr-green" label="Loaded" />
<LegendKey tone="teal.3" label="Empty" />
<SectionExportButton
label="by-type"
onExport={exportByType}
@@ -1122,21 +1243,23 @@ const WagonPerformancePage = () => {
</Table.Td>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<Progress.Root
size="sm"
radius="xl"
style={{ flex: 1 }}
<Box style={{ flex: 1 }}>
<SplitBar
primaryPct={t.loadedPct}
secondaryPct={t.emptyPct}
primaryLabel="Loaded"
secondaryLabel="Empty"
/>
</Box>
<Text
size="xs"
fw={600}
w={34}
ta="right"
style={{
fontVariantNumeric: "tabular-nums",
}}
>
<Progress.Section
value={t.loadedPct}
color="edr-green"
/>
<Progress.Section
value={t.emptyPct}
color="teal.4"
/>
</Progress.Root>
<Text size="xs" fw={600} w={34} ta="right">
{t.loadedPct}%
</Text>
</Group>
@@ -1491,7 +1614,15 @@ const WagonPerformancePage = () => {
}
w={72}
/>
<Text size="sm" fw={600} w={38} ta="right">
<Text
size="sm"
fw={600}
w={38}
ta="right"
style={{
fontVariantNumeric: "tabular-nums",
}}
>
{share}%
</Text>
</Group>

View File

@@ -0,0 +1,380 @@
/**
* Presentation primitives for the wagon performance report.
*
* Pure display — every one of these takes numbers that are already derived
* and draws them. Kept apart from the page so the report's markup stays about
* what is being said, not about how a bar is rounded.
*
* House rules these encode (so charts across the report agree):
* · columns cap at 28px and never fill their slot — the leftover is air;
* · a data-end is rounded 4px, the baseline end stays square;
* · touching fills are separated by a 2px gap in the surface colour, never
* by a border — ink that is not data;
* · text wears text tokens; the colour lives on the mark beside it.
*/
import type { ReactNode } from "react";
import { Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import "./wagonPerformance.css";
/**
* Thousands-separated count. Fleet figures run into four digits, and `1284`
* is read as a code rather than a quantity.
*/
export const formatCount = (n: number): string => n.toLocaleString("en-US");
/** One-step-off-surface hairline, for gridlines and baselines. */
export const GRID_LINE = "var(--mantine-color-edr-divider-0)";
/** Resolve a Mantine colour name (`edr-green`, `red.6`) to a CSS variable. */
export const toneVar = (tone: string, fallbackShade = 6): string => {
const [name, shade] = tone.split(".");
return `var(--mantine-color-${name}-${shade ?? fallbackShade})`;
};
/* ────────────────────────────────────────────────────────────────────────── */
export interface SparkBarDatum {
label: string;
count: number;
/** Bar height as a share of the tallest bar, 0100. */
pct: number;
tone: string;
}
/**
* Column chart for a bucketed distribution.
*
* Bars sit on a real baseline with three recessive gridlines behind them, so
* a reader can judge a middle bar against a neighbour instead of guessing.
* Only the tallest column keeps a permanent value label; the rest carry theirs
* in the hover tooltip, because a number over every column stops being read.
*/
export const ColumnChart = ({
data,
height = 190,
unit = "wagons",
}: {
data: SparkBarDatum[];
height?: number;
unit?: string;
}) => {
const peak = Math.max(...data.map((d) => d.count), 0);
return (
<Box mt="lg">
<Box style={{ position: "relative", height, marginTop: 18 }}>
{/* Gridlines at the peak and two even steps below it, behind the
bars. The peak line doubles as the chart's top edge, so the
tallest column reads as touching it rather than floating. */}
{[0, 1, 2].map((i) => (
<Box
key={i}
style={{
position: "absolute",
left: 0,
right: 0,
top: `${(i * 100) / 3}%`,
borderTop: `1px solid ${GRID_LINE}`,
pointerEvents: "none",
}}
/>
))}
<Group
align="flex-end"
gap="xs"
h="100%"
wrap="nowrap"
className="wp-col-chart"
style={{ position: "relative" }}
>
{data.map((d) => {
const isPeak = d.count === peak && peak > 0;
return (
<Tooltip
key={d.label}
withArrow
label={`${d.label} · ${d.count} ${unit}`}
>
<Stack
gap={0}
align="center"
justify="flex-end"
h="100%"
className="wp-col-slot"
style={{ flex: 1, cursor: "default" }}
>
{/* The label is absolutely positioned above its bar so it
never eats the bar's own height — otherwise the tallest
column can never reach the peak gridline. */}
<Box
w="100%"
maw={28}
h={`${Math.max(2, d.pct)}%`}
className="wp-col-bar"
style={{
position: "relative",
background: toneVar(d.tone),
borderRadius: "4px 4px 0 0",
minHeight: 2,
transition: "opacity 120ms ease",
}}
>
{isPeak ? (
<Text
size="xs"
fw={700}
lh={1}
ta="center"
style={{
position: "absolute",
left: "50%",
bottom: "100%",
transform: "translateX(-50%)",
marginBottom: 5,
}}
>
{d.count}
</Text>
) : null}
</Box>
</Stack>
</Tooltip>
);
})}
</Group>
</Box>
{/* Baseline: one weight heavier than the gridlines, so zero reads. */}
<Box
style={{ borderTop: `1px solid var(--mantine-color-edr-border-0)` }}
/>
<Group gap="xs" wrap="nowrap" mt={8}>
{data.map((d) => (
<Text
key={d.label}
size="xs"
c="dimmed"
ta="center"
style={{ flex: 1, whiteSpace: "nowrap" }}
>
{d.label}
</Text>
))}
</Group>
</Box>
);
};
/* ────────────────────────────────────────────────────────────────────────── */
export interface StackSegment {
key: string;
label: string;
value: number;
/** Segment width as a share of the whole, 0100. */
pct: number;
tone: string;
}
/**
* A single stacked proportion bar.
*
* Segments are separated by a 2px gap in the surface colour rather than a
* stroke, so neighbouring shades stay distinct without extra ink. Every
* segment is hoverable; none is labelled inline, since interior segments have
* no free end to label without clipping.
*/
export const StackedBar = ({
segments,
height = 12,
unit = "",
}: {
segments: StackSegment[];
height?: number;
unit?: string;
}) => (
<Group gap={2} wrap="nowrap" style={{ width: "100%" }}>
{segments
.filter((s) => s.value > 0)
.map((s, i, shown) => (
<Tooltip
key={s.key}
withArrow
label={`${s.label} · ${s.value}${unit ? ` ${unit}` : ""} (${s.pct}%)`}
>
<Box
h={height}
style={{
// Flex-grow by share, but never vanish: a 1-wagon status still
// needs a visible sliver to be hoverable.
flex: `${Math.max(s.pct, 0.5)} 1 0`,
minWidth: 3,
background: toneVar(s.tone),
borderRadius:
shown.length === 1
? 999
: i === 0
? "999px 2px 2px 999px"
: i === shown.length - 1
? "2px 999px 999px 2px"
: 2,
cursor: "default",
}}
/>
</Tooltip>
))}
</Group>
);
/* ────────────────────────────────────────────────────────────────────────── */
/**
* Two-tone split bar for a loaded / empty style mix, sized inside a table row.
* The unfilled remainder is a lighter step of the same ramp, so the whole
* track carries state rather than only the filled part.
*/
export const SplitBar = ({
primaryPct,
secondaryPct,
primaryTone = "edr-green",
secondaryTone = "teal.3",
primaryLabel,
secondaryLabel,
}: {
primaryPct: number;
secondaryPct: number;
primaryTone?: string;
secondaryTone?: string;
primaryLabel: string;
secondaryLabel: string;
}) => {
const both = primaryPct > 0 && secondaryPct > 0;
// A zero-value side is dropped entirely rather than shown as a sliver —
// a 1px nub of the wrong colour on a 100% bar reads as bad data.
return (
<Group gap={both ? 2 : 0} wrap="nowrap" style={{ width: "100%" }}>
{primaryPct > 0 ? (
<Tooltip withArrow label={`${primaryLabel} · ${primaryPct}%`}>
<Box
h={8}
style={{
flex: `${primaryPct} 1 0`,
minWidth: 3,
background: toneVar(primaryTone),
borderRadius: both ? "999px 2px 2px 999px" : 999,
cursor: "default",
}}
/>
</Tooltip>
) : null}
{secondaryPct > 0 ? (
<Tooltip withArrow label={`${secondaryLabel} · ${secondaryPct}%`}>
<Box
h={8}
style={{
flex: `${secondaryPct} 1 0`,
minWidth: 3,
background: toneVar(secondaryTone),
borderRadius: both ? "2px 999px 999px 2px" : 999,
cursor: "default",
}}
/>
</Tooltip>
) : null}
{/* Nothing moved at all — an empty track, so the row still has a shape. */}
{primaryPct === 0 && secondaryPct === 0 ? (
<Box
h={8}
style={{
flex: 1,
background: "var(--mantine-color-edr-divider-0)",
borderRadius: 999,
}}
/>
) : null}
</Group>
);
};
/* ────────────────────────────────────────────────────────────────────────── */
/** Legend swatch + label + value, the identity channel beside every chart. */
export const LegendRow = ({
tone,
label,
value,
pct,
}: {
tone: string;
label: string;
value: ReactNode;
pct?: number;
}) => (
<Group justify="space-between" gap="sm" wrap="nowrap">
<Group gap={9} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={8}
h={8}
style={{ borderRadius: 2, background: toneVar(tone), flexShrink: 0 }}
/>
<Text size="sm" truncate>
{label}
</Text>
</Group>
<Group gap="sm" wrap="nowrap">
<Text size="sm" fw={700} style={{ fontVariantNumeric: "tabular-nums" }}>
{value}
</Text>
{pct == null ? null : (
<Text
size="xs"
c="dimmed"
w={34}
ta="right"
style={{ fontVariantNumeric: "tabular-nums" }}
>
{pct}%
</Text>
)}
</Group>
</Group>
);
/** Small square colour key used in a card header's inline legend. */
export const LegendKey = ({ tone, label }: { tone: string; label: string }) => (
<Group gap={6} wrap="nowrap">
<Box
w={10}
h={10}
style={{ borderRadius: 2, background: toneVar(tone), flexShrink: 0 }}
/>
<Text size="xs" c="dimmed">
{label}
</Text>
</Group>
);
/** A card's title block: name, one line of context, and its own actions. */
export const CardHeader = ({
title,
subtitle,
action,
}: {
title: string;
subtitle?: string;
action?: ReactNode;
}) => (
<Group justify="space-between" wrap="nowrap" align="flex-start" gap="sm">
<div style={{ minWidth: 0 }}>
<Text fw={600}>{title}</Text>
{subtitle ? (
<Text size="xs" c="dimmed" mt={4}>
{subtitle}
</Text>
) : null}
</div>
{action}
</Group>
);

View File

@@ -0,0 +1,46 @@
/* ============================================================
Wagon performance report — hover affordances.
Only the states Mantine props cannot express live here. Everything
structural stays in the components; this file is purely "what changes
under the pointer".
============================================================ */
/* Leaderboard rows are buttons that open a wagon — they need to say so. */
.wp-rank-row {
border-radius: 8px;
transition:
background-color 120ms ease,
transform 120ms ease;
}
.wp-rank-row:hover {
background: var(--mantine-color-edr-slate-soft-0);
}
.wp-rank-row:active {
transform: scale(0.995);
}
.wp-rank-row:focus-visible {
outline: 2px solid var(--mantine-color-edr-green-5);
outline-offset: -2px;
}
/* Cards lift very slightly on hover — enough to read as a surface, not
enough to make a still page feel restless. */
.wp-stat-tile {
transition:
box-shadow 140ms ease,
border-color 140ms ease;
}
.wp-stat-tile:hover {
border-color: var(--mantine-color-edr-border-0);
box-shadow: 0 4px 14px rgba(16, 24, 40, 0.07);
}
/* Bars dim their neighbours on hover so the hovered one reads as selected. */
.wp-col-chart:hover .wp-col-bar {
opacity: 0.45;
}
.wp-col-chart .wp-col-bar:hover,
.wp-col-chart:hover .wp-col-slot:hover .wp-col-bar {
opacity: 1;
}

View File

@@ -91,6 +91,7 @@ import type {
TrainScheduleFilters,
TrainScheduleListFilters,
TrainScheduleListResponse,
ReduceScheduleCloseOffsetPayload,
UpdateScheduleWindowRulePayload,
TrainSchedulePreviewPayload,
TrainSchedulePreviewResponse,
@@ -713,6 +714,18 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
reduceScheduleCloseOffset: endpoint<
{ id: string; payload: ReduceScheduleCloseOffsetPayload },
TrainScheduleDetail
>(
"train-scheduling",
"reduce-schedule-close-offset",
({ id, payload }) =>
trainSchedulingService.reduceScheduleCloseOffset(id, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
updateScheduleDate: endpoint<
{ id: string; scheduleDate: string },
TrainScheduleDetail
@@ -3220,11 +3233,23 @@ export const api = {
bookingsService.reviewOperation(id, decision, { note }),
),
proceedToOperation: endpoint<
{ id: string; scheduledDate: string },
rescheduleOperation: endpoint<
{
id: string;
scheduledDate: string;
trainScheduleId?: string;
note?: string;
},
BookingDetail
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
bookingsService.proceedToOperation(id, scheduledDate),
>("bookings", "rescheduleOperation", ({ id, ...payload }) =>
bookingsService.rescheduleOperation(id, payload),
),
proceedToOperation: endpoint<
{ id: string; scheduledDate: string; trainScheduleId?: string },
BookingDetail
>("bookings", "proceedToOperation", ({ id, scheduledDate, trainScheduleId }) =>
bookingsService.proceedToOperation(id, scheduledDate, trainScheduleId),
),
generateContract: endpoint<{ id: string }, BookingDetail>(

View File

@@ -355,8 +355,21 @@ export const bookingsService = {
* customer path uses the same endpoint from the portal; GL needs it here
* because a customs booking is GL's to fix, not the customer's.
*/
proceedToOperation: (id: string, scheduledDate: string) =>
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
proceedToOperation: (id: string, scheduledDate: string, trainScheduleId?: string) =>
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), {
scheduledDate,
...(trainScheduleId ? { trainScheduleId } : {}),
}),
/**
* Operations changes the shipment day (and, for export rail, the train) of a
* pending operation request on the customer's behalf — the booking stays at
* OPERATION_REQUEST_PENDING for the normal accept.
*/
rescheduleOperation: (
id: string,
payload: { scheduledDate: string; trainScheduleId?: string; note?: string },
) => postBooking<BookingDetail>(B.OPERATION_RESCHEDULE(id), payload),
generateContract: (id: string) =>
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),

Some files were not shown because too many files have changed in this diff Show More