mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +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
|
||||
|
||||
Reference in New Issue
Block a user