Merge pull request #1382 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-22 02:17:34 +03:00
committed by GitHub
22 changed files with 2295 additions and 1690 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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, {

View File

@@ -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