Files
edr-platform/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts
marshalyordanos c5c8712c5b 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.
2026-09-06 10:29:22 +03:00

347 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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