mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
Add tareWeightTons property to CreateWagonTypeDto and update related components
This commit is contained in:
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user