mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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>
432 lines
18 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|
|
|