Add tareWeightTons property to CreateWagonTypeDto and update related components

This commit is contained in:
Marshal
2026-07-09 06:05:34 +00:00
parent bedcdca78b
commit 1b2b3f68f8
19 changed files with 979 additions and 263 deletions

View File

@@ -30,12 +30,17 @@ import { BillingService } from "../billing/billing.service";
import {
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_BULK_WAGON_TARE_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
DEFAULT_WAGONS_PER_BOOKING,
} from "./booking-batch.constants";
import {
WagonTypeDimensions,
bookingGrossWeightTons,
bookingTrainLengthMeters,
deriveTrainCapacityFromLocomotive,
trainHardCaps,
wagonTypeDimensionsFromEntity,
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
@@ -63,7 +68,14 @@ interface RouteDayGroup {
day: string;
}
type WagonLengths = { container: number; bulk: number };
/**
* Per-freight-type wagon dimensions used to size a booking's capacity draw:
* its length on the train and the tare it adds to the locomotive's gross load.
*/
type WagonDims = {
container: { lengthMeters: number; tareWeightTons: number };
bulk: { lengthMeters: number; tareWeightTons: number };
};
export type BatchBoardBookingState =
| "ALLOCATED"
@@ -501,8 +513,8 @@ export class BookingBatchService implements OnModuleInit {
}
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const required = need ?? this.needFor(booking, wagonLengths);
const wagonDims = await this.loadWagonDims();
const required = need ?? this.needFor(booking, wagonDims);
let corridorMatched = false;
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
@@ -511,7 +523,7 @@ 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.remainingBudget(schedule, limits, wagonLengths);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) continue; // this train's route doesn't carry the booking's leg
corridorMatched = true;
@@ -549,8 +561,8 @@ export class BookingBatchService implements OnModuleInit {
if (!partner || partner.status !== 'FULLY_EXECUTED') {
return;
}
const wagonLengths = await this.loadWagonLengths();
const need = this.combinedNeed(booking, partner, wagonLengths);
const wagonDims = await this.loadWagonDims();
const need = this.combinedNeed(booking, partner, wagonDims);
const scheduleId = await this.pickExportSchedule(booking, need);
await this.reserveOnExport([booking, partner], scheduleId);
}
@@ -620,7 +632,8 @@ export class BookingBatchService implements OnModuleInit {
order: { scheduledDepartureDate: "ASC" },
});
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const rules = await this.loadGlobalRules();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const board: BatchBoardSchedule[] = [];
@@ -635,7 +648,7 @@ export class BookingBatchService implements OnModuleInit {
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
const items: BatchBoardBooking[] = bookings.map((b) => {
const need = this.needFor(b, wagonLengths);
const need = this.needFor(b, wagonDims);
return {
id: b.id,
reference: b.reference ?? b.id.slice(0, 8),
@@ -655,7 +668,7 @@ export class BookingBatchService implements OnModuleInit {
};
});
board.push(this.buildScheduleSummary(s, items));
board.push(this.buildScheduleSummary(s, items, rules));
}
return board;
}
@@ -678,7 +691,8 @@ export class BookingBatchService implements OnModuleInit {
);
}
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const rules = await this.loadGlobalRules();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -724,7 +738,7 @@ export class BookingBatchService implements OnModuleInit {
}
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonLengths);
const need = this.needFor(b, wagonDims);
const alloc = allocationByBooking.get(b.id);
return {
id: b.id,
@@ -871,7 +885,7 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
@@ -902,6 +916,13 @@ export class BookingBatchService implements OnModuleInit {
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
/**
* Board capacity figures. `usedWeightTons` is GROSS (each item's weight already
* includes the tare of the wagons it occupies), so the ceiling it is measured
* against must be the same one the fill loop spends from: the locomotive floored
* by the global rule caps and widened by its overage tolerance. Reading the raw
* `loco.maxPullWeightTons` here showed staff a ceiling the batch engine did not use.
*/
private computeBoardCapacity(
items: Array<{
state: BatchBoardBookingState;
@@ -911,22 +932,40 @@ export class BookingBatchService implements OnModuleInit {
}>,
loco: Locomotive | null,
maxWagons: number | null,
rules: TrainSchedulingGlobalRules | null,
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter(
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
);
const caps = loco
? trainHardCaps(
{
maxPullWeightTons: Number(loco.maxPullWeightTons),
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
overageToleranceTons: Number(loco.overageToleranceTons) || 0,
overageToleranceMeters: Number(loco.overageToleranceMeters) || 0,
},
{
maxTrainWeightTons: rules?.maxTrainWeightTons
? Number(rules.maxTrainWeightTons)
: undefined,
maxTrainLengthMeters: rules?.maxTrainLengthMeters
? Number(rules.maxTrainLengthMeters)
: undefined,
},
)
: null;
const round2 = (value: number) => Math.round(value * 100) / 100;
return {
allocatedWagons: allocated.reduce((sum, i) => sum + i.wagons, 0),
allocatedLengthMeters:
Math.round(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0) * 100,
) / 100,
maxLengthMeters: loco ? Number(loco.maxTrainLengthMeters) : null,
usedWeightTons:
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
allocatedLengthMeters: round2(
allocated.reduce((sum, i) => sum + i.lengthMeters, 0),
),
maxLengthMeters: caps ? caps.maxLengthMeters : null,
usedWeightTons: round2(committed.reduce((sum, i) => sum + i.weightTons, 0)),
maxWeightTons: caps ? caps.maxWeightTons : null,
maxWagons: maxWagons ?? null,
};
}
@@ -934,6 +973,7 @@ export class BookingBatchService implements OnModuleInit {
private buildScheduleSummary(
s: TrainSchedule,
items: BatchBoardBooking[],
rules: TrainSchedulingGlobalRules | null,
): BatchBoardSchedule {
const loco = s.trainSet?.locomotive ?? null;
@@ -966,7 +1006,7 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null, rules),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
@@ -1036,10 +1076,10 @@ export class BookingBatchService implements OnModuleInit {
}
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
if (budget.maxRemaining().wagons <= 0) {
await this.setWindow(scheduleId, "FULL");
return 0;
@@ -1064,8 +1104,8 @@ export class BookingBatchService implements OnModuleInit {
const { primary: booking, partner } = unit;
const isPair = partner != null;
const need = isPair
? this.combinedNeed(booking, partner, wagonLengths)
: this.needFor(booking, wagonLengths);
? this.combinedNeed(booking, partner, wagonDims)
: this.needFor(booking, wagonDims);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
// Consolidated partners always share one corridor, so the primary's leg
// stands for the pair.
@@ -1084,7 +1124,7 @@ export class BookingBatchService implements OnModuleInit {
need,
leg,
budget,
wagonLengths,
wagonDims,
);
if (!freed) continue; // still doesn't fit even after preempt
} else {
@@ -1151,6 +1191,42 @@ export class BookingBatchService implements OnModuleInit {
destinationYardId: string,
day: string,
): Promise<string[]> {
const { scheduleIds } = await this.fillRouteDayInternal(
originYardId,
destinationYardId,
day,
);
return scheduleIds;
}
/**
* Route-day top-up for a single schedule: re-run the DAY pool over the whole
* corridor the schedule belongs to, and report how many commercial units got a
* fresh pay window.
*
* `fillSchedule` cannot do this job. Its pool (`findBatchPool`) is keyed on
* `booking.train_schedule_id = :scheduleId`, but under day-level pooling a
* booking that has not been reserved yet has a NULL `train_schedule_id` — it is
* only pinned by `reserve()`. So the schedule-scoped top-up returned zero rows
* and the waiting list never boarded after an expiry freed capacity; bookings
* trickled in one per window cycle instead.
*/
private async topUpFill(scheduleId: string): Promise<number> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule?.scheduledDepartureDate) return 0;
const { commercialReserved } = await this.fillRouteDayInternal(
schedule.originStationId,
schedule.destinationStationId,
eatDay(schedule.scheduledDepartureDate),
);
return commercialReserved;
}
private async fillRouteDayInternal(
originYardId: string,
destinationYardId: string,
day: string,
): Promise<{ scheduleIds: string[]; commercialReserved: number }> {
// The day's fillable schedules on this exact corridor, earliest first. Fillable
// covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT —
// the batch must run while the customer window is closed.
@@ -1168,23 +1244,36 @@ export class BookingBatchService implements OnModuleInit {
},
],
});
const scheduleIds = corridor
const onDay = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
this.isFillable(s),
eatDay(s.scheduledDepartureDate) === day,
)
.sort(
(a, b) =>
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
)
.map((s) => s.id);
);
if (scheduleIds.length === 0) return [];
// A schedule flagged FULL is rejected by isFillable() before its budget is
// ever consulted. Re-derive that flag from live capacity first, so a train
// whose bookings all expired is not skipped forever with an empty consist.
for (const s of onDay) {
if (s.bookingWindowStatus === "FULL") {
await this.refreshWindowStatus(s.id);
const fresh = await this.trainSchedulesRepository.findById(s.id);
if (fresh) s.bookingWindowStatus = fresh.bookingWindowStatus;
}
}
const scheduleIds = onDay.filter((s) => this.isFillable(s)).map((s) => s.id);
if (scheduleIds.length === 0) {
return { scheduleIds: [], commercialReserved: 0 };
}
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
// Live per-schedule corridor budget + arm flag, in departure order.
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
@@ -1200,10 +1289,10 @@ export class BookingBatchService implements OnModuleInit {
}
const limits = await this.capacityLimits(locomotive, rules);
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
trains.push({ id, budget, armed: false });
}
if (trains.length === 0) return [];
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
// 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
@@ -1225,13 +1314,14 @@ export class BookingBatchService implements OnModuleInit {
`poolSize=${pool.length} units=${units.length}`,
);
let reservedThisPass = 0;
let commercialReserved = 0;
for (const unit of units) {
const { primary: booking, partner } = unit;
const isPair = partner != null;
const need = isPair
? this.combinedNeed(booking, partner, wagonLengths)
: this.needFor(booking, wagonLengths);
? this.combinedNeed(booking, partner, wagonDims)
: this.needFor(booking, wagonDims);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
const legOn = (t: { budget: CorridorBudget }): CorridorLeg | null =>
@@ -1267,7 +1357,7 @@ export class BookingBatchService implements OnModuleInit {
need,
leg,
t.budget,
wagonLengths,
wagonDims,
);
if (freed) {
target = t;
@@ -1284,7 +1374,12 @@ export class BookingBatchService implements OnModuleInit {
// non-import never split — isSplitEligible guards that. Passing the live
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
if (offered) continue;
if (offered) {
// A partial offer opens a real commercial pay window, same as reserve().
commercialReserved += 1;
reservedThisPass += 1;
continue;
}
// Stays in the pool, retried next batch/window cycle.
this.notifier.unplaced(booking, day);
if (partner) this.notifier.unplaced(partner, day);
@@ -1305,6 +1400,7 @@ export class BookingBatchService implements OnModuleInit {
await this.reserve(booking, target.id);
if (partner) await this.reserve(partner, target.id);
target.armed = true;
commercialReserved += 1;
}
target.budget.subtract(need, legOn(target)!);
reservedThisPass += 1;
@@ -1326,7 +1422,7 @@ export class BookingBatchService implements OnModuleInit {
void this.triggerWagonAllocation(t.id);
}
return trains.map((t) => t.id);
return { scheduleIds: trains.map((t) => t.id), commercialReserved };
}
/**
@@ -1396,7 +1492,7 @@ export class BookingBatchService implements OnModuleInit {
if (booking.consolidationPartnerId) return null;
if (await this.splitService.findOpenOffer(booking.id)) return null;
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
const sized = await this.splitService.sizeOffer(
booking,
@@ -1408,11 +1504,16 @@ export class BookingBatchService implements OnModuleInit {
const offeredNeed: Capacity = {
wagons: sized.offeredWagons,
weightTons: sized.offeredWeightTons,
lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
weightTons: bookingGrossWeightTons(
sized.offeredWeightTons,
sized.offeredWagons,
this.tareFor(booking.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
booking.freightType,
sized.offeredWagons,
this.lengthsOf(wagonDims),
),
};
if (!this.fits(offeredNeed, budget)) return null;
@@ -1510,7 +1611,7 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
);
const topUpReserved = await this.fillSchedule(scheduleId);
const topUpReserved = await this.topUpFill(scheduleId);
// A top-up opened a fresh pay window for waiting bookings — push the
// schedule's PAYMENT phase out so the window tick's concludeCycle doesn't
// fire before those customers' new deadlines and expire them prematurely.
@@ -1526,7 +1627,7 @@ export class BookingBatchService implements OnModuleInit {
async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId);
await this.settleReserved(scheduleId, true);
const topUpReserved = await this.fillSchedule(scheduleId);
const topUpReserved = await this.topUpFill(scheduleId);
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(scheduleId);
}
@@ -1632,11 +1733,14 @@ export class BookingBatchService implements OnModuleInit {
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
// Capture the train before expire() detaches the booking from it — the
// top-up has to run against the schedule whose wagons were just freed.
const freedScheduleId = booking.trainScheduleId;
await this.expire(booking);
if (booking.trainScheduleId) {
const topUpReserved = await this.fillSchedule(booking.trainScheduleId);
if (freedScheduleId) {
const topUpReserved = await this.topUpFill(freedScheduleId);
if (topUpReserved > 0) {
await this.extendPaymentPhaseForTopUp(booking.trainScheduleId);
await this.extendPaymentPhaseForTopUp(freedScheduleId);
}
}
}
@@ -1659,10 +1763,10 @@ export class BookingBatchService implements OnModuleInit {
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return null;
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
const budget = await this.remainingBudget(schedule, limits, wagonDims);
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
}
/**
@@ -1827,6 +1931,7 @@ export class BookingBatchService implements OnModuleInit {
* it failed to pay for — it's back in the day pool for staff to act on.
*/
private async expire(booking: Booking): Promise<void> {
const freedScheduleId = booking.trainScheduleId;
await this.bookingsRepository.update(booking.id, {
trainScheduleId: null,
status: "EXPIRED",
@@ -1835,6 +1940,9 @@ export class BookingBatchService implements OnModuleInit {
selectedForBatchAt: null,
} as never);
booking.trainScheduleId = null;
// The wagons this reservation held are back — a schedule parked at FULL
// because of it must reopen, or it can never be filled again.
if (freedScheduleId) await this.refreshWindowStatus(freedScheduleId);
// An unpaid partial offer dies with the reservation — the booking stays whole.
if (this.splitService) {
await this.splitService.expireOpenOffer(booking.id);
@@ -1939,7 +2047,7 @@ export class BookingBatchService implements OnModuleInit {
need: Capacity,
leg: CorridorLeg,
budget: CorridorBudget,
wagonLengths: WagonLengths,
wagonDims: WagonDims,
): Promise<boolean> {
if (budget.fits(need, leg)) return true;
const reservedCommercial = (
@@ -1987,7 +2095,10 @@ export class BookingBatchService implements OnModuleInit {
);
});
this.notifier.displaced(victim);
budget.add(this.needFor(victim, wagonLengths), victimLeg);
budget.add(this.needFor(victim, wagonDims), victimLeg);
// Displacing frees wagons the same way an expiry does — don't leave the
// schedule stuck at FULL.
await this.refreshWindowStatus(scheduleId);
}
return budget.fits(need, leg);
}
@@ -2041,7 +2152,7 @@ export class BookingBatchService implements OnModuleInit {
private combinedNeed(
primary: Booking,
partner: Booking,
wagonLengths: WagonLengths,
wagonDims: WagonDims,
): Capacity {
const containers = (b: Booking): number =>
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
@@ -2050,15 +2161,36 @@ export class BookingBatchService implements OnModuleInit {
totalContainers > 0
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
: this.wagonsFor(primary) + this.wagonsFor(partner);
const weightTons =
const cargoTons =
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
return {
wagons: sharedWagons,
weightTons,
lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
// Consolidation saves tare as well as slots: the pair rides `sharedWagons`
// wagons, so it is charged `sharedWagons` tares, not one per booking.
weightTons: bookingGrossWeightTons(
cargoTons,
sharedWagons,
this.tareFor(primary.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
primary.freightType,
sharedWagons,
this.lengthsOf(wagonDims),
),
};
}
/** Per-wagon tare for the wagon type this freight rides on. */
private tareFor(freightType: string | null | undefined, wagonDims: WagonDims): number {
return freightType === 'BULK'
? wagonDims.bulk.tareWeightTons
: wagonDims.container.tareWeightTons;
}
private lengthsOf(wagonDims: WagonDims): { container: number; bulk: number } {
return {
container: wagonDims.container.lengthMeters,
bulk: wagonDims.bulk.lengthMeters,
};
}
@@ -2077,16 +2209,28 @@ export class BookingBatchService implements OnModuleInit {
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers);
}
/** What one booking consumes along all three capacity axes. */
private needFor(booking: Booking, wagonLengths: WagonLengths): Capacity {
/**
* What one booking consumes along all three capacity axes.
*
* The weight axis is GROSS — cargo plus the tare of every wagon the booking
* occupies — because it is spent against the locomotive's pull limit, which
* governs the whole train and not just its payload. Charging cargo alone let a
* 37-wagon box-wagon train read 2590T when it really weighed 3522T.
*/
private needFor(booking: Booking, wagonDims: WagonDims): Capacity {
const wagons = this.wagonsFor(booking);
return {
wagons,
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
lengthMeters: bookingTrainLengthMeters(booking.freightType, wagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
weightTons: bookingGrossWeightTons(
Number(booking.cargoTotalWeightVgm ?? 0),
wagons,
this.tareFor(booking.freightType, wagonDims),
),
lengthMeters: bookingTrainLengthMeters(
booking.freightType,
wagons,
this.lengthsOf(wagonDims),
),
};
}
@@ -2098,7 +2242,11 @@ export class BookingBatchService implements OnModuleInit {
);
}
/** Locomotive + wagon-type-derived caps (weight, length, wagon slots — not a fixed 53). */
/**
* Hard caps for a schedule's train: gross pull weight, train length, and the
* length-derived wagon slot count (never a fixed 53). Bookings spend against
* these via {@link needFor}, whose weight axis is gross.
*/
private async capacityLimits(
locomotive: Locomotive,
rules: TrainSchedulingGlobalRules | null,
@@ -2143,31 +2291,48 @@ export class BookingBatchService implements OnModuleInit {
}
}
private async loadWagonTypeDimensions(): Promise<
Array<{ lengthMeters: number; capacityTons: number }>
> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: "NW5" }, { code: "CW3" }],
});
/**
* Every active wagon type, so the slot count is derived from the shortest wagon
* the fleet can actually marshal rather than from an arbitrary two-code sample.
*/
private async loadWagonTypeDimensions(): Promise<WagonTypeDimensions[]> {
const types = await this.dataSource
.getRepository(WagonType)
.find({ where: { isActive: true } });
if (types.length) return types.map(wagonTypeDimensionsFromEntity);
return [
{ lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS, capacityTons: 70 },
{ lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS, capacityTons: 60 },
{
lengthMeters: DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
capacityTons: 70,
tareWeightTons: DEFAULT_CONTAINER_WAGON_TARE_TONS,
},
{
lengthMeters: DEFAULT_BULK_WAGON_LENGTH_METERS,
capacityTons: 60,
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
},
];
}
private async loadWagonLengths(): Promise<WagonLengths> {
/** Representative wagon per freight type: NW5 flat for containers, CW3 gondola for bulk. */
private async loadWagonDims(): Promise<WagonDims> {
const types = await this.dataSource.getRepository(WagonType).find({
where: [{ code: "NW5" }, { code: "CW3" }],
});
const byCode = new Map(
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
);
const nw5 = byCode.get("NW5");
const cw3 = byCode.get("CW3");
return {
container:
byCode.get("NW5")?.lengthMeters ??
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
bulk: byCode.get("CW3")?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
container: {
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
},
bulk: {
lengthMeters: cw3?.lengthMeters ?? DEFAULT_BULK_WAGON_LENGTH_METERS,
tareWeightTons: cw3?.tareWeightTons ?? DEFAULT_BULK_WAGON_TARE_TONS,
},
};
}
@@ -2204,7 +2369,7 @@ export class BookingBatchService implements OnModuleInit {
private async remainingBudget(
schedule: TrainSchedule,
limits: Capacity,
wagonLengths: WagonLengths,
wagonDims: WagonDims,
): Promise<CorridorBudget> {
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits);
@@ -2216,7 +2381,7 @@ export class BookingBatchService implements OnModuleInit {
);
for (const b of [...allocated, ...reserved]) {
budget.subtract(
this.needFor(b, wagonLengths),
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
@@ -2228,7 +2393,7 @@ export class BookingBatchService implements OnModuleInit {
* ≤ 0 means no leg can take another booking — the train-wide FULL signal.
*/
private async remainingWagons(schedule: TrainSchedule): Promise<number> {
const wagonLengths = await this.loadWagonLengths();
const wagonDims = await this.loadWagonDims();
const budget = await this.remainingBudget(
schedule,
{
@@ -2236,7 +2401,7 @@ export class BookingBatchService implements OnModuleInit {
weightTons: Number.POSITIVE_INFINITY,
lengthMeters: Number.POSITIVE_INFINITY,
},
wagonLengths,
wagonDims,
);
return budget.maxRemaining().wagons;
}
@@ -2269,6 +2434,35 @@ export class BookingBatchService implements OnModuleInit {
return (await this.remainingWagons(schedule)) <= 0;
}
/**
* Re-derive `bookingWindowStatus` from live capacity after wagons were freed
* (a reservation expired, a booking was displaced, a link was removed).
*
* FULL used to be a one-way door: `isFillable()` rejects a FULL schedule before
* it ever looks at the budget, and the only writers of OPEN skip a FULL row. So
* a train that filled once and then lost every booking to expiry stayed FULL
* with all its wagons free — permanently unfillable, cycling PRE_WINDOW→PAYMENT
* forever while `concludeCycle` (which reads real capacity, not the flag) kept
* reopening it. Clearing FULL here is what lets the next batch actually run.
*
* Only the customer-facing OPEN phases may go back to OPEN; a schedule mid
* DOC_REVIEW/PAYMENT drops to CLOSED, which `isFillable()` still admits.
*/
async refreshWindowStatus(scheduleId: string): Promise<void> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "FULL") return;
if ((await this.remainingWagons(schedule)) <= 0) return;
const customerWindowOpen =
schedule.windowPhase == null || schedule.windowPhase === "OPEN";
await this.setWindow(scheduleId, customerWindowOpen ? "OPEN" : "CLOSED");
this.logger.log(
`[BATCH] ${scheduleId} cleared stale FULL — wagons freed, window is now ` +
`${customerWindowOpen ? "OPEN" : "CLOSED"} and the batch can fill it again`,
);
}
// ---- timer plumbing -------------------------------------------------------
/** Configured customer pay window in ms (global rules, with defaults). */