mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
changes
This commit is contained in:
@@ -1026,6 +1026,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
|
||||
// TODO(change-c-diagnostics): remove once the "only 1 reserved" capacity cause
|
||||
// is confirmed. Logs the caps + pool so we can see which axis rejects unit #2.
|
||||
this.logger.debug(
|
||||
`[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` +
|
||||
`maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` +
|
||||
`poolSize=${pool.length} units=${units.length}`,
|
||||
);
|
||||
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
@@ -1037,6 +1045,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// stands for the pair.
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
|
||||
// TODO(change-c-diagnostics): remove once the capacity cause is confirmed.
|
||||
this.logger.debug(
|
||||
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
|
||||
);
|
||||
|
||||
if (!budget.fits(need, leg)) {
|
||||
if (isGov) {
|
||||
const freed = await this.preemptForGovernment(
|
||||
@@ -1048,6 +1062,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
if (!freed) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
// Doesn't fit whole. A split-eligible import booking is offered the part
|
||||
// that fits in the remaining room (top-up path splits the boundary
|
||||
// booking, mirroring fillRouteDay); otherwise skip and try the next.
|
||||
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
|
||||
id: scheduleId,
|
||||
budget,
|
||||
armed,
|
||||
};
|
||||
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
|
||||
armed = cand.armed;
|
||||
continue;
|
||||
}
|
||||
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||
}
|
||||
}
|
||||
@@ -1149,6 +1175,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
|
||||
// TODO(change-c-diagnostics): remove once the "only 1 reserved" capacity cause
|
||||
// is confirmed. Shows each train's caps + the day pool size.
|
||||
this.logger.debug(
|
||||
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` +
|
||||
`trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` +
|
||||
`poolSize=${pool.length} units=${units.length}`,
|
||||
);
|
||||
|
||||
for (const unit of units) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
@@ -1167,6 +1201,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return leg != null && t.budget.fits(need, leg);
|
||||
});
|
||||
|
||||
// TODO(change-c-diagnostics): remove once the capacity cause is confirmed.
|
||||
this.logger.debug(
|
||||
`[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||
`targetTrain=${target?.id ?? "none"} ` +
|
||||
`rooms=${trains
|
||||
.map((t) => {
|
||||
const leg = legOn(t);
|
||||
return leg ? `${t.id}:${JSON.stringify(t.budget.remainingFor(leg))}` : `${t.id}:offleg`;
|
||||
})
|
||||
.join(",")}`,
|
||||
);
|
||||
|
||||
if (!target && isGov) {
|
||||
// Government fits nowhere on its own — try to preempt commercial
|
||||
// on each corridor-matching train (earliest first) until one frees room.
|
||||
@@ -1188,38 +1234,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// 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 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 &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
booking.contractKind === "GENERAL" &&
|
||||
this.splitService
|
||||
) {
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
partialTarget.t.id,
|
||||
partialTarget.room,
|
||||
need,
|
||||
);
|
||||
if (offered) {
|
||||
partialTarget.t.budget.subtract(offered, partialTarget.leg);
|
||||
partialTarget.t.armed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fits no train whole. A split-eligible booking is offered the largest
|
||||
// part that fits on the train with the most free wagons on its leg (this
|
||||
// covers both "fits nowhere" and the boundary case where earlier bookings
|
||||
// already consumed most of the room). Consolidated pairs / government /
|
||||
// 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;
|
||||
// Stays in the pool, retried next batch/window cycle.
|
||||
this.notifier.unplaced(booking, day);
|
||||
if (partner) this.notifier.unplaced(partner, day);
|
||||
@@ -1246,6 +1268,57 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return trains.map((t) => t.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be
|
||||
* offered a partial (split-on-payment). Consolidated pairs never split (both-or-
|
||||
* neither shared wagon) and government bookings never split (they preempt).
|
||||
*/
|
||||
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
||||
return (
|
||||
!isPair &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
|
||||
this.splitService != null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer the largest fitting part of a booking that does not fit any candidate
|
||||
* train whole, on the train with the most free wagons on the booking's leg.
|
||||
* Mutates the chosen candidate's budget + armed flag in place. Returns true when
|
||||
* an offer was opened (caller should `continue` past this unit), false otherwise.
|
||||
* Shared by fillRouteDay (multi-train) and fillSchedule (single train). The leg
|
||||
* is computed per candidate from the booking's yards, so callers pass their live
|
||||
* train entries and only leg-carrying trains are considered.
|
||||
*/
|
||||
private async maybeOfferPartial(
|
||||
booking: Booking,
|
||||
isPair: boolean,
|
||||
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
|
||||
need: Capacity,
|
||||
): Promise<boolean> {
|
||||
if (!this.isSplitEligible(booking, isPair)) return false;
|
||||
const target = candidates
|
||||
.map((c) => {
|
||||
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
return leg ? { c, leg, room: c.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 (!target) return false;
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
target.c.id,
|
||||
target.room,
|
||||
need,
|
||||
);
|
||||
if (!offered) return false;
|
||||
target.c.budget.subtract(offered, target.leg);
|
||||
target.c.armed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offer the largest fitting part of an over-capacity booking as a partial
|
||||
* (split-on-payment). Returns the capacity the offer consumes, or null when no
|
||||
@@ -1659,6 +1732,79 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of stop yards across the day's fillable schedules on this corridor —
|
||||
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
||||
* are covered. Empty when no fillable schedule exists for the group.
|
||||
*/
|
||||
private async corridorYardsForRouteDay(
|
||||
group: RouteDayGroup,
|
||||
): Promise<string[]> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{
|
||||
originStationId: group.originYardId,
|
||||
destinationStationId: group.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Draft,
|
||||
},
|
||||
{
|
||||
originStationId: group.originYardId,
|
||||
destinationStationId: group.destinationYardId,
|
||||
status: TrainScheduleStatusEnum.Scheduled,
|
||||
},
|
||||
],
|
||||
});
|
||||
const yards = new Set<string>();
|
||||
for (const schedule of corridor) {
|
||||
if (
|
||||
schedule.scheduledDepartureDate == null ||
|
||||
eatDay(schedule.scheduledDepartureDate) !== group.day
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const yardId of await this.stopsForSchedule(schedule)) {
|
||||
yards.add(yardId);
|
||||
}
|
||||
}
|
||||
return [...yards];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sweep bookings on a route-day whose operation request staff did NOT accept by
|
||||
* the time the window's document-review phase ends. They never reached
|
||||
* FULLY_EXECUTED, so they never enter the batch — expire them (customer must
|
||||
* rebook a new window). No reservation and no invoice exists yet at this stage,
|
||||
* so this is a lighter expiry than `expire()`: just flip status + notify, and
|
||||
* best-effort close any payable if one was issued early. Government/export are
|
||||
* excluded by the query.
|
||||
*/
|
||||
async expireUnacceptedForRouteDay(group: RouteDayGroup): Promise<void> {
|
||||
const corridorYards = await this.corridorYardsForRouteDay(group);
|
||||
if (corridorYards.length === 0) return;
|
||||
const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
|
||||
corridorYards,
|
||||
group.day,
|
||||
);
|
||||
for (const booking of unaccepted) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
// Free the shipment day so the customer can rebook a fresh window.
|
||||
scheduledDate: null,
|
||||
} as never);
|
||||
// Close any payable issued before doc-review end (normally none — the invoice
|
||||
// is created at ops-accept, which by definition has not happened here).
|
||||
await this.billing
|
||||
.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID")
|
||||
.catch(() => undefined);
|
||||
this.notifier.expired(booking);
|
||||
this.logger.log(
|
||||
`Expired unaccepted booking ${booking.reference}:${booking.id} at doc-review end ` +
|
||||
`(${group.originYardId}->${group.destinationYardId} ${group.day})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Free capacity for a government booking by displacing the lowest-priority commercial
|
||||
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
|
||||
|
||||
Reference in New Issue
Block a user