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.
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
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',
|
|
});
|