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

@@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
@@ -54,6 +55,18 @@ export interface CreateBookingUnderContractResult {
warnings: string[];
}
/**
* Outstanding split remainder of a contract: what was booked in the first split
* booking's pre-split snapshot MINUS everything currently booked. Container
* contracts report per size; bulk reports one tonnage figure. `null` when the
* contract has no live split chain. Consumed by the remainder-placement engine
* to size the auto-created remainder booking.
*/
export type SplitOutstanding = {
bySize: Map<string, { total: number; outstanding: number }>;
bulk: { total: number; outstanding: number } | null;
};
/**
* The single create path for shipment bookings under a contract.
*
@@ -966,9 +979,11 @@ export class ContractBookingService {
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
* contract has no live split booking.
*/
private async splitOutstanding(
contract: Contract,
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> {
/**
* Public: the remainder-placement engine reads this to size the auto-created
* remainder booking. Returns `null` when there is no live split chain.
*/
async splitOutstanding(contract: Contract): Promise<SplitOutstanding | null> {
const first = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
@@ -1015,6 +1030,25 @@ export class ContractBookingService {
const probe = await this.buildExportProbe(contract, route, dto, yards);
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (report.scheduleId) return;
// With export split ON a booking no longer has to ride ONE train whole: the
// largest fitting part is offered and the leftover is rebooked on the next
// train. Rejecting on the single-train fit here would block exactly the
// bookings the split exists to serve — including the auto-created remainder,
// which by definition did not fit the train it was split off. Fall back to
// the day total: unbookable only when NO export train that day has room.
if (process.env.FREIGHT_EXPORT_SPLIT === 'true') {
const fitting = await this.bookingBatchService.fittingTrainsForDay(
probe,
eatDay(new Date(dto.scheduledDate)),
'EXPORT',
);
if (fitting.length > 0) return;
throw new BadRequestException(
'No export train on this day has space left — pick another shipment day.',
);
}
throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.',
);
@@ -1636,6 +1670,8 @@ export class ContractBookingService {
isGovernment: contract.isGovernment,
shippingLineId: null,
contractRouteId: route?.id ?? null,
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,