feat(train-crew): implement crew assignment functionality

- Added TrainCrewAssignment module with controller and service for managing crew assignments.
- Integrated TrainCrewAssignmentService into TrainSchedulingService to ensure crew readiness before train dispatch.
- Updated TrainScheduling module to include TrainCrewModule for dependency injection.
- Introduced new permissions for assigning train crew in freight permissions registry.
- Enhanced front-end ScheduleCrewPage to allow assignment of crew members to train schedules, including validation and UI for adding/removing drivers and support crew.
- Created trainCrewAssignment.service to handle API interactions for crew assignments.
This commit is contained in:
marshalyordanos
2026-09-06 10:29:22 +03:00
parent 63ccb060c6
commit c5c8712c5b
16 changed files with 2324 additions and 8 deletions

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
`);
}
}