Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts
Hagernesh a5973de6e1 feat(intercity): a facility only handles the cargo its equipment can lift
Containers need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa
take them. Bulk needs far less and is handled at all five facilities. Having a
facility was previously enough to load anything, so a container booking through
Sebeta or Adama would have been accepted and then had nothing to lift it.

- yard_facilities gains handles_container / handles_bulk, both defaulting true so
  a facility handles everything unless told otherwise; the seeder states the real
  capability.
- The intercity gate now refuses cargo a facility cannot lift, saying which type,
  not just "no facility". canHandleFreight keeps that rule in the resolver so
  callers cannot get it subtly wrong.
- The intercity list resolves each end against the booking's own freight type, so
  the view flags a container booking routed through a bulk-only yard while the
  train is still coming rather than when the load is refused.

Import/export untouched — the gate is still DOMESTIC-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 07:13:51 +00:00

432 lines
18 KiB
TypeScript

import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingBatchService } from './booking-batch.service';
import { BookingJourneyService } from './booking-journey.service';
/**
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
* train — they ride a passing import/export schedule whose route milestones
* contain the booking's origin strictly before its destination.
*
* Flow: the customer books a corridor with no date; at finalize time staff see
* every waiting intercity booking whose corridor lies on the schedule's route,
* with its wagon/weight/length need against the train's remaining capacity;
* accepting reserves it (pay window → payment → allocation, same lifecycle as
* a batch reservation). Cargo is loaded manually when the train reaches the
* booking's origin yard and unloaded at its destination yard.
*/
@Injectable()
export class IntercityService {
private readonly logger = new Logger(IntercityService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly bookingBatchService: BookingBatchService,
private readonly bookingJourneyService: BookingJourneyService,
) {}
/**
* Waiting intercity bookings this schedule could carry, with the train's
* remaining capacity along all three axes (wagons, weight, length) and each
* booking's need, so staff can pick what fits.
*/
/**
* Every intercity booking and where it is in its ride-along, across all trains.
*
* The per-schedule candidate list answers "what can THIS train carry"; this
* answers "what is happening to intercity cargo" — which is what a yard
* operator needs when the work is spread over whichever trains happen to pass.
*
* Carries each end's facility state, because a booking whose origin or
* destination has no facility can never be loaded or unloaded there and the
* operator should see that before the train arrives, not when the load is
* refused.
*/
async listBookings() {
return this.dataSource.query(
`SELECT b.id AS "bookingId",
b.reference AS "reference",
b.status AS "status",
b.freight_type AS "freightType",
b.cargo_total_weight_vgm AS "weightTons",
b.loaded_at AS "loadedAt",
b.arrived_at AS "arrivedAt",
company.name AS "customer",
b.train_schedule_id AS "trainScheduleId",
ts.train_number AS "trainNumber",
ts.status AS "scheduleStatus",
oy.id AS "originYardId",
COALESCE(oy.label, oy.code) AS "origin",
-- Can that end actually handle THIS booking's cargo? A container
-- booking needs a facility with a stacker; bulk needs any facility.
(oy.has_facility AND COALESCE(
CASE WHEN b.freight_type = 'CONTAINER'
THEN ofac.handles_container ELSE ofac.handles_bulk END, false))
AS "originHasFacility",
dy.id AS "destinationYardId",
COALESCE(dy.label, dy.code) AS "destination",
(dy.has_facility AND COALESCE(
CASE WHEN b.freight_type = 'CONTAINER'
THEN dfac.handles_container ELSE dfac.handles_bulk END, false))
AS "destinationHasFacility",
-- Where the train actually is, so the operator knows if the cargo
-- can be worked right now.
cp.yard_id AS "trainAtYardId",
-- Most recent GRN raised for this booking at a facility.
fh.grn_number AS "grnNumber"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.yard_facilities ofac
ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true
LEFT JOIN freight.yard_facilities dfac
ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true
LEFT JOIN freight.train_schedules ts
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
LEFT JOIN LATERAL (
SELECT c.yard_id
FROM freight.train_checkpoint_events c
WHERE c.train_schedule_id = b.train_schedule_id
ORDER BY c.occurred_at DESC, c.created_at DESC
LIMIT 1
) cp ON true
LEFT JOIN LATERAL (
SELECT e.grn_number
FROM freight.facility_handling_events e
WHERE e.booking_id = b.id AND e.deleted_at IS NULL
ORDER BY e.occurred_at DESC
LIMIT 1
) fh ON true
WHERE b.deleted_at IS NULL
AND b.trade_direction = 'DOMESTIC'
ORDER BY b.created_at DESC`,
);
}
async listCandidates(scheduleId: string) {
const schedule = await this.getSchedule(scheduleId);
const milestones = await this.routeMilestones(schedule);
const milestoneSeq = this.milestoneSequenceOf(schedule, milestones);
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
const waiting = milestoneSeq
? await this.findWaitingIntercityBookings(milestoneSeq)
: [];
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
// Mid-corridor intercity matching needs a real stop list (>= 2 route
// milestones). Without one the fallback is a 2-stop origin->destination
// pseudo-route that only matches bookings on the train's exact corridor —
// surface that so an empty candidate list isn't misread as "nobody waiting".
const warning =
milestoneSeq == null
? 'This schedule has no route or origin/destination set, so no intercity corridors can be served.'
: schedule.routeId && milestones.length < 2
? "This schedule's route has no stop list (needs at least 2 route milestones), so mid-corridor intercity bookings cannot be matched — only bookings on the train's exact origin→destination will appear."
: null;
return {
scheduleId,
routeId: schedule.routeId ?? null,
warning,
// Segment-based: "remaining" is the most-open edge; each candidate's
// `fits` is judged against ITS OWN leg, so a booking on a free leg fits
// even when the train is full elsewhere.
remaining: capacity?.budget.maxRemaining() ?? null,
candidates: waiting.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
// legForYards, not legOf: on a built train the budget is a single
// whole-route edge (see intercityCapacity), so a mid-corridor booking
// must draw from that one pool via the whole-route fallback. On a
// locomotive-derived schedule it still resolves to the booking's own leg.
const leg = capacity?.budget.legForYards(
booking.originYardId,
booking.destinationYardId,
);
return {
...this.mapBooking(booking, need),
need,
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
};
}),
accepted: accepted.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return { ...this.mapBooking(booking, need), need };
}),
};
}
/**
* Accept selected waiting intercity bookings onto this train, in the given
* order, each re-checked against the shrinking capacity budget. Commercial
* bookings open a pay window (payment → allocation runs on the existing
* settle lifecycle); government bookings allocate immediately.
*/
async acceptBookings(scheduleId: string, bookingIds: string[]) {
if (bookingIds.length === 0) {
throw new BadRequestException('Select at least one intercity booking');
}
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
if (!milestoneSeq) {
throw new BadRequestException(
'Schedule has no route milestones — cannot serve intercity corridors',
);
}
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
if (!capacity) {
throw new BadRequestException(
'Schedule has no locomotive/train set — capacity unknown',
);
}
const accepted: string[] = [];
const rejected: Array<{ bookingId: string; reason: string }> = [];
const budget = capacity.budget;
for (const bookingId of bookingIds) {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({
where: { id: bookingId },
relations: {
bookingContainers: { containerType: true },
cargoType: true,
},
});
if (!booking) {
rejected.push({ bookingId, reason: 'Booking not found' });
continue;
}
const notWaiting = this.whyNotWaiting(booking, milestoneSeq);
if (notWaiting) {
rejected.push({ bookingId, reason: notWaiting });
continue;
}
const need = capacity.needFor(booking);
// legForYards, not legOf: a built train's budget is a single whole-route
// pool (mid-corridor wagons are committed for the whole trip and never
// reloaded), so the booking draws from that pool via the whole-route
// fallback; a locomotive-derived schedule still gets the booking's own
// leg, so it can still board a train that is full only on other legs.
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
if (!budget.fits(need, leg)) {
rejected.push({
bookingId,
reason:
'Does not fit the remaining wagon/weight/length capacity for this train',
});
continue;
}
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
budget.subtract(need, leg);
accepted.push(bookingId);
this.logger.log(
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
);
}
return { accepted, rejected, remaining: budget.maxRemaining() };
}
/**
* Mark an accepted intercity booking's cargo as loaded. Delegates to the
* shared per-booking journey flow (same checkpoint gating as import/export).
*/
async loadBooking(scheduleId: string, bookingId: string) {
await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
return this.bookingJourneyService.loadBooking(scheduleId, bookingId);
}
/**
* Mark an intercity booking's cargo as unloaded at its destination yard —
* requires the latest checkpoint to be at that yard. Completes the booking.
*/
async unloadBooking(scheduleId: string, bookingId: string) {
await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
return this.bookingJourneyService.unloadBooking(scheduleId, bookingId);
}
// ---- helpers ---------------------------------------------------------------
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return schedule;
}
/**
* yardId → sequenceNo for the schedule's route. Falls back to a two-stop
* origin/destination pseudo-route for legacy schedules without a routeId,
* so an intercity booking exactly matching the train's own corridor still
* qualifies.
*/
private async routeMilestones(
schedule: TrainSchedule,
): Promise<RouteMilestone[]> {
if (!schedule.routeId) return [];
return this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
}
private milestoneSequenceOf(
schedule: TrainSchedule,
milestones: RouteMilestone[],
): Map<string, number> | null {
if (milestones.length >= 2) {
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
}
if (schedule.originStationId && schedule.destinationStationId) {
return new Map([
[schedule.originStationId, 1],
[schedule.destinationStationId, 2],
]);
}
return null;
}
private async routeMilestoneSequence(
schedule: TrainSchedule,
): Promise<Map<string, number> | null> {
return this.milestoneSequenceOf(
schedule,
await this.routeMilestones(schedule),
);
}
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
private async findWaitingIntercityBookings(
milestoneSeq: Map<string, number>,
): Promise<Booking[]> {
const pool = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq));
}
/** Intercity bookings already reserved/allocated on this schedule. */
private async findAcceptedIntercityBookings(
scheduleId: string,
): Promise<Booking[]> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id = :scheduleId', { scheduleId })
.orderBy('booking.created_at', 'ASC')
.getMany();
}
private corridorOnRoute(
booking: Booking,
milestoneSeq: Map<string, number>,
): boolean {
const originSeq = milestoneSeq.get(booking.originYardId);
const destinationSeq = milestoneSeq.get(booking.destinationYardId);
return (
originSeq != null && destinationSeq != null && originSeq < destinationSeq
);
}
private whyNotWaiting(
booking: Booking,
milestoneSeq: Map<string, number>,
): string | null {
if (booking.tradeDirection !== 'DOMESTIC') {
return 'Not an intercity booking';
}
if (booking.trainScheduleId) {
return 'Already assigned to a train';
}
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
if (booking.status !== readyStatus) {
return `Not ready to board (status ${booking.status})`;
}
if (!this.corridorOnRoute(booking, milestoneSeq)) {
return "Corridor is not on this schedule's route";
}
return null;
}
private async getAcceptedBooking(scheduleId: string, bookingId: string) {
const schedule = await this.getSchedule(scheduleId);
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (booking.trainScheduleId !== scheduleId) {
throw new BadRequestException('Booking is not assigned to this schedule');
}
if (booking.tradeDirection !== 'DOMESTIC') {
throw new BadRequestException('Not an intercity booking');
}
return { schedule, booking };
}
/**
* `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is
* spent in. Prefer it, so the row's weight sits on the same axis as the
* remaining-capacity figure shown beside it; cargo VGM is the fallback.
*/
private mapBooking(booking: Booking, need?: { weightTons: number } | null) {
return {
id: booking.id,
reference: booking.reference,
status: booking.status,
freightType: booking.freightType,
isGovernment: booking.isGovernment,
customer: booking.company?.name ?? 'Unknown customer',
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
origin:
booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ??
booking.destinationYard?.code ??
'Unknown destination',
weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0),
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
};
}
}