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', });