feat(train-crew): implement crew assignment functionality

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

View File

@@ -0,0 +1,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<TrainCrewAssignment>,
@InjectRepository(TrainCrewMember)
private readonly memberRepo: Repository<TrainCrewMember>,
private readonly dataSource: DataSource,
) {}
/** Every assignment on a schedule, with the roster member joined. */
async listForSchedule(scheduleId: string): Promise<TrainCrewAssignment[]> {
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<CrewDemandInput> {
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<CorridorContext> {
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<string, CorridorYard>(
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<CorridorYard[]> {
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<CrewValidationResult> {
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<void> {
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<TrainCrewMember[]> {
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',
});