mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
changes
This commit is contained in:
@@ -21,6 +21,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
findPaidUnlinkedForSchedule: jest.Mock;
|
||||
findBatchPool: jest.Mock;
|
||||
findBatchPoolByRouteDay: jest.Mock;
|
||||
findBatchPoolByCorridorDay: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -53,6 +54,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
@@ -196,6 +198,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
cargoTotalWeightVgm: 10,
|
||||
freightType: 'CONTAINER',
|
||||
bookingContainers: [],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
}) as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -227,13 +231,15 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
trainSetId: `set-${id}`,
|
||||
trainSet: { locomotive: smallLoco },
|
||||
scheduleBookings: [],
|
||||
originStationId: originYardId,
|
||||
destinationStationId: destinationYardId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('spills overflow to the next train by priority, then reports unplaced', async () => {
|
||||
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||||
commercial('hi', 30),
|
||||
commercial('mid', 20),
|
||||
commercial('lo', 10),
|
||||
@@ -241,9 +247,8 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
|
||||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
expect(bookingsRepository.findBatchPoolByCorridorDay).toHaveBeenCalledWith(
|
||||
[originYardId, destinationYardId],
|
||||
day,
|
||||
);
|
||||
// Both trains were processed.
|
||||
@@ -258,7 +263,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
});
|
||||
|
||||
it('reserves the chosen train id on each commercial booking', async () => {
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([commercial('hi', 30)]);
|
||||
|
||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
@@ -286,9 +291,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
freightType: 'CONTAINER',
|
||||
consolidationPartnerId: partnerId,
|
||||
bookingContainers: [{ quantity: 1 }],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
}) as unknown as Booking;
|
||||
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([
|
||||
consol('a', 'b', 30),
|
||||
consol('b', 'a', 20),
|
||||
]);
|
||||
@@ -313,9 +320,11 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
freightType: 'CONTAINER',
|
||||
consolidationPartnerId: 'missing-partner',
|
||||
bookingContainers: [{ quantity: 1 }],
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
} as unknown as Booking;
|
||||
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]);
|
||||
bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([lonely]);
|
||||
|
||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { formatRouteLabel } from '../routes/entities/route.entity';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
@@ -42,13 +43,14 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||
import {
|
||||
Capacity,
|
||||
CorridorBudget,
|
||||
CorridorLeg,
|
||||
stopYardsFor,
|
||||
} from './corridor-capacity.util';
|
||||
|
||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||
export interface Capacity {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
export type { Capacity } from './corridor-capacity.util';
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
@@ -459,18 +461,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
throw new BadRequestException('Booking has no scheduled date');
|
||||
}
|
||||
const day = eatDay(new Date(booking.scheduledDate));
|
||||
// Corridor-aware: any train whose route carries the booking's origin
|
||||
// strictly before its destination qualifies — a Dire→Djibouti booking may
|
||||
// ride an Addis→…→Djibouti train. The leg check below (legOf) enforces the
|
||||
// stop order, so we fetch the day's open trains without endpoint filters.
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
@@ -493,6 +491,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const required = need ?? this.needFor(booking, wagonLengths);
|
||||
let corridorMatched = false;
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
@@ -500,8 +499,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (this.fits(required, budget)) return schedule.id;
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
corridorMatched = true;
|
||||
if (budget.fits(required, leg)) return schedule.id;
|
||||
}
|
||||
if (!corridorMatched) {
|
||||
throw new ConflictException(
|
||||
'No export train is accepting bookings for this day',
|
||||
);
|
||||
}
|
||||
throw new ConflictException('Train is full — no export capacity left for this day');
|
||||
}
|
||||
@@ -1009,8 +1016,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
let budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
if (budget.wagons <= 0) {
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
if (budget.maxRemaining().wagons <= 0) {
|
||||
await this.setWindow(scheduleId, "FULL");
|
||||
return;
|
||||
}
|
||||
@@ -1026,16 +1033,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? this.combinedNeed(booking, partner, wagonLengths)
|
||||
: this.needFor(booking, wagonLengths);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
// Consolidated partners always share one corridor, so the primary's leg
|
||||
// stands for the pair.
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
|
||||
if (!this.fits(need, budget)) {
|
||||
if (!budget.fits(need, leg)) {
|
||||
if (isGov) {
|
||||
budget = await this.preemptForGovernment(
|
||||
const freed = await this.preemptForGovernment(
|
||||
scheduleId,
|
||||
need,
|
||||
leg,
|
||||
budget,
|
||||
wagonLengths,
|
||||
);
|
||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
||||
if (!freed) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||
}
|
||||
@@ -1049,11 +1060,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (partner) await this.reserve(partner, scheduleId);
|
||||
armed = true;
|
||||
}
|
||||
budget = this.subtract(budget, need);
|
||||
if (budget.wagons <= 0) break; // no wagon slots left — nothing more can board
|
||||
budget.subtract(need, leg);
|
||||
if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board
|
||||
}
|
||||
|
||||
if (budget.wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
||||
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
@@ -1106,8 +1117,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
|
||||
// Live per-schedule budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
||||
// Live per-schedule corridor budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
|
||||
for (const id of scheduleIds) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
@@ -1120,18 +1131,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingCapacity(
|
||||
schedule,
|
||||
limits,
|
||||
wagonLengths,
|
||||
);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
trains.push({ id, budget, armed: false });
|
||||
}
|
||||
if (trains.length === 0) return [];
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
// The day pool covers every booking whose leg lies somewhere on one of the
|
||||
// day's corridors — full-route AND sub-corridor (e.g. Dire→Djibouti on an
|
||||
// Addis→Djibouti train). Which train actually takes a booking is decided
|
||||
// by the per-train legOf check below.
|
||||
const corridorYards = [...new Set(trains.flatMap((t) => t.budget.stops))];
|
||||
const pool = await this.bookingsRepository.findBatchPoolByCorridorDay(
|
||||
corridorYards,
|
||||
day,
|
||||
);
|
||||
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||
@@ -1146,20 +1157,30 @@ export class BookingBatchService implements OnModuleInit {
|
||||
: this.needFor(booking, wagonLengths);
|
||||
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||
|
||||
// First train (earliest departure) that fits this unit as-is.
|
||||
let target = trains.find((t) => this.fits(need, t.budget));
|
||||
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
|
||||
t.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
|
||||
// First train (earliest departure) whose corridor carries this booking's
|
||||
// leg and still fits it as-is.
|
||||
let target = trains.find((t) => {
|
||||
const leg = legOn(t);
|
||||
return leg != null && t.budget.fits(need, leg);
|
||||
});
|
||||
|
||||
if (!target && isGov) {
|
||||
// Government fits nowhere on its own — try to preempt commercial
|
||||
// on each train (earliest first) until one frees enough room.
|
||||
// on each corridor-matching train (earliest first) until one frees room.
|
||||
for (const t of trains) {
|
||||
t.budget = await this.preemptForGovernment(
|
||||
const leg = legOn(t);
|
||||
if (!leg) continue;
|
||||
const freed = await this.preemptForGovernment(
|
||||
t.id,
|
||||
need,
|
||||
leg,
|
||||
t.budget,
|
||||
wagonLengths,
|
||||
);
|
||||
if (this.fits(need, t.budget)) {
|
||||
if (freed) {
|
||||
target = t;
|
||||
break;
|
||||
}
|
||||
@@ -1170,10 +1191,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// A consolidated pair is placed whole or not at all — never split.
|
||||
if (!isPair) {
|
||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||
// partial-capacity offer on the train with the most free wagons.
|
||||
const partialTarget = [...trains]
|
||||
.filter((t) => t.budget.wagons >= 1)
|
||||
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
||||
// partial-capacity offer on the train with the most free wagons on the
|
||||
// booking's own leg.
|
||||
const partialTarget = trains
|
||||
.map((t) => {
|
||||
const leg = legOn(t);
|
||||
return leg ? { t, leg, room: t.budget.remainingFor(leg) } : null;
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
|
||||
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
|
||||
if (
|
||||
partialTarget &&
|
||||
!booking.isGovernment &&
|
||||
@@ -1183,13 +1209,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
) {
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
partialTarget.id,
|
||||
partialTarget.budget,
|
||||
partialTarget.t.id,
|
||||
partialTarget.room,
|
||||
need,
|
||||
);
|
||||
if (offered) {
|
||||
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
||||
partialTarget.armed = true;
|
||||
partialTarget.t.budget.subtract(offered, partialTarget.leg);
|
||||
partialTarget.t.armed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1208,11 +1234,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (partner) await this.reserve(partner, target.id);
|
||||
target.armed = true;
|
||||
}
|
||||
target.budget = this.subtract(target.budget, need);
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
}
|
||||
|
||||
for (const t of trains) {
|
||||
if (t.budget.wagons <= 0) await this.setWindow(t.id, "FULL");
|
||||
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL");
|
||||
if (t.armed) this.armSettle(t.id);
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
@@ -1414,10 +1440,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
"Target schedule is not accepting bookings",
|
||||
);
|
||||
}
|
||||
if (
|
||||
schedule.originStationId !== booking.originYardId ||
|
||||
schedule.destinationStationId !== booking.destinationYardId
|
||||
) {
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const fromIdx = stops.indexOf(booking.originYardId);
|
||||
const toIdx = stops.indexOf(booking.destinationYardId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) {
|
||||
throw new BadRequestException(
|
||||
"Target schedule is not on the booking route",
|
||||
);
|
||||
@@ -1461,13 +1487,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// ---- intercity ride-along API ---------------------------------------------
|
||||
|
||||
/**
|
||||
* Remaining capacity budget (wagons / weight / length) for a schedule, and
|
||||
* the per-booking need calculator — exposed for the intercity accept flow,
|
||||
* which reserves ride-along bookings onto import/export trains outside the
|
||||
* batch engine.
|
||||
* Remaining corridor capacity budget (per-edge wagons / weight / length) for
|
||||
* a schedule, and the per-booking need calculator — exposed for the intercity
|
||||
* accept flow, which reserves ride-along bookings onto import/export trains
|
||||
* outside the batch engine. Segment-based: an intercity booking fits whenever
|
||||
* ITS leg has room, even if the train is full on other legs.
|
||||
*/
|
||||
async intercityCapacity(scheduleId: string): Promise<{
|
||||
budget: Capacity;
|
||||
budget: CorridorBudget;
|
||||
needFor: (booking: Booking) => Capacity;
|
||||
} | null> {
|
||||
const schedule =
|
||||
@@ -1477,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
|
||||
}
|
||||
|
||||
@@ -1635,13 +1662,17 @@ export class BookingBatchService implements OnModuleInit {
|
||||
/**
|
||||
* Free capacity for a government booking by displacing the lowest-priority commercial
|
||||
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
|
||||
* Only victims whose legs overlap the government booking's leg actually free useful
|
||||
* room, so others are skipped. Mutates `budget`; returns whether the need now fits.
|
||||
*/
|
||||
private async preemptForGovernment(
|
||||
scheduleId: string,
|
||||
need: Capacity,
|
||||
budget: Capacity,
|
||||
leg: CorridorLeg,
|
||||
budget: CorridorBudget,
|
||||
wagonLengths: WagonLengths,
|
||||
): Promise<Capacity> {
|
||||
): Promise<boolean> {
|
||||
if (budget.fits(need, leg)) return true;
|
||||
const reservedCommercial = (
|
||||
await this.bookingsRepository.findReservedForSchedule(scheduleId)
|
||||
).filter((b) => !b.isGovernment);
|
||||
@@ -1655,9 +1686,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
(a, b) => (a.priorityScore ?? 0) - (b.priorityScore ?? 0),
|
||||
);
|
||||
|
||||
let freed = budget;
|
||||
for (const victim of candidates) {
|
||||
if (this.fits(need, freed)) break;
|
||||
if (budget.fits(need, leg)) break;
|
||||
const victimLeg = budget.legForYards(
|
||||
victim.originYardId,
|
||||
victim.destinationYardId,
|
||||
);
|
||||
// Displacing a booking on a disjoint leg frees nothing the government
|
||||
// booking can use — don't kill it for nothing.
|
||||
const overlaps = victimLeg.fromEdge < leg.toEdge && leg.fromEdge < victimLeg.toEdge;
|
||||
if (!overlaps) continue;
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
scheduleId,
|
||||
@@ -1680,9 +1718,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
});
|
||||
this.notifier.displaced(victim);
|
||||
freed = this.add(freed, this.needFor(victim, wagonLengths));
|
||||
budget.add(this.needFor(victim, wagonLengths), victimLeg);
|
||||
}
|
||||
return freed;
|
||||
return budget.fits(need, leg);
|
||||
}
|
||||
|
||||
// ---- capacity helpers -----------------------------------------------------
|
||||
@@ -1790,22 +1828,6 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
private subtract(budget: Capacity, need: Capacity): Capacity {
|
||||
return {
|
||||
wagons: budget.wagons - need.wagons,
|
||||
weightTons: budget.weightTons - need.weightTons,
|
||||
lengthMeters: budget.lengthMeters - need.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
private add(budget: Capacity, freed: Capacity): Capacity {
|
||||
return {
|
||||
wagons: budget.wagons + freed.wagons,
|
||||
weightTons: budget.weightTons + freed.weightTons,
|
||||
lengthMeters: budget.lengthMeters + freed.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
|
||||
private async capacityLimits(
|
||||
locomotive: Locomotive,
|
||||
@@ -1883,37 +1905,68 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.findOne({ where: {} });
|
||||
}
|
||||
|
||||
/** Remaining capacity = hard caps minus what allocated + reserved bookings already use. */
|
||||
private async remainingCapacity(
|
||||
/**
|
||||
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||
*/
|
||||
private async stopsForSchedule(schedule: TrainSchedule): Promise<string[]> {
|
||||
let milestoneYards: string[] | null = null;
|
||||
if (schedule.routeId) {
|
||||
const milestones = await this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||
if (milestones.length >= 2) milestoneYards = milestones.map((m) => m.yardId);
|
||||
}
|
||||
return stopYardsFor(
|
||||
milestoneYards,
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaining capacity per corridor edge = hard caps minus what allocated +
|
||||
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
||||
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
||||
*/
|
||||
private async remainingBudget(
|
||||
schedule: TrainSchedule,
|
||||
limits: Capacity,
|
||||
wagonLengths: WagonLengths,
|
||||
): Promise<Capacity> {
|
||||
): Promise<CorridorBudget> {
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits);
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const used = [...allocated, ...reserved].reduce<Capacity>(
|
||||
(acc, b) => this.add(acc, this.needFor(b, wagonLengths)),
|
||||
{ wagons: 0, weightTons: 0, lengthMeters: 0 },
|
||||
);
|
||||
return this.subtract(limits, used);
|
||||
for (const b of [...allocated, ...reserved]) {
|
||||
budget.subtract(
|
||||
this.needFor(b, wagonLengths),
|
||||
budget.legForYards(b.originYardId, b.destinationYardId),
|
||||
);
|
||||
}
|
||||
return budget;
|
||||
}
|
||||
|
||||
/** maxWagons minus wagons already taken by allocated + reserved bookings. */
|
||||
/**
|
||||
* Wagon slots still boardable somewhere on the corridor (most-open edge).
|
||||
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
|
||||
*/
|
||||
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
const budget = await this.remainingBudget(
|
||||
schedule,
|
||||
{
|
||||
wagons: schedule.maxWagons ?? 0,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
wagonLengths,
|
||||
);
|
||||
const used =
|
||||
allocated.reduce((s, b) => s + this.wagonsFor(b), 0) +
|
||||
reserved.reduce((s, b) => s + this.wagonsFor(b), 0);
|
||||
return (schedule.maxWagons ?? 0) - used;
|
||||
return budget.maxRemaining().wagons;
|
||||
}
|
||||
|
||||
async setWindow(
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
|
||||
/**
|
||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||
*
|
||||
* A booking rides only its own origin→destination leg, so "dispatched" and
|
||||
* "arrived" are per-booking facts confirmed by the yard operator, not train
|
||||
* facts: load at the booking's origin yard (PAID → IN_TRANSIT, loadedAt) and
|
||||
* unload at its destination yard (IN_TRANSIT → ARRIVED for import/export,
|
||||
* → COMPLETED for intercity), possibly long before the train's final arrival.
|
||||
* Both are gated on the train's latest recorded checkpoint being at that yard.
|
||||
*
|
||||
* Unloading also settles the physical wagons: each wagon that alights with the
|
||||
* booking is released at that yard and the move is written to the
|
||||
* wagon_movements ledger.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingJourneyService {
|
||||
private readonly logger = new Logger(BookingJourneyService.name);
|
||||
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
) {}
|
||||
|
||||
/** Statuses from which a booking may be loaded (gov bookings don't prepay). */
|
||||
private canLoad(booking: Booking): boolean {
|
||||
if (booking.status === 'PAID') return true;
|
||||
return booking.isGovernment && booking.status === 'APPROVED';
|
||||
}
|
||||
|
||||
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
|
||||
throw new BadRequestException('Booking is already loaded');
|
||||
}
|
||||
if (!this.canLoad(booking)) {
|
||||
throw new BadRequestException(
|
||||
`Booking must be paid before loading (currently ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
status: 'IN_TRANSIT',
|
||||
loadedAt: now,
|
||||
loadedByUserId: userId ?? null,
|
||||
} as never);
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
||||
});
|
||||
|
||||
// Customer tracking: cargo is on the train — loading milestones plus the
|
||||
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
||||
// bookings (intercity) and already-completed codes.
|
||||
void this.completeMilestones(booking, [
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
...(booking.tradeDirection === 'IMPORT'
|
||||
? ['DEPARTED_FROM_DJIBOUTI']
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? ['DEPARTED_TO_DJIBOUTI']
|
||||
: []),
|
||||
]);
|
||||
|
||||
return { bookingId, status: 'IN_TRANSIT' as const, loadedAt: now.toISOString() };
|
||||
}
|
||||
|
||||
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
if (booking.status !== 'IN_TRANSIT') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||
|
||||
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||
// export continue into clearance, keyed on the booking's own arrival.
|
||||
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Booking).update(bookingId, {
|
||||
status: nextStatus,
|
||||
arrivedAt: now,
|
||||
arrivedByUserId: userId ?? null,
|
||||
} as never);
|
||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
||||
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
||||
});
|
||||
|
||||
// Customer tracking: THIS booking arrived (train may still be rolling).
|
||||
void this.completeMilestones(booking, [
|
||||
...(booking.tradeDirection === 'IMPORT'
|
||||
? ['ARRIVED_ETHIOPIA']
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? ['ARRIVED_AT_DJIBOUTI']
|
||||
: []),
|
||||
]);
|
||||
|
||||
return { bookingId, status: nextStatus, arrivedAt: now.toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-yard operator worklist for a schedule: which bookings board / alight at
|
||||
* each stop, with their journey state, so the yard operator at Dire sees
|
||||
* exactly what to load and unload when the train is there.
|
||||
*/
|
||||
async listYardWork(scheduleId: string) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const bookings = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.innerJoin(
|
||||
'freight.train_schedule_bookings',
|
||||
'tsb',
|
||||
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
||||
{ scheduleId },
|
||||
)
|
||||
.getMany();
|
||||
|
||||
const latest = await this.latestCheckpoint(scheduleId);
|
||||
const yardIds = [
|
||||
...new Set(
|
||||
bookings.flatMap((b) => [b.originYardId, b.destinationYardId]).filter(Boolean),
|
||||
),
|
||||
];
|
||||
const yards = yardIds.length
|
||||
? await this.dataSource.getRepository(Yard).find({ where: { id: In(yardIds) } })
|
||||
: [];
|
||||
const yardById = new Map(yards.map((y) => [y.id, y]));
|
||||
const yardLabel = (id: string) =>
|
||||
yardById.get(id)?.label ?? yardById.get(id)?.code ?? id;
|
||||
|
||||
const mapBooking = (b: Booking) => ({
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
status: b.status,
|
||||
tradeDirection: b.tradeDirection,
|
||||
isGovernment: b.isGovernment,
|
||||
customer: b.company?.name ?? 'Unknown customer',
|
||||
originYardId: b.originYardId,
|
||||
destinationYardId: b.destinationYardId,
|
||||
origin: yardLabel(b.originYardId),
|
||||
destination: yardLabel(b.destinationYardId),
|
||||
loadedAt: b.loadedAt?.toISOString() ?? null,
|
||||
arrivedAt: b.arrivedAt?.toISOString() ?? null,
|
||||
canLoad: !b.loadedAt && this.canLoad(b),
|
||||
canUnload: b.status === 'IN_TRANSIT',
|
||||
});
|
||||
|
||||
const byYard = new Map<
|
||||
string,
|
||||
{ yardId: string; yard: string; toLoad: ReturnType<typeof mapBooking>[]; toUnload: ReturnType<typeof mapBooking>[] }
|
||||
>();
|
||||
const bucket = (yardId: string) => {
|
||||
let entry = byYard.get(yardId);
|
||||
if (!entry) {
|
||||
entry = { yardId, yard: yardLabel(yardId), toLoad: [], toUnload: [] };
|
||||
byYard.set(yardId, entry);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
for (const b of bookings) {
|
||||
bucket(b.originYardId).toLoad.push(mapBooking(b));
|
||||
bucket(b.destinationYardId).toUnload.push(mapBooking(b));
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleId,
|
||||
scheduleStatus: schedule.status,
|
||||
trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId),
|
||||
yards: [...byYard.values()],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk fallback at the train's FINAL arrival: any booking destined for the
|
||||
* final yard that operators didn't unload individually gets its per-booking
|
||||
* arrival stamped now, so nothing stays stuck. Mid-corridor bookings are NOT
|
||||
* touched — their arrival is their own unload. Returns the affected ids.
|
||||
*/
|
||||
async autoArriveAtFinalYard(
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
now: Date,
|
||||
): Promise<string[]> {
|
||||
const rows: Array<{ id: string; trade_direction: string }> = await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = CASE WHEN b.trade_direction = 'DOMESTIC' THEN 'COMPLETED' ELSE 'ARRIVED' END,
|
||||
scheduling_status = 'DISPATCHED',
|
||||
arrived_at = COALESCE(b.arrived_at, $3),
|
||||
loaded_at = COALESCE(b.loaded_at, b.created_at)
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = b.id
|
||||
AND tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.destination_yard_id = $2
|
||||
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED', 'ARRIVED', 'DELIVERED')
|
||||
RETURNING b.id, b.trade_direction`,
|
||||
[schedule.id, schedule.destinationStationId, now],
|
||||
);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
|
||||
private async getScheduleBooking(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');
|
||||
}
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
private async latestCheckpoint(scheduleId: string): Promise<TrainCheckpointEvent | null> {
|
||||
return this.dataSource.getRepository(TrainCheckpointEvent).findOne({
|
||||
where: { trainScheduleId: scheduleId },
|
||||
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
||||
* or — for a booking boarding at the train's own origin — when the train has
|
||||
* not recorded any checkpoint yet (still sitting at its origin).
|
||||
*/
|
||||
private async assertTrainAtYard(
|
||||
schedule: TrainSchedule,
|
||||
yardId: string,
|
||||
side: 'origin' | 'destination',
|
||||
): Promise<void> {
|
||||
const latest = await this.latestCheckpoint(schedule.id);
|
||||
if (!latest) {
|
||||
if (side === 'origin' && schedule.originStationId === yardId) return;
|
||||
throw new BadRequestException(
|
||||
'Train has not reached this yard yet — record its checkpoint first',
|
||||
);
|
||||
}
|
||||
if (latest.yardId !== yardId) {
|
||||
throw new BadRequestException(
|
||||
`Train's last recorded position is not at the booking's ${side} yard`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async setAllocationStatuses(
|
||||
manager: EntityManager,
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
status: 'LOADED' | 'DEPARTED',
|
||||
): Promise<void> {
|
||||
const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId);
|
||||
if (!allocations.length) return;
|
||||
await manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.update({ id: In(allocations.map((a) => a.id)) }, { status });
|
||||
}
|
||||
|
||||
private async allocationsForBooking(
|
||||
manager: EntityManager,
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
): Promise<Array<WagonBookingAllocation & { trainSetWagon?: TrainSetWagon }>> {
|
||||
return manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.createQueryBuilder('alloc')
|
||||
.innerJoinAndSelect('alloc.trainSetWagon', 'slot')
|
||||
.innerJoin(
|
||||
'freight.train_schedules',
|
||||
'schedule',
|
||||
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||
{ scheduleId },
|
||||
)
|
||||
.where('alloc.booking_id = :bookingId', { bookingId })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* On unload: write the wagon_movements ledger rows (board yard → unload yard,
|
||||
* kind LOADED) for the booking's pinned wagons, and release each wagon whose
|
||||
* slot alights here — it detaches, stays at this yard, and becomes Available
|
||||
* (dynamic consist). Wagons shared with a still-loaded consolidated partner
|
||||
* stay pinned until the last booking on the slot unloads.
|
||||
*/
|
||||
private async settleWagonsOnUnload(
|
||||
manager: EntityManager,
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
now: Date,
|
||||
userId: string | null,
|
||||
): Promise<void> {
|
||||
const allocations = await this.allocationsForBooking(manager, schedule.id, booking.id);
|
||||
for (const alloc of allocations) {
|
||||
const slot = alloc.trainSetWagon;
|
||||
if (!slot?.physicalWagonId) continue;
|
||||
|
||||
const boardYardId = slot.boardYardId ?? schedule.originStationId;
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: slot.physicalWagonId,
|
||||
fromYardId: boardYardId,
|
||||
toYardId: booking.destinationYardId,
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
kind: Freight.WagonMovementKind.Loaded,
|
||||
movedByUserId: userId,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
|
||||
// Detach only when this yard is where the slot's leg ends and no other
|
||||
// booking on the wagon is still in transit.
|
||||
const slotAlightYardId = slot.alightYardId ?? schedule.destinationStationId;
|
||||
if (slotAlightYardId !== booking.destinationYardId) continue;
|
||||
const siblings = await manager
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.createQueryBuilder('alloc')
|
||||
.innerJoin('alloc.booking', 'b')
|
||||
.where('alloc.train_set_wagon_id = :slotId', { slotId: slot.id })
|
||||
.andWhere('alloc.booking_id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere(`b.status = 'IN_TRANSIT'`)
|
||||
.getCount();
|
||||
if (siblings > 0) continue;
|
||||
|
||||
await manager.getRepository(TrainSetWagon).update(slot.id, { status: 'DEPARTED' });
|
||||
const wagon = await manager
|
||||
.getRepository(Wagon)
|
||||
.findOne({ where: { id: slot.physicalWagonId } });
|
||||
// Only settle a wagon still bound to this schedule (it may have been
|
||||
// re-pinned elsewhere already).
|
||||
if (wagon && wagon.currentTrainScheduleId === schedule.id) {
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentYardId: booking.destinationYardId,
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: Freight.WagonStatus.Available,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async completeMilestones(booking: Booking, codes: string[]): Promise<void> {
|
||||
if (!this.milestoneService || !codes.length) return;
|
||||
for (const code of codes) {
|
||||
try {
|
||||
await this.milestoneService.completeByDocTrigger({ bookingId: booking.id }, code);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Milestone ${code} completion failed for booking ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Segment (leg) aware capacity accounting for corridor bookings.
|
||||
*
|
||||
* A train's route is an ordered list of stops; a booking occupies only the
|
||||
* edges between its own origin and destination. Capacity (wagons / weight /
|
||||
* length) is therefore tracked PER EDGE, not per train: two bookings whose
|
||||
* legs don't overlap (Addis→Dire and Dire→Djibouti) consume the same wagon
|
||||
* budget on disjoint edges and can share physical wagons.
|
||||
*
|
||||
* Legacy schedules without route milestones degrade to a single-edge corridor
|
||||
* ([origin, destination]) where this is exactly the old train-wide math.
|
||||
*/
|
||||
|
||||
export interface Capacity {
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
/** Half-open edge span along the stop list: occupies edges [fromEdge, toEdge). */
|
||||
export interface CorridorLeg {
|
||||
fromEdge: number;
|
||||
toEdge: number;
|
||||
}
|
||||
|
||||
export function addCapacity(a: Capacity, b: Capacity): Capacity {
|
||||
return {
|
||||
wagons: a.wagons + b.wagons,
|
||||
weightTons: a.weightTons + b.weightTons,
|
||||
lengthMeters: a.lengthMeters + b.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
export function subtractCapacity(a: Capacity, b: Capacity): Capacity {
|
||||
return {
|
||||
wagons: a.wagons - b.wagons,
|
||||
weightTons: a.weightTons - b.weightTons,
|
||||
lengthMeters: a.lengthMeters - b.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
export function capacityFits(need: Capacity, budget: Capacity): boolean {
|
||||
return (
|
||||
need.wagons <= budget.wagons &&
|
||||
need.weightTons <= budget.weightTons &&
|
||||
need.lengthMeters <= budget.lengthMeters
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yard ids for a schedule. Route milestones (already ordered by
|
||||
* sequence) when there are at least two; otherwise the schedule's own
|
||||
* origin/destination pair — the legacy two-stop pseudo-route.
|
||||
*/
|
||||
export function stopYardsFor(
|
||||
milestoneYardIdsInOrder: string[] | null | undefined,
|
||||
originStationId: string,
|
||||
destinationStationId: string,
|
||||
): string[] {
|
||||
if (milestoneYardIdsInOrder && milestoneYardIdsInOrder.length >= 2) {
|
||||
return milestoneYardIdsInOrder;
|
||||
}
|
||||
return [originStationId, destinationStationId];
|
||||
}
|
||||
|
||||
/** Per-edge capacity budget along a schedule's stop list. */
|
||||
export class CorridorBudget {
|
||||
private readonly edges: Capacity[];
|
||||
private readonly stopIndex: Map<string, number>;
|
||||
|
||||
constructor(
|
||||
readonly stops: string[],
|
||||
initial: Capacity,
|
||||
) {
|
||||
const edgeCount = Math.max(1, stops.length - 1);
|
||||
this.edges = Array.from({ length: edgeCount }, () => ({ ...initial }));
|
||||
this.stopIndex = new Map(stops.map((yardId, i) => [yardId, i]));
|
||||
}
|
||||
|
||||
/** The leg between two stops, or null when they aren't on this corridor in order. */
|
||||
legOf(originYardId: string, destinationYardId: string): CorridorLeg | null {
|
||||
const from = this.stopIndex.get(originYardId);
|
||||
const to = this.stopIndex.get(destinationYardId);
|
||||
if (from == null || to == null || from >= to) return null;
|
||||
return { fromEdge: from, toEdge: to };
|
||||
}
|
||||
|
||||
/** Every edge — for whole-route consumers and unknown-leg fallbacks. */
|
||||
fullLeg(): CorridorLeg {
|
||||
return { fromEdge: 0, toEdge: this.edges.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* The leg a booking occupies; bookings whose yards aren't on the corridor
|
||||
* (legacy data drift) conservatively occupy the whole route so capacity is
|
||||
* never double-booked against them.
|
||||
*/
|
||||
legForYards(originYardId: string, destinationYardId: string): CorridorLeg {
|
||||
return this.legOf(originYardId, destinationYardId) ?? this.fullLeg();
|
||||
}
|
||||
|
||||
/** Remaining capacity usable by this leg = min across its edges. */
|
||||
remainingFor(leg: CorridorLeg): Capacity {
|
||||
let min = { ...this.edges[leg.fromEdge] };
|
||||
for (let i = leg.fromEdge + 1; i < leg.toEdge; i++) {
|
||||
const e = this.edges[i];
|
||||
min = {
|
||||
wagons: Math.min(min.wagons, e.wagons),
|
||||
weightTons: Math.min(min.weightTons, e.weightTons),
|
||||
lengthMeters: Math.min(min.lengthMeters, e.lengthMeters),
|
||||
};
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
fits(need: Capacity, leg: CorridorLeg): boolean {
|
||||
return capacityFits(need, this.remainingFor(leg));
|
||||
}
|
||||
|
||||
subtract(need: Capacity, leg: CorridorLeg): void {
|
||||
for (let i = leg.fromEdge; i < leg.toEdge; i++) {
|
||||
this.edges[i] = subtractCapacity(this.edges[i], need);
|
||||
}
|
||||
}
|
||||
|
||||
add(freed: Capacity, leg: CorridorLeg): void {
|
||||
for (let i = leg.fromEdge; i < leg.toEdge; i++) {
|
||||
this.edges[i] = addCapacity(this.edges[i], freed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The most open edge — when even this has no wagon slots left, nothing can
|
||||
* board anywhere and the schedule's window is genuinely FULL. (A train can be
|
||||
* full on one leg while another still has room, so train-wide FULL keys on
|
||||
* the max, not the min.)
|
||||
*/
|
||||
maxRemaining(): Capacity {
|
||||
return this.edges.reduce(
|
||||
(max, e) => ({
|
||||
wagons: Math.max(max.wagons, e.wagons),
|
||||
weightTons: Math.max(max.weightTons, e.weightTons),
|
||||
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
|
||||
}),
|
||||
{ ...this.edges[0] },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ 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, type Capacity } from './booking-batch.service';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
|
||||
/**
|
||||
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
|
||||
@@ -32,6 +32,7 @@ export class IntercityService {
|
||||
constructor(
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -52,13 +53,20 @@ export class IntercityService {
|
||||
return {
|
||||
scheduleId,
|
||||
routeId: schedule.routeId ?? null,
|
||||
remaining: capacity?.budget ?? null,
|
||||
// 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;
|
||||
const leg = capacity?.budget.legOf(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
return {
|
||||
...this.mapBooking(booking),
|
||||
need,
|
||||
fits: need && capacity ? fits(need, capacity.budget) : false,
|
||||
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||
};
|
||||
}),
|
||||
accepted: accepted.map((booking) => ({
|
||||
@@ -94,7 +102,7 @@ export class IntercityService {
|
||||
|
||||
const accepted: string[] = [];
|
||||
const rejected: Array<{ bookingId: string; reason: string }> = [];
|
||||
let budget = capacity.budget;
|
||||
const budget = capacity.budget;
|
||||
|
||||
for (const bookingId of bookingIds) {
|
||||
const booking = await this.dataSource
|
||||
@@ -110,45 +118,35 @@ export class IntercityService {
|
||||
continue;
|
||||
}
|
||||
const need = capacity.needFor(booking);
|
||||
if (!fits(need, budget)) {
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
// Segment-based: only the booking's own leg must have room, so an
|
||||
// intercity booking still boards a train that is full on other legs.
|
||||
if (!leg || !budget.fits(need, leg)) {
|
||||
rejected.push({
|
||||
bookingId,
|
||||
reason: 'Does not fit the remaining wagon/weight/length capacity',
|
||||
reason:
|
||||
'Does not fit the remaining wagon/weight/length capacity on its leg',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
|
||||
budget = subtract(budget, need);
|
||||
budget.subtract(need, leg);
|
||||
accepted.push(bookingId);
|
||||
this.logger.log(
|
||||
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { accepted, rejected, remaining: budget };
|
||||
return { accepted, rejected, remaining: budget.maxRemaining() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an accepted intercity booking's cargo as loaded. Only allowed while
|
||||
* the train is physically at the booking's origin yard: either it has not
|
||||
* departed yet and the booking boards at the train's own origin, or the
|
||||
* latest recorded checkpoint is at the booking's origin yard.
|
||||
* 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) {
|
||||
const { schedule, booking } = await this.getAcceptedBooking(
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (booking.status !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be paid before loading (currently ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { status: 'IN_TRANSIT' });
|
||||
return { bookingId, status: 'IN_TRANSIT' as const };
|
||||
await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
|
||||
return this.bookingJourneyService.loadBooking(scheduleId, bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,20 +154,8 @@ export class IntercityService {
|
||||
* requires the latest checkpoint to be at that yard. Completes the booking.
|
||||
*/
|
||||
async unloadBooking(scheduleId: string, bookingId: string) {
|
||||
const { schedule, booking } = await this.getAcceptedBooking(
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (booking.status !== 'IN_TRANSIT') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
||||
);
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { status: 'COMPLETED' });
|
||||
return { bookingId, status: 'COMPLETED' as const };
|
||||
await this.getAcceptedBooking(scheduleId, bookingId); // intercity-only guard
|
||||
return this.bookingJourneyService.unloadBooking(scheduleId, bookingId);
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
@@ -298,36 +284,6 @@ export class IntercityService {
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
/**
|
||||
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
||||
* or — for a booking boarding at the train's own origin — when the train has
|
||||
* not recorded any checkpoint yet (still sitting at its origin).
|
||||
*/
|
||||
private async assertTrainAtYard(
|
||||
schedule: TrainSchedule,
|
||||
yardId: string,
|
||||
side: 'origin' | 'destination',
|
||||
): Promise<void> {
|
||||
const latest = await this.dataSource
|
||||
.getRepository(TrainCheckpointEvent)
|
||||
.findOne({
|
||||
where: { trainScheduleId: schedule.id },
|
||||
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
if (!latest) {
|
||||
if (side === 'origin' && schedule.originStationId === yardId) return;
|
||||
throw new BadRequestException(
|
||||
'Train has not reached this yard yet — record its checkpoint first',
|
||||
);
|
||||
}
|
||||
if (latest.yardId !== yardId) {
|
||||
throw new BadRequestException(
|
||||
`Train's last recorded position is not at the booking's ${side} yard`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapBooking(booking: Booking) {
|
||||
return {
|
||||
id: booking.id,
|
||||
@@ -350,18 +306,3 @@ export class IntercityService {
|
||||
}
|
||||
}
|
||||
|
||||
function fits(need: Capacity, budget: Capacity): boolean {
|
||||
return (
|
||||
need.wagons <= budget.wagons &&
|
||||
need.weightTons <= budget.weightTons &&
|
||||
need.lengthMeters <= budget.lengthMeters
|
||||
);
|
||||
}
|
||||
|
||||
function subtract(budget: Capacity, need: Capacity): Capacity {
|
||||
return {
|
||||
wagons: budget.wagons - need.wagons,
|
||||
weightTons: budget.weightTons - need.weightTons,
|
||||
lengthMeters: budget.lengthMeters - need.lengthMeters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.d
|
||||
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
import { BookingJourneyService } from "./booking-journey.service";
|
||||
import { BookingWindowService } from "./booking-window.service";
|
||||
import { IntercityService } from "./intercity.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
@@ -60,6 +61,7 @@ export class TrainSchedulingController {
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly bookingWindowService: BookingWindowService,
|
||||
private readonly intercityService: IntercityService,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
private readonly billingService: BillingService,
|
||||
) { }
|
||||
|
||||
@@ -432,6 +434,42 @@ export class TrainSchedulingController {
|
||||
return this.intercityService.acceptBookings(id, dto.bookingIds);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/yard-work")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Per-yard operator worklist: which bookings board/alight at each stop, with journey state",
|
||||
})
|
||||
getYardWork(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingJourneyService.listYardWork(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/load")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)",
|
||||
})
|
||||
loadScheduleBooking(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
) {
|
||||
return this.bookingJourneyService.loadBooking(id, bookingId);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/unload")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival",
|
||||
})
|
||||
unloadScheduleBooking(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
) {
|
||||
return this.bookingJourneyService.unloadBooking(id, bookingId);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/load")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -30,8 +30,10 @@ import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingWindowService } from './booking-window.service';
|
||||
import { IntercityService } from './intercity.service';
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { ContractsModule } from '../contracts/contracts.module';
|
||||
@@ -51,6 +53,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
TrainCheckpointEvent,
|
||||
ImportDjiboutiOperation,
|
||||
BookingBatchOffer,
|
||||
WagonMovement,
|
||||
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
|
||||
Session,
|
||||
]),
|
||||
@@ -77,6 +80,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
BookingWindowService,
|
||||
BookingSplitService,
|
||||
IntercityService,
|
||||
BookingJourneyService,
|
||||
],
|
||||
exports: [
|
||||
TrainSchedulingService,
|
||||
|
||||
@@ -155,6 +155,9 @@ describe('TrainSchedulingService', () => {
|
||||
htmlToPdfBuffer: jest.fn(),
|
||||
} as never,
|
||||
{ emitPhase: jest.fn() } as never, // bookingWindowGateway
|
||||
{
|
||||
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
|
||||
} as never, // bookingJourneyService
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
SchedulingStatus,
|
||||
TrainCheckpointKind,
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
WagonMovementKind,
|
||||
WagonStatus,
|
||||
} from '@edr/types';
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
@@ -113,6 +115,7 @@ import {
|
||||
eatDay,
|
||||
} from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
@@ -276,6 +279,7 @@ export class TrainSchedulingService {
|
||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
private readonly bookingJourneyService: BookingJourneyService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
@@ -290,15 +294,26 @@ export class TrainSchedulingService {
|
||||
private async completeMilestonesForScheduleBookings(
|
||||
scheduleId: string,
|
||||
codes: string[],
|
||||
filter?: { originYardId?: string; destinationYardId?: string },
|
||||
): Promise<void> {
|
||||
if (!this.milestoneService || codes.length === 0) return;
|
||||
try {
|
||||
const conditions = ['tsb.train_schedule_id = $1', 'tsb.deleted_at IS NULL'];
|
||||
const params: unknown[] = [scheduleId];
|
||||
if (filter?.originYardId) {
|
||||
params.push(filter.originYardId);
|
||||
conditions.push(`b.origin_yard_id = $${params.length}`);
|
||||
}
|
||||
if (filter?.destinationYardId) {
|
||||
params.push(filter.destinationYardId);
|
||||
conditions.push(`b.destination_yard_id = $${params.length}`);
|
||||
}
|
||||
const rows: Array<{ booking_id: string }> = await this.dataSource.query(
|
||||
`SELECT tsb.booking_id
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id
|
||||
WHERE ${conditions.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
for (const { booking_id } of rows) {
|
||||
for (const code of codes) {
|
||||
@@ -1457,6 +1472,24 @@ export class TrainSchedulingService {
|
||||
manager,
|
||||
);
|
||||
}
|
||||
// Per-booking journey fallback: bookings boarding at the TRAIN's origin
|
||||
// that the operator didn't load individually are auto-loaded now — the
|
||||
// train is leaving with them. Mid-corridor boarders stay PAID until the
|
||||
// operator loads them at their own yard.
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = 'IN_TRANSIT',
|
||||
loaded_at = COALESCE(b.loaded_at, $3)
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = b.id
|
||||
AND tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.origin_yard_id = $2
|
||||
AND b.loaded_at IS NULL
|
||||
AND (b.status = 'PAID' OR (b.is_government = true AND b.status = 'APPROVED'))`,
|
||||
[scheduleId, schedule.originStationId, now],
|
||||
);
|
||||
// Close the booking window; any still-pending (unallocated) reservations don't ride this train.
|
||||
await manager
|
||||
.getRepository(TrainSchedule)
|
||||
@@ -1488,18 +1521,24 @@ export class TrainSchedulingService {
|
||||
// Dispatch closed the window — drop it from portal/GL cards right away.
|
||||
void this.emitWindowState(scheduleId);
|
||||
// Customer tracking: cargo is on the departing train — loading milestones
|
||||
// plus the direction's "departed" handoff milestone.
|
||||
// plus the direction's "departed" handoff milestone. Restricted to bookings
|
||||
// that BOARD at the train's origin; mid-corridor boarders get their loading
|
||||
// milestones from their own operator load at their own yard.
|
||||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||||
// doc-trigger path no-ops it for import bookings.
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
schedule.direction === 'IMPORT'
|
||||
? 'DEPARTED_FROM_DJIBOUTI'
|
||||
: 'DEPARTED_TO_DJIBOUTI',
|
||||
]);
|
||||
void this.completeMilestonesForScheduleBookings(
|
||||
scheduleId,
|
||||
[
|
||||
// CARGO_ARRIVED is export-only (cargo reached the origin yard) — the
|
||||
// doc-trigger path no-ops it for import bookings.
|
||||
'CARGO_ARRIVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
schedule.direction === 'IMPORT'
|
||||
? 'DEPARTED_FROM_DJIBOUTI'
|
||||
: 'DEPARTED_TO_DJIBOUTI',
|
||||
],
|
||||
{ originYardId: schedule.originStationId },
|
||||
);
|
||||
}
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
@@ -2426,18 +2465,11 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
await manager.query(
|
||||
`UPDATE freight.bookings b
|
||||
SET status = $2,
|
||||
scheduling_status = $3
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = b.id
|
||||
AND tsb.train_schedule_id = $1
|
||||
AND tsb.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED')`,
|
||||
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
|
||||
);
|
||||
// Per-booking journey: bookings destined for the FINAL yard that the
|
||||
// operator didn't unload individually get their arrival stamped now as a
|
||||
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
|
||||
// their own unload (possibly already done while the train kept rolling).
|
||||
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
|
||||
|
||||
// Release every locomotive of the set (not just the legacy primary) and move it
|
||||
// to the destination yard where it physically arrived.
|
||||
@@ -2455,12 +2487,33 @@ export class TrainSchedulingService {
|
||||
.getRepository(Wagon)
|
||||
.findOne({ where: { id: slot.physicalWagonId } });
|
||||
if (!wagon) continue;
|
||||
// A wagon that already alighted mid-route (unload released it, possibly
|
||||
// re-pinned elsewhere since) is no longer this schedule's to move.
|
||||
if (wagon.currentTrainScheduleId !== scheduleId) continue;
|
||||
// Dynamic consist: the wagon settles at its slot's alight yard, not
|
||||
// blanket at the train's destination.
|
||||
const settleYardId = slot.alightYardId ?? schedule.destinationStationId;
|
||||
await manager.getRepository(Wagon).update(wagon.id, {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
status: WagonStatus.Available,
|
||||
currentYardId: schedule.destinationStationId,
|
||||
currentYardId: settleYardId,
|
||||
});
|
||||
// Ledger: the wagon rode this schedule to its settle yard.
|
||||
const slotAllocations = slot.allocations ?? [];
|
||||
await manager.getRepository(WagonMovement).save(
|
||||
manager.getRepository(WagonMovement).create({
|
||||
wagonId: wagon.id,
|
||||
fromYardId: slot.boardYardId ?? schedule.originStationId,
|
||||
toYardId: settleYardId,
|
||||
trainScheduleId: scheduleId,
|
||||
bookingId: slotAllocations[0]?.bookingId ?? null,
|
||||
kind: slotAllocations.length
|
||||
? WagonMovementKind.Loaded
|
||||
: WagonMovementKind.EmptyReposition,
|
||||
occurredAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure a destination checkpoint exists so the timeline shows ARRIVED.
|
||||
@@ -2482,11 +2535,15 @@ export class TrainSchedulingService {
|
||||
}
|
||||
});
|
||||
|
||||
// Customer tracking: the train reached the corridor's far end.
|
||||
// Customer tracking: the train reached the corridor's far end. Restricted
|
||||
// to bookings destined for the FINAL yard — mid-corridor bookings get their
|
||||
// arrival milestone from their own operator unload at their own yard.
|
||||
if (schedule.direction === 'IMPORT' || schedule.direction === 'EXPORT') {
|
||||
void this.completeMilestonesForScheduleBookings(scheduleId, [
|
||||
schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI',
|
||||
]);
|
||||
void this.completeMilestonesForScheduleBookings(
|
||||
scheduleId,
|
||||
[schedule.direction === 'IMPORT' ? 'ARRIVED_ETHIOPIA' : 'ARRIVED_AT_DJIBOUTI'],
|
||||
{ destinationYardId: schedule.destinationStationId },
|
||||
);
|
||||
}
|
||||
|
||||
const detail = await this.getTrainScheduleById(scheduleId);
|
||||
@@ -2635,17 +2692,26 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
if (
|
||||
bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
await (async () => {
|
||||
// Corridor-aware: a booking belongs on this train when its origin and
|
||||
// destination lie on the schedule's stop list in order — sub-corridor
|
||||
// bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid.
|
||||
let stops = [dto.originStationId, dto.destinationStationId];
|
||||
if (targetScheduleId) {
|
||||
const target = await this.trainSchedulesRepository.findById(targetScheduleId);
|
||||
if (target) stops = await this.stopYardsForSchedule(target);
|
||||
}
|
||||
return (
|
||||
b.originYardId !== dto.originStationId ||
|
||||
b.destinationYardId !== dto.destinationStationId
|
||||
);
|
||||
})
|
||||
return bookings.some((b) => {
|
||||
if (targetScheduleId && b.trainScheduleId === targetScheduleId) {
|
||||
return false;
|
||||
}
|
||||
const fromIdx = stops.indexOf(b.originYardId);
|
||||
const toIdx = stops.indexOf(b.destinationYardId);
|
||||
return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx;
|
||||
});
|
||||
})()
|
||||
) {
|
||||
violations.push('Selected bookings must share the same origin and destination as the schedule');
|
||||
violations.push('Selected bookings must lie on the schedule route (origin before destination)');
|
||||
}
|
||||
|
||||
if (!forceAssign) {
|
||||
@@ -2695,7 +2761,37 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const originYardId = dto.originStationId;
|
||||
const fleetCounts = await this.countFleetAvailability(originYardId, targetScheduleId);
|
||||
// Dynamic consist: a slot's physical wagon may ride from the train's origin
|
||||
// OR already sit at the booking's own boarding yard and attach there — so
|
||||
// the usable fleet is the union across the origin and every boarding yard.
|
||||
const boardYardIds = [
|
||||
...new Set(
|
||||
[originYardId, ...bookings.map((b) => b.originYardId)].filter(Boolean),
|
||||
),
|
||||
];
|
||||
const fleetCountsByYard = await Promise.all(
|
||||
boardYardIds.map((yardId) =>
|
||||
this.countFleetAvailability(yardId, targetScheduleId),
|
||||
),
|
||||
);
|
||||
const mergedFleet = new Map<string, { code: string; available: number }>();
|
||||
for (const rows of fleetCountsByYard) {
|
||||
for (const row of rows) {
|
||||
const existing = mergedFleet.get(row.wagonTypeId) ?? {
|
||||
code: row.wagonTypeCode,
|
||||
available: 0,
|
||||
};
|
||||
existing.available += row.available;
|
||||
mergedFleet.set(row.wagonTypeId, existing);
|
||||
}
|
||||
}
|
||||
const fleetCounts = [...mergedFleet.entries()].map(
|
||||
([wagonTypeId, value]) => ({
|
||||
wagonTypeId,
|
||||
wagonTypeCode: value.code,
|
||||
available: value.available,
|
||||
}),
|
||||
);
|
||||
const fleetByTypeId = new Map(fleetCounts.map((row) => [row.wagonTypeId, row.available]));
|
||||
fleetAvailability = computeFleetAvailability(
|
||||
demandPlan,
|
||||
@@ -2720,6 +2816,12 @@ export class TrainSchedulingService {
|
||||
containerWagonType,
|
||||
bulkWagonType,
|
||||
});
|
||||
this.stampSlotLegs(
|
||||
wagonPlan,
|
||||
fittingBookings,
|
||||
dto.originStationId,
|
||||
dto.destinationStationId,
|
||||
);
|
||||
|
||||
violations.push(
|
||||
...(await this.validatePhysicalFleetForPlan(
|
||||
@@ -3047,6 +3149,7 @@ export class TrainSchedulingService {
|
||||
wagonTypeId: slot.wagonTypeId,
|
||||
wagonTypeCode: typeCodeById.get(slot.wagonTypeId) ?? slot.wagonTypeId,
|
||||
trainSetWagonId: slot.id,
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
}));
|
||||
|
||||
const unpinnable = this.findUnpinnableWagonSlots(
|
||||
@@ -3100,6 +3203,7 @@ export class TrainSchedulingService {
|
||||
sequenceNo: slot.sequenceNo,
|
||||
wagonTypeId: slot.wagonTypeId,
|
||||
wagonTypeCode: slot.wagonTypeCode,
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
})),
|
||||
wagons,
|
||||
targetScheduleId,
|
||||
@@ -3108,7 +3212,12 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private findUnpinnableWagonSlots(
|
||||
slots: Array<{ sequenceNo: number; wagonTypeId: string; wagonTypeCode: string }>,
|
||||
slots: Array<{
|
||||
sequenceNo: number;
|
||||
wagonTypeId: string;
|
||||
wagonTypeCode: string;
|
||||
boardYardId?: string | null;
|
||||
}>,
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
originYardId: string,
|
||||
@@ -3136,22 +3245,35 @@ export class TrainSchedulingService {
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic consist: a slot's wagon may either ride from the train's origin
|
||||
* yard (attaching there, possibly empty until the slot's board yard) or
|
||||
* already sit AT the slot's board yard and hook on when the train arrives.
|
||||
*/
|
||||
private pickPhysicalWagonForSlot(
|
||||
slot: { wagonTypeId: string },
|
||||
slot: { wagonTypeId: string; boardYardId?: string | null },
|
||||
wagons: Wagon[],
|
||||
scheduleId: string | undefined,
|
||||
originYardId: string,
|
||||
assignedPhysicalIds: Set<string>,
|
||||
): Wagon | undefined {
|
||||
return wagons.find((wagon) => {
|
||||
const usable = (wagon: Wagon): boolean => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
if (assignedPhysicalIds.has(wagon.id)) return false;
|
||||
const pinnedOnSchedule = scheduleId
|
||||
? wagon.currentTrainScheduleId === scheduleId
|
||||
: false;
|
||||
if (wagon.status !== WagonStatus.Available && !pinnedOnSchedule) return false;
|
||||
return wagon.currentYardId === originYardId;
|
||||
});
|
||||
return wagon.status === WagonStatus.Available || pinnedOnSchedule;
|
||||
};
|
||||
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
|
||||
// fall back to one riding from the train's origin.
|
||||
if (slot.boardYardId) {
|
||||
const atBoardYard = wagons.find(
|
||||
(w) => usable(w) && w.currentYardId === slot.boardYardId,
|
||||
);
|
||||
if (atBoardYard) return atBoardYard;
|
||||
}
|
||||
return wagons.find((w) => usable(w) && w.currentYardId === originYardId);
|
||||
}
|
||||
|
||||
private positiveNumber(value: number | undefined, fallback: number): number {
|
||||
@@ -3303,6 +3425,42 @@ export class TrainSchedulingService {
|
||||
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp each plan slot with the leg it occupies (dynamic consist): the
|
||||
* boarding/alighting yards of the bookings it carries. Null means the
|
||||
* schedule's own endpoint (whole-route slot, legacy behavior). A slot
|
||||
* carrying bookings with mixed corridors stays whole-route (conservative).
|
||||
*/
|
||||
private stampSlotLegs(
|
||||
wagonPlan: WagonPlanSlot[],
|
||||
bookings: Booking[],
|
||||
scheduleOriginYardId: string,
|
||||
scheduleDestinationYardId: string,
|
||||
): void {
|
||||
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||||
for (const slot of wagonPlan) {
|
||||
const slotBookings = [
|
||||
...new Set(slot.allocations.map((a) => a.bookingId)),
|
||||
]
|
||||
.map((id) => bookingById.get(id))
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
if (!slotBookings.length) continue;
|
||||
const [first] = slotBookings;
|
||||
const sameCorridor = slotBookings.every(
|
||||
(b) =>
|
||||
b.originYardId === first.originYardId &&
|
||||
b.destinationYardId === first.destinationYardId,
|
||||
);
|
||||
if (!sameCorridor) continue;
|
||||
slot.boardYardId =
|
||||
first.originYardId === scheduleOriginYardId ? null : first.originYardId;
|
||||
slot.alightYardId =
|
||||
first.destinationYardId === scheduleDestinationYardId
|
||||
? null
|
||||
: first.destinationYardId;
|
||||
}
|
||||
}
|
||||
|
||||
private async persistTrainSetWagons(
|
||||
manager: EntityManager,
|
||||
trainSetId: string,
|
||||
@@ -3318,6 +3476,8 @@ export class TrainSchedulingService {
|
||||
lengthMeters: slot.lengthMeters,
|
||||
assignedWeightTons: slot.assignedWeightTons,
|
||||
status: 'PLANNED',
|
||||
boardYardId: slot.boardYardId ?? null,
|
||||
alightYardId: slot.alightYardId ?? null,
|
||||
}),
|
||||
);
|
||||
return manager.getRepository(TrainSetWagon).save(wagons);
|
||||
@@ -4012,26 +4172,44 @@ export class TrainSchedulingService {
|
||||
|
||||
// How many wagons of that type the cargo needs.
|
||||
const slotsNeeded = this.wagonsNeededForCargo(input, requiredType);
|
||||
void slotsNeeded; // TEMP: unused while the wagon-availability filter is off.
|
||||
|
||||
// AVAILABLE wagons of the required type, counted once per origin yard.
|
||||
const availableByYard = new Map<string, number>();
|
||||
const availableAt = async (yardId: string): Promise<number> => {
|
||||
const cached = availableByYard.get(yardId);
|
||||
if (cached !== undefined) return cached;
|
||||
const counts = await this.countFleetAvailability(yardId);
|
||||
const n =
|
||||
counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
||||
availableByYard.set(yardId, n);
|
||||
return n;
|
||||
};
|
||||
// TEMP (per request): wagon-availability filtering is DISABLED. A day is now
|
||||
// offered whenever a bookable schedule that day has remaining train capacity
|
||||
// — regardless of whether matching wagons are actually available at the
|
||||
// origin / boarding yard. This surfaces days even when no wagon is on hand.
|
||||
// Restore the block below to bring back the "enough matching wagons" gate.
|
||||
//
|
||||
// // AVAILABLE wagons of the required type, counted once per origin yard.
|
||||
// const availableByYard = new Map<string, number>();
|
||||
// const availableAt = async (yardId: string): Promise<number> => {
|
||||
// const cached = availableByYard.get(yardId);
|
||||
// if (cached !== undefined) return cached;
|
||||
// const counts = await this.countFleetAvailability(yardId);
|
||||
// const n =
|
||||
// counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
|
||||
// availableByYard.set(yardId, n);
|
||||
// return n;
|
||||
// };
|
||||
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
const hasCapacity =
|
||||
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
|
||||
if (!hasCapacity) continue;
|
||||
const enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
||||
if (!enoughWagons) continue;
|
||||
// TEMP (per request): wagon-availability check commented out — see note
|
||||
// above. Dynamic consist: wagons may ride from the train's origin OR
|
||||
// already sit at the booking's own boarding yard and attach when the train
|
||||
// arrives — either pool can serve a sub-corridor booking.
|
||||
// let enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
|
||||
// if (
|
||||
// !enoughWagons &&
|
||||
// input.originYardId &&
|
||||
// input.originYardId !== s.originStationId
|
||||
// ) {
|
||||
// enoughWagons = (await availableAt(input.originYardId)) >= slotsNeeded;
|
||||
// }
|
||||
// if (!enoughWagons) continue;
|
||||
if (s.scheduledDepartureDate)
|
||||
days.add(eatDay(new Date(s.scheduledDepartureDate)));
|
||||
}
|
||||
@@ -4063,6 +4241,33 @@ export class TrainSchedulingService {
|
||||
return Math.max(1, Math.ceil(teu / 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of a schedule's route: origin → milestones → destination,
|
||||
* de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule
|
||||
* has no route milestones. Shared by corridor (sub-leg) validation everywhere.
|
||||
*/
|
||||
async stopYardsForSchedule(schedule: TrainSchedule): Promise<string[]> {
|
||||
let milestoneYards: string[] = [];
|
||||
if (schedule.route?.milestones?.length) {
|
||||
milestoneYards = [...schedule.route.milestones]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((m) => m.yardId);
|
||||
} else if (schedule.routeId) {
|
||||
const milestones = await this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||
milestoneYards = milestones.map((m) => m.yardId);
|
||||
}
|
||||
const raw = milestoneYards.length >= 2
|
||||
? milestoneYards
|
||||
: [schedule.originStationId, ...milestoneYards, schedule.destinationStationId];
|
||||
const unique: string[] = [];
|
||||
for (const yardId of raw) {
|
||||
if (yardId && !unique.includes(yardId)) unique.push(yardId);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||||
async existsOpenScheduleOnRouteDay(
|
||||
originYardId: string,
|
||||
|
||||
@@ -38,6 +38,13 @@ export type WagonPlanSlot = {
|
||||
assignedWeightTons: number;
|
||||
allocations: WagonAllocationRecord[];
|
||||
slotLoadType?: SlotLoadType;
|
||||
/**
|
||||
* Leg occupancy for sub-corridor bookings (dynamic consist): the slot boards
|
||||
* at boardYardId and alights at alightYardId. Null = the schedule's own
|
||||
* endpoint (whole-route slot, legacy behavior).
|
||||
*/
|
||||
boardYardId?: string | null;
|
||||
alightYardId?: string | null;
|
||||
};
|
||||
|
||||
export type ContainerUnitRow = {
|
||||
|
||||
Reference in New Issue
Block a user