mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 07:10:57 +00:00
Refactor wagon type handling and container wagon calculations
- Removed maxWagonsPerTrain from WagonType entity and related DTOs. - Updated containerWagonsForLines function to calculate required wagons based on container lines more accurately. - Added unit tests for containerWagonsForLines to ensure correct calculations. - Adjusted related services and scripts to reflect the removal of maxWagonsPerTrain. - Enhanced booking and contract components to use new status labels for better user experience. - Implemented validation for unique container numbers in shipment forms.
This commit is contained in:
@@ -42,7 +42,10 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
containerWagonsForLines,
|
||||
} from './wagon-plan.util';
|
||||
import {
|
||||
Capacity,
|
||||
CorridorBudget,
|
||||
@@ -1014,17 +1017,22 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT";
|
||||
}
|
||||
|
||||
/** Fill one schedule from its priority-ordered pool until full. */
|
||||
async fillSchedule(scheduleId: string): Promise<void> {
|
||||
/**
|
||||
* Fill one schedule from its priority-ordered pool until full. Returns the
|
||||
* number of commercial units it RESERVED this pass (0 for government-only or
|
||||
* no-fit passes) so a top-up caller can extend the payment phase only when a
|
||||
* fresh pay window actually opened.
|
||||
*/
|
||||
async fillSchedule(scheduleId: string): Promise<number> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule || !this.isFillable(schedule)) return;
|
||||
if (!schedule || !this.isFillable(schedule)) return 0;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(
|
||||
`Schedule ${scheduleId} has no locomotive/train set — skipped.`,
|
||||
);
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
@@ -1034,13 +1042,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonLengths);
|
||||
if (budget.maxRemaining().wagons <= 0) {
|
||||
await this.setWindow(scheduleId, "FULL");
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
let reservedThisPass = 0;
|
||||
let commercialReserved = 0;
|
||||
|
||||
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
|
||||
// reservations trickle instead of landing in one pass (a reserve() throwing
|
||||
@@ -1106,6 +1115,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.reserve(booking, scheduleId);
|
||||
if (partner) await this.reserve(partner, scheduleId);
|
||||
armed = true;
|
||||
commercialReserved += 1;
|
||||
}
|
||||
budget.subtract(need, leg);
|
||||
reservedThisPass += 1;
|
||||
@@ -1125,6 +1135,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
|
||||
if (armed) this.armSettle(scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
return commercialReserved;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1499,7 +1510,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
|
||||
);
|
||||
await this.fillSchedule(scheduleId);
|
||||
const topUpReserved = await this.fillSchedule(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.
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1509,7 +1526,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async settleBatch(scheduleId: string): Promise<void> {
|
||||
this.removeTimeout(scheduleId);
|
||||
await this.settleReserved(scheduleId, true);
|
||||
await this.fillSchedule(scheduleId);
|
||||
const topUpReserved = await this.fillSchedule(scheduleId);
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(scheduleId);
|
||||
}
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
@@ -1613,8 +1633,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
await this.expire(booking);
|
||||
if (booking.trainScheduleId)
|
||||
await this.fillSchedule(booking.trainScheduleId);
|
||||
if (booking.trainScheduleId) {
|
||||
const topUpReserved = await this.fillSchedule(booking.trainScheduleId);
|
||||
if (topUpReserved > 0) {
|
||||
await this.extendPaymentPhaseForTopUp(booking.trainScheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- intercity ride-along API ---------------------------------------------
|
||||
@@ -1671,6 +1695,28 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* so the engine sets it as it picks the train.
|
||||
*/
|
||||
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||
// Idempotency guard: a booking already reserved (pay window open) or already
|
||||
// paid on THIS schedule must never be re-reserved — that would fire a second
|
||||
// `payNow` and reset its deadline, the "asked to pay again after paying"
|
||||
// symptom. Read fresh state (the in-memory `booking` may be stale from the
|
||||
// pooled query). Only bookings not yet committed to this train pass through.
|
||||
const fresh = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: booking.id } });
|
||||
if (
|
||||
fresh &&
|
||||
fresh.trainScheduleId === scheduleId &&
|
||||
(fresh.status === "SELECTED_FOR_BATCH" ||
|
||||
fresh.status === "AWAITING_PAYMENT" ||
|
||||
fresh.status === "PAID" ||
|
||||
fresh.paymentStatus === "PAID")
|
||||
) {
|
||||
this.logger.debug(
|
||||
`[BATCH] reserve skipped for ${booking.reference} — already ` +
|
||||
`${fresh.status}/${fresh.paymentStatus} on schedule ${scheduleId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
@@ -2020,14 +2066,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
||||
return Math.ceil(booking.wagonsRequired);
|
||||
}
|
||||
const fromContainers = (booking.bookingContainers ?? []).reduce(
|
||||
(sum, c) => sum + Number(c.quantity ?? 0),
|
||||
0,
|
||||
);
|
||||
return Math.max(
|
||||
DEFAULT_WAGONS_PER_BOOKING,
|
||||
fromContainers || DEFAULT_WAGONS_PER_BOOKING,
|
||||
// booking.wagonsRequired is NULL for most rows (only set on certain
|
||||
// scheduling paths). Derive from the container lines, TEU-aware: two 20ft
|
||||
// share one wagon (wagonsPerUnit = 0.5). The old fallback summed raw
|
||||
// container QUANTITY, so 20×20ft counted as 20 wagons instead of 10 and
|
||||
// wrongly filled the train.
|
||||
const fromContainers = containerWagonsForLines(
|
||||
booking.bookingContainers ?? [],
|
||||
);
|
||||
return Math.max(DEFAULT_WAGONS_PER_BOOKING, fromContainers);
|
||||
}
|
||||
|
||||
/** What one booking consumes along all three capacity axes. */
|
||||
@@ -2061,6 +2108,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
{
|
||||
maxPullWeightTons: Number(locomotive.maxPullWeightTons),
|
||||
maxTrainLengthMeters: Number(locomotive.maxTrainLengthMeters),
|
||||
overageToleranceTons: Number(locomotive.overageToleranceTons) || 0,
|
||||
overageToleranceMeters: Number(locomotive.overageToleranceMeters) || 0,
|
||||
},
|
||||
wagonTypes,
|
||||
{
|
||||
@@ -2256,6 +2305,45 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A top-up reservation (settle freed capacity mid-cycle, so the next waiting
|
||||
* booking got a fresh pay window) sets a NEW paymentDeadline. But the schedule's
|
||||
* `paymentPhaseEndsAt` — which the window tick watches to end PAYMENT and run
|
||||
* concludeCycle — was frozen when the phase started. Without this, concludeCycle
|
||||
* fires before the top-up customer's deadline and expires a booking that still
|
||||
* had time to pay. Push `paymentPhaseEndsAt` to at least cover a full payment
|
||||
* window from now, but never past departure. Only while the schedule is still
|
||||
* in the PAYMENT phase (a reopened cycle manages its own phase).
|
||||
*/
|
||||
async extendPaymentPhaseForTopUp(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: scheduleId } });
|
||||
if (!schedule || schedule.windowPhase !== "PAYMENT") return;
|
||||
const windowMs = await this.paymentWindowMs();
|
||||
let target = new Date(Date.now() + windowMs);
|
||||
if (
|
||||
schedule.scheduledDepartureDate &&
|
||||
target > schedule.scheduledDepartureDate
|
||||
) {
|
||||
target = schedule.scheduledDepartureDate;
|
||||
}
|
||||
// Only ever push the deadline OUT, never pull it in.
|
||||
if (
|
||||
schedule.paymentPhaseEndsAt &&
|
||||
schedule.paymentPhaseEndsAt.getTime() >= target.getTime()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.update(scheduleId, { paymentPhaseEndsAt: target });
|
||||
this.logger.log(
|
||||
`[BATCH] extended PAYMENT phase for ${scheduleId} to ${target.toISOString()} ` +
|
||||
`(top-up reservation opened a fresh pay window)`,
|
||||
);
|
||||
}
|
||||
|
||||
private removeTimeout(scheduleId: string): void {
|
||||
const name = this.timeoutName(scheduleId);
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user