mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
Every SQL tonnage in the export datasets and report definitions used `COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)`. COALESCE falls through on NULL, never on 0 — and the portal booking wizard stores `cargo_total_weight_vgm = 0` for container freight on purpose, because VGM is captured per container line, not as a booking-level figure. So every portal-created container booking reported as weighing nothing. The backoffice wizard does store a booking-level total, so the same table holds both shapes and the numbers looked erratic rather than uniformly zero. Extract the resolver the TypeScript side already has three copies of (bookingCargoTons, cargoTonsAndItems, totalVgmTons) into one SQL helper: NULLIF both booking-level columns, then fall back to SUM(booking_container.total_vgm_tons). Applied to the bookings and train-schedules export datasets, the cargo-summary, contract-utilization and booking-status-breakdown reports, and the intercity booking list. On dev data this recovers 116 of 154 zero-weight container bookings and raises live booking tonnage from 42,973 t to 61,424 t.
459 lines
19 KiB
TypeScript
459 lines
19 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import { DataSource } from 'typeorm';
|
|
|
|
import { bookingTonsSql } from '../bookings/booking-tons.sql';
|
|
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",
|
|
${bookingTonsSql('b')} 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: the booking draws only from ITS OWN leg's edges, with a
|
|
// whole-route fallback when its yards aren't on the budget's stop list.
|
|
const leg = capacity?.budget.legForYards(
|
|
booking.originYardId,
|
|
booking.destinationYardId,
|
|
);
|
|
return {
|
|
...this.mapBooking(booking, need),
|
|
need,
|
|
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
|
|
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,
|
|
wagonBreakdown: capacity?.breakdownFor(booking) ?? [],
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 },
|
|
// wagonTypes drives the break-bulk items-per-wagon fit — the accept
|
|
// check must size the booking exactly as the candidate list did.
|
|
cargoType: { wagonTypes: 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: charge only the edges this booking rides, 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)) {
|
|
// Offer the part that DOES fit the leg (split-on-payment): customer is
|
|
// notified with a pay window for the fitting wagons; the remainder can
|
|
// be re-booked on a later train. Budget is consumed by the offer so the
|
|
// next booking in this pass sees the reduced room.
|
|
const offered = await this.bookingBatchService.offerIntercityPartial(
|
|
booking,
|
|
scheduleId,
|
|
budget,
|
|
);
|
|
rejected.push({
|
|
bookingId,
|
|
reason: offered
|
|
? 'Does not fit whole — a partial offer for the wagons that fit was sent to the customer'
|
|
: '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')
|
|
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
|
|
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
|
|
// the wagon count silently degrades to tonnage-only.
|
|
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
|
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
|
.where(`booking.trade_direction = 'DOMESTIC'`)
|
|
.andWhere('booking.train_schedule_id IS NULL')
|
|
// PAID = customer paid but staff have not placed it on a train yet
|
|
// (intercity allocation is manual) — it stays in the pool until they do.
|
|
.andWhere(
|
|
`((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID'))
|
|
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')
|
|
// The allowed wagon-type list is what sizes a break-bulk (PER_ITEM)
|
|
// booking: without it `bulkItemsFitFor` reads no items-per-wagon fit and
|
|
// the wagon count silently degrades to tonnage-only.
|
|
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoWagonType')
|
|
.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';
|
|
}
|
|
// Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed,
|
|
// awaiting manual placement) links straight onto the chosen train.
|
|
const readyStatuses = booking.isGovernment
|
|
? ['APPROVED']
|
|
: ['FULLY_EXECUTED', 'PAID'];
|
|
if (!readyStatuses.includes(booking.status)) {
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
|