fix issue -> consolidation

This commit is contained in:
Marshal
2026-08-24 12:24:23 +00:00
parent 979f024869
commit 2a107e8ba3
20 changed files with 475 additions and 39 deletions

View File

@@ -26,9 +26,18 @@ import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util'; import { assertBookingStatus } from './booking-status.util';
import { ContainerValidationService } from './container-validation.service'; 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 { export interface OverweightLine {
containerTypeCode: string; containerTypeCode: string;
/** Container number when known, else "<code> #2" — identifies the box. */
containerLabel: string;
/** This container's VGM, not the line total. */
totalVgmTons: number; totalVgmTons: number;
/** The per-container limit. */
maxAllowedTons: number; maxAllowedTons: number;
excessTons: number; excessTons: number;
} }
@@ -250,9 +259,10 @@ export class BookingPricingService {
clearanceBlocked.push(...clearance.blocked); clearanceBlocked.push(...clearance.blocked);
} }
// Overweight detail for the customer: map the engine's per-line results back // Overweight detail for the customer: one row per over-limit CONTAINER,
// to the booking's container lines (same order) for code + weights. maxAllowed // mapped back to the booking's container lines (same order) for the code and
// is derived from the line total minus the excess the engine computed. // the physical container numbers. maxAllowed is the per-container limit,
// recovered from that container's weight minus its own excess.
const overweightLines: OverweightLine[] = []; const overweightLines: OverweightLine[] = [];
const containerLines = (booking.bookingContainers ?? []).filter( const containerLines = (booking.bookingContainers ?? []).filter(
(bc) => bc.containerTypeId != null, (bc) => bc.containerTypeId != null,
@@ -261,8 +271,6 @@ export class BookingPricingService {
const wr = ruleResult.containerWeightResults[i]; const wr = ruleResult.containerWeightResults[i];
if (!wr?.isOverweight) continue; if (!wr?.isOverweight) continue;
const line = containerLines[i]; const line = containerLines[i];
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
const excessTons = Number(wr.overweightExcessTons ?? 0);
let code = line?.containerSize ?? ''; let code = line?.containerSize ?? '';
if (line?.containerTypeId) { if (line?.containerTypeId) {
try { try {
@@ -271,12 +279,32 @@ export class BookingPricingService {
// fall back to the container size label // fall back to the container size label
} }
} }
overweightLines.push({ const numbers = (line?.units ?? [])
containerTypeCode: code, .slice()
totalVgmTons, .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
maxAllowedTons: Math.max(0, totalVgmTons - excessTons), .map((u) => u.containerNumber);
excessTons, // 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 { return {
@@ -346,6 +374,13 @@ export class BookingPricingService {
quantity: qty, quantity: qty,
vgmPerUnitTons: vgm, vgmPerUnitTons: vgm,
totalVgmTons: qty * 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, isReefer: ct.isReefer,
// Per-container opt-ins — PER_CONTAINER surcharges bill these. // Per-container opt-ins — PER_CONTAINER surcharges bill these.
hazardousQuantity: Number(bc.hazardousQuantity ?? 0), hazardousQuantity: Number(bc.hazardousQuantity ?? 0),

View File

@@ -383,6 +383,9 @@ export class BookingWagonCancellationService {
status: 'CANCELLED', status: 'CANCELLED',
trainScheduleId: null, trainScheduleId: null,
requestedTrainScheduleId: 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); await this.detachFromSchedule(b);
} }
@@ -568,6 +571,9 @@ export class BookingWagonCancellationService {
status: 'CANCELLED', status: 'CANCELLED',
trainScheduleId: null, trainScheduleId: null,
requestedTrainScheduleId: 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); await this.detachFromSchedule(booking);
this.notifyCustomer( this.notifyCustomer(

View File

@@ -27,11 +27,15 @@ export class PriceLineItemDto {
currency!: string; currency!: string;
} }
/** One physical container over its per-container VGM limit. */
export class OverweightLineDto { export class OverweightLineDto {
@ApiProperty() @ApiProperty()
containerTypeCode!: string; containerTypeCode!: string;
@ApiProperty() @ApiProperty({ description: 'Container number, or "<code> #2" when unnumbered' })
containerLabel!: string;
@ApiProperty({ description: "This container's VGM in tons" })
totalVgmTons!: number; totalVgmTons!: number;
@ApiProperty() @ApiProperty()

View File

@@ -2091,6 +2091,7 @@ export class ContractBookingService {
): Promise<{ ): Promise<{
overweightLines: Array<{ overweightLines: Array<{
containerTypeCode: string; containerTypeCode: string;
containerLabel: string;
totalVgmTons: number; totalVgmTons: number;
maxAllowedTons: number; maxAllowedTons: number;
excessTons: number; excessTons: number;
@@ -2200,6 +2201,12 @@ export class ContractBookingService {
: 0, : 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0, vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons, 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)), wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
}), }),
), ),
@@ -2233,6 +2240,7 @@ export class ContractBookingService {
containerTypeId: ct.id, containerTypeId: ct.id,
quantity: line.quantity, quantity: line.quantity,
totalVgmTons, totalVgmTons,
unitVgmTons: (line.units ?? []).map((u) => Number(u.vgmTons ?? 0)),
})), })),
contract.tradeDirection, contract.tradeDirection,
); );
@@ -2312,7 +2320,12 @@ export class ContractBookingService {
(s, u) => s + Number(u.vgmTons ?? 0), (s, u) => s + Number(u.vgmTons ?? 0),
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)),
};
}), }),
); );

View File

@@ -14,6 +14,8 @@ import {
type ClearanceTrainState, type ClearanceTrainState,
} from '@edr/types'; } from '@edr/types';
import { DataSource } from 'typeorm';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
@@ -117,6 +119,18 @@ export interface ContractClearanceView {
linkedBookingReviewNote?: string | null; linkedBookingReviewNote?: string | null;
/** Shipment day the booking currently holds — the default when GL resubmits. */ /** Shipment day the booking currently holds — the default when GL resubmits. */
linkedBookingScheduledDate?: string | null; 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?: { dutyAdvice?: {
amount: number; amount: number;
currency: string; currency: string;
@@ -171,6 +185,7 @@ export class ContractClearanceService {
private readonly glOperationsService: GlOperationsService, private readonly glOperationsService: GlOperationsService,
private readonly notifier: ContractNotifierService, private readonly notifier: ContractNotifierService,
private readonly transitAgentsService: TransitAgentsService, private readonly transitAgentsService: TransitAgentsService,
private readonly dataSource: DataSource,
) {} ) {}
private isPhasedCustoms(contract: Contract): boolean { 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 — // 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 // otherwise the clearance page sees no linked booking at all and cannot show
// its status or the actions that depend on it. // 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.bookingsService.findById(cycle.bookingId)
: await this.contractsRepository.findLatestBookingForContract(contractId); : 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) { if (booking) {
linkedBookingId = booking.id ?? null; linkedBookingId = booking.id ?? null;
linkedBookingReference = booking.reference ?? 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 { return {
contractId, contractId,
status: contract.status, status: contract.status,
@@ -433,6 +500,7 @@ export class ContractClearanceService {
linkedBookingStatus, linkedBookingStatus,
linkedBookingReviewNote, linkedBookingReviewNote,
linkedBookingScheduledDate, linkedBookingScheduledDate,
linkedBookingCancellation,
dutyAdvice, dutyAdvice,
dutyDispute, dutyDispute,
transitAssignee, transitAssignee,

View File

@@ -63,6 +63,7 @@ describe('ContractClearanceService — duty dispute', () => {
{} as never, // glOperationsService {} as never, // glOperationsService
notifier as never, notifier as never,
{} as never, // transitAgentsService {} as never, // transitAgentsService
{} as never, // dataSource
); );
build([ build([
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'), milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),

View File

@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
{} as never, {} as never,
notifier as never, notifier as never,
transitAgentsService as never, transitAgentsService as never,
{} as never, // dataSource
); );
}); });

View File

@@ -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', () => { describe('RuleEngineService — empty-container return per route + container type', () => {
const returnRate20: Rate = { const returnRate20: Rate = {
id: 'rate-return-20', id: 'rate-return-20',

View File

@@ -33,6 +33,32 @@ import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants';
// from multipart form-data) and a non-empty "false" string is truthy. // from multipart form-data) and a non-empty "false" string is truthy.
const truthy = (v: unknown): boolean => v === true || v === 'true'; 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<number>(missing).fill(rest / missing)];
};
export interface BookingContainerEvalInput { export interface BookingContainerEvalInput {
containerTypeId: string; containerTypeId: string;
quantity: number; quantity: number;
@@ -41,6 +67,15 @@ export interface BookingContainerEvalInput {
isReefer?: boolean; isReefer?: boolean;
isOverweight?: boolean; isOverweight?: boolean;
overweightExcessTons?: number | null; 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 * How many individual containers on this line opted into each handling
* service. PER_CONTAINER surcharges bill these counts, not the line * service. PER_CONTAINER surcharges bill these counts, not the line
@@ -131,11 +166,22 @@ export interface AppliedCargoModifier {
billingUnit?: string; 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 { export interface ContainerWeightResult {
containerTypeId: string; containerTypeId: string;
weightLimitRuleId: string | null; weightLimitRuleId: string | null;
isOverweight: boolean; isOverweight: boolean;
/** Sum of the per-container excesses on this line. */
overweightExcessTons: number | null; overweightExcessTons: number | null;
/** Which containers of the line are over, and by how much. */
overweightUnits?: OverweightUnit[];
} }
export interface RuleEvaluationResult { export interface RuleEvaluationResult {
@@ -221,22 +267,37 @@ export class RuleEngineService {
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null); lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
let isOverweight = container.isOverweight ?? false; let isOverweight = container.isOverweight ?? false;
let excess = container.overweightExcessTons ?? null; let excess = container.overweightExcessTons ?? null;
let overweightUnits: OverweightUnit[] | undefined;
if (rule) { if (rule) {
const maxTotal = Number(rule.maxVgmTons) * container.quantity; const perUnitLimit = Number(rule.maxVgmTons);
const totalVgm = container.totalVgmTons; // Per-container, never pooled: an underloaded box does not absorb the
if (totalVgm > maxTotal) { // 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; isOverweight = true;
excess = Math.max(0, totalVgm - maxTotal); excess = round3(
warnings.push( overweightUnits.reduce((sum, u) => sum + u.excessTons, 0),
`Container type ${container.containerTypeId} VGM ${totalVgm}t exceeds limit ${maxTotal}t`,
); );
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({ containerWeightResults.push({
containerTypeId: container.containerTypeId, containerTypeId: container.containerTypeId,
weightLimitRuleId: rule.id, weightLimitRuleId: rule.id,
isOverweight, isOverweight,
overweightExcessTons: excess, overweightExcessTons: excess,
overweightUnits,
}); });
} else { } else {
containerWeightResults.push({ containerWeightResults.push({
@@ -719,6 +780,7 @@ export class RuleEngineService {
containerTypeId: string; containerTypeId: string;
quantity: number; quantity: number;
totalVgmTons: number; totalVgmTons: number;
unitVgmTons?: number[];
}>, }>,
tradeDirection: string, tradeDirection: string,
): Promise<string[]> { ): Promise<string[]> {
@@ -731,13 +793,17 @@ export class RuleEngineService {
const rule = rules[0]; const rule = rules[0];
if (!rule || rule.maxCapacityTons == null) continue; if (!rule || rule.maxCapacityTons == null) continue;
const perUnit = Number(rule.maxCapacityTons); const perUnit = Number(rule.maxCapacityTons);
const maxTotal = perUnit * container.quantity; const label = rule.containerType?.code ?? container.containerTypeId;
if (container.totalVgmTons > maxTotal) { // Capacity is a physical ceiling on one box, so it is checked per box for
const label = rule.containerType?.code ?? container.containerTypeId; // the same reason the VGM limit is — a light container cannot carry the
violations.push( // overload of a heavy one.
`${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`, 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; return violations;
} }

View File

@@ -484,6 +484,13 @@ export class ShippingLineBookingCompletionService {
returnQuantity: 0, returnQuantity: 0,
vgmPerUnitTons: figures.vgmPerUnit, vgmPerUnitTons: figures.vgmPerUnit,
totalVgmTons: figures.totalVgm, 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( wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt), line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
), ),

View File

@@ -195,6 +195,20 @@ describe('BookingBatchService — PAID reconcile', () => {
expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2); 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 () => { it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => {
trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({ trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({
wagonTypeCodes: 'NW6', wagonTypeCodes: 'NW6',

View File

@@ -571,6 +571,12 @@ export class BookingBatchService implements OnModuleInit {
relations: { company: true }, relations: { company: true },
}); });
if (!booking) return; 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) { if (!booking.trainScheduleId) {
// A paid booking with no train is money taken and nothing boarding. The // A paid booking with no train is money taken and nothing boarding. The
// hold was expired before the payment landed (webhook lag beat the // hold was expired before the payment landed (webhook lag beat the

View File

@@ -2410,9 +2410,9 @@ export default function GlCreateBookingForm() {
<Stack gap={6}> <Stack gap={6}>
{overweightLines.map((line, i) => ( {overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00"> <Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds {line.containerLabel || line.containerTypeCode}:{" "}
limit {line.maxAllowedTons}t (+{line.excessTons}t {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t
overweight) (+{line.excessTons}t overweight)
</Text> </Text>
))} ))}
<Text fz="xs" c="#9A5B00" mt={2}> <Text fz="xs" c="#9A5B00" mt={2}>

View File

@@ -1,6 +1,6 @@
import { directionLabel } from "@/lib/utils"; import { directionLabel } from "@/lib/utils";
import { useMemo } from "react"; import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { useLocation, useParams } from "react-router-dom"; import { useLocation, useParams } from "react-router-dom";
import { import {
Alert, Alert,
@@ -14,6 +14,7 @@ import {
Stack, Stack,
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { import {
AlertCircle, AlertCircle,
ArrowRight, ArrowRight,
@@ -22,9 +23,16 @@ import {
PackageCheck, PackageCheck,
RefreshCw, RefreshCw,
ShieldCheck, ShieldCheck,
XCircle,
} from "lucide-react"; } from "lucide-react";
import toast from "react-hot-toast";
import { Link } from "react-router-dom"; 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 { useAuth } from "@/auth/useAuth";
import { import {
FREIGHT_PERMS, FREIGHT_PERMS,
@@ -146,6 +154,32 @@ export default function ContractClearanceDetailPage() {
!isDjiboutiGl(user); !isDjiboutiGl(user);
const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner; const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner;
const canRebook = bookingExpired && 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<Date | null>(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 // Rebook completes the SAME expired booking (it already carries the price and
// cargo from its first completion) via the /complete endpoint's EXPIRED // cargo from its first completion) via the /complete endpoint's EXPIRED
// branch — routing it through create-booking instead would create a // branch — routing it through create-booking instead would create a
@@ -252,7 +286,18 @@ export default function ContractClearanceDetailPage() {
Customs Customs
</Badge> </Badge>
) : null} ) : null}
{bookingExpired ? ( {cancellation ? (
<Badge
variant="light"
color="red"
radius="sm"
leftSection={<XCircle size={13} />}
>
{cancellation.status === "FEE_PENDING"
? "Cancelled — fee pending"
: "Cancelled — rebook credit"}
</Badge>
) : bookingExpired ? (
<Badge <Badge
variant="light" variant="light"
color="orange" color="orange"
@@ -308,7 +353,59 @@ export default function ContractClearanceDetailPage() {
it can actually create the booking without checking the schedule board. */} it can actually create the booking without checking the schedule board. */}
{id ? <GlUpcomingWindowsSection contractId={id} /> : null} {id ? <GlUpcomingWindowsSection contractId={id} /> : null}
{bookingExpired ? ( {cancellation ? (
<Alert
color="red"
radius="md"
icon={<XCircle size={16} />}
title={`Booking ${clearance.linkedBookingReference ?? ""} cancelled — consolidation partner not paid`}
>
<Stack gap="sm" align="flex-start">
{cancellation.status === "FEE_PENDING" ? (
<Text size="sm">
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.
</Text>
) : (
<>
<Text size="sm">
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).
</Text>
{canRebookCredit ? (
<Group gap="sm" align="flex-end">
<DatePickerInput
label="New shipment day"
placeholder="Pick a day"
value={creditRebookDate}
onChange={(v) => setCreditRebookDate(v ? new Date(v) : null)}
minDate={new Date()}
w={220}
/>
<Button
color="grape"
radius="md"
size="sm"
leftSection={<RefreshCw size={15} />}
loading={creditRebook.isPending}
disabled={!creditRebookDate}
onClick={() => creditRebook.mutate()}
>
Rebook for customer
</Button>
</Group>
) : null}
</>
)}
</Stack>
</Alert>
) : bookingExpired ? (
<Alert <Alert
color="orange" color="orange"
radius="md" radius="md"

View File

@@ -56,6 +56,9 @@ export interface ShipmentPriceLine {
export interface ShipmentValidation { export interface ShipmentValidation {
overweightLines: Array<{ overweightLines: Array<{
containerTypeCode: string; containerTypeCode: string;
/** Container number, or "<code> #2" when unnumbered. */
containerLabel: string;
/** This container's VGM — the limit is per container, never pooled. */
totalVgmTons: number; totalVgmTons: number;
maxAllowedTons: number; maxAllowedTons: number;
excessTons: number; excessTons: number;

View File

@@ -1024,8 +1024,9 @@ function PriceConfirmModal({
<Stack gap={6}> <Stack gap={6}>
{overweightLines.map((line, i) => ( {overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00"> <Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "} {line.containerLabel || line.containerTypeCode}:{" "}
{line.maxAllowedTons}t (+{line.excessTons}t overweight) {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t (+
{line.excessTons}t overweight)
</Text> </Text>
))} ))}
<Text fz="xs" c="#9A5B00" mt={2}> <Text fz="xs" c="#9A5B00" mt={2}>

View File

@@ -644,8 +644,9 @@ function PriceConfirmModal({
<Stack gap={6}> <Stack gap={6}>
{overweightLines.map((line, i) => ( {overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00"> <Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "} {line.containerLabel || line.containerTypeCode}:{" "}
{line.maxAllowedTons}t (+{line.excessTons}t overweight) {line.totalVgmTons}t exceeds limit {line.maxAllowedTons}t (+
{line.excessTons}t overweight)
</Text> </Text>
))} ))}
<Text fz="xs" c="#9A5B00" mt={2}> <Text fz="xs" c="#9A5B00" mt={2}>

View File

@@ -33,9 +33,12 @@ export interface SubmitContractResponse {
message?: string; 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 { export interface OverweightLine {
containerTypeCode: string; containerTypeCode: string;
/** Container number, or "<code> #2" when unnumbered. */
containerLabel: string;
/** This container's VGM — the limit is per container, never pooled. */
totalVgmTons: number; totalVgmTons: number;
maxAllowedTons: number; maxAllowedTons: number;
excessTons: number; excessTons: number;

View File

@@ -70,6 +70,9 @@ export interface ShippingLinePriceQuote {
/** Containers over their type's weight limit — a surcharge, not a block. */ /** Containers over their type's weight limit — a surcharge, not a block. */
overweightLines: { overweightLines: {
containerTypeCode: string; containerTypeCode: string;
/** Container number, or "<code> #2" when unnumbered. */
containerLabel: string;
/** This container's VGM — the limit is per container, never pooled. */
totalVgmTons: number; totalVgmTons: number;
maxAllowedTons: number; maxAllowedTons: number;
excessTons: number; excessTons: number;

View File

@@ -511,6 +511,18 @@ export interface ContractClearanceView {
linkedBookingReviewNote?: string | null; linkedBookingReviewNote?: string | null;
/** Shipment day the booking holds; the default when GL resubmits it. */ /** Shipment day the booking holds; the default when GL resubmits it. */
linkedBookingScheduledDate?: string | null; 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 * Pre-declaration handshake with GL Djibouti: who handles the shipment in
* transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot * transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot