mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1409 from Tria-plc/freight_feature/usermanagement
fix issue -> consolidation
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
{} as never,
|
||||
notifier as never,
|
||||
transitAgentsService as never,
|
||||
{} as never, // dataSource
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user