mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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 { 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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user