mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 01:18:18 +00:00
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:
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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 Indode–Dire Dawa, Dire Dawa–Nagad
|
||||
* 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
|
||||
`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user