mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
split export
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user