shipping line

This commit is contained in:
Marshal
2026-08-13 18:56:52 +00:00
parent 0e00a98ef3
commit b9ba830a09
48 changed files with 6766 additions and 639 deletions

View File

@@ -26,6 +26,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { formatRouteLabel } from '../routes/entities/route.entity';
import { isRoadService } from '../bookings/road.util';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
@@ -154,6 +155,19 @@ export interface ExportTrainOption {
}>;
}
/** Form-entered cargo for a train-options probe (nothing persisted yet). */
export interface TrainOptionCargoOverrides {
/** Container types drive the per-type space. */
containerTypeIds?: string[];
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
containerSizes?: string[];
/** Bulk counterparts of the container inputs. */
cargoTypeId?: string;
cargoTypeCode?: string;
/** Needed wagons estimate from the form (drives the `fits` flag). */
wagons?: number;
}
/** A train a paid-unallocated booking can board (route + capacity verified). */
export interface AllocationCandidate {
id: string;
@@ -1051,19 +1065,84 @@ export class BookingBatchService implements OnModuleInit {
async exportTrainOptionsForDay(
booking: Booking,
day: string,
overrides?: {
/** Cargo the customer is entering on a form (bare contract instance —
* nothing persisted yet): container types drive the per-type space. */
containerTypeIds?: string[];
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
containerSizes?: string[];
/** Bulk counterparts of the container inputs. */
cargoTypeId?: string;
cargoTypeCode?: string;
/** Needed wagons estimate from the form (drives the `fits` flag). */
wagons?: number;
},
overrides?: TrainOptionCargoOverrides,
): Promise<ExportTrainOption[]> {
booking = await this.withCargoOverrides(booking, overrides);
const corridor = await this.trainSchedulesRepository.findAll({
where: [
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.direction === 'EXPORT',
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
return this.buildTrainOptions(booking, candidates);
}
/**
* The same per-train wagon-availability cards, but for the trains DEDICATED
* to a shipping line on the booking's lane + day. Same option shape as the
* export picker so the portal reuses the same component; `isOpen`
* additionally respects the dedicated close offset (windowClosesAt), since
* these trains run no window cycle.
*/
async dedicatedTrainOptionsForDay(
booking: Booking,
day: string | null,
shippingLineCompanyId: string,
overrides?: TrainOptionCargoOverrides,
): Promise<ExportTrainOption[]> {
booking = await this.withCargoOverrides(booking, overrides);
const dedicated = await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId },
],
});
const candidates = dedicated
.filter(
(s) =>
s.scheduledDepartureDate != null &&
// A day narrows to that departure day; without one, every upcoming
// departure on the lane is listed (the picker's full card list).
(day
? eatDay(s.scheduledDepartureDate) === day
: s.scheduledDepartureDate.getTime() > Date.now() - 3_600_000) &&
(!booking.originYardId || s.originStationId === booking.originYardId) &&
(!booking.destinationYardId ||
s.destinationStationId === booking.destinationYardId),
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
const options = await this.buildTrainOptions(booking, candidates);
const now = Date.now();
return options.map((o) => ({
...o,
isOpen:
o.isOpen &&
(o.bookingClosesAt == null || o.bookingClosesAt.getTime() > now),
}));
}
/** Resolve form-entered cargo onto an (unpersisted) booking probe. */
private async withCargoOverrides(
booking: Booking,
overrides?: TrainOptionCargoOverrides,
): Promise<Booking> {
const sizeFts = (overrides?.containerSizes ?? [])
.map((s) => parseInt(s, 10))
.filter((n) => Number.isFinite(n) && n > 0);
@@ -1095,26 +1174,14 @@ export class BookingBatchService implements OnModuleInit {
if (overrides?.wagons && overrides.wagons > 0) {
booking = { ...booking, wagonsRequired: overrides.wagons } as Booking;
}
const corridor = await this.trainSchedulesRepository.findAll({
where: [
// Dedicated shipping-line trains are never customer-booking targets.
{ status: TrainScheduleStatusEnum.Draft, shippingLineCompanyId: IsNull() },
{ status: TrainScheduleStatusEnum.Scheduled, shippingLineCompanyId: IsNull() },
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
s.direction === 'EXPORT',
)
.sort(
(a, b) =>
a.scheduledDepartureDate!.getTime() -
b.scheduledDepartureDate!.getTime(),
);
return booking;
}
/** One availability card per candidate schedule — the export picker's math. */
private async buildTrainOptions(
booking: Booking,
candidates: TrainSchedule[],
): Promise<ExportTrainOption[]> {
const wagonDims = await this.loadWagonDims();
const allowed = this.allowedDimsWithTypes(booking, wagonDims);
const neededWagons = this.wagonsFor(booking, wagonDims);
@@ -3185,6 +3252,99 @@ export class BookingBatchService implements OnModuleInit {
}
}
/**
* Auto-allocate an accepted SHIPPING-LINE booking onto its company's
* dedicated train for the booking's lane and shipment day.
*
* Runs at operation-accept: shipping lines pay later on the credit ledger,
* so there is no pay window between accept and wagon placement — the
* booking boards its train immediately. Customer bookings never come here;
* they keep the batch pool → reserve → pay → allocate pipeline.
*
* Wagon shortage parks the booking WAITING_FOR_WAGON on the schedule
* (without the PAID stamps the customer hold writes — nothing was paid).
* No dedicated train on the day is not an error: the booking simply stays
* in the ordinary day pool for the batch engine.
*/
async allocateShippingLineAccepted(bookingId: string): Promise<void> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: bookingId },
relations: { bookingContainers: { containerType: true }, cargoType: true },
});
if (!booking?.shippingLineCompanyId || !booking.scheduledDate) return;
if (isRoadService(booking.serviceType)) return;
const day = eatDay(booking.scheduledDate);
const dedicated = await this.dataSource.getRepository(TrainSchedule).find({
where: [
{
shippingLineCompanyId: booking.shippingLineCompanyId,
originStationId: booking.originYardId,
destinationStationId: booking.destinationYardId,
status: TrainScheduleStatusEnum.Draft,
},
{
shippingLineCompanyId: booking.shippingLineCompanyId,
originStationId: booking.originYardId,
destinationStationId: booking.destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
},
],
});
const target = dedicated.find(
(s) =>
s.scheduledDepartureDate && eatDay(s.scheduledDepartureDate) === day,
);
if (!target) {
this.logger.log(
`[BATCH] shipping-line booking ${booking.reference} has no dedicated ` +
`train on ${day} — left in the day pool for the batch engine`,
);
return;
}
// Point the booking at its train BEFORE the shortage probe — the probe
// reads the link to size the need against that schedule's wagons.
await this.dataSource.getRepository(Booking).update(booking.id, {
trainScheduleId: target.id,
} as never);
booking.trainScheduleId = target.id;
// One dedicated train carries ONE booking: the accept claims the train by
// closing its booking window on the spot. Both gates a later booking
// passes — the day picker (isStillOpen on windowClosesAt) and the
// completion's dedicated-day check — read these fields, so a second
// booking can never pick this train.
await this.dataSource.getRepository(TrainSchedule).update(target.id, {
bookingWindowStatus: "CLOSED",
windowClosesAt: new Date(),
} as never);
this.notifyBoardChanged(target.id, "shipping_line_train_claimed");
const shortage =
await this.trainSchedulingService.previewPaidBookingWagonShortage(
target.id,
booking.id,
);
if (shortage) {
// Parked for staff to attach wagons — WITHOUT the customer hold's PAID
// stamps: a shipping line has paid nothing, its debt sits on the ledger.
await this.dataSource.getRepository(Booking).update(booking.id, {
schedulingStatus: "WAITING_FOR_WAGON",
} as never);
this.logger.warn(
`Shipping-line booking ${booking.reference} WAITING FOR WAGON on its ` +
`dedicated train ${target.reference ?? target.id}: needs ` +
`${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` +
`${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}).`,
);
this.notifyBoardChanged(target.id, "booking_waiting_wagon");
return;
}
await this.allocate(target.id, booking, "shipping_line");
}
/**
* One reminder per hold, shortly before its pay deadline (the window tick
* calls this every pass; `payment_reminder_sent_at` dedups). Skips paid
@@ -3544,7 +3704,7 @@ export class BookingBatchService implements OnModuleInit {
private async allocate(
scheduleId: string,
booking: Booking,
reason: "paid" | "gov",
reason: "paid" | "gov" | "shipping_line",
): Promise<void> {
// Stamp the computed wagon need on the link. Several callers pass a booking
// loaded without cargo relations (ensurePaidBookingAllocated), and a NULL