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 { 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 "<code> #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),

View File

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

View File

@@ -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 "<code> #2" when unnumbered' })
containerLabel!: string;
@ApiProperty({ description: "This container's VGM in tons" })
totalVgmTons!: number;
@ApiProperty()

View File

@@ -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)),
};
}),
);

View File

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

View File

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

View File

@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
{} as never,
notifier 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', () => {
const returnRate20: Rate = {
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.
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 {
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<string[]> {
@@ -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;
}

View File

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

View File

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

View File

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