mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1382 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -63,7 +63,7 @@ describe('BookingTransitionService — paired staff decisions', () => {
|
||||
expect(result.partner.id).toBe('b-2');
|
||||
});
|
||||
|
||||
it('cancels both halves with the same reason', async () => {
|
||||
it('cancels via cancel() once — its pair cascade settles the partner', async () => {
|
||||
const { service } = makeService(paired);
|
||||
const cancel = jest
|
||||
.spyOn(service, 'cancel')
|
||||
@@ -73,21 +73,23 @@ describe('BookingTransitionService — paired staff decisions', () => {
|
||||
reason: 'customer withdrew',
|
||||
});
|
||||
|
||||
expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew');
|
||||
expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew');
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(cancel).toHaveBeenCalledWith('b-1', 'customer withdrew');
|
||||
});
|
||||
|
||||
it('propagates a failure on the second half so neither is committed', async () => {
|
||||
const { service, dataSource } = makeService(paired);
|
||||
jest
|
||||
.spyOn(service, 'cancel')
|
||||
.spyOn(service, 'acceptIntake')
|
||||
.mockImplementationOnce(async (id) => ({ id }) as Booking)
|
||||
.mockImplementationOnce(async () => {
|
||||
throw new Error('partner is already in transit');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
|
||||
service.applyPairedDecision('b-1', 'accept', 'staff-1', {
|
||||
validityDays: 30,
|
||||
}),
|
||||
).rejects.toThrow('partner is already in transit');
|
||||
|
||||
// Both halves ran inside one transaction, so the throw rolls the first back.
|
||||
|
||||
@@ -91,28 +91,10 @@ export class BookingTransitionService {
|
||||
|
||||
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
|
||||
private async assert20ftPairable(booking: Booking): Promise<void> {
|
||||
// Parity gate. 20ft ride two per wagon, so an odd total leaves one container
|
||||
// that cannot be placed. Consolidation (pairing it with another customer's
|
||||
// odd booking) is built end to end but switched off for now, so an odd total
|
||||
// is rejected here rather than parked for a partner.
|
||||
// containerSize is not always populated (some rows carry only the container
|
||||
// type), so fall back to the type's sizeFt rather than silently skipping
|
||||
// those lines and letting an odd booking through.
|
||||
const ft20Quantity = (booking.bookingContainers ?? [])
|
||||
.filter((bc) =>
|
||||
bc.containerSize
|
||||
? bc.containerSize.includes("20")
|
||||
: Number(bc.containerType?.sizeFt) === 20,
|
||||
)
|
||||
.reduce((sum, bc) => sum + Number(bc.quantity || 0), 0);
|
||||
if (ft20Quantity % 2 === 1) {
|
||||
throw new BadRequestException(
|
||||
`20ft containers travel two per wagon, so they must be booked in even ` +
|
||||
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
|
||||
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Odd 20ft totals are not rejected here: runConsolidationOnSubmit (called
|
||||
// right after this gate) auto-pairs the odd leftover with another
|
||||
// customer's odd booking or parks the booking as PENDING_CONSOLIDATION.
|
||||
// Only the weight-pairing rule hard-blocks.
|
||||
const violations =
|
||||
await this.containerValidationService.validate20ftPairing(booking);
|
||||
if (violations.length) {
|
||||
@@ -456,11 +438,42 @@ export class BookingTransitionService {
|
||||
async cancelHold(bookingId: string, reason?: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ["SELECTED_FOR_BATCH"]);
|
||||
if (booking.consolidationPartnerId) {
|
||||
throw new BadRequestException(
|
||||
"This booking shares a consolidated wagon with another booking — " +
|
||||
"contact support to cancel it.",
|
||||
// Consolidated pair: the shared wagon dies with this hold. An unpaid
|
||||
// partner's hold is released with it (both cancel, no fee); a PAID partner
|
||||
// keeps the whole wagon and this canceller owes the cancellation fee.
|
||||
const partnerId = booking.consolidationPartnerId;
|
||||
if (partnerId) {
|
||||
const partner = await this.bookingsService.findById(partnerId);
|
||||
const partnerPaid =
|
||||
partner.paymentStatus === "PAID" || partner.status === "PAID";
|
||||
await this.bookingsRepository.clearConsolidationPair(
|
||||
booking.id,
|
||||
partnerId,
|
||||
);
|
||||
if (partnerPaid) {
|
||||
this.events.emit("booking.consolidation.partnerLapsed", {
|
||||
expiredBookingId: booking.id,
|
||||
});
|
||||
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
|
||||
const partnerReason = "Cancelled with its consolidation partner";
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
partnerId,
|
||||
partnerReason,
|
||||
"REJECTION",
|
||||
);
|
||||
if (partner.status === "SELECTED_FOR_BATCH") {
|
||||
await this.bookingBatchService.cancelReservation(partnerId);
|
||||
} else {
|
||||
await this.invoiceService.expireOpenInvoices(partnerId);
|
||||
await this.bookingsRepository.update(partnerId, {
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
}
|
||||
this.notifier.cancelled(
|
||||
await this.bookingsService.findById(partnerId),
|
||||
partnerReason,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
@@ -514,6 +527,17 @@ export class BookingTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
// cancel() carries its own pair cascade (it settles the partner too), so
|
||||
// running it twice would trip on the already-cancelled partner.
|
||||
if (decision === "cancel") {
|
||||
const own = await this.cancel(
|
||||
bookingId,
|
||||
options.reason ?? "Cancelled with its consolidation partner",
|
||||
);
|
||||
const other = await this.bookingsService.findById(partnerId);
|
||||
return { booking: own, partner: other };
|
||||
}
|
||||
|
||||
const runOne = async (id: string): Promise<Booking> => {
|
||||
switch (decision) {
|
||||
case "accept":
|
||||
@@ -525,11 +549,6 @@ export class BookingTransitionService {
|
||||
);
|
||||
}
|
||||
return this.acceptIntake(id, actorId, Number(options.validityDays));
|
||||
case "cancel":
|
||||
return this.cancel(
|
||||
id,
|
||||
options.reason ?? "Cancelled with its consolidation partner",
|
||||
);
|
||||
case "operationAccept":
|
||||
return this.reviewOperationRequest(id, "ACCEPT", actorId, {
|
||||
note: options.note,
|
||||
@@ -566,8 +585,53 @@ export class BookingTransitionService {
|
||||
"PENDING_APPROVAL",
|
||||
"CONTRACT_READY",
|
||||
"OPERATION_REQUEST_PENDING",
|
||||
// A booking parked waiting for a consolidation partner can be walked
|
||||
// away from — nothing is reserved yet.
|
||||
"PENDING_CONSOLIDATION",
|
||||
]);
|
||||
|
||||
// Consolidated pair: a shared wagon never ships half-full, so cancelling
|
||||
// one half settles the other too. Neither paid → both cancel, no fee. A
|
||||
// PAID partner instead keeps the whole wagon and the unpaid canceller
|
||||
// owes the cancellation fee (opened by the partnerLapsed listener). A
|
||||
// PAID booking itself never comes through here (status gate above) — it
|
||||
// cancels via wagon cancellation, where the fee machinery lives.
|
||||
const partnerId = booking.consolidationPartnerId;
|
||||
if (partnerId) {
|
||||
const partner = await this.bookingsService.findById(partnerId);
|
||||
const partnerPaid =
|
||||
partner.paymentStatus === "PAID" || partner.status === "PAID";
|
||||
await this.bookingsRepository.clearConsolidationPair(
|
||||
booking.id,
|
||||
partnerId,
|
||||
);
|
||||
if (partnerPaid) {
|
||||
this.events.emit("booking.consolidation.partnerLapsed", {
|
||||
expiredBookingId: booking.id,
|
||||
});
|
||||
} else if (!["CANCELLED", "EXPIRED"].includes(partner.status)) {
|
||||
const partnerReason = "Cancelled with its consolidation partner";
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
partnerId,
|
||||
partnerReason,
|
||||
"REJECTION",
|
||||
);
|
||||
await this.invoiceService.expireOpenInvoices(partnerId);
|
||||
if (partner.status === "SELECTED_FOR_BATCH") {
|
||||
// Reserved hold: release the wagons through the batch engine.
|
||||
await this.bookingBatchService.cancelReservation(partnerId);
|
||||
} else {
|
||||
await this.bookingsRepository.update(partnerId, {
|
||||
status: "CANCELLED",
|
||||
} as never);
|
||||
}
|
||||
this.notifier.cancelled(
|
||||
await this.bookingsService.findById(partnerId),
|
||||
partnerReason,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||
@@ -140,7 +141,29 @@ export class BookingWagonCancellationService {
|
||||
creditAmount: number;
|
||||
}> {
|
||||
const booking = await this.loadCancellableBooking(bookingId);
|
||||
const cut = await this.resolveRequestedCut(booking, dto);
|
||||
// Empty dto = the whole booking ("Cancel booking" button).
|
||||
const cut = this.isEmptyCut(dto)
|
||||
? await this.resolveFullCut(booking)
|
||||
: await this.resolveRequestedCut(booking, dto);
|
||||
// Consolidated booking: preview the same rules the request enforces — a
|
||||
// full cut breaks the pair (canceller fee = ceil of its fractional
|
||||
// wagons); a partial cut must spare the shared wagon.
|
||||
if (booking.consolidationPartnerId) {
|
||||
const full = await this.resolveFullCut(booking);
|
||||
if (cut.wagons >= full.wagons) {
|
||||
const feeWagons = Math.ceil(cut.wagons);
|
||||
const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons });
|
||||
return {
|
||||
wagons: cut.wagons,
|
||||
weightTons: cut.weightTons,
|
||||
feePerWagon: fee.perWagon,
|
||||
feeAmount: fee.amount,
|
||||
feeCurrency: fee.currency,
|
||||
creditAmount: this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
};
|
||||
}
|
||||
this.assertCutSparesSharedWagon(cut);
|
||||
}
|
||||
const fee = await this.priceFee(booking, cut);
|
||||
return {
|
||||
wagons: cut.wagons,
|
||||
@@ -158,6 +181,24 @@ export class BookingWagonCancellationService {
|
||||
userId?: string,
|
||||
): Promise<BookingWagonCancellation> {
|
||||
const booking = await this.loadCancellableBooking(bookingId);
|
||||
// Consolidated booking: the shared wagon itself is untouchable — its other
|
||||
// half belongs to the partner. The customer may still cancel
|
||||
// - the WHOLE booking (breaks the pair: both cancel, ceil/floor fees), or
|
||||
// - a PARTIAL cut of their own full wagons — an EVEN number of 20ft
|
||||
// containers, so the odd one stays on the shared wagon and the pair
|
||||
// survives untouched.
|
||||
if (booking.consolidationPartnerId) {
|
||||
if (this.isEmptyCut(dto)) {
|
||||
return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId);
|
||||
}
|
||||
const full = await this.resolveFullCut(booking);
|
||||
const cut = await this.resolveRequestedCut(booking, dto);
|
||||
if (cut.wagons >= full.wagons) {
|
||||
return this.cancelConsolidatedPair(booking, dto.reason ?? null, userId);
|
||||
}
|
||||
this.assertCutSparesSharedWagon(cut);
|
||||
// fall through: a pair-safe partial cut rides the normal partial flow.
|
||||
}
|
||||
const open = await this.repo.findOpenForBooking(bookingId);
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
@@ -165,7 +206,10 @@ export class BookingWagonCancellationService {
|
||||
);
|
||||
}
|
||||
|
||||
const cut = await this.resolveRequestedCut(booking, dto);
|
||||
// Empty dto = the whole booking ("Cancel booking" button).
|
||||
const cut = this.isEmptyCut(dto)
|
||||
? await this.resolveFullCut(booking)
|
||||
: await this.resolveRequestedCut(booking, dto);
|
||||
const fee = await this.priceFee(booking, cut);
|
||||
const feeAmount = fee.amount;
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
@@ -280,6 +324,253 @@ export class BookingWagonCancellationService {
|
||||
return (await this.repo.update(row.id, { status: 'WITHDRAWN' }))!;
|
||||
}
|
||||
|
||||
// ── Consolidated-pair cancellation ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cancel BOTH halves of a consolidated pair — a shared wagon never ships
|
||||
* half-full, so a paired booking always cancels whole, together with its
|
||||
* partner.
|
||||
*
|
||||
* Fee split (the canceller's leftover 20ft claims the shared wagon):
|
||||
* canceller pays ceil(its wagons), the partner floor(its wagons) — e.g.
|
||||
* 11 + 13 × 20ft = 12 wagons → canceller 7, partner 5, total 12. A PAID side
|
||||
* keeps its full freight as a rebooking credit (rebooked by GL through the
|
||||
* normal rebook endpoint once its fee settles); an UNPAID partner is
|
||||
* cancelled with no fee and no credit.
|
||||
*/
|
||||
private async cancelConsolidatedPair(
|
||||
booking: Booking,
|
||||
reason: string | null,
|
||||
userId?: string,
|
||||
): Promise<BookingWagonCancellation> {
|
||||
const partnerId = booking.consolidationPartnerId!;
|
||||
const partner = await this.bookingsRepository.findById(partnerId);
|
||||
if (!partner) {
|
||||
throw new NotFoundException(`Partner booking ${partnerId} not found.`);
|
||||
}
|
||||
const partnerPaid =
|
||||
partner.paymentStatus === 'PAID' || partner.status === 'PAID';
|
||||
|
||||
// Break the link first — every write below treats each side singly.
|
||||
await this.bookingsRepository.clearConsolidationPair(booking.id, partnerId);
|
||||
|
||||
const row = await this.openConsolidationBreak(
|
||||
booking,
|
||||
'ceil',
|
||||
this.creditFor(booking, Number(booking.wagonsRequired ?? 0)),
|
||||
reason ?? 'Consolidated pair cancelled',
|
||||
userId,
|
||||
);
|
||||
if (partnerPaid) {
|
||||
await this.openConsolidationBreak(
|
||||
partner,
|
||||
'floor',
|
||||
this.creditFor(partner, Number(partner.wagonsRequired ?? 0)),
|
||||
`Cancelled with its consolidation partner ${booking.reference}`,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
// Unpaid partner: no fee — just make sure no payable invoice stays open.
|
||||
await this.billing
|
||||
.expirePayable(Freight.InvoiceSource.Booking, partner.id, 'PREPAID')
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
for (const b of [booking, partner]) {
|
||||
await this.dataSource.getRepository(Booking).update(b.id, {
|
||||
status: 'CANCELLED',
|
||||
trainScheduleId: null,
|
||||
requestedTrainScheduleId: null,
|
||||
});
|
||||
await this.detachFromSchedule(b);
|
||||
}
|
||||
this.notifyCustomer(
|
||||
booking,
|
||||
'Consolidated booking cancelled',
|
||||
`${booking.reference} shared a wagon with another booking, so both are cancelled. Your paid freight is kept as credit — pay the cancellation fee to rebook.`,
|
||||
);
|
||||
this.notifyCustomer(
|
||||
partner,
|
||||
'Consolidated booking cancelled',
|
||||
partnerPaid
|
||||
? `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Your paid freight is kept as credit — pay the cancellation fee to rebook.`
|
||||
: `${partner.reference} shared a wagon with a booking that was cancelled, so it is cancelled too. Nothing was paid — no fee applies.`,
|
||||
);
|
||||
this.notifyStaff(
|
||||
booking,
|
||||
'Consolidated pair cancelled',
|
||||
`${booking.reference} + ${partner.reference}: shared-wagon pair cancelled; cancellation fee invoice(s) issued.`,
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one side's ledger row for a consolidation break: a FULL cut whose fee
|
||||
* is priced on the ceil/floor split of the cut's own FRACTIONAL wagons —
|
||||
* never booking.wagonsRequired, which the contract flow persists already
|
||||
* ceiled (3 × 20ft is stored as 2, not 1.5, and floor(2) would over-charge
|
||||
* the partner). E.g. 1 + 3 × 20ft: canceller ceil(0.5) = 1 wagon, partner
|
||||
* floor(1.5) = 1 wagon — 2 wagons total, matching the pair's real space.
|
||||
* feeWagons 0 (the floor side of a lone 20ft) skips the fee entirely — the
|
||||
* row goes straight to CREDIT_AVAILABLE.
|
||||
*/
|
||||
private async openConsolidationBreak(
|
||||
booking: Booking,
|
||||
mode: 'ceil' | 'floor',
|
||||
creditAmount: number,
|
||||
reason: string,
|
||||
userId?: string,
|
||||
): Promise<BookingWagonCancellation> {
|
||||
const open = await this.repo.findOpenForBooking(booking.id);
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
`Booking ${booking.reference} already has a cancellation awaiting its fee. Pay or withdraw it first.`,
|
||||
);
|
||||
}
|
||||
const cut = await this.resolveFullCut(booking);
|
||||
const feeWagons =
|
||||
mode === 'ceil' ? Math.ceil(cut.wagons) : Math.floor(cut.wagons);
|
||||
// The pair is dead the moment it breaks — the wagons leave the schedule
|
||||
// with the cancel itself, so T2 must not release them again.
|
||||
const quantities = { ...cut.quantities, releasedAtRequest: true };
|
||||
|
||||
if (feeWagons <= 0) {
|
||||
return this.repo.create({
|
||||
bookingId: booking.id,
|
||||
wagonsCancelled: cut.wagons,
|
||||
weightTons: cut.weightTons,
|
||||
cancelledQuantities: quantities,
|
||||
creditAmount,
|
||||
feeAmount: 0,
|
||||
feeCurrency: booking.paymentCurrency ?? 'ETB',
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
feePaidAt: new Date(),
|
||||
reason,
|
||||
requestedByUserId: userId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const fee = await this.priceFee(booking, { ...cut, wagons: feeWagons });
|
||||
const row = await this.repo.create({
|
||||
bookingId: booking.id,
|
||||
wagonsCancelled: cut.wagons,
|
||||
weightTons: cut.weightTons,
|
||||
cancelledQuantities: quantities,
|
||||
creditAmount,
|
||||
feeRateId: fee.rates[0].id,
|
||||
feeAmount: fee.amount,
|
||||
feeCurrency: fee.currency,
|
||||
status: 'FEE_PENDING',
|
||||
reason,
|
||||
requestedByUserId: userId ?? null,
|
||||
});
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: booking.id,
|
||||
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: fee.currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CANCELLATION_FEE',
|
||||
description: `Consolidation cancellation fee — ${feeWagons} wagon(s) of booking ${booking.reference}`,
|
||||
quantity: feeWagons,
|
||||
unitRate: fee.perWagon,
|
||||
amount: fee.amount,
|
||||
currency: fee.currency,
|
||||
metadata: { wagonCancellationId: row.id },
|
||||
},
|
||||
],
|
||||
totalAmount: fee.amount,
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
});
|
||||
return (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row;
|
||||
}
|
||||
|
||||
/** No cut named at all — the "Cancel booking" button cancelling everything. */
|
||||
private isEmptyCut(dto: RequestWagonCancellationDto): boolean {
|
||||
return (
|
||||
!dto.containers?.length && !dto.wagonAllocationIds?.length && !dto.wagons
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A partial cut on a consolidated booking must leave the shared wagon whole:
|
||||
* the odd 20ft riding it stays, so the cut's 20ft count must be EVEN (whole
|
||||
* own wagons only). An odd cut — including picking the shared wagon itself in
|
||||
* the Wagons tab (it contributes exactly one 20ft) — is rejected.
|
||||
*/
|
||||
private assertCutSparesSharedWagon(cut: RequestedCut): void {
|
||||
const ft20Cut = Object.entries(cut.quantities.bySize ?? {})
|
||||
.filter(([size]) => sizeFtOf(size) === 20)
|
||||
.reduce((sum, [, qty]) => sum + qty, 0);
|
||||
if (ft20Cut % 2 === 1) {
|
||||
throw new BadRequestException(
|
||||
'This booking shares a wagon with another booking — the shared wagon cannot be cancelled on its own. Cancel an even number of 20ft containers (your own whole wagons), or cancel the whole booking to end the consolidation.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole booking as a cut — everything it still carries. */
|
||||
private async resolveFullCut(booking: Booking): Promise<RequestedCut> {
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
const lines = await this.dataSource.getRepository(BookingContainer).find({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
const bySize = new Map<string, number>();
|
||||
for (const line of lines) {
|
||||
const size = line.containerSize ?? '';
|
||||
bySize.set(size, (bySize.get(size) ?? 0) + Number(line.quantity ?? 0));
|
||||
}
|
||||
const containers = [...bySize.entries()]
|
||||
.filter(([, quantity]) => quantity > 0)
|
||||
.map(([containerSize, quantity]) => ({ containerSize, quantity }));
|
||||
return this.resolveRequestedCut(booking, {
|
||||
containers,
|
||||
} as RequestWagonCancellationDto);
|
||||
}
|
||||
return this.resolveRequestedCut(booking, {
|
||||
wagons: Number(booking.wagonsRequired ?? 0),
|
||||
} as RequestWagonCancellationDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* The batch engine expired an UNPAID booking whose consolidation partner had
|
||||
* already PAID: the paid partner keeps the whole wagon at no extra cost; the
|
||||
* lapsed side owes the cancellation fee on its own wagons — shared wagon
|
||||
* included (ceil). Credit is 0 (nothing was paid); once the fee settles GL
|
||||
* rebooks the customer through a normal new booking.
|
||||
*/
|
||||
@OnEvent('booking.consolidation.partnerLapsed')
|
||||
async onConsolidationPartnerLapsed(payload: {
|
||||
expiredBookingId: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const booking = await this.bookingsRepository.findById(
|
||||
payload.expiredBookingId,
|
||||
);
|
||||
if (!booking) return;
|
||||
if (await this.repo.findOpenForBooking(booking.id)) return; // already charged
|
||||
const row = await this.openConsolidationBreak(
|
||||
booking,
|
||||
'ceil',
|
||||
0,
|
||||
'Expired while its consolidation partner had paid — cancellation fee applies',
|
||||
);
|
||||
if (row.status !== 'FEE_PENDING') return; // nothing owed
|
||||
this.notifyCustomer(
|
||||
booking,
|
||||
'Cancellation fee due',
|
||||
`${booking.reference} expired unpaid while sharing a wagon with a paid booking. A cancellation fee for ${Math.ceil(Number(row.wagonsCancelled))} wagon(s) has been invoiced — settle it before booking again.`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Consolidation-lapse fee failed for booking ${payload.expiredBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── T2: fee settled ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -454,6 +745,13 @@ export class BookingWagonCancellationService {
|
||||
`This credit cannot be rebooked (status is ${row.status}).`,
|
||||
);
|
||||
}
|
||||
// A consolidation-lapse row on an UNPAID booking carries no credit — the
|
||||
// customer never paid freight, so there is nothing to redeem. Book fresh.
|
||||
if (Number(row.creditAmount) <= 0) {
|
||||
throw new BadRequestException(
|
||||
'This cancellation has no rebooking credit — the booking was never paid. Create a new booking instead.',
|
||||
);
|
||||
}
|
||||
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (!source) throw new NotFoundException(`Booking ${row.bookingId} not found.`);
|
||||
if (!source.contractId) {
|
||||
|
||||
@@ -596,6 +596,21 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal un-pair: break the consolidation link only, touching neither
|
||||
* status. Used when one half of a pair is cancelled/expired — the caller
|
||||
* decides each side's fate ({@link unpairConsolidation} instead re-parks
|
||||
* BOTH sides to PENDING_CONSOLIDATION, which is wrong for a dying booking).
|
||||
*/
|
||||
async clearConsolidationPair(bookingId: string, partnerId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
consolidationPartnerId: null,
|
||||
} as never);
|
||||
await this.repository.update(partnerId, {
|
||||
consolidationPartnerId: null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** Un-pair a consolidation. */
|
||||
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
|
||||
await this.repository.update(bookingId, {
|
||||
|
||||
@@ -427,7 +427,8 @@ export class BookingsService {
|
||||
'containerNumber', ci.container_number,
|
||||
'sealNumber', ci.seal_number,
|
||||
'positionOnWagon', ci.position_on_wagon,
|
||||
'grossWeightTons', ci.gross_weight_tons
|
||||
'grossWeightTons', ci.gross_weight_tons,
|
||||
'sizeFt', cit.size_ft
|
||||
) ORDER BY ci.position_on_wagon, ci.container_number
|
||||
) FILTER (WHERE ci.id IS NOT NULL),
|
||||
'[]'
|
||||
@@ -443,6 +444,7 @@ export class BookingsService {
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
|
||||
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
||||
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
|
||||
@@ -2458,22 +2458,12 @@ export class ContractBookingService {
|
||||
private async assert20ftPairableAtCreate(
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<void> {
|
||||
// Parity gate. 20ft containers ride two per wagon, so an odd total leaves
|
||||
// one container that cannot be placed. Consolidation (pairing it with
|
||||
// another customer's odd booking) is built end to end but switched off for
|
||||
// now, so an odd total is rejected outright — server-side, because the
|
||||
// frontend block alone is not a guarantee.
|
||||
const ft20Quantity = (dto.containers ?? [])
|
||||
.filter((line) => (line.containerSize ?? '').includes('20'))
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
if (ft20Quantity % 2 === 1) {
|
||||
throw new BadRequestException(
|
||||
`20ft containers travel two per wagon, so they must be booked in even ` +
|
||||
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
|
||||
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
|
||||
);
|
||||
}
|
||||
|
||||
// Odd 20ft totals are no longer rejected here: the wagon consolidation gate
|
||||
// that runs right after (consolidateDrawdown / needsConsolidationFromBooking,
|
||||
// same machinery the plain booking flow already uses live) auto-pairs an odd
|
||||
// total with another customer's odd booking or parks it as
|
||||
// PENDING_CONSOLIDATION until one appears. This assert now only checks that
|
||||
// any 20ft containers actually present can be weight-paired on a wagon.
|
||||
const twentyFtUnits = (dto.containers ?? [])
|
||||
.filter((line) => (line.containerSize ?? '').includes('20'))
|
||||
.flatMap((line, lineIdx) =>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||
import {
|
||||
Between,
|
||||
@@ -92,6 +93,7 @@ import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
containerWagonsForLines,
|
||||
roundTons,
|
||||
} from './utils/wagon-plan.util';
|
||||
import {
|
||||
Capacity,
|
||||
@@ -398,6 +400,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
@Optional() private readonly splitService?: BookingSplitService,
|
||||
// Optional so hand-constructed spec instances keep compiling.
|
||||
@Optional() private readonly eventEmitter?: EventEmitter2,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => RemainderPlacementService))
|
||||
private readonly remainderPlacement?: RemainderPlacementService,
|
||||
@@ -1484,6 +1488,43 @@ export class BookingBatchService implements OnModuleInit {
|
||||
"Train is full — no export capacity left for this day",
|
||||
);
|
||||
}
|
||||
// Physical wagon gate — a pay window must never open for wagons that do
|
||||
// not exist in a type this cargo can ride. PER_TON bulk is seated
|
||||
// type-by-type at its per-wagon caps (the count allocation will really
|
||||
// need); everything else checks the summed free stock of its types.
|
||||
const stock = await this.stockLedgerFor(
|
||||
schedule,
|
||||
budget,
|
||||
bookings.map((b) => b.id),
|
||||
);
|
||||
const allowedWagonTypes = await this.loadAllowedWagonTypeIds();
|
||||
const primary = bookings[0];
|
||||
const wagonTypeIds = this.allowedWagonTypeIdsFor(primary, allowedWagonTypes);
|
||||
const perItemBulk =
|
||||
Number(primary.bulkTotalWeightTons ?? 0) > 0 &&
|
||||
Number(primary.cargoTotalWeightVgm ?? 0) > 0;
|
||||
const useSmart =
|
||||
bookings.length === 1 &&
|
||||
primary.freightType === "BULK" &&
|
||||
!perItemBulk &&
|
||||
wagonTypeIds.length > 0;
|
||||
const smart = useSmart
|
||||
? this.smartBulkNeed(
|
||||
primary,
|
||||
wagonDims,
|
||||
stock,
|
||||
leg,
|
||||
this.scarcityRankForPool([primary], allowedWagonTypes),
|
||||
)
|
||||
: null;
|
||||
const seated = useSmart
|
||||
? smart != null && budget.fits(smart.need, leg)
|
||||
: this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
|
||||
if (!seated) {
|
||||
throw new ConflictException(
|
||||
"Train has no free wagons of a type this cargo can ride — payment was not opened",
|
||||
);
|
||||
}
|
||||
|
||||
for (const b of bookings) await this.reserve(b, scheduleId);
|
||||
});
|
||||
@@ -2275,6 +2316,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.recomputeBulkPriorities(pool, wagonDims);
|
||||
this.resortPoolByPriority(pool, await this.windowCycleIndexer(schedule));
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes);
|
||||
let armed = false;
|
||||
let preempted = false;
|
||||
let reservedThisPass = 0;
|
||||
@@ -2301,17 +2343,34 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
|
||||
// Abstract room AND real wagons of a type this booking can ride — see
|
||||
// fillRouteDayInternal for why both gates are needed.
|
||||
const stocked = this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
|
||||
// fillRouteDayInternal for why both gates are needed. PER_TON bulk
|
||||
// singles get the smart gate (exact per-type seating at the cargo's
|
||||
// caps); a booking is only reserved — and only ever invoiced — when
|
||||
// that seating is proven against the train's actual free wagons.
|
||||
const perItemBulk =
|
||||
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
|
||||
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
|
||||
const useSmart =
|
||||
!isPair &&
|
||||
booking.freightType === "BULK" &&
|
||||
!perItemBulk &&
|
||||
wagonTypeIds.length > 0;
|
||||
const smart = useSmart
|
||||
? this.smartBulkNeed(booking, wagonDims, stock, leg, scarcityRank)
|
||||
: null;
|
||||
const admitted = useSmart
|
||||
? smart != null && budget.fits(smart.need, leg)
|
||||
: budget.fits(need, leg) &&
|
||||
this.hasWagonStock(stock, wagonTypeIds, need.wagons, leg);
|
||||
|
||||
// Per-unit fit trace: which axis (wagons/weight/length/stock) admits or rejects.
|
||||
this.logger.debug(
|
||||
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)} ` +
|
||||
`stocked=${stocked}`,
|
||||
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(
|
||||
smart?.need ?? need,
|
||||
)} roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} admitted=${admitted}`,
|
||||
);
|
||||
|
||||
if (!budget.fits(need, leg) || !stocked) {
|
||||
if (!admitted) {
|
||||
if (isGov) {
|
||||
const freed = await this.preemptForGovernment(
|
||||
scheduleId,
|
||||
@@ -2355,9 +2414,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
armed = true;
|
||||
commercialReserved += 1;
|
||||
}
|
||||
budget.subtract(need, leg);
|
||||
budget.subtract(smart?.need ?? need, leg);
|
||||
// Hold the physical wagons too — the next unit must not re-count them.
|
||||
stock.consume(wagonTypeIds, need.wagons, leg);
|
||||
// The smart gate holds the exact per-type counts it seated.
|
||||
if (smart) {
|
||||
for (const part of smart.perType) {
|
||||
stock.consume([part.wagonTypeId], part.wagons, leg);
|
||||
}
|
||||
} else {
|
||||
stock.consume(wagonTypeIds, need.wagons, leg);
|
||||
}
|
||||
reservedThisPass += 1;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
@@ -2532,6 +2598,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
// Least-shareable-type-first seating for bulk (see smartBulkNeed): ranked
|
||||
// once against the whole pool, so what containers will need is known
|
||||
// before any bulk booking picks its wagons.
|
||||
const scarcityRank = this.scarcityRankForPool(pool, allowedWagonTypes);
|
||||
|
||||
// Batch fill trace: each train's caps + the day pool size at entry.
|
||||
this.logger.debug(
|
||||
@@ -2555,18 +2625,47 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// Consolidated pairs share one wagon set; the primary's types stand for both.
|
||||
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowedWagonTypes);
|
||||
|
||||
// PER_TON bulk singles get the smart gate: seated type-by-type at the
|
||||
// cargo's per-wagon caps, scarcest type first — the count the allocator
|
||||
// will actually need, not a one-type estimate. Pairs, PER_ITEM and
|
||||
// unconfigured cargo keep the generic gate (gov preemption and partial
|
||||
// offers below also still size on the generic `need`).
|
||||
const perItemBulk =
|
||||
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
|
||||
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
|
||||
const useSmart =
|
||||
!isPair &&
|
||||
booking.freightType === "BULK" &&
|
||||
!perItemBulk &&
|
||||
wagonTypeIds.length > 0;
|
||||
let smart: {
|
||||
need: Capacity;
|
||||
perType: Array<{ wagonTypeId: string; wagons: number }>;
|
||||
} | null = null;
|
||||
|
||||
// First train (earliest departure) whose corridor carries this booking's
|
||||
// leg, still fits it as-is AND physically holds enough wagons of a type the
|
||||
// booking can ride. Both gates matter: abstract room without the right
|
||||
// wagon type is space the allocator can never turn into a loaded consist.
|
||||
let target = trains.find((t) => {
|
||||
let target: (typeof trains)[number] | undefined;
|
||||
for (const t of trains) {
|
||||
const leg = legOn(t);
|
||||
return (
|
||||
leg != null &&
|
||||
if (leg == null) continue;
|
||||
if (useSmart) {
|
||||
const probe = this.smartBulkNeed(booking, wagonDims, t.stock, leg, scarcityRank);
|
||||
if (probe != null && t.budget.fits(probe.need, leg)) {
|
||||
smart = probe;
|
||||
target = t;
|
||||
break;
|
||||
}
|
||||
} else if (
|
||||
t.budget.fits(need, leg) &&
|
||||
this.hasWagonStock(t.stock, wagonTypeIds, need.wagons, leg)
|
||||
);
|
||||
});
|
||||
) {
|
||||
target = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-unit trace: chosen train + each train's remaining room on this leg.
|
||||
this.logger.debug(
|
||||
@@ -2645,10 +2744,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
target.armed = true;
|
||||
commercialReserved += 1;
|
||||
}
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
target.budget.subtract(smart?.need ?? need, legOn(target)!);
|
||||
// Hold the physical wagons too, so the next unit in this pass sees them
|
||||
// gone — otherwise two bookings both "fit" the same 16 NW5.
|
||||
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
|
||||
// gone — otherwise two bookings both "fit" the same 16 NW5. The smart
|
||||
// gate holds the EXACT per-type counts it seated (10 PW2 + 17 NW5),
|
||||
// not a type-blind total drained deepest-first.
|
||||
if (smart) {
|
||||
for (const part of smart.perType) {
|
||||
target.stock.consume([part.wagonTypeId], part.wagons, legOn(target)!);
|
||||
}
|
||||
} else {
|
||||
target.stock.consume(wagonTypeIds, need.wagons, legOn(target)!);
|
||||
}
|
||||
target.changed = true;
|
||||
reservedThisPass += 1;
|
||||
} catch (err) {
|
||||
@@ -2724,6 +2831,21 @@ export class BookingBatchService implements OnModuleInit {
|
||||
wagonTypeIds: string[] = [],
|
||||
): Promise<boolean> {
|
||||
if (!this.isSplitEligible(booking, isPair)) return false;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
// PER_TON bulk partials are sized on ONE concrete wagon type at the
|
||||
// cargo's per-wagon cap — sizing on the first type's raw 70T rating
|
||||
// offered tonnage the wagons could never carry (Perishable caps at
|
||||
// 20/30T), taking payment for cargo that stalls at allocation.
|
||||
// ponytail: single-type bulk partials; a multi-type partial (PW2+NW5
|
||||
// mixed) is the upgrade path if offers come out too small.
|
||||
const perItemBulk =
|
||||
Number(booking.bulkTotalWeightTons ?? 0) > 0 &&
|
||||
Number(booking.cargoTotalWeightVgm ?? 0) > 0;
|
||||
const cappedBulk =
|
||||
!isPair &&
|
||||
booking.freightType === "BULK" &&
|
||||
!perItemBulk &&
|
||||
wagonTypeIds.length > 0;
|
||||
const target = candidates
|
||||
.map((c) => {
|
||||
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
@@ -2734,12 +2856,39 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// them NW5" into an offer for 16 — the customer pays for 16 and the
|
||||
// other 4 leave as the usual remainder booking, instead of paying for
|
||||
// 20 and stalling at allocation on wagon 17.
|
||||
if (cappedBulk) {
|
||||
const best = this.allowedDimsWithTypes(booking, wagonDims)
|
||||
.filter((o): o is { wagonTypeId: string; dims: PerWagonDims } =>
|
||||
o.wagonTypeId != null,
|
||||
)
|
||||
.map((o) => ({
|
||||
...o,
|
||||
free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0,
|
||||
takePerWagon: bulkTonsPerWagon(
|
||||
booking.cargoType,
|
||||
o.wagonTypeId,
|
||||
o.dims.capacityTons,
|
||||
),
|
||||
}))
|
||||
.filter((o) => o.free > 0 && o.takePerWagon > 0)
|
||||
.sort((a, b) => b.takePerWagon - a.takePerWagon)[0];
|
||||
if (!best) return null;
|
||||
return {
|
||||
c,
|
||||
leg,
|
||||
room: { ...room, wagons: Math.min(room.wagons, best.free) },
|
||||
seat: {
|
||||
wagonTypeId: best.wagonTypeId,
|
||||
perWagon: { ...best.dims, capacityTons: best.takePerWagon },
|
||||
},
|
||||
};
|
||||
}
|
||||
const physical = wagonTypeIds.length
|
||||
? c.stock?.availableFor(wagonTypeIds, leg)
|
||||
: undefined;
|
||||
const wagons =
|
||||
physical == null ? room.wagons : Math.min(room.wagons, physical);
|
||||
return { c, leg, room: { ...room, wagons } };
|
||||
return { c, leg, room: { ...room, wagons }, seat: undefined };
|
||||
})
|
||||
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
|
||||
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
|
||||
@@ -2749,10 +2898,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
target.c.id,
|
||||
target.room,
|
||||
need,
|
||||
target.seat,
|
||||
);
|
||||
if (!offered) return false;
|
||||
target.c.budget.subtract(offered, target.leg);
|
||||
target.c.stock?.consume(wagonTypeIds, offered.wagons, target.leg);
|
||||
target.c.stock?.consume(
|
||||
target.seat ? [target.seat.wagonTypeId] : wagonTypeIds,
|
||||
offered.wagons,
|
||||
target.leg,
|
||||
);
|
||||
target.c.armed = true;
|
||||
return true;
|
||||
}
|
||||
@@ -2767,6 +2921,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
scheduleId: string,
|
||||
budget: Capacity,
|
||||
need: Capacity,
|
||||
/**
|
||||
* Capped-bulk seating (see maybeOfferPartial): the ONE wagon type this
|
||||
* offer rides, with capacityTons already reduced to the cargo's per-wagon
|
||||
* cap — so the offered tonnage is what those wagons can really carry.
|
||||
*/
|
||||
seat?: { wagonTypeId: string; perWagon: PerWagonDims },
|
||||
): Promise<Capacity | null> {
|
||||
if (!this.splitService) return null;
|
||||
// A consolidated booking is already half of a shared wagon — never split it.
|
||||
@@ -2784,8 +2944,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// measured on the booking's REAL wagon type — the same one allocation
|
||||
// validates against. Bulk splits ride FULL wagons only: the offer never
|
||||
// part-loads its last wagon.
|
||||
const perWagon = this.dimsFor(booking, wagonDims);
|
||||
const partial = sizePartialOfferWagons(budget, need.wagons, perWagon, {
|
||||
const perWagon = seat?.perWagon ?? this.dimsFor(booking, wagonDims);
|
||||
// With a capped seat, the whole booking's wagon count follows the cap too
|
||||
// (695T at 30T/wagon = 24, not 10 at the raw rating) — the offer must be a
|
||||
// strict subset of THAT count.
|
||||
const wholeWagons = seat
|
||||
? Math.max(1, Math.ceil(bookingCargoTons(booking) / perWagon.capacityTons))
|
||||
: need.wagons;
|
||||
const partial = sizePartialOfferWagons(budget, wholeWagons, perWagon, {
|
||||
fullWagonsOnly: booking.freightType === "BULK",
|
||||
});
|
||||
if (!partial) return null;
|
||||
@@ -2793,7 +2959,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const sized = await this.splitService.sizeOffer(
|
||||
booking,
|
||||
partial.wagons,
|
||||
need.wagons,
|
||||
wholeWagons,
|
||||
perWagon.capacityTons,
|
||||
partial.maxCargoTons,
|
||||
);
|
||||
@@ -2832,8 +2998,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
|
||||
* how to treat a reservation with no deadline (durable path: leave it; timeout
|
||||
* path: expire it). Consolidated pairs settle atomically: both allocate only
|
||||
* when both paid; if either partner expires, both expire (a half-paid shared
|
||||
* wagon must not ship). Returns whether anything changed.
|
||||
* when both paid; when neither paid, both expire. A half-paid pair splits:
|
||||
* the paid half keeps the whole wagon, the lapsed half expires and owes the
|
||||
* cancellation fee (expire()'s pair cascade). Returns whether anything changed.
|
||||
*/
|
||||
private async settleReserved(
|
||||
scheduleId: string,
|
||||
@@ -2876,8 +3043,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.allocate(scheduleId, partner, "paid");
|
||||
anySettled = true;
|
||||
} else if (isExpired(booking) || isExpired(partner)) {
|
||||
// One call is enough: expire()'s pair cascade settles both sides —
|
||||
// both expire when neither paid; a paid half is rescued (keeps the
|
||||
// whole wagon) while the lapsed half expires with its fee.
|
||||
await this.expire(booking);
|
||||
await this.expire(partner);
|
||||
anySettled = true;
|
||||
}
|
||||
continue;
|
||||
@@ -3816,6 +3985,55 @@ export class BookingBatchService implements OnModuleInit {
|
||||
booking: Booking,
|
||||
reason: "payment" | "no-capacity" = "payment",
|
||||
): Promise<void> {
|
||||
// Consolidated pair: break the link FIRST, then settle each side singly.
|
||||
// - neither paid → both expire, no fee.
|
||||
// - one side paid → the paid half keeps the whole wagon (rescued by the
|
||||
// paid guard below at no extra cost); the lapsed half expires and owes
|
||||
// the cancellation fee (the 'partnerLapsed' event opens the fee invoice
|
||||
// in BookingWagonCancellationService).
|
||||
// - both paid → nothing to expire; the paid guard rescues.
|
||||
if (booking.consolidationPartnerId) {
|
||||
const partnerId = booking.consolidationPartnerId;
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
const partnerRow = await bookingRepo.findOne({
|
||||
where: { id: partnerId },
|
||||
relations: { company: true },
|
||||
});
|
||||
const freshSelf = await bookingRepo.findOne({
|
||||
where: { id: booking.id },
|
||||
});
|
||||
const paidOf = (b: Booking | null) =>
|
||||
b != null && (b.paymentStatus === "PAID" || b.status === "PAID");
|
||||
const selfPaid = paidOf(freshSelf);
|
||||
const partnerPaid = paidOf(partnerRow);
|
||||
|
||||
await this.bookingsRepository.clearConsolidationPair(
|
||||
booking.id,
|
||||
partnerId,
|
||||
);
|
||||
booking.consolidationPartnerId = null;
|
||||
if (partnerRow) partnerRow.consolidationPartnerId = null;
|
||||
|
||||
if (selfPaid && !partnerPaid) {
|
||||
// Wrong side called first: the lapsed partner is the one that expires
|
||||
// (with its fee); this paid booking falls through to the rescue below.
|
||||
if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) {
|
||||
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
|
||||
expiredBookingId: partnerRow.id,
|
||||
});
|
||||
await this.expire(partnerRow, reason);
|
||||
}
|
||||
} else if (!selfPaid && partnerPaid) {
|
||||
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
|
||||
expiredBookingId: booking.id,
|
||||
});
|
||||
// fall through: this side expires below; the paid partner is untouched.
|
||||
} else if (!selfPaid && !partnerPaid) {
|
||||
if (partnerRow && !["EXPIRED", "CANCELLED"].includes(partnerRow.status)) {
|
||||
await this.expire(partnerRow, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!booking.consolidationPartnerId) {
|
||||
const fresh = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
@@ -4097,7 +4315,50 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// and push once per schedule after the sweep (most unaccepted rows are
|
||||
// unpinned under day-level pooling, so this usually emits nothing).
|
||||
const touchedScheduleIds = new Set<string>();
|
||||
const swept = new Set<string>();
|
||||
for (const booking of unaccepted) {
|
||||
if (swept.has(booking.id)) continue;
|
||||
swept.add(booking.id);
|
||||
// Consolidated pair: the partner may sit outside this route-day's result
|
||||
// set (different yards/day/status), so cascade explicitly — an unpaid
|
||||
// partner expires with this booking; a PAID partner keeps the whole
|
||||
// wagon and this booking owes the cancellation fee (partnerLapsed).
|
||||
if (booking.consolidationPartnerId) {
|
||||
const partner = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: booking.consolidationPartnerId },
|
||||
relations: { company: true },
|
||||
});
|
||||
await this.bookingsRepository.clearConsolidationPair(
|
||||
booking.id,
|
||||
booking.consolidationPartnerId,
|
||||
);
|
||||
booking.consolidationPartnerId = null;
|
||||
if (partner) {
|
||||
const partnerPaid =
|
||||
partner.paymentStatus === "PAID" || partner.status === "PAID";
|
||||
if (partnerPaid) {
|
||||
this.eventEmitter?.emit("booking.consolidation.partnerLapsed", {
|
||||
expiredBookingId: booking.id,
|
||||
});
|
||||
} else if (!["EXPIRED", "CANCELLED"].includes(partner.status)) {
|
||||
swept.add(partner.id);
|
||||
partner.consolidationPartnerId = null;
|
||||
if (partner.trainScheduleId) touchedScheduleIds.add(partner.trainScheduleId);
|
||||
await this.bookingsRepository.update(partner.id, {
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
scheduledDate: null,
|
||||
} as never);
|
||||
await this.billing
|
||||
.expirePayable(Freight.InvoiceSource.Booking, partner.id, "PREPAID")
|
||||
.catch(() => undefined);
|
||||
this.notifier.expired(partner);
|
||||
this.logger.log(
|
||||
`[BATCH] EXPIRED (unaccepted, with consolidation partner) ${partner.reference}:${partner.id} at doc-review end`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: "EXPIRED",
|
||||
@@ -4798,12 +5059,34 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.loadAllowedWagonTypeIds(),
|
||||
]);
|
||||
const anyType = [...stock.remainingByTypeId.keys()];
|
||||
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
|
||||
const committed = await this.committedBookings(schedule, excludeBookingIds);
|
||||
// Debit committed PER_TON bulk the way it was SEATED — per type at the
|
||||
// cargo's caps, scarcest type first — not a one-type wagon count drained
|
||||
// deepest-first (which mis-charged 695T Perishable as 24 NW5 when it holds
|
||||
// 10 PW2 + 17 NW5, so later passes over-counted free PW2 and sold NW5 that
|
||||
// were already spoken for).
|
||||
const rank = this.scarcityRankForPool(committed, allowed);
|
||||
for (const b of committed) {
|
||||
const typeIds = this.allowedWagonTypeIdsFor(b, allowed);
|
||||
const leg = budget.legForYards(b.originYardId, b.destinationYardId);
|
||||
const perItemBulk =
|
||||
Number(b.bulkTotalWeightTons ?? 0) > 0 &&
|
||||
Number(b.cargoTotalWeightVgm ?? 0) > 0;
|
||||
if (b.freightType === "BULK" && !perItemBulk && typeIds.length) {
|
||||
const smart = this.smartBulkNeed(b, wagonDims, ledger, leg, rank);
|
||||
if (smart) {
|
||||
for (const part of smart.perType) {
|
||||
ledger.consume([part.wagonTypeId], part.wagons, leg);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Over-committed (stock cannot seat it any more) — drain what exists,
|
||||
// same as before, so the shortage stays visible to the gates.
|
||||
}
|
||||
ledger.consume(
|
||||
typeIds.length ? typeIds : anyType,
|
||||
this.wagonsFor(b, wagonDims),
|
||||
budget.legForYards(b.originYardId, b.destinationYardId),
|
||||
leg,
|
||||
);
|
||||
}
|
||||
return ledger;
|
||||
@@ -4825,6 +5108,112 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return stock.availableFor(wagonTypeIds, leg) >= wagonsNeeded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scarcity rank over the day pool: how many distinct demand groups (bulk
|
||||
* cargo types / container types among these bookings) may ride each wagon
|
||||
* type. The batch seats least-shareable types first, so bulk with a
|
||||
* bulk-only alternative (PW2) never eats the container-capable stock (NW5)
|
||||
* that containers cannot substitute.
|
||||
*/
|
||||
private scarcityRankForPool(
|
||||
pool: Booking[],
|
||||
allowed: {
|
||||
byCargoTypeId: Map<string, string[]>;
|
||||
byContainerTypeId: Map<string, string[]>;
|
||||
},
|
||||
): Map<string, number> {
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const b of pool) {
|
||||
if (b.freightType === "BULK") {
|
||||
const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id;
|
||||
if (cargoTypeId) {
|
||||
groups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []);
|
||||
}
|
||||
} else {
|
||||
for (const line of b.bookingContainers ?? []) {
|
||||
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
|
||||
if (containerTypeId) {
|
||||
groups.set(
|
||||
`C:${containerTypeId}`,
|
||||
allowed.byContainerTypeId.get(containerTypeId) ?? [],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const rank = new Map<string, number>();
|
||||
for (const ids of groups.values()) {
|
||||
for (const id of ids) rank.set(id, (rank.get(id) ?? 0) + 1);
|
||||
}
|
||||
return rank;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap-aware, scarcity-ordered seating of a PER_TON bulk booking across the
|
||||
* wagon types this train actually has free on its leg — the same policy the
|
||||
* wagon planner applies at allocation time (least-shareable type first, each
|
||||
* wagon filled to the cargo type's per-wagon cap, one booking per wagon).
|
||||
*
|
||||
* This is the payment gate's real fit check for bulk: the generic
|
||||
* `hasWagonStock` sums free wagons across allowed types against a count
|
||||
* sized on ONE type, so 695T Perishable read "24 wagons needed, 28 free"
|
||||
* when seating it across 10 PW2 (20T) + NW5 (30T) really takes 27 wagons.
|
||||
* Returns the exact per-type counts and the three-axis capacity they
|
||||
* consume, or null when the free stock cannot seat the whole booking.
|
||||
*/
|
||||
private smartBulkNeed(
|
||||
booking: Booking,
|
||||
wagonDims: WagonDims,
|
||||
stock: WagonStockLedger,
|
||||
leg: CorridorLeg,
|
||||
scarcityRank: Map<string, number>,
|
||||
): { need: Capacity; perType: Array<{ wagonTypeId: string; wagons: number }> } | null {
|
||||
const options = this.allowedDimsWithTypes(booking, wagonDims)
|
||||
.filter((o): o is { wagonTypeId: string; dims: PerWagonDims } => o.wagonTypeId != null)
|
||||
.map((o) => ({
|
||||
...o,
|
||||
free: stock.availableFor([o.wagonTypeId], leg),
|
||||
takePerWagon: bulkTonsPerWagon(
|
||||
booking.cargoType,
|
||||
o.wagonTypeId,
|
||||
o.dims.capacityTons,
|
||||
),
|
||||
}))
|
||||
.filter((o) => o.free > 0 && o.takePerWagon > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(scarcityRank.get(a.wagonTypeId) ?? 1) -
|
||||
(scarcityRank.get(b.wagonTypeId) ?? 1) ||
|
||||
b.takePerWagon - a.takePerWagon,
|
||||
);
|
||||
|
||||
let remaining = bookingCargoTons(booking);
|
||||
if (remaining <= 0) return null;
|
||||
const perType: Array<{ wagonTypeId: string; wagons: number }> = [];
|
||||
let weightTons = remaining; // gross: cargo plus each seated wagon's tare
|
||||
let lengthMeters = 0;
|
||||
let wagons = 0;
|
||||
for (const option of options) {
|
||||
if (remaining <= 1e-9) break;
|
||||
const take = Math.min(option.free, Math.ceil(remaining / option.takePerWagon));
|
||||
if (take <= 0) continue;
|
||||
remaining = roundTons(Math.max(0, remaining - take * option.takePerWagon));
|
||||
wagons += take;
|
||||
weightTons += take * option.dims.tareWeightTons;
|
||||
lengthMeters += take * option.dims.lengthMeters;
|
||||
perType.push({ wagonTypeId: option.wagonTypeId, wagons: take });
|
||||
}
|
||||
if (remaining > 1e-9) return null;
|
||||
return {
|
||||
need: {
|
||||
wagons,
|
||||
weightTons: roundTons(weightTons),
|
||||
lengthMeters: roundTons(lengthMeters),
|
||||
},
|
||||
perType,
|
||||
};
|
||||
}
|
||||
|
||||
private allowedWagonTypeCache: {
|
||||
byCargoTypeId: Map<string, string[]>;
|
||||
byContainerTypeId: Map<string, string[]>;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { WagonStockLedger } from './wagon-stock-ledger.util';
|
||||
|
||||
/**
|
||||
* smartBulkNeed math in isolation: the private helpers it touches
|
||||
* (allowedDimsWithTypes) read only their arguments, so a bare prototype
|
||||
* instance is enough — no Nest wiring.
|
||||
*/
|
||||
describe('BookingBatchService.smartBulkNeed', () => {
|
||||
const service = Object.create(BookingBatchService.prototype) as BookingBatchService;
|
||||
const call = (
|
||||
booking: Booking,
|
||||
stock: WagonStockLedger,
|
||||
rank: Map<string, number>,
|
||||
) =>
|
||||
(
|
||||
service as unknown as {
|
||||
smartBulkNeed: (
|
||||
b: Booking,
|
||||
d: unknown,
|
||||
s: WagonStockLedger,
|
||||
l: { fromEdge: number; toEdge: number },
|
||||
r: Map<string, number>,
|
||||
) => { need: { wagons: number }; perType: Array<{ wagonTypeId: string; wagons: number }> } | null;
|
||||
}
|
||||
).smartBulkNeed(booking, wagonDims, stock, { fromEdge: 0, toEdge: 1 }, rank);
|
||||
|
||||
const nw5 = { id: 'wt-nw5', capacityTons: 70 };
|
||||
const pw2 = { id: 'wt-pw2', capacityTons: 70 };
|
||||
const perishable = {
|
||||
id: 'cargo-perishable',
|
||||
wagonTypes: [nw5, pw2],
|
||||
tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 },
|
||||
};
|
||||
const wagonDims = {
|
||||
container: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 },
|
||||
bulk: { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 },
|
||||
byWagonTypeId: new Map([
|
||||
[nw5.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }],
|
||||
[pw2.id, { lengthMeters: 14, tareWeightTons: 24, capacityTons: 70 }],
|
||||
]),
|
||||
};
|
||||
const booking = (tons: number): Booking =>
|
||||
({
|
||||
id: 'b1',
|
||||
reference: 'b1',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: tons,
|
||||
cargoTypeId: perishable.id,
|
||||
cargoType: perishable,
|
||||
bookingContainers: [],
|
||||
}) as unknown as Booking;
|
||||
// Containers compete for NW5 → NW5 rank 2, PW2 rank 1.
|
||||
const contested = new Map([
|
||||
[nw5.id, 2],
|
||||
[pw2.id, 1],
|
||||
]);
|
||||
|
||||
it('seats 695T as 10 PW2 (20T) + 17 NW5 (30T) = 27 wagons, PW2 first', () => {
|
||||
const stock = new WagonStockLedger(
|
||||
new Map([
|
||||
[nw5.id, 18],
|
||||
[pw2.id, 10],
|
||||
]),
|
||||
1,
|
||||
);
|
||||
const smart = call(booking(695), stock, contested);
|
||||
expect(smart).not.toBeNull();
|
||||
expect(smart!.need.wagons).toBe(27);
|
||||
expect(smart!.perType).toEqual([
|
||||
{ wagonTypeId: pw2.id, wagons: 10 },
|
||||
{ wagonTypeId: nw5.id, wagons: 17 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when the free stock cannot seat the whole booking', () => {
|
||||
const stock = new WagonStockLedger(
|
||||
new Map([
|
||||
[nw5.id, 5],
|
||||
[pw2.id, 10],
|
||||
]),
|
||||
1,
|
||||
);
|
||||
// 10×20 + 5×30 = 350T < 695T.
|
||||
expect(call(booking(695), stock, contested)).toBeNull();
|
||||
});
|
||||
|
||||
it('uncontested types fall back to biggest per-cargo take (fewest wagons)', () => {
|
||||
const stock = new WagonStockLedger(
|
||||
new Map([
|
||||
[nw5.id, 10],
|
||||
[pw2.id, 10],
|
||||
]),
|
||||
1,
|
||||
);
|
||||
const even = new Map([
|
||||
[nw5.id, 1],
|
||||
[pw2.id, 1],
|
||||
]);
|
||||
const smart = call(booking(60), stock, even);
|
||||
expect(smart!.perType).toEqual([{ wagonTypeId: nw5.id, wagons: 2 }]);
|
||||
});
|
||||
});
|
||||
@@ -6081,6 +6081,18 @@ export class TrainSchedulingService {
|
||||
const trainSetWagon = savedWagons[i];
|
||||
if (!slot || !trainSetWagon) continue;
|
||||
|
||||
// Last line of defense behind validateWagonCargoExclusivity: a wagon
|
||||
// with bulk on it carries that one load only — never a container and
|
||||
// never a second bulk booking.
|
||||
if (
|
||||
slot.allocations.length > 1 &&
|
||||
slot.allocations.some((a) => a.loadType === AllocationLoadType.Bulk)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const alloc of slot.allocations) {
|
||||
const savedAllocation = await manager.getRepository(WagonBookingAllocation).save(
|
||||
manager.getRepository(WagonBookingAllocation).create({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
sumWagonsRequired,
|
||||
validate20ftContainerRules,
|
||||
validateContainerPlacements,
|
||||
validateWagonCargoExclusivity,
|
||||
} from './wagon-plan.util';
|
||||
|
||||
const nw5: WagonType = {
|
||||
@@ -222,6 +223,85 @@ describe('wagon-plan.util', () => {
|
||||
expect(plan[0]?.slotLoadType).toBe('BULK');
|
||||
expect(buildBulkWagonPlan([bulkBooking], cw3)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('never pools two bulk bookings on one wagon', () => {
|
||||
// 5T + 40T both fit a single 60T CW3 by tonnage — but a wagon with bulk
|
||||
// takes that one load only, so each booking gets its own wagon.
|
||||
const small = {
|
||||
id: 'bulk-5',
|
||||
reference: 'bulk-5',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 5,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
const other = {
|
||||
id: 'bulk-40',
|
||||
reference: 'bulk-40',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 40,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
const plan = buildBulkWagonPlan([small, other], cw3);
|
||||
expect(plan).toHaveLength(2);
|
||||
for (const slot of plan) {
|
||||
expect(slot.allocations).toHaveLength(1);
|
||||
}
|
||||
expect(plan[0]?.allocations[0]?.bookingId).toBe('bulk-5');
|
||||
expect(plan[1]?.allocations[0]?.bookingId).toBe('bulk-40');
|
||||
expect(validateWagonCargoExclusivity(plan)).toEqual([]);
|
||||
});
|
||||
|
||||
it('a multi-wagon bulk booking still spreads over its own wagons', () => {
|
||||
const big = {
|
||||
id: 'bulk-130',
|
||||
reference: 'bulk-130',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 130,
|
||||
bookingContainers: [],
|
||||
} as unknown as Booking;
|
||||
const plan = buildBulkWagonPlan([big], cw3);
|
||||
expect(plan).toHaveLength(3);
|
||||
expect(plan.map((s) => s.allocations[0]?.allocatedWeightTons)).toEqual([60, 60, 10]);
|
||||
});
|
||||
|
||||
it('flags a wagon mixing bulk with anything else', () => {
|
||||
const bulkAlloc = {
|
||||
bookingId: 'b',
|
||||
bookingReference: 'b',
|
||||
allocatedWeightTons: 5,
|
||||
loadType: AllocationLoadType.Bulk,
|
||||
};
|
||||
const containerAlloc = {
|
||||
bookingId: 'c',
|
||||
bookingReference: 'c',
|
||||
allocatedWeightTons: 25,
|
||||
loadType: AllocationLoadType.Container,
|
||||
};
|
||||
const slot = (allocations: (typeof bulkAlloc)[]) => ({
|
||||
sequenceNo: 1,
|
||||
wagonTypeId: cw3.id,
|
||||
wagonTypeCode: cw3.code,
|
||||
capacityTons: 60,
|
||||
lengthMeters: 14,
|
||||
tareWeightTons: 24,
|
||||
assignedWeightTons: 0,
|
||||
allocations,
|
||||
});
|
||||
// bulk + container on one wagon
|
||||
expect(validateWagonCargoExclusivity([slot([bulkAlloc, containerAlloc])]))
|
||||
.toHaveLength(1);
|
||||
// bulk + bulk on one wagon
|
||||
expect(
|
||||
validateWagonCargoExclusivity([slot([bulkAlloc, { ...bulkAlloc, bookingId: 'b2' }])]),
|
||||
).toHaveLength(1);
|
||||
// bulk alone, and containers sharing, are fine
|
||||
expect(validateWagonCargoExclusivity([slot([bulkAlloc])])).toEqual([]);
|
||||
expect(
|
||||
validateWagonCargoExclusivity([
|
||||
slot([containerAlloc, { ...containerAlloc, bookingId: 'c2' }]),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => {
|
||||
|
||||
@@ -200,16 +200,14 @@ export function buildBulkWagonPlan(
|
||||
);
|
||||
const cappedTonSlots = cappedTonSlotsByBooking.reduce((sum, n) => sum + n, 0);
|
||||
|
||||
const totalWeight = roundTons(
|
||||
bookings.reduce(
|
||||
(sum, b, i) =>
|
||||
itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0
|
||||
? sum
|
||||
: sum + Number(b.cargoTotalWeightVgm ?? 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
const tonSlots = totalWeight > 0 ? Math.ceil(totalWeight / capacity) : 0;
|
||||
// One bulk booking per wagon — bookings never pool tonnage on a shared
|
||||
// wagon, so each uncapped booking sizes its own wagons (ceil per booking,
|
||||
// not over the pooled total).
|
||||
const tonSlots = bookings.reduce((sum, b, i) => {
|
||||
if (itemSlotsByBooking[i] > 0 || cappedTonSlotsByBooking[i] > 0) return sum;
|
||||
const weight = roundTons(Number(b.cargoTotalWeightVgm ?? 0));
|
||||
return weight > 0 ? sum + Math.ceil(weight / capacity) : sum;
|
||||
}, 0);
|
||||
const slots = Math.max(1, tonSlots + itemSlots + cappedTonSlots);
|
||||
|
||||
const basePlan: WagonPlanSlot[] = Array.from({ length: slots }, (_, index) => ({
|
||||
@@ -374,13 +372,12 @@ function allocateBookingsToSlots(
|
||||
|
||||
if (booking.remainingWeightTons <= 0) {
|
||||
bookingIndex += 1;
|
||||
} else if (allocatedWeightTons >= takeCap) {
|
||||
// The cap stopped this wagon short of its rating and the booking has
|
||||
// more to load. The leftover room is NOT free: `buildBulkWagonPlan`
|
||||
// already reserved a wagon for the rest, so backfilling another booking
|
||||
// here would double-book the consist. Close the wagon.
|
||||
break;
|
||||
}
|
||||
// One bulk booking per wagon: a wagon carrying bulk takes nothing else —
|
||||
// never a second booking's cargo. `buildBulkWagonPlan` sized the slots
|
||||
// per booking, so leftover room on this wagon is not free capacity.
|
||||
// Close the wagon after its single allocation.
|
||||
break;
|
||||
}
|
||||
|
||||
return { ...slot, assignedWeightTons, allocations };
|
||||
@@ -504,6 +501,26 @@ export function sumWagonsRequired(booking: Booking, wagonPlan?: WagonPlanSlot[])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One wagon carries one kind of cargo: a slot with a BULK allocation holds
|
||||
* nothing else — no container beside it and no second bulk booking. Container
|
||||
* allocations may still share a wagon with each other (TEU rules apply).
|
||||
*/
|
||||
export function validateWagonCargoExclusivity(wagonPlan: WagonPlanSlot[]): string[] {
|
||||
const violations: string[] = [];
|
||||
for (const slot of wagonPlan) {
|
||||
const hasBulk = slot.allocations.some(
|
||||
(a) => a.loadType === AllocationLoadType.Bulk,
|
||||
);
|
||||
if (hasBulk && slot.allocations.length > 1) {
|
||||
violations.push(
|
||||
`Wagon #${slot.sequenceNo} mixes bulk with other cargo — a wagon carrying bulk takes that one load only`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function validateBulkWagonSlotWeights(wagonPlan: WagonPlanSlot[]): string[] {
|
||||
const violations: string[] = [];
|
||||
for (const slot of wagonPlan.filter((s) => s.slotLoadType === 'BULK')) {
|
||||
@@ -547,6 +564,7 @@ export function validateTrainLimits(
|
||||
);
|
||||
|
||||
violations.push(...validateBulkWagonSlotWeights(wagonPlan));
|
||||
violations.push(...validateWagonCargoExclusivity(wagonPlan));
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
@@ -490,3 +490,112 @@ describe('planWagonsWithStock — consist split across yards', () => {
|
||||
expect(result.deferred.map((d) => d.reference)).toEqual(['BKG-G']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planWagonsWithStock — scarcity-aware bulk (one booking per wagon, capped fill)', () => {
|
||||
// The S-2026-00044 shape: Perishable rides NW5 (30T cap) or PW2 (20T cap);
|
||||
// containers ride only NW5. NW5 is the shared, scarce type.
|
||||
const nw5: WagonType = {
|
||||
id: 'wt-nw5',
|
||||
code: 'NW5',
|
||||
name: 'Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
supportsContainer: true,
|
||||
} as WagonType;
|
||||
const pw2: WagonType = {
|
||||
id: 'wt-pw2',
|
||||
code: 'PW2',
|
||||
name: 'Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
supportedLoadTypes: ['BULK'],
|
||||
isActive: true,
|
||||
supportsContainer: false,
|
||||
} as WagonType;
|
||||
const perishable = {
|
||||
id: 'cargo-perishable',
|
||||
cargoTypeName: 'Perishable',
|
||||
wagonTypes: [nw5, pw2],
|
||||
tonsPerWagonMap: { [nw5.id]: 30, [pw2.id]: 20 },
|
||||
};
|
||||
const bulkBooking = (id: string, tons: number): Booking =>
|
||||
({
|
||||
id,
|
||||
reference: id,
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: tons,
|
||||
cargoTypeId: perishable.id,
|
||||
cargoType: perishable,
|
||||
bookingContainers: [],
|
||||
}) as unknown as Booking;
|
||||
const allowed = {
|
||||
byContainerTypeId: new Map([['ct-1', [nw5]]]),
|
||||
byCargoTypeId: new Map([[perishable.id, [nw5, pw2]]]),
|
||||
};
|
||||
const stockOf = (nw5Count: number, pw2Count: number) => ({
|
||||
mode: 'YARD' as const,
|
||||
remainingByTypeId: new Map([
|
||||
[nw5.id, nw5Count],
|
||||
[pw2.id, pw2Count],
|
||||
]),
|
||||
codesByTypeId: new Map([
|
||||
[nw5.id, nw5.code],
|
||||
[pw2.id, pw2.code],
|
||||
]),
|
||||
});
|
||||
|
||||
it('fills the bulk-only PW2s first when containers compete for NW5', () => {
|
||||
// 695T Perishable + one 40ft container. Smart split: 10 PW2 × 20T = 200T,
|
||||
// remainder 495T → 17 NW5 × 30T. The container still gets an NW5.
|
||||
const container = containerBooking('BKG-C', 1, 1);
|
||||
container.bookingContainers![0]!.containerType = { code: '40GP', sizeFt: 40 } as never;
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [bulkBooking('BKG-BULK', 695), container],
|
||||
allowed,
|
||||
stock: stockOf(18, 10),
|
||||
});
|
||||
|
||||
expect(result.deferred).toEqual([]);
|
||||
const bulkSlots = result.plan.filter((s) => s.slotLoadType === 'BULK');
|
||||
expect(bulkSlots.filter((s) => s.wagonTypeCode === 'PW2')).toHaveLength(10);
|
||||
expect(bulkSlots.filter((s) => s.wagonTypeCode === 'NW5')).toHaveLength(17);
|
||||
// Capped fill: no PW2 slot above 20T, no NW5 bulk slot above 30T.
|
||||
for (const slot of bulkSlots) {
|
||||
expect(slot.assignedWeightTons).toBeLessThanOrEqual(
|
||||
slot.wagonTypeCode === 'PW2' ? 20 : 30,
|
||||
);
|
||||
}
|
||||
const containerSlots = result.plan.filter((s) => s.slotLoadType === 'CONTAINER');
|
||||
expect(containerSlots).toHaveLength(1);
|
||||
expect(containerSlots[0]?.wagonTypeCode).toBe('NW5');
|
||||
});
|
||||
|
||||
it('prefers the bigger per-cargo take when nothing competes for the shared type', () => {
|
||||
// Bulk alone (no containers in the run): NW5 30T beats PW2 20T — fewest
|
||||
// wagons wins, PW2-first would waste consist length.
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [bulkBooking('BKG-BULK', 60)],
|
||||
allowed,
|
||||
stock: stockOf(10, 10),
|
||||
});
|
||||
expect(result.deferred).toEqual([]);
|
||||
expect(result.plan).toHaveLength(2);
|
||||
expect(result.plan.every((s) => s.wagonTypeCode === 'NW5')).toBe(true);
|
||||
});
|
||||
|
||||
it('never puts two bulk bookings on one wagon, even same cargo type', () => {
|
||||
// 5T + 40T both fit one wagon's cap by tonnage — each still gets its own.
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [bulkBooking('BKG-A', 5), bulkBooking('BKG-B', 40)],
|
||||
allowed,
|
||||
stock: stockOf(10, 0),
|
||||
});
|
||||
expect(result.deferred).toEqual([]);
|
||||
expect(result.plan).toHaveLength(3); // 5T → 1 wagon; 40T @30 cap → 2 wagons
|
||||
for (const slot of result.plan) {
|
||||
expect(new Set(slot.allocations.map((a) => a.bookingId)).size).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkTonsPerWagon,
|
||||
bulkWagonsForAllowedTypes,
|
||||
} from './train-capacity.util';
|
||||
import {
|
||||
@@ -187,8 +188,10 @@ const addAllocation = (
|
||||
* containers/tonnage placed on wagons whose type is allowed for its container
|
||||
* or cargo type) or is deferred with the shortfall reason. Wagon purity rules:
|
||||
* a wagon carries one kind at a time — containers pack by TEU (one 40ft, or
|
||||
* two 20ft, never mixed sizes), bulk fills by weight and never shares a wagon
|
||||
* with a different cargo type.
|
||||
* two 20ft, never mixed sizes); a bulk wagon carries ONE booking's cargo only,
|
||||
* filled to the cargo type's per-wagon cap. Type choice is scarcity-aware:
|
||||
* least-shareable wagon type first, so bulk with a PW2 alternative leaves the
|
||||
* container-capable NW5s to the containers.
|
||||
*/
|
||||
export function planWagonsWithStock(params: {
|
||||
bookings: Booking[];
|
||||
@@ -221,6 +224,38 @@ export function planWagonsWithStock(params: {
|
||||
const deferred: DeferredBookingRow[] = [];
|
||||
const configIssues = new Set<string>();
|
||||
|
||||
// Scarcity rank: how many distinct demand groups (container types / bulk
|
||||
// cargo types) among THESE bookings can ride each wagon type. When a cargo
|
||||
// can choose, it takes the least-shareable type first, keeping versatile
|
||||
// types (e.g. container-capable NW5) free for the cargo that has no
|
||||
// alternative. A type nobody else wants ranks 1; unranked types rank 1 too
|
||||
// (nothing competes for them).
|
||||
const demandGroups = new Map<string, WagonType[]>();
|
||||
for (const b of bookings) {
|
||||
if (b.freightType === 'CONTAINER') {
|
||||
for (const line of b.bookingContainers ?? []) {
|
||||
const containerTypeId = line.containerTypeId ?? line.containerType?.id;
|
||||
if (!containerTypeId) continue;
|
||||
demandGroups.set(
|
||||
`C:${containerTypeId}`,
|
||||
allowed.byContainerTypeId.get(containerTypeId) ?? [],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const cargoTypeId = b.cargoTypeId ?? b.cargoType?.id;
|
||||
if (cargoTypeId) {
|
||||
demandGroups.set(`B:${cargoTypeId}`, allowed.byCargoTypeId.get(cargoTypeId) ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
const scarcityRank = new Map<string, number>();
|
||||
for (const types of demandGroups.values()) {
|
||||
for (const wt of types) {
|
||||
scarcityRank.set(wt.id, (scarcityRank.get(wt.id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const rankOf = (wt: WagonType): number => scarcityRank.get(wt.id) ?? 1;
|
||||
|
||||
const legFor = (booking: Booking): BookingLeg => {
|
||||
const leg = legs?.get(booking.id);
|
||||
if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) {
|
||||
@@ -277,18 +312,26 @@ export function planWagonsWithStock(params: {
|
||||
kind: SlotLoadType,
|
||||
cargoTypeId: string | null,
|
||||
leg: BookingLeg,
|
||||
/** Bulk only: the booking's cargo type, for its per-wagon tonnage cap. */
|
||||
cargoType?: Booking['cargoType'],
|
||||
): OpenSlot | PlacementProblem => {
|
||||
const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0);
|
||||
if (!inStock.length) {
|
||||
return { kind: 'stock', message: noStockMessage(candidates, leg), candidates };
|
||||
}
|
||||
// Bulk favors the largest wagon (fewest wagons for the tonnage); containers
|
||||
// Least-shareable type first (see scarcityRank) so cargo with alternatives
|
||||
// never starves cargo without one. Bulk then favors the biggest per-wagon
|
||||
// take for THIS cargo (its configured cap, not the raw rating); containers
|
||||
// favor the deepest stock so the consist drains evenly. Ties keep config order.
|
||||
const bulkTakeOf = (wt: WagonType): number =>
|
||||
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons));
|
||||
const chosen = [...inStock].sort((a, b) =>
|
||||
kind === 'BULK'
|
||||
? Number(b.capacityTons) - Number(a.capacityTons) ||
|
||||
? rankOf(a) - rankOf(b) ||
|
||||
bulkTakeOf(b) - bulkTakeOf(a) ||
|
||||
availableFor(b.id, leg) - availableFor(a.id, leg)
|
||||
: availableFor(b.id, leg) - availableFor(a.id, leg),
|
||||
: rankOf(a) - rankOf(b) ||
|
||||
availableFor(b.id, leg) - availableFor(a.id, leg),
|
||||
)[0];
|
||||
const pool = poolOf(leg);
|
||||
const row = usedRow(rowKeyFor(chosen.id, pool));
|
||||
@@ -298,7 +341,10 @@ export function planWagonsWithStock(params: {
|
||||
teuPerEdge: new Array<number>(edgeCount).fill(0),
|
||||
kind,
|
||||
cargoTypeId,
|
||||
freeCapacityTons: Number(chosen.capacityTons),
|
||||
// A bulk wagon fills to the cargo type's configured per-wagon cap
|
||||
// (Perishable: 20T on PW2, 30T on NW5), never the raw 70T rating.
|
||||
freeCapacityTons:
|
||||
kind === 'BULK' ? bulkTakeOf(chosen) : Number(chosen.capacityTons),
|
||||
legKey: legKeyOf(leg),
|
||||
covered: { ...leg },
|
||||
pool,
|
||||
@@ -411,7 +457,6 @@ export function planWagonsWithStock(params: {
|
||||
message: `Cargo type "${booking.cargoType?.cargoTypeName ?? booking.cargoType?.code ?? 'unknown'}" has no wagon types configured — set them in its configuration before scheduling.`,
|
||||
};
|
||||
}
|
||||
const allowedIds = new Set(candidates.map((wt) => wt.id));
|
||||
// Break-bulk (PER_ITEM): `cargoTotalWeightVgm` is the ITEM COUNT and the
|
||||
// real tonnage lives in `bulkTotalWeightTons` — bookingCargoTons resolves
|
||||
// it either way. Items are indivisible, so a wagon takes whole items only,
|
||||
@@ -423,68 +468,41 @@ export function planWagonsWithStock(params: {
|
||||
const perItemTons = perItem ? remainingWeight / quantity : 0;
|
||||
let remainingItems = perItem ? quantity : 0;
|
||||
|
||||
/** Whole items one wagon of this slot's type can still take. */
|
||||
const itemRoomOf = (open: OpenSlot): number =>
|
||||
Math.min(
|
||||
open.freeItems ?? Number.MAX_SAFE_INTEGER,
|
||||
perItemTons > 0 ? Math.floor(open.freeCapacityTons / perItemTons) : 0,
|
||||
);
|
||||
/** Fresh wagon's whole-item budget: items-fit map floor'd by tonnage. */
|
||||
/** Fresh wagon's whole-item budget: items-fit map floor'd by (capped) tonnage. */
|
||||
const itemBudgetOf = (open: OpenSlot): number => {
|
||||
const fit = bulkItemsFitFor(booking.cargoType, open.slot.wagonTypeId);
|
||||
const byTonnage =
|
||||
perItemTons > 0
|
||||
? Math.max(1, Math.floor(Number(open.slot.capacityTons) / perItemTons))
|
||||
? Math.max(1, Math.floor(open.freeCapacityTons / perItemTons))
|
||||
: 1;
|
||||
return Math.min(fit ?? Number.MAX_SAFE_INTEGER, byTonnage);
|
||||
};
|
||||
let placedAnywhere = false;
|
||||
|
||||
// Per-item: prefer the type carrying the most whole items per wagon.
|
||||
// openSlot's own capacity sort is stable, so this order breaks its ties.
|
||||
// Per-item: least-shareable type first (same scarcity rule as openSlot),
|
||||
// then the type carrying the most whole items per wagon.
|
||||
const itemBudgetOfType = (wt: WagonType): number =>
|
||||
Math.min(
|
||||
bulkItemsFitFor(booking.cargoType, wt.id) ?? Number.MAX_SAFE_INTEGER,
|
||||
perItemTons > 0
|
||||
? Math.max(1, Math.floor(Number(wt.capacityTons) / perItemTons))
|
||||
? Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)) /
|
||||
perItemTons,
|
||||
),
|
||||
)
|
||||
: 1,
|
||||
);
|
||||
const orderedCandidates = perItem
|
||||
? [...candidates].sort((a, b) => itemBudgetOfType(b) - itemBudgetOfType(a))
|
||||
? [...candidates].sort(
|
||||
(a, b) => rankOf(a) - rankOf(b) || itemBudgetOfType(b) - itemBudgetOfType(a),
|
||||
)
|
||||
: candidates;
|
||||
|
||||
// Top off wagons already carrying THIS cargo type before opening new ones.
|
||||
// ponytail: per-item cargo only shares wagons that were opened per-item
|
||||
// (freeItems tracked); mixing itemized and loose loads of one cargo type
|
||||
// on one wagon is not modeled — open a new wagon instead.
|
||||
for (const open of openSlots) {
|
||||
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
|
||||
if (open.kind !== 'BULK') continue;
|
||||
if (open.legKey !== legKey) continue;
|
||||
if (open.cargoTypeId !== cargoTypeId) continue;
|
||||
if (!allowedIds.has(open.slot.wagonTypeId)) continue;
|
||||
if (open.freeCapacityTons <= 0) continue;
|
||||
if (perItem !== (open.freeItems !== undefined)) continue;
|
||||
const takeItems = perItem ? Math.min(itemRoomOf(open), remainingItems) : 0;
|
||||
if (perItem && takeItems <= 0) continue;
|
||||
const take = perItem
|
||||
? roundTons(takeItems * perItemTons)
|
||||
: roundTons(Math.min(open.freeCapacityTons, remainingWeight));
|
||||
addAllocation(
|
||||
open.slot,
|
||||
booking.id,
|
||||
booking.reference,
|
||||
take,
|
||||
AllocationLoadType.Bulk,
|
||||
);
|
||||
open.freeCapacityTons = roundTons(open.freeCapacityTons - take);
|
||||
if (perItem) {
|
||||
open.freeItems = (open.freeItems ?? 0) - takeItems;
|
||||
remainingItems -= takeItems;
|
||||
}
|
||||
remainingWeight = roundTons(remainingWeight - take);
|
||||
placedAnywhere = true;
|
||||
}
|
||||
// One bulk booking per wagon: a wagon carrying bulk takes that one
|
||||
// booking's cargo only — never topped up from another booking, even of
|
||||
// the same cargo type. Every bulk booking therefore opens its own wagons.
|
||||
|
||||
while ((perItem ? remainingItems > 0 : remainingWeight > 0) || !placedAnywhere) {
|
||||
// Per-item: openSlot's stock-depth tie-break would override the fit
|
||||
@@ -498,6 +516,7 @@ export function planWagonsWithStock(params: {
|
||||
'BULK',
|
||||
cargoTypeId,
|
||||
leg,
|
||||
booking.cargoType,
|
||||
);
|
||||
if ('message' in openedSlot) return openedSlot;
|
||||
let take: number;
|
||||
|
||||
Reference in New Issue
Block a user