split export

This commit is contained in:
Marshal
2026-07-18 09:12:52 +00:00
parent 6100a121d2
commit 406fbf6c45
14 changed files with 1092 additions and 41 deletions

View File

@@ -71,6 +71,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { RemainderPlacementService } from './remainder-placement.service';
import { BookingWindowGateway } from './booking-window.gateway';
import {
MAX_TEU_SLOTS_PER_WAGON,
@@ -317,9 +318,29 @@ export class BookingBatchService implements OnModuleInit {
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService,
@Optional()
@Inject(forwardRef(() => RemainderPlacementService))
private readonly remainderPlacement?: RemainderPlacementService,
) {}
/**
* Auto-place a paid booking's split remainder onto the next fitting train.
* Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true.
*/
private get autoRemainderEnabled(): boolean {
return process.env.FREIGHT_AUTO_REMAINDER === "true";
}
/**
* Let EXPORT bookings split (offer the largest fitting part, leftover rebooks
* on the next train). Separate flag from auto-remainder: export touches the
* FCFS money path, so partial-offer can be enabled independently.
*/
private get exportSplitEnabled(): boolean {
return process.env.FREIGHT_EXPORT_SPLIT === "true";
}
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
async onModuleInit(): Promise<void> {
const groups = await this.openRouteDayGroups();
@@ -496,6 +517,34 @@ export class BookingBatchService implements OnModuleInit {
// to the offered part before it boards (remainder returns to the contract cap).
if (this.splitService) {
await this.splitService.applySplit(bookingId);
// The split only happens on payment (here) — so auto-placing the remainder
// also only happens once the customer has accepted+paid. Re-read to see if
// applySplit actually reduced this booking (an open offer existed); if so,
// auto-create + place the remainder booking on the next fitting train.
// applySplit committed its own transaction before returning, so this reads
// the reduced lines. Best-effort: a placement failure never blocks the
// paid booking from boarding — the remainder falls back to manual rebook.
if (this.autoRemainderEnabled && this.remainderPlacement) {
const split = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
// Export remainders only auto-place when export split is on — otherwise
// an export booking never splits in the first place.
const directionOn =
split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled;
if (split?.isSplit && directionOn) {
await this.remainderPlacement
.placeRemainder(split)
.catch((err) =>
this.logger.error(
`Auto-place remainder failed for ${split.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
}
}
}
const linked =
@@ -731,6 +780,76 @@ export class BookingBatchService implements OnModuleInit {
);
}
/**
* Trains that can carry a booking's leg on a given day, earliest departure
* first, each with the largest number of wagons it could still admit for the
* booking's wagon type. Direction-filtered: EXPORT bookings see export trains,
* IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL
* allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a
* non-primary allowed type still counts. The remainder placer uses this to
* pick the next fitting train; the `free` wagon count is the best across the
* allowed types (a train fits under whichever allowed type gives most room).
*/
async fittingTrainsForDay(
booking: Booking,
day: string,
direction: "IMPORT" | "EXPORT",
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.bookingWindowStatus !== "FULL" &&
(direction === "EXPORT"
? s.direction === "EXPORT"
: s.direction !== "EXPORT"),
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
const wagonDims = await this.loadWagonDims();
const dimsOptions = this.dimsForAllowed(booking, wagonDims);
const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = [];
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive);
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
const room = budget.remainingFor(leg);
// Best usable wagons across the allowed types — a train fits under
// whichever configured wagon type gives it the most room.
let freeWagons = 0;
for (const dims of dimsOptions) {
const w = this.bookableWithin(room, dims).wagons;
if (w > freeWagons) freeWagons = w;
}
if (freeWagons > 0) {
out.push({
scheduleId: schedule.id,
departure: schedule.scheduledDepartureDate!,
freeWagons,
});
}
}
return out;
}
/**
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
* summed across every train on the booking's corridor that day. Unlike the
@@ -789,6 +908,58 @@ export class BookingBatchService implements OnModuleInit {
return { freeWagons, need, trainsForDay };
}
/**
* Export split: no single train carries the whole booking, so offer the
* largest fitting part on the export train with the most room for its leg.
* Returns true when an offer was opened (the caller must NOT then reserve —
* the offer already opened its own pay window), false when the booking fits
* whole somewhere (normal FCFS path) or no meaningful partial exists.
*
* Only the offer is written here: the booking is reduced to the offered part
* on payment (applySplit), and the leftover is auto-placed afterwards. So an
* unpaid export booking stays whole and the customer may still cancel it.
*/
private async tryExportPartialOffer(booking: Booking): Promise<boolean> {
if (!this.splitService) return false;
const report = await this.exportSpaceReport(booking);
// A train fits it whole — nothing to split, take the normal path.
if (report.scheduleId) return false;
if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false;
if (!booking.scheduledDate) return false;
const day = eatDay(new Date(booking.scheduledDate));
const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT");
if (!fitting.length) return false;
// Most room first — the largest single part ships now, the smallest leftover
// is what has to find another train.
const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0];
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
target.scheduleId,
);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return false;
const wagonDims = await this.loadWagonDims();
const limits = await this.capacityLimits(locomotive);
const budget = await this.remainingBudget(schedule, limits, wagonDims);
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
if (!leg) return false;
const offered = await this.tryPartialOffer(
booking,
schedule.id,
budget.remainingFor(leg),
report.need,
);
if (!offered) return false;
this.logger.log(
`[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` +
`${schedule.id} — leftover rebooks on the next train once paid.`,
);
this.notifyBoardChanged(schedule.id, "batch_fill");
return true;
}
/**
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
* A consolidated booking reserves as a pair only once BOTH partners are ready
@@ -800,6 +971,15 @@ export class BookingBatchService implements OnModuleInit {
async acceptExportBooking(booking: Booking): Promise<void> {
const partnerId = booking.consolidationPartnerId ?? null;
if (!partnerId) {
// Export split: when no single train carries the whole booking, offer the
// largest fitting part instead of failing the accept. The customer pays
// that part; on payment applySplit reduces this booking to it and the
// leftover is auto-placed as its own booking on the next train. Pairs are
// excluded (handled below) — a shared wagon is never split.
if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) {
const offered = await this.tryExportPartialOffer(booking);
if (offered) return;
}
const scheduleId = await this.pickExportSchedule(booking);
await this.reserveOnExport([booking], scheduleId);
return;
@@ -1852,15 +2032,23 @@ export class BookingBatchService implements OnModuleInit {
}
/**
* 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).
* A lone commercial 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).
*
* IMPORT is always eligible. EXPORT is eligible only when export split is
* enabled: export historically rides one train whole, so splitting it changes
* the FCFS money path — each split part still rides ONE train whole, and the
* leftover becomes its own booking on the next train.
*/
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
const directionOk =
booking.tradeDirection === "IMPORT" ||
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
return (
!isPair &&
!booking.isGovernment &&
booking.tradeDirection === "IMPORT" &&
directionOk &&
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
this.splitService != null
);
@@ -3174,6 +3362,40 @@ export class BookingBatchService implements OnModuleInit {
};
}
/**
* EVERY wagon-type dimension a booking may ride — its cargo/container type's
* full allowed (many-to-many) wagon-type list, not just the first like
* {@link dimsFor}. The remainder placer needs the whole set so a train that
* stocks a non-primary allowed type still counts as fitting: a container type
* mapped to both NW5 and (say) NW7 must be measured against whichever a given
* train actually has free. Deduped by wagon-type id; falls back to the single
* representative dims when no allowed type is configured.
*/
private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
const fallback =
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
const ids =
booking.freightType === "BULK"
? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id)
: (booking.bookingContainers ?? [])
.flatMap((line) => line.containerType?.wagonTypes ?? [])
.map((wt) => wt.id);
const seen = new Set<string>();
const dims: PerWagonDims[] = [];
for (const id of ids) {
if (!id || seen.has(id)) continue;
seen.add(id);
const d = wagonDims.byWagonTypeId.get(id);
if (d) {
dims.push({
...d,
capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
});
}
}
return dims.length ? dims : [fallback];
}
/**
* Ordered stop yards of the schedule's route (origin → milestones →
* destination); the legacy two-stop pseudo-route when milestones are absent.