diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 49732d2df..29104bbce 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -26,9 +26,18 @@ import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; import { ContainerValidationService } from './container-validation.service'; +/** + * One physical container over its VGM limit. Weight limits are per container, + * so an overloaded box is reported (and billed) on its own tons above the + * limit — a lighter box on the same line never absorbs them. + */ export interface OverweightLine { containerTypeCode: string; + /** Container number when known, else " #2" — identifies the box. */ + containerLabel: string; + /** This container's VGM, not the line total. */ totalVgmTons: number; + /** The per-container limit. */ maxAllowedTons: number; excessTons: number; } @@ -250,9 +259,10 @@ export class BookingPricingService { clearanceBlocked.push(...clearance.blocked); } - // Overweight detail for the customer: map the engine's per-line results back - // to the booking's container lines (same order) for code + weights. maxAllowed - // is derived from the line total minus the excess the engine computed. + // Overweight detail for the customer: one row per over-limit CONTAINER, + // mapped back to the booking's container lines (same order) for the code and + // the physical container numbers. maxAllowed is the per-container limit, + // recovered from that container's weight minus its own excess. const overweightLines: OverweightLine[] = []; const containerLines = (booking.bookingContainers ?? []).filter( (bc) => bc.containerTypeId != null, @@ -261,8 +271,6 @@ export class BookingPricingService { const wr = ruleResult.containerWeightResults[i]; if (!wr?.isOverweight) continue; const line = containerLines[i]; - const totalVgmTons = Number(line?.totalVgmTons ?? 0); - const excessTons = Number(wr.overweightExcessTons ?? 0); let code = line?.containerSize ?? ''; if (line?.containerTypeId) { try { @@ -271,12 +279,32 @@ export class BookingPricingService { // fall back to the container size label } } - overweightLines.push({ - containerTypeCode: code, - totalVgmTons, - maxAllowedTons: Math.max(0, totalVgmTons - excessTons), - excessTons, - }); + const numbers = (line?.units ?? []) + .slice() + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) + .map((u) => u.containerNumber); + // Legacy weight results carry no per-unit detail (a line total only) — + // report the line as a single row, as before. + const units = wr.overweightUnits?.length + ? wr.overweightUnits + : [ + { + unitIndex: 0, + vgmTons: Number(line?.totalVgmTons ?? 0), + excessTons: Number(wr.overweightExcessTons ?? 0), + }, + ]; + for (const u of units) { + overweightLines.push({ + containerTypeCode: code, + containerLabel: + (u.unitIndex > 0 ? numbers[u.unitIndex - 1] : null) || + (u.unitIndex > 0 ? `${code} #${u.unitIndex}` : code), + totalVgmTons: u.vgmTons, + maxAllowedTons: Math.max(0, u.vgmTons - u.excessTons), + excessTons: u.excessTons, + }); + } } return { @@ -346,6 +374,13 @@ export class BookingPricingService { quantity: qty, vgmPerUnitTons: vgm, totalVgmTons: qty * vgm, + // Real per-box weights when the booking recorded them: weight + // limits are per container, so 22/18/20t is 2t over on the first + // box even though the line total fits a 3x20t allowance. + unitVgmTons: (bc.units ?? []) + .slice() + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) + .map((u) => Number(u.vgmTons ?? 0)), isReefer: ct.isReefer, // Per-container opt-ins — PER_CONTAINER surcharges bill these. hazardousQuantity: Number(bc.hazardousQuantity ?? 0), diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index e9c85a91c..d3e3edfe5 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -383,6 +383,9 @@ export class BookingWagonCancellationService { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null, + // A dead booking holds no shipment day — leaving it set lets the + // stranded-PAID day sweep pick the booking up and resurrect it. + scheduledDate: null, }); await this.detachFromSchedule(b); } @@ -568,6 +571,9 @@ export class BookingWagonCancellationService { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null, + // A dead booking holds no shipment day — leaving it set lets the + // stranded-PAID day sweep pick the booking up and resurrect it. + scheduledDate: null, }); await this.detachFromSchedule(booking); this.notifyCustomer( diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts index 0ee77aeed..d5e1a0e69 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -27,11 +27,15 @@ export class PriceLineItemDto { currency!: string; } +/** One physical container over its per-container VGM limit. */ export class OverweightLineDto { @ApiProperty() containerTypeCode!: string; - @ApiProperty() + @ApiProperty({ description: 'Container number, or " #2" when unnumbered' }) + containerLabel!: string; + + @ApiProperty({ description: "This container's VGM in tons" }) totalVgmTons!: number; @ApiProperty() diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index f4236ce7f..313364d8c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -2091,6 +2091,7 @@ export class ContractBookingService { ): Promise<{ overweightLines: Array<{ containerTypeCode: string; + containerLabel: string; totalVgmTons: number; maxAllowedTons: number; excessTons: number; @@ -2200,6 +2201,12 @@ export class ContractBookingService { : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, totalVgmTons, + // Per-box weights drive the overweight check — the limit is per + // container, so a heavy box is billed even when the line total fits. + units: (line.units ?? []).map((u, idx) => ({ + vgmTons: Number(u.vgmTons ?? 0), + sortOrder: idx, + })) as BookingContainer['units'], wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)), }), ), @@ -2233,6 +2240,7 @@ export class ContractBookingService { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons, + unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)), })), contract.tradeDirection, ); @@ -2312,7 +2320,12 @@ export class ContractBookingService { (s, u) => s + Number(u.vgmTons ?? 0), 0, ); - return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons }; + return { + containerTypeId: ct.id, + quantity: line.quantity, + totalVgmTons, + unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)), + }; }), ); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index cf253347b..c95eb73b7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -14,6 +14,8 @@ import { type ClearanceTrainState, } from '@edr/types'; +import { DataSource } from 'typeorm'; + import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FilesService } from '../files/files.service'; @@ -117,6 +119,18 @@ export interface ContractClearanceView { linkedBookingReviewNote?: string | null; /** Shipment day the booking currently holds — the default when GL resubmits. */ linkedBookingScheduledDate?: string | null; + /** + * Open wagon-cancellation on a CANCELLED linked booking (consolidation + * partner lapsed, staff cut): FEE_PENDING = customer must pay the + * cancellation fee; CREDIT_AVAILABLE = fee settled, GL rebooks the credit. + */ + linkedBookingCancellation?: { + id: string; + status: string; + wagonsCancelled: number; + creditAmount: number; + creditCurrency: string; + } | null; dutyAdvice?: { amount: number; currency: string; @@ -171,6 +185,7 @@ export class ContractClearanceService { private readonly glOperationsService: GlOperationsService, private readonly notifier: ContractNotifierService, private readonly transitAgentsService: TransitAgentsService, + private readonly dataSource: DataSource, ) {} private isPhasedCustoms(contract: Contract): boolean { @@ -369,9 +384,27 @@ export class ContractClearanceService { // without a cycle row), so fall back to the contract's own live booking — // otherwise the clearance page sees no linked booking at all and cannot show // its status or the actions that depend on it. - const booking = cycle?.bookingId + let booking = cycle?.bookingId ? await this.bookingsService.findById(cycle.bookingId) : await this.contractsRepository.findLatestBookingForContract(contractId); + // The fallback skips terminal bookings, but a CANCELLED one with an open + // wagon-cancellation still belongs on this page: the fee gate and the + // rebook-from-credit action live here. Surface the newest such booking. + if (!booking) { + const [open] = await this.dataSource.query<{ booking_id: string }[]>( + `SELECT c.booking_id + FROM freight.booking_wagon_cancellations c + JOIN freight.bookings b ON b.id = c.booking_id + WHERE b.contract_id = $1 + AND b.status = 'CANCELLED' + AND c.status IN ('FEE_PENDING', 'CREDIT_AVAILABLE') + AND c.deleted_at IS NULL + ORDER BY c.created_at DESC + LIMIT 1`, + [contractId], + ); + if (open) booking = await this.bookingsService.findById(open.booking_id); + } if (booking) { linkedBookingId = booking.id ?? null; linkedBookingReference = booking.reference ?? null; @@ -395,6 +428,40 @@ export class ContractClearanceService { } } + // A CANCELLED booking may carry an open wagon-cancellation (consolidation + // partner lapsed, staff cut): FEE_PENDING gates on the customer paying the + // cancellation fee; CREDIT_AVAILABLE lets GL rebook from the credit here. + let linkedBookingCancellation: { + id: string; + status: string; + wagonsCancelled: number; + creditAmount: number; + creditCurrency: string; + } | null = null; + if (booking && linkedBookingStatus === 'CANCELLED') { + const [row] = await this.dataSource.query< + { id: string; status: string; wagons_cancelled: string; credit_amount: string }[] + >( + `SELECT id, status, wagons_cancelled, credit_amount + FROM freight.booking_wagon_cancellations + WHERE booking_id = $1 + AND status IN ('FEE_PENDING', 'CREDIT_AVAILABLE') + AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT 1`, + [booking.id], + ); + if (row) { + linkedBookingCancellation = { + id: row.id, + status: row.status, + wagonsCancelled: Number(row.wagons_cancelled), + creditAmount: Number(row.credit_amount), + creditCurrency: booking.paymentCurrency ?? 'ETB', + }; + } + } + return { contractId, status: contract.status, @@ -433,6 +500,7 @@ export class ContractClearanceService { linkedBookingStatus, linkedBookingReviewNote, linkedBookingScheduledDate, + linkedBookingCancellation, dutyAdvice, dutyDispute, transitAssignee, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts index b86b08444..a7bd3cd7d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-duty-dispute.spec.ts @@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => { {} as never, // glOperationsService notifier as never, {} as never, // transitAgentsService + {} as never, // dataSource ); build([ milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), diff --git a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts index a6c8c8d6f..58c97619a 100644 --- a/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/transit-assignee.spec.ts @@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => { {} as never, notifier as never, transitAgentsService as never, + {} as never, // dataSource ); }); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index c530e5314..2d91d2da7 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -169,6 +169,101 @@ describe('RuleEngineService — overweight surcharge by trade direction', () => }); }); +describe('RuleEngineService — overweight is per container, never pooled', () => { + const configuredOverweight: Rate = { + id: 'rate-ow', + rateType: 'OVERWEIGHT_PER_TON', + trigger: 'OVERWEIGHT', + rateValue: 10, + rateUnit: 'PER_TON', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + } as Rate; + + const makeService = (maxCapacityTons: number | null) => + new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue([configuredOverweight]) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + // The reported case: 3 × 20ft at 22 / 18 / 20 t against a 20 t limit. The + // line total (60 t) fits a pooled 3 × 20 t allowance, but the first box is + // 2 t over and must be billed for it. + const input = (unitVgmTons: number[]): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'EXPORT', + isHazardous: false, + totalWagons: 2, + containers: [ + { + containerTypeId: 'ct-20', + quantity: unitVgmTons.length, + vgmPerUnitTons: unitVgmTons.reduce((s, v) => s + v, 0) / unitVgmTons.length, + totalVgmTons: unitVgmTons.reduce((s, v) => s + v, 0), + unitVgmTons, + }, + ], + }); + + it('bills only the tons the heavy container is over, not the line total', async () => { + const result = await makeService(null).evaluate(input([22, 18, 20])); + const wr = result.containerWeightResults[0]; + expect(wr.isOverweight).toBe(true); + expect(wr.overweightExcessTons).toBe(2); + expect(wr.overweightUnits).toEqual([{ unitIndex: 1, vgmTons: 22, excessTons: 2 }]); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow[0].calculatedAmount).toBe(20); // 2 t × 10 USD/t + }); + + it('sums the excess of every over-limit container', async () => { + const result = await makeService(null).evaluate(input([22, 18, 23])); + const wr = result.containerWeightResults[0]; + expect(wr.overweightExcessTons).toBe(5); + expect(wr.overweightUnits?.map((u) => u.unitIndex)).toEqual([1, 3]); + }); + + it('is not overweight when no single container is over the limit', async () => { + const result = await makeService(null).evaluate(input([20, 18, 20])); + expect(result.containerWeightResults[0].isOverweight).toBe(false); + }); + + it('falls back to an even spread when a line carries no per-unit weights', async () => { + const result = await makeService(null).evaluate({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'EXPORT', + isHazardous: false, + totalWagons: 2, + containers: [ + { containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 21, totalVgmTons: 63 }, + ], + }); + // 3 boxes at 21 t each → 1 t over on each. + expect(result.containerWeightResults[0].overweightExcessTons).toBe(3); + }); + + it('blocks on capacity per container, not on the pooled line total', async () => { + const violations = await makeService(30).capacityViolations( + [{ containerTypeId: 'ct-20', quantity: 3, totalVgmTons: 60, unitVgmTons: [35, 5, 20] }], + 'EXPORT', + ); + expect(violations).toHaveLength(1); + expect(violations[0]).toContain('#1'); + }); +}); + describe('RuleEngineService — empty-container return per route + container type', () => { const returnRate20: Rate = { id: 'rate-return-20', diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 5c9055415..3fbecc036 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -33,6 +33,32 @@ import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants'; // from multipart form-data) and a non-empty "false" string is truthy. const truthy = (v: unknown): boolean => v === true || v === 'true'; +/** Tons carry 3 decimals in the schema; keep derived tonnage on that grid. */ +const round3 = (n: number): number => Math.round(n * 1000) / 1000; + +/** + * The VGM of every physical container on a line. Uses the per-unit weights the + * booking recorded; when a line has none (or fewer than its quantity — legacy + * rows only kept a line total), the remainder is spread evenly, which is the + * uniform load those bookings were entered as. + */ +export const unitWeights = (container: { + quantity: number; + totalVgmTons: number; + unitVgmTons?: number[]; +}): number[] => { + const known = (container.unitVgmTons ?? []) + .slice(0, container.quantity) + .map((v) => Number(v ?? 0)); + const missing = Math.max(0, Number(container.quantity || 0) - known.length); + if (missing === 0) return known; + const rest = Math.max( + 0, + Number(container.totalVgmTons || 0) - known.reduce((s, v) => s + v, 0), + ); + return [...known, ...Array(missing).fill(rest / missing)]; +}; + export interface BookingContainerEvalInput { containerTypeId: string; quantity: number; @@ -41,6 +67,15 @@ export interface BookingContainerEvalInput { isReefer?: boolean; isOverweight?: boolean; overweightExcessTons?: number | null; + /** + * VGM of each physical container on this line, when the booking carries + * per-unit weights. Weight limits are a per-container ceiling: 3x20ft at + * 22/18/20t against a 20t limit is 2t overweight on the first box, not + * zero because the line total happens to fit. Missing/short (legacy lines + * that only carry a line total) falls back to an even spread across + * `quantity`, which is what those bookings actually recorded. + */ + unitVgmTons?: number[]; /** * How many individual containers on this line opted into each handling * service. PER_CONTAINER surcharges bill these counts, not the line @@ -131,11 +166,22 @@ export interface AppliedCargoModifier { billingUnit?: string; } +/** One physical container that broke the per-container VGM limit. */ +export interface OverweightUnit { + /** 1-based position of the container within its line. */ + unitIndex: number; + vgmTons: number; + excessTons: number; +} + export interface ContainerWeightResult { containerTypeId: string; weightLimitRuleId: string | null; isOverweight: boolean; + /** Sum of the per-container excesses on this line. */ overweightExcessTons: number | null; + /** Which containers of the line are over, and by how much. */ + overweightUnits?: OverweightUnit[]; } export interface RuleEvaluationResult { @@ -221,22 +267,37 @@ export class RuleEngineService { lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null); let isOverweight = container.isOverweight ?? false; let excess = container.overweightExcessTons ?? null; + let overweightUnits: OverweightUnit[] | undefined; if (rule) { - const maxTotal = Number(rule.maxVgmTons) * container.quantity; - const totalVgm = container.totalVgmTons; - if (totalVgm > maxTotal) { + const perUnitLimit = Number(rule.maxVgmTons); + // Per-container, never pooled: an underloaded box does not absorb the + // excess of an overloaded one — each container is billed on its own + // tons above the limit. + overweightUnits = unitWeights(container) + .map((vgmTons, i) => ({ + unitIndex: i + 1, + vgmTons, + excessTons: round3(Math.max(0, vgmTons - perUnitLimit)), + })) + .filter((u) => u.excessTons > 0); + if (overweightUnits.length > 0) { isOverweight = true; - excess = Math.max(0, totalVgm - maxTotal); - warnings.push( - `Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`, + excess = round3( + overweightUnits.reduce((sum, u) => sum + u.excessTons, 0), ); + for (const u of overweightUnits) { + warnings.push( + `Container type ${container.containerTypeId} #${u.unitIndex} VGM ${u.vgmTons}t exceeds the ${perUnitLimit}t limit by ${u.excessTons}t`, + ); + } } containerWeightResults.push({ containerTypeId: container.containerTypeId, weightLimitRuleId: rule.id, isOverweight, overweightExcessTons: excess, + overweightUnits, }); } else { containerWeightResults.push({ @@ -719,6 +780,7 @@ export class RuleEngineService { containerTypeId: string; quantity: number; totalVgmTons: number; + unitVgmTons?: number[]; }>, tradeDirection: string, ): Promise { @@ -731,13 +793,17 @@ export class RuleEngineService { const rule = rules[0]; if (!rule || rule.maxCapacityTons == null) continue; const perUnit = Number(rule.maxCapacityTons); - const maxTotal = perUnit * container.quantity; - if (container.totalVgmTons > maxTotal) { - const label = rule.containerType?.code ?? container.containerTypeId; - violations.push( - `${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`, - ); - } + const label = rule.containerType?.code ?? container.containerTypeId; + // Capacity is a physical ceiling on one box, so it is checked per box for + // the same reason the VGM limit is — a light container cannot carry the + // overload of a heavy one. + unitWeights(container).forEach((vgmTons, i) => { + if (vgmTons > perUnit) { + violations.push( + `${label} #${i + 1} weight ${round3(vgmTons)}t exceeds the maximum capacity of ${perUnit}t per container — the booking cannot be created; reduce the cargo weight`, + ); + } + }); } return violations; } diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts index ce5a7fad8..c43781c43 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts @@ -484,6 +484,13 @@ export class ShippingLineBookingCompletionService { returnQuantity: 0, vgmPerUnitTons: figures.vgmPerUnit, totalVgmTons: figures.totalVgm, + // In-memory units so the probe prices the same per-container + // overweight the persisted booking will: the limit applies to each + // box, not to the line's pooled tonnage. + units: (line.units ?? []).map((u, idx) => ({ + vgmTons: Number(u.vgmTons ?? 0), + sortOrder: idx, + })) as BookingContainer['units'], wagonsRequired: Math.ceil( line.quantity * wagonsPerUnitForSize(containerType.sizeFt), ), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index fa4be32bb..5c76b7803 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -195,6 +195,20 @@ describe('BookingBatchService — PAID reconcile', () => { expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2); }); + it('ensurePaidBookingAllocated never resurrects a CANCELLED booking that is still paymentStatus PAID', async () => { + dataSource.getRepository().findOne.mockResolvedValue({ + ...paidBooking, + status: 'CANCELLED', + trainScheduleId: null, + } as unknown as Booking); + + await service.ensurePaidBookingAllocated(bookingId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + expect(dataSource.getRepository().update).not.toHaveBeenCalled(); + }); + it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => { trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({ wagonTypeCodes: 'NW6', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 8a348e625..625ccfda7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -571,6 +571,12 @@ export class BookingBatchService implements OnModuleInit { relations: { company: true }, }); if (!booking) return; + // A dead booking keeps payment_status = 'PAID' (it was paid before it died), + // so every rescue path below would happily re-place and re-allocate it — + // that is how a cancelled consolidation-lapse booking came back onto its + // train 30s after being cancelled. Never resurrect a dead booking. + if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status)) + return; if (!booking.trainScheduleId) { // A paid booking with no train is money taken and nothing boarding. The // hold was expired before the payment landed (webhook lag beat the diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index dd47ae36e..e208328cc 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -2410,9 +2410,9 @@ export default function GlCreateBookingForm() { {overweightLines.map((line, i) => ( - {line.containerTypeCode}: {line.totalVgmTons}t exceeds - limit {line.maxAllowedTons}t (+{line.excessTons}t - overweight) + {line.containerLabel || line.containerTypeCode}:{" "} + {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t + (+{line.excessTons}t overweight) ))} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index dacae6cea..f91d2525e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -1,6 +1,6 @@ import { directionLabel } from "@/lib/utils"; -import { useMemo } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useLocation, useParams } from "react-router-dom"; import { Alert, @@ -14,6 +14,7 @@ import { Stack, Text, } from "@mantine/core"; +import { DatePickerInput } from "@mantine/dates"; import { AlertCircle, ArrowRight, @@ -22,9 +23,16 @@ import { PackageCheck, RefreshCw, ShieldCheck, + XCircle, } from "lucide-react"; +import toast from "react-hot-toast"; import { Link } from "react-router-dom"; +import { api } from "@/auth/http"; +import { extractErrorMessage } from "@/utils/errorExtractor"; +import { formatMoney } from "@/lib/format"; +import { toDayString } from "@/hooks/useListControls"; + import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, @@ -146,6 +154,32 @@ export default function ContractClearanceDetailPage() { !isDjiboutiGl(user); const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner; const canRebook = bookingExpired && isGlBookingOwner; + // The linked booking was CANCELLED with an open wagon-cancellation ledger row + // (consolidation partner lapsed unpaid, staff cut). FEE_PENDING: the customer + // must settle the cancellation-fee invoice first; CREDIT_AVAILABLE: the paid + // freight is credit and GL rebooks it from here (new booking under this + // contract, marked PAID — it re-enters consolidation pairing if odd 20ft). + const cancellation = + clearance?.linkedBookingStatus === "CANCELLED" + ? clearance?.linkedBookingCancellation + : null; + const canRebookCredit = + cancellation?.status === "CREDIT_AVAILABLE" && + hasPermission(user, FREIGHT_PERMS.bookings.wagonCancellationRebook); + const [creditRebookDate, setCreditRebookDate] = useState(null); + const creditRebook = useMutation({ + mutationFn: () => + api.post(`/bookings/wagon-cancellations/${cancellation!.id}/rebook`, { + scheduledDate: toDayString(creditRebookDate!), + }), + onSuccess: () => { + toast.success("Booking recreated from the credit and marked paid."); + void refetch(); + void refetchContract(); + }, + onError: (err) => + toast.error(extractErrorMessage(err, "Rebook failed")), + }); // Rebook completes the SAME expired booking (it already carries the price and // cargo from its first completion) via the /complete endpoint's EXPIRED // branch — routing it through create-booking instead would create a @@ -252,7 +286,18 @@ export default function ContractClearanceDetailPage() { Customs ) : null} - {bookingExpired ? ( + {cancellation ? ( + } + > + {cancellation.status === "FEE_PENDING" + ? "Cancelled — fee pending" + : "Cancelled — rebook credit"} + + ) : bookingExpired ? ( : null} - {bookingExpired ? ( + {cancellation ? ( + } + title={`Booking ${clearance.linkedBookingReference ?? ""} cancelled — consolidation partner not paid`} + > + + {cancellation.status === "FEE_PENDING" ? ( + + The booking shared a wagon with a partner booking that was + never paid, so it could not board and was cancelled. Its paid + freight ({formatMoney(cancellation.creditAmount, cancellation.creditCurrency)}) is held as + rebooking credit. The customer must first pay the cancellation + fee from the portal Payments tab — once it settles, rebook the + shipment here. + + ) : ( + <> + + The cancellation fee is settled. Pick the new shipment day + and rebook — the booking is recreated under this contract + from the {formatMoney(cancellation.creditAmount, cancellation.creditCurrency)} credit and + marked paid (no new freight charge). + + {canRebookCredit ? ( + + setCreditRebookDate(v ? new Date(v) : null)} + minDate={new Date()} + w={220} + /> + + + ) : null} + + )} + + + ) : bookingExpired ? ( #2" when unnumbered. */ + containerLabel: string; + /** This container's VGM — the limit is per container, never pooled. */ totalVgmTons: number; maxAllowedTons: number; excessTons: number; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index f27e75d7c..cc92d3ae2 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -1024,8 +1024,9 @@ function PriceConfirmModal({ {overweightLines.map((line, i) => ( - {line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "} - {line.maxAllowedTons}t (+{line.excessTons}t overweight) + {line.containerLabel || line.containerTypeCode}:{" "} + {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t (+ + {line.excessTons}t overweight) ))} diff --git a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineCompletePage.tsx b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineCompletePage.tsx index 809c8e5c1..1a8aecf71 100644 --- a/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineCompletePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/shipping-line/ShippingLineCompletePage.tsx @@ -644,8 +644,9 @@ function PriceConfirmModal({ {overweightLines.map((line, i) => ( - {line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "} - {line.maxAllowedTons}t (+{line.excessTons}t overweight) + {line.containerLabel || line.containerTypeCode}:{" "} + {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t (+ + {line.excessTons}t overweight) ))} diff --git a/apps/edr-freight-web/portal/src/services/contracts.service.ts b/apps/edr-freight-web/portal/src/services/contracts.service.ts index 4e9cb79de..2979cf274 100644 --- a/apps/edr-freight-web/portal/src/services/contracts.service.ts +++ b/apps/edr-freight-web/portal/src/services/contracts.service.ts @@ -33,9 +33,12 @@ export interface SubmitContractResponse { message?: string; } -/** A container line whose total VGM exceeds the weight-limit rule. */ +/** One physical container whose VGM exceeds the weight-limit rule. */ export interface OverweightLine { containerTypeCode: string; + /** Container number, or " #2" when unnumbered. */ + containerLabel: string; + /** This container's VGM — the limit is per container, never pooled. */ totalVgmTons: number; maxAllowedTons: number; excessTons: number; diff --git a/apps/edr-freight-web/portal/src/services/shipping-line-bookings.service.ts b/apps/edr-freight-web/portal/src/services/shipping-line-bookings.service.ts index 74797e6dc..c95d5d453 100644 --- a/apps/edr-freight-web/portal/src/services/shipping-line-bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/shipping-line-bookings.service.ts @@ -70,6 +70,9 @@ export interface ShippingLinePriceQuote { /** Containers over their type's weight limit — a surcharge, not a block. */ overweightLines: { containerTypeCode: string; + /** Container number, or " #2" when unnumbered. */ + containerLabel: string; + /** This container's VGM — the limit is per container, never pooled. */ totalVgmTons: number; maxAllowedTons: number; excessTons: number; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 98ec76a0c..867787efa 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -511,6 +511,18 @@ export interface ContractClearanceView { linkedBookingReviewNote?: string | null; /** Shipment day the booking holds; the default when GL resubmits it. */ linkedBookingScheduledDate?: string | null; + /** + * Open wagon-cancellation on a CANCELLED linked booking (consolidation + * partner lapsed, staff cut): FEE_PENDING = customer must pay the + * cancellation fee; CREDIT_AVAILABLE = fee settled, GL rebooks the credit. + */ + linkedBookingCancellation?: { + id: string; + status: string; + wagonsCancelled: number; + creditAmount: number; + creditCurrency: string; + } | null; /** * Pre-declaration handshake with GL Djibouti: who handles the shipment in * transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot