mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 10:08:21 +00:00
- 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.
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
import { CrewDutyRole } from './entities/train-crew-assignment.entity';
|
||
import { TrainCrewRole } from './entities/train-crew-member.entity';
|
||
import {
|
||
AssignmentFacts,
|
||
CorridorContext,
|
||
CorridorYard,
|
||
CrewDemandInput,
|
||
legAllowsNationality,
|
||
overtimeHours,
|
||
specializedRequirements,
|
||
technicianRequirement,
|
||
validateCrewComposition,
|
||
} from './crew-composition.rules';
|
||
|
||
/**
|
||
* A slice of the real corridor, using the production display_order values:
|
||
* GMP 3, Feto 7, Meiso 10, Dire Dawa 12, Nagad 19.
|
||
*/
|
||
const YARD: Record<string, CorridorYard> = {
|
||
GMP: { id: 'y-gmp', label: 'GMP', country: 'Ethiopia', displayOrder: 3 },
|
||
FETO: { id: 'y-feto', label: 'Feto', country: 'Ethiopia', displayOrder: 7 },
|
||
MEISO: { id: 'y-meiso', label: 'Meiso', country: 'Ethiopia', displayOrder: 10 },
|
||
DIRE_DAWA: { id: 'y-dd', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 12 },
|
||
NAGAD: { id: 'y-nagad', label: 'Nagad', country: 'Djibouti', displayOrder: 19 },
|
||
};
|
||
|
||
const CORRIDOR: CorridorContext = {
|
||
yards: new Map(Object.values(YARD).map((y) => [y.id, y])),
|
||
originOrder: YARD.GMP.displayOrder,
|
||
destinationOrder: YARD.NAGAD.displayOrder,
|
||
direDawaOrder: YARD.DIRE_DAWA.displayOrder,
|
||
};
|
||
|
||
const NO_DEMAND: CrewDemandInput = {
|
||
hasBadOrderWagon: false,
|
||
badOrderWagonLabels: [],
|
||
hasReeferCargo: false,
|
||
reeferSources: [],
|
||
hasHazmatCargo: false,
|
||
hazmatSources: [],
|
||
hasBreakBulkCargo: false,
|
||
breakBulkSources: [],
|
||
hasLivestockCargo: false,
|
||
livestockSources: [],
|
||
};
|
||
|
||
let seq = 0;
|
||
const driver = (
|
||
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN',
|
||
from: CorridorYard,
|
||
to: CorridorYard,
|
||
dutyRole: CrewDutyRole,
|
||
): AssignmentFacts => ({
|
||
crewMemberId: `driver-${++seq}`,
|
||
role: TrainCrewRole.TRAIN_DRIVER,
|
||
dutyRole,
|
||
fromYardId: from.id,
|
||
toYardId: to.id,
|
||
nationality,
|
||
memberName: `Driver ${seq}`,
|
||
});
|
||
|
||
const crewOfRole = (role: TrainCrewRole, count: number): AssignmentFacts[] =>
|
||
Array.from({ length: count }, () => ({
|
||
crewMemberId: `member-${++seq}`,
|
||
role,
|
||
nationality: 'ETHIOPIAN',
|
||
memberName: `Member ${seq}`,
|
||
}));
|
||
|
||
const codes = (result: { issues: Array<{ code: string }> }) =>
|
||
result.issues.map((i) => i.code);
|
||
|
||
describe('crew composition rules (ITLMS Rolling Stock)', () => {
|
||
const validate = (
|
||
assignments: AssignmentFacts[],
|
||
demand: CrewDemandInput = NO_DEMAND,
|
||
) => validateCrewComposition(assignments, demand, CORRIDOR);
|
||
|
||
/** One Ethiopian Primary over the whole route — the minimum viable crew. */
|
||
const soloPrimary = () =>
|
||
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY);
|
||
|
||
describe('free-form crew sizing', () => {
|
||
it('accepts a single driver working the whole corridor', () => {
|
||
const result = validate([soloPrimary()]);
|
||
expect(result.issues).toEqual([]);
|
||
expect(result.complete).toBe(true);
|
||
});
|
||
|
||
it.each([1, 3, 4, 6, 8])('accepts a crew of %i drivers on one leg', (count) => {
|
||
const drivers = [
|
||
soloPrimary(),
|
||
...Array.from({ length: count - 1 }, () =>
|
||
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||
),
|
||
];
|
||
expect(validate(drivers).complete).toBe(true);
|
||
});
|
||
|
||
it('accepts any number of federal police, including none', () => {
|
||
for (const count of [0, 1, 4, 9]) {
|
||
const result = validate([
|
||
soloPrimary(),
|
||
...crewOfRole(TrainCrewRole.FEDERAL_POLICE, count),
|
||
]);
|
||
expect(result.complete).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('requires at least one driver', () => {
|
||
const result = validate(crewOfRole(TrainCrewRole.FEDERAL_POLICE, 4));
|
||
expect(codes(result)).toContain('DRIVER_COUNT');
|
||
});
|
||
});
|
||
|
||
describe('yard-to-yard legs', () => {
|
||
it('lets staff hand over at any intermediate yard', () => {
|
||
// Three legs the old fixed segments could not express: 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);
|
||
});
|
||
});
|
||
});
|