From c5c8712c5bb2c2186190570c129624a78b823722 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Sun, 6 Sep 2026 10:29:22 +0300 Subject: [PATCH 01/22] 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. --- .../3860000000000-TrainCrewAssignments.ts | 88 +++ .../3870000000000-DropCrewingCase.ts | 39 ++ .../migrations/3880000000000-CrewLegYards.ts | 91 +++ .../train-crew/crew-composition.rules.spec.ts | 346 ++++++++++ .../train-crew/crew-composition.rules.ts | 437 +++++++++++++ .../dto/save-crew-assignments.dto.ts | 45 ++ .../entities/train-crew-assignment.entity.ts | 114 ++++ .../train-crew-assignment.controller.ts | 53 ++ .../train-crew-assignment.service.ts | 389 ++++++++++++ .../modules/train-crew/train-crew.module.ts | 11 +- .../services/train-scheduling.service.ts | 9 + .../train-scheduling.module.ts | 2 + .../src/seed/freight-permissions.registry.ts | 6 + .../backoffice/src/lib/permissions.ts | 1 + .../trainScheduling/ScheduleCrewPage.tsx | 597 +++++++++++++++++- .../services/trainCrewAssignment.service.ts | 104 +++ 16 files changed, 2324 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3860000000000-TrainCrewAssignments.ts create mode 100644 apps/edr-freight-api/src/migrations/3870000000000-DropCrewingCase.ts create mode 100644 apps/edr-freight-api/src/migrations/3880000000000-CrewLegYards.ts create mode 100644 apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.ts create mode 100644 apps/edr-freight-api/src/modules/train-crew/dto/save-crew-assignments.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-crew/entities/train-crew-assignment.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.controller.ts create mode 100644 apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/trainCrewAssignment.service.ts diff --git a/apps/edr-freight-api/src/migrations/3860000000000-TrainCrewAssignments.ts b/apps/edr-freight-api/src/migrations/3860000000000-TrainCrewAssignments.ts new file mode 100644 index 000000000..04dba29ba --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3860000000000-TrainCrewAssignments.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3870000000000-DropCrewingCase.ts b/apps/edr-freight-api/src/migrations/3870000000000-DropCrewingCase.ts new file mode 100644 index 000000000..2feaef0b4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3870000000000-DropCrewingCase.ts @@ -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 { + 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 { + 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') + ) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3880000000000-CrewLegYards.ts b/apps/edr-freight-api/src/migrations/3880000000000-CrewLegYards.ts new file mode 100644 index 000000000..37607802d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3880000000000-CrewLegYards.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts new file mode 100644 index 000000000..8b589ef65 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts @@ -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 = { + 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: GMP–Feto, + // Feto–Meiso, Meiso–Nagad. + 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); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.ts b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.ts new file mode 100644 index 000000000..9c2a04838 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.ts @@ -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; + 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(); + 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(); + 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(); + 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(); + 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, +): '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, + }; +}; diff --git a/apps/edr-freight-api/src/modules/train-crew/dto/save-crew-assignments.dto.ts b/apps/edr-freight-api/src/modules/train-crew/dto/save-crew-assignments.dto.ts new file mode 100644 index 000000000..4938f6260 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/dto/save-crew-assignments.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-assignment.entity.ts b/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-assignment.entity.ts new file mode 100644 index 000000000..3591a45a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-assignment.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.controller.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.controller.ts new file mode 100644 index 000000000..97e012a5a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.controller.ts @@ -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); + } +} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.service.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.service.ts new file mode 100644 index 000000000..df1c1321f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.service.ts @@ -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, + @InjectRepository(TrainCrewMember) + private readonly memberRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + /** Every assignment on a schedule, with the roster member joined. */ + async listForSchedule(scheduleId: string): Promise { + 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 { + 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 { + 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( + 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 { + 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 { + 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 { + 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 { + 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', +}); diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts index e242204c3..8e9d01da9 100644 --- a/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts @@ -1,14 +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])], - providers: [TrainCrewService], - controllers: [TrainCrewController], - exports: [TrainCrewService], + imports: [TypeOrmModule.forFeature([TrainCrewMember, TrainCrewAssignment])], + providers: [TrainCrewService, TrainCrewAssignmentService], + controllers: [TrainCrewController, TrainCrewAssignmentController], + exports: [TrainCrewService, TrainCrewAssignmentService], }) export class TrainCrewModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 03d75da18..4d8a5ea34 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -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'; @@ -449,6 +450,9 @@ export class TrainSchedulingService { // Trailing + @Optional so the positional constructors in the existing specs // keep working; production always resolves it from ExportsModule. @Optional() private readonly tabularExport?: TabularExportService, + // Crew composition gate (ITLMS Rolling Stock §1.2 "prior to departure"). + // Trailing + @Optional for the same positional-spec reason as above. + @Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService, ) {} /** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */ @@ -3027,6 +3031,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'); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index fdbc504b5..69bbbc965 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -17,6 +17,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'; @@ -76,6 +77,7 @@ import { ContractsModule } from '../contracts/contracts.module'; forwardRef(() => WarehousesModule), RuleEngineModule, forwardRef(() => ContractsModule), + TrainCrewModule, ], controllers: [TrainSchedulingController], providers: [ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0292fd7c5..020d093ad 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1531,6 +1531,11 @@ export const TRAIN_CREW_PERMISSIONS: FreightPermissionSeed[] = [ "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) @@ -2364,6 +2369,7 @@ export const FREIGHT_PERMS = { 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", diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index bcf6e021c..4cf7f0c79 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -271,6 +271,7 @@ export const FREIGHT_PERMS = { 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", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/ScheduleCrewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/ScheduleCrewPage.tsx index dad6db35f..cc6eb6bfb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/ScheduleCrewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/ScheduleCrewPage.tsx @@ -1,23 +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"; /** - * Train crew assignment for one schedule. + * 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. * - * Intentionally blank: the assignment rules — crew counts per role, the driver - * pairing cases, and which corridor segment each driver covers — are still to - * be specified, so only the route and header exist so far. + * 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([]); + /** Support and specialist picks, keyed by role. */ + const [supportIds, setSupportIds] = useState>>({}); + + 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> = {}; + 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(); + 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) => + 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 ( + + + + + + ); + } + + const demand = crew?.demand; + const specialized = crew?.requirements.specialized ?? []; + const technicianRule = crew?.requirements.technician; + const validation = crew?.validation; return ( : + } + > + {validation.complete ? "Ready to dispatch" : "Incomplete"} + + ) : null + } + action={ + canAssign ? ( + + ) : null + } /> + + + + + + 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. + + + {drivers.length === 0 ? ( + No drivers added yet. + ) : ( + drivers.map((row, index) => ( + + + + + + + + Driver {index + 1} + + + + setDrivers((prev) => prev.filter((d) => d.key !== row.key)) + } + > + + + + + setDriver(row.key, { toYardId: val })} + /> + setDriver(row.key, { crewMemberId: val })} + /> + + + )) + )} + + + + + + + + } + 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)} + /> + + } + 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) => ( + } + 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)} + /> + )) + ) : ( + + No specialized cargo detected on this train — no reefer, HAZMAT, break-bulk + or livestock crew is required. + + )} + + + + + + + + Composition checklist + + {validation?.complete ? ( + + + + + Every rule passes — this train may be dispatched. + + ) : ( + + {validation?.issues.map((issue) => ( + + + + + {issue.message} + + ))} + + )} + + {validation?.runType ? ( + + Derived run type:{" "} + + {validation.runType === "LONG_RUN" ? "Long run" : "Short run"} + + + ) : null} + + + + + + + + ); } + +/** 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; + onCount: (count: number) => void; + onPick: (index: number, value: string | null) => void; + options: (currentValue: string | null) => Array<{ value: string; label: string }>; +}) { + return ( + + + + {icon} + +
+ {title} + + {hint} + +
+
+ + {alert ? ( + } mb="md"> + {alert} + + ) : null} + + + {values.map((value, index) => ( + +