mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lashing is now BULK-only and sold per trade direction (IMPORT / EXPORT),
|
||||||
|
* optionally narrowed to one leaf commodity. Rates that no longer fit —
|
||||||
|
* container-scoped, or carrying no direction — cannot be mapped and are
|
||||||
|
* retired (SUPERSEDED + soft-deleted), kept readable for snapshot history.
|
||||||
|
* Matched on trigger, not rate_type (CONSOLIDATION shares rate_type LASHING).
|
||||||
|
*/
|
||||||
|
export class LashingBulkOnlyPerDirection2890000000000 implements MigrationInterface {
|
||||||
|
name = 'LashingBulkOnlyPerDirection2890000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.rates
|
||||||
|
SET status = 'SUPERSEDED',
|
||||||
|
deleted_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND "trigger" = 'LASHING'
|
||||||
|
AND (container_type_id IS NOT NULL
|
||||||
|
OR trade_direction IS NULL
|
||||||
|
OR trade_direction NOT IN ('IMPORT', 'EXPORT'));
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Retired rates stay retired — re-enter per-direction bulk rates instead.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -379,6 +379,25 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
|||||||
expect(line!.amount).toBe(600);
|
expect(line!.amount).toBe(600);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('the fee scoped to the booking commodity wins over the catch-all', async () => {
|
||||||
|
const service = makeService({
|
||||||
|
liveRates: [
|
||||||
|
{ ...bulkFeePerTon, id: 'rate-cc-catchall', rateValue: 5 } as Rate,
|
||||||
|
{
|
||||||
|
...bulkFeePerTon,
|
||||||
|
id: 'rate-cc-sugar',
|
||||||
|
rateValue: 9,
|
||||||
|
cargoTypeId: 'cargo-1',
|
||||||
|
} as Rate,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = await service.computePriceForBooking(bulkBooking());
|
||||||
|
|
||||||
|
const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE');
|
||||||
|
expect(line!.unitAmount).toBe(9); // commodity rate, not the 5 USD catch-all
|
||||||
|
expect(line!.amount).toBe(1080);
|
||||||
|
});
|
||||||
|
|
||||||
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
|
it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => {
|
||||||
const service = makeService({
|
const service = makeService({
|
||||||
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],
|
liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate],
|
||||||
|
|||||||
@@ -1028,8 +1028,15 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
// Bulk — one fee for the whole booking. The bulk snapshot and the legacy
|
// Bulk — one fee for the whole booking. The bulk snapshot and the legacy
|
||||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||||
|
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||||
|
// commodity-less rate (legacy) is the catch-all fallback.
|
||||||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||||
const live = onLeg.find((r) => !r.containerTypeId);
|
const live =
|
||||||
|
(booking.cargoTypeId
|
||||||
|
? onLeg.find(
|
||||||
|
(r) => !r.containerTypeId && r.cargoTypeId === booking.cargoTypeId,
|
||||||
|
)
|
||||||
|
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
||||||
if (!frozen && !live) {
|
if (!frozen && !live) {
|
||||||
blocked.push(missingRateMessage('bulk cargo'));
|
blocked.push(missingRateMessage('bulk cargo'));
|
||||||
return { lineItems, usedRates, blocked };
|
return { lineItems, usedRates, blocked };
|
||||||
|
|||||||
@@ -189,6 +189,36 @@ export class ContractPricingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Lashing / cargo securing — BULK only, shown when the contract's commodity
|
||||||
|
// needs lashing (cargoType.hasLashing). The commodity-scoped rate for the
|
||||||
|
// contract's direction wins over the commodity-wide catch-all; billed at
|
||||||
|
// booking on the live rate (per ton / per wagon), this line is display.
|
||||||
|
if (contract.freightType === 'BULK') {
|
||||||
|
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
if (scope?.cargoType?.hasLashing) {
|
||||||
|
const onDirection = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.trigger === 'LASHING' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
!r.containerTypeId &&
|
||||||
|
r.tradeDirection === contract.tradeDirection,
|
||||||
|
);
|
||||||
|
const lashing =
|
||||||
|
onDirection.find((r) => r.cargoTypeId === scope.cargoTypeId) ??
|
||||||
|
onDirection.find((r) => !r.cargoTypeId);
|
||||||
|
if (lashing && Number(lashing.rateValue) > 0) {
|
||||||
|
lineItems.push({
|
||||||
|
code: 'LASHING',
|
||||||
|
label: `Lashing / cargo securing (${scope.cargoType.cargoTypeName})`,
|
||||||
|
unit: toContractUnit(lashing.rateUnit),
|
||||||
|
unitPrice: convert(Number(lashing.rateValue)),
|
||||||
|
cargoTypeCode: scope.cargoType.code ?? null,
|
||||||
|
conditionalOn: 'has_lashing',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Empty-container return service — container contracts only, toggled on the
|
// Empty-container return service — container contracts only, toggled on the
|
||||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||||
if (
|
if (
|
||||||
@@ -295,18 +325,26 @@ export class ContractPricingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Bulk fee = the route's type-less customs rate (per ton / per wagon).
|
// Bulk fee — the rate scoped to the contract's commodity wins; a
|
||||||
const rate = onLeg.find((r) => !r.containerTypeId);
|
// commodity-less rate (legacy) is the catch-all fallback.
|
||||||
|
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||||
|
const rate =
|
||||||
|
(scope?.cargoTypeId
|
||||||
|
? onLeg.find(
|
||||||
|
(r) => !r.containerTypeId && r.cargoTypeId === scope.cargoTypeId,
|
||||||
|
)
|
||||||
|
: undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId);
|
||||||
if (!rate || Number(rate.rateValue) <= 0) {
|
if (!rate || Number(rate.rateValue) <= 0) {
|
||||||
throw new UnprocessableEntityException(
|
throw new UnprocessableEntityException(
|
||||||
'No bulk customs clearance service fee is configured for this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this origin → destination.',
|
'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lineItems.push({
|
lineItems.push({
|
||||||
code: 'CUSTOMS_CLEARANCE',
|
code: 'CUSTOMS_CLEARANCE',
|
||||||
label: 'Customs clearance service (bulk)',
|
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
||||||
unit: toContractUnit(rate.rateUnit),
|
unit: toContractUnit(rate.rateUnit),
|
||||||
unitPrice: convert(Number(rate.rateValue)),
|
unitPrice: convert(Number(rate.rateValue)),
|
||||||
|
cargoTypeCode: scope?.cargoType?.code ?? null,
|
||||||
isClearance: true,
|
isClearance: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export class CreateRateDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: CARGO_KINDS,
|
enum: CARGO_KINDS,
|
||||||
description:
|
description:
|
||||||
'Whether a customs clearance / lashing rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or LASHING. Not stored — container fees carry a containerTypeId, bulk fees none.',
|
'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...CARGO_KINDS])
|
@IsIn([...CARGO_KINDS])
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
|||||||
export function allowedRateUnits(input: {
|
export function allowedRateUnits(input: {
|
||||||
appliesTo: RateAppliesTo;
|
appliesTo: RateAppliesTo;
|
||||||
trigger: RateTrigger;
|
trigger: RateTrigger;
|
||||||
/** CUSTOMS_CLEARANCE / LASHING only: which cargo kind the fee covers. */
|
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
|
||||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||||
}): RateUnit[] {
|
}): RateUnit[] {
|
||||||
const { appliesTo, trigger } = input;
|
const { appliesTo, trigger } = input;
|
||||||
@@ -37,12 +37,14 @@ export function allowedRateUnits(input: {
|
|||||||
case 'CANCELLATION':
|
case 'CANCELLATION':
|
||||||
return ['FLAT', 'PER_INVOICE'];
|
return ['FLAT', 'PER_INVOICE'];
|
||||||
case 'CUSTOMS_CLEARANCE':
|
case 'CUSTOMS_CLEARANCE':
|
||||||
case 'LASHING':
|
|
||||||
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||||
// fees per ton or per wagon. Billed on the booking invoice.
|
// fees per ton or per wagon. Billed on the booking invoice.
|
||||||
return input.cargoKind === 'BULK'
|
return input.cargoKind === 'BULK'
|
||||||
? ['PER_TON', 'PER_WAGON']
|
? ['PER_TON', 'PER_WAGON']
|
||||||
: ['PER_CONTAINER', 'PER_WAGON'];
|
: ['PER_CONTAINER', 'PER_WAGON'];
|
||||||
|
case 'LASHING':
|
||||||
|
// Bulk-only cargo securing — per ton or per wagon.
|
||||||
|
return ['PER_TON', 'PER_WAGON'];
|
||||||
case 'CONSOLIDATION':
|
case 'CONSOLIDATION':
|
||||||
return ['PER_CONTAINER', 'FLAT'];
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
case 'SHIPPING_LINE':
|
case 'SHIPPING_LINE':
|
||||||
|
|||||||
@@ -308,70 +308,43 @@ describe('RuleEngineService — empty-container return per route + container typ
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('RuleEngineService — lashing per cargo kind', () => {
|
describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => {
|
||||||
const lashing20: Rate = {
|
const lashingBulkImport: Rate = {
|
||||||
id: 'rate-lash-20',
|
id: 'rate-lash-bulk',
|
||||||
rateType: 'LASHING',
|
rateType: 'LASHING',
|
||||||
trigger: 'LASHING',
|
trigger: 'LASHING',
|
||||||
rateValue: 30,
|
rateValue: 2,
|
||||||
rateUnit: 'PER_CONTAINER',
|
rateUnit: 'PER_TON',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
status: 'LIVE',
|
status: 'LIVE',
|
||||||
containerTypeId: 'ct-20',
|
containerTypeId: null,
|
||||||
cargoTypeId: null,
|
cargoTypeId: null,
|
||||||
tradeDirection: null,
|
tradeDirection: 'IMPORT',
|
||||||
originYardId: null,
|
originYardId: null,
|
||||||
destinationYardId: null,
|
destinationYardId: null,
|
||||||
} as Rate;
|
} as Rate;
|
||||||
|
|
||||||
const lashingBulk: Rate = {
|
|
||||||
...lashing20,
|
|
||||||
id: 'rate-lash-bulk',
|
|
||||||
rateValue: 2,
|
|
||||||
rateUnit: 'PER_TON',
|
|
||||||
containerTypeId: null,
|
|
||||||
} as Rate;
|
|
||||||
|
|
||||||
const buildService = (rates: Rate[]): RuleEngineService =>
|
const buildService = (rates: Rate[]): RuleEngineService =>
|
||||||
new RuleEngineService(
|
new RuleEngineService(
|
||||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
|
||||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
|
||||||
{
|
{
|
||||||
findActiveByContainerTypeId: jest
|
findById: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
.mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }),
|
||||||
} as never,
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
{ findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
|
||||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
const containerInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
|
||||||
serviceTypeId: 'svc-1',
|
|
||||||
paymentCurrency: 'USD',
|
|
||||||
tradeDirection: 'IMPORT',
|
|
||||||
isHazardous: false,
|
|
||||||
hasLashing: true,
|
|
||||||
totalWagons: 2,
|
|
||||||
containers: [
|
|
||||||
{
|
|
||||||
containerTypeId: 'ct-20',
|
|
||||||
quantity: 4,
|
|
||||||
vgmPerUnitTons: 10,
|
|
||||||
totalVgmTons: 40,
|
|
||||||
wagonsPerUnit: 0.5,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
|
||||||
serviceTypeId: 'svc-1',
|
serviceTypeId: 'svc-1',
|
||||||
paymentCurrency: 'USD',
|
paymentCurrency: 'USD',
|
||||||
tradeDirection: 'IMPORT',
|
tradeDirection: 'IMPORT',
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
hasLashing: true,
|
cargoTypeId: 'cargo-sugar',
|
||||||
totalWagons: 0,
|
totalWagons: 0,
|
||||||
bulkTons: 100,
|
bulkTons: 100,
|
||||||
bulkWagons: 3,
|
bulkWagons: 3,
|
||||||
@@ -382,53 +355,70 @@ describe('RuleEngineService — lashing per cargo kind', () => {
|
|||||||
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
|
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
|
||||||
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
|
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
|
||||||
|
|
||||||
it('bills each container line at its own container type rate', async () => {
|
it('bulk lashing bills per ton on the direction-matched rate', async () => {
|
||||||
const result = await buildService([lashing20]).evaluate(containerInput());
|
const result = await buildService([lashingBulkImport]).evaluate(bulkInput());
|
||||||
const mods = lashingMods(result);
|
const mods = lashingMods(result);
|
||||||
expect(mods).toHaveLength(1);
|
expect(mods).toHaveLength(1);
|
||||||
expect(mods[0].triggerValue).toBe(4);
|
|
||||||
expect(mods[0].calculatedAmount).toBe(120);
|
|
||||||
expect(mods[0].billingUnit).toBe('PER_CONTAINER');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PER_WAGON container lashing bills the wagons the line occupies', async () => {
|
|
||||||
const result = await buildService([
|
|
||||||
{ ...lashing20, rateUnit: 'PER_WAGON' } as Rate,
|
|
||||||
]).evaluate(containerInput());
|
|
||||||
const mods = lashingMods(result);
|
|
||||||
expect(mods[0].triggerValue).toBe(2);
|
|
||||||
expect(mods[0].calculatedAmount).toBe(60);
|
|
||||||
expect(mods[0].billingUnit).toBe('PER_WAGON');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('an unmatched container type simply bills nothing (lenient)', async () => {
|
|
||||||
const result = await buildService([
|
|
||||||
{ ...lashing20, containerTypeId: 'ct-40' } as Rate,
|
|
||||||
]).evaluate(containerInput());
|
|
||||||
expect(lashingMods(result)).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bulk lashing bills per ton on the tonnage', async () => {
|
|
||||||
const result = await buildService([lashingBulk]).evaluate(bulkInput());
|
|
||||||
const mods = lashingMods(result);
|
|
||||||
expect(mods[0].triggerValue).toBe(100);
|
expect(mods[0].triggerValue).toBe(100);
|
||||||
expect(mods[0].calculatedAmount).toBe(200);
|
expect(mods[0].calculatedAmount).toBe(200);
|
||||||
expect(mods[0].billingUnit).toBe('PER_TON');
|
expect(mods[0].billingUnit).toBe('PER_TON');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a rate for the other direction never bills', async () => {
|
||||||
|
const result = await buildService([
|
||||||
|
{ ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate,
|
||||||
|
]).evaluate(bulkInput());
|
||||||
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
|
it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
|
||||||
const result = await buildService([
|
const result = await buildService([
|
||||||
{ ...lashingBulk, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
|
{ ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
|
||||||
]).evaluate(bulkInput());
|
]).evaluate(bulkInput());
|
||||||
const mods = lashingMods(result);
|
const mods = lashingMods(result);
|
||||||
expect(mods[0].triggerValue).toBe(3);
|
expect(mods[0].triggerValue).toBe(3);
|
||||||
expect(mods[0].calculatedAmount).toBe(75);
|
expect(mods[0].calculatedAmount).toBe(75);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('no lashing charge when the cargo does not need lashing', async () => {
|
it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => {
|
||||||
const result = await buildService([lashing20]).evaluate(
|
const result = await buildService([
|
||||||
containerInput({ hasLashing: false }),
|
lashingBulkImport,
|
||||||
|
{ ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate,
|
||||||
|
]).evaluate(bulkInput());
|
||||||
|
const mods = lashingMods(result);
|
||||||
|
expect(mods).toHaveLength(1);
|
||||||
|
expect(mods[0].unitPriceUsd).toBe(7);
|
||||||
|
expect(mods[0].calculatedAmount).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('container bookings never incur lashing (bulk-only service)', async () => {
|
||||||
|
const result = await buildService([lashingBulkImport]).evaluate(
|
||||||
|
bulkInput({
|
||||||
|
cargoTypeId: null,
|
||||||
|
hasLashing: true,
|
||||||
|
containers: [
|
||||||
|
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
|
||||||
|
],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(lashingMods(result)).toHaveLength(0);
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('no lashing charge when the cargo does not need lashing', async () => {
|
||||||
|
const service = new RuleEngineService(
|
||||||
|
{
|
||||||
|
findById: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }),
|
||||||
|
} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue([lashingBulkImport]) } as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const result = await service.evaluate(bulkInput());
|
||||||
|
expect(lashingMods(result)).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -581,66 +581,50 @@ export class RuleEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cargo securing / lashing — sold per cargo kind, like the customs clearance
|
* Cargo securing / lashing — BULK only, sold per trade direction, optionally
|
||||||
* fee. Container bookings bill each container line at its own container
|
* narrowed to one leaf commodity (the commodity-scoped rate wins over the
|
||||||
* type's lashing rate (PER_CONTAINER × boxes, or PER_WAGON × the wagons the
|
* commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the
|
||||||
* line occupies); bulk bookings bill the type-less rate (PER_TON × tonnage,
|
* wagons the bulk occupies. Container bookings never incur lashing, and an
|
||||||
* or PER_WAGON × the wagons the bulk occupies). An unconfigured rate simply
|
* unconfigured rate simply bills nothing — same leniency as hazard/reefer.
|
||||||
* bills nothing — same leniency as hazard/reefer.
|
|
||||||
*/
|
*/
|
||||||
private lashingCharges(
|
private lashingCharges(
|
||||||
input: BookingEvaluationInput,
|
input: BookingEvaluationInput,
|
||||||
liveRates: Rate[],
|
liveRates: Rate[],
|
||||||
): AppliedCargoModifier[] {
|
): AppliedCargoModifier[] {
|
||||||
const modifiers: AppliedCargoModifier[] = [];
|
const modifiers: AppliedCargoModifier[] = [];
|
||||||
const lashingRates = liveRates.filter(
|
if (input.containers.length > 0) return modifiers; // bulk-only service
|
||||||
(r) => r.trigger === 'LASHING' && r.currency === 'USD',
|
|
||||||
|
const onDirection = liveRates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.trigger === 'LASHING' &&
|
||||||
|
r.currency === 'USD' &&
|
||||||
|
!r.containerTypeId &&
|
||||||
|
r.tradeDirection === input.tradeDirection,
|
||||||
);
|
);
|
||||||
if (lashingRates.length === 0) return modifiers;
|
const rate =
|
||||||
|
(input.cargoTypeId
|
||||||
const push = (rate: Rate, billedQty: number): void => {
|
? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId)
|
||||||
const rateValue = Number(rate.rateValue);
|
: undefined) ?? onDirection.find((r) => !r.cargoTypeId);
|
||||||
const amount =
|
|
||||||
rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
|
|
||||||
if (!(amount > 0)) return;
|
|
||||||
modifiers.push({
|
|
||||||
rateId: rate.id,
|
|
||||||
surchargeCode: this.surchargeCode(rate),
|
|
||||||
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
|
|
||||||
calculatedAmount: amount,
|
|
||||||
currency: rate.currency,
|
|
||||||
unitPriceUsd: rateValue,
|
|
||||||
billingUnit: rate.rateUnit,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (input.containers.length > 0) {
|
|
||||||
for (const container of input.containers) {
|
|
||||||
const qty = Number(container.quantity || 0);
|
|
||||||
if (!(qty > 0)) continue;
|
|
||||||
const rate = lashingRates.find(
|
|
||||||
(r) => r.containerTypeId === container.containerTypeId,
|
|
||||||
);
|
|
||||||
if (!rate) continue;
|
|
||||||
const billedQty =
|
|
||||||
rate.rateUnit === 'PER_WAGON'
|
|
||||||
? Math.ceil(qty * (container.wagonsPerUnit ?? 1))
|
|
||||||
: qty;
|
|
||||||
push(rate, billedQty);
|
|
||||||
}
|
|
||||||
return modifiers;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk — the type-less rate covers the whole booking.
|
|
||||||
const rate = lashingRates.find((r) => !r.containerTypeId);
|
|
||||||
if (!rate) return modifiers;
|
if (!rate) return modifiers;
|
||||||
|
|
||||||
const billedQty =
|
const billedQty =
|
||||||
rate.rateUnit === 'PER_TON'
|
rate.rateUnit === 'PER_TON'
|
||||||
? Math.max(0, Number(input.bulkTons ?? 0))
|
? Math.max(0, Number(input.bulkTons ?? 0))
|
||||||
: rate.rateUnit === 'PER_WAGON'
|
: rate.rateUnit === 'PER_WAGON'
|
||||||
? Math.max(0, Number(input.bulkWagons ?? 0))
|
? Math.max(0, Number(input.bulkWagons ?? 0))
|
||||||
: 1;
|
: 1;
|
||||||
push(rate, billedQty);
|
const rateValue = Number(rate.rateValue);
|
||||||
|
const amount = rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue;
|
||||||
|
if (!(amount > 0)) return modifiers;
|
||||||
|
modifiers.push({
|
||||||
|
rateId: rate.id,
|
||||||
|
surchargeCode: this.surchargeCode(rate),
|
||||||
|
triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty,
|
||||||
|
calculatedAmount: amount,
|
||||||
|
currency: rate.currency,
|
||||||
|
unitPriceUsd: rateValue,
|
||||||
|
billingUnit: rate.rateUnit,
|
||||||
|
});
|
||||||
return modifiers;
|
return modifiers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,14 +194,8 @@ export class RatesService {
|
|||||||
}): void {
|
}): void {
|
||||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||||
const { containerTypeId, cargoTypeId } = input;
|
const { containerTypeId, cargoTypeId } = input;
|
||||||
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') {
|
if (trigger === 'CUSTOMS_CLEARANCE') {
|
||||||
const label = trigger === 'CUSTOMS_CLEARANCE' ? 'customs clearance' : 'lashing';
|
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||||
// Customs is sold per direction; lashing applies both ways.
|
|
||||||
if (
|
|
||||||
trigger === 'CUSTOMS_CLEARANCE' &&
|
|
||||||
tradeDirection !== 'IMPORT' &&
|
|
||||||
tradeDirection !== 'EXPORT'
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
|
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
|
||||||
);
|
);
|
||||||
@@ -211,17 +205,44 @@ export class RatesService {
|
|||||||
// that absence is what marks it as the bulk fee.
|
// that absence is what marks it as the bulk fee.
|
||||||
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A ${label} rate must say whether it covers containers or bulk.`,
|
'A customs clearance rate must say whether it covers containers or bulk.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (cargoKind === 'CONTAINER' && !containerTypeId) {
|
if (cargoKind === 'CONTAINER' && !containerTypeId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A container ${label} rate must name the container type it covers.`,
|
'A container customs clearance rate must name the container type it covers.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (cargoKind === 'BULK' && containerTypeId) {
|
if (cargoKind === 'BULK' && containerTypeId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A bulk ${label} rate cannot be scoped to a container type.`,
|
'A bulk customs clearance rate cannot be scoped to a container type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The bulk customs fee names the commodity it covers (sugar and
|
||||||
|
// fertilizer clear differently).
|
||||||
|
if (cargoKind === 'BULK' && !cargoTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A bulk customs clearance rate must name the bulk cargo type it covers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoKind === 'CONTAINER' && cargoTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A container customs clearance rate cannot be scoped to a bulk cargo type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (trigger === 'LASHING') {
|
||||||
|
// Bulk-only cargo securing, sold per direction. May narrow to one leaf
|
||||||
|
// commodity (specific wins over the commodity-wide catch-all).
|
||||||
|
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A lashing rate must say whether it covers IMPORT or EXPORT.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Lashing is bulk-only — it cannot be scoped to a container type.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -309,22 +330,27 @@ export class RatesService {
|
|||||||
// container type — both are sold per lane (and per container type).
|
// container type — both are sold per lane (and per container type).
|
||||||
const isSurcharge = trigger !== 'ALWAYS';
|
const isSurcharge = trigger !== 'ALWAYS';
|
||||||
const cargoKind =
|
const cargoKind =
|
||||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING'
|
trigger === 'CUSTOMS_CLEARANCE'
|
||||||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const containerTypeId =
|
const containerTypeId =
|
||||||
trigger === 'WITH_RETURN' ||
|
trigger === 'WITH_RETURN' ||
|
||||||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
|
||||||
cargoKind === 'CONTAINER')
|
|
||||||
? (dto.containerTypeId ?? null)
|
? (dto.containerTypeId ?? null)
|
||||||
: isSurcharge
|
: isSurcharge
|
||||||
? null
|
? null
|
||||||
: (dto.containerTypeId ?? null);
|
: (dto.containerTypeId ?? null);
|
||||||
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
const cargoTypeId =
|
||||||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||||
|
trigger === 'LASHING'
|
||||||
|
? (dto.cargoTypeId ?? null)
|
||||||
|
: isSurcharge
|
||||||
|
? null
|
||||||
|
: (dto.cargoTypeId ?? null);
|
||||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||||
// its yard pair already says where it runs.
|
// its yard pair already says where it runs.
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
|
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||||
? (dto.tradeDirection ?? null)
|
? (dto.tradeDirection ?? null)
|
||||||
: isSurcharge || appliesTo === 'INTERCITY'
|
: isSurcharge || appliesTo === 'INTERCITY'
|
||||||
? null
|
? null
|
||||||
@@ -456,7 +482,7 @@ export class RatesService {
|
|||||||
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
||||||
// has — read back off its container scope (container fees carry the type).
|
// has — read back off its container scope (container fees carry the type).
|
||||||
const cargoKind =
|
const cargoKind =
|
||||||
trigger !== 'CUSTOMS_CLEARANCE' && trigger !== 'LASHING'
|
trigger !== 'CUSTOMS_CLEARANCE'
|
||||||
? null
|
? null
|
||||||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||||
@@ -464,20 +490,23 @@ export class RatesService {
|
|||||||
const keepsContainerType =
|
const keepsContainerType =
|
||||||
!isSurcharge ||
|
!isSurcharge ||
|
||||||
trigger === 'WITH_RETURN' ||
|
trigger === 'WITH_RETURN' ||
|
||||||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
|
||||||
cargoKind === 'CONTAINER');
|
|
||||||
const containerTypeId = !keepsContainerType
|
const containerTypeId = !keepsContainerType
|
||||||
? null
|
? null
|
||||||
: dto.containerTypeId !== undefined
|
: dto.containerTypeId !== undefined
|
||||||
? dto.containerTypeId
|
? dto.containerTypeId
|
||||||
: existing.containerTypeId;
|
: existing.containerTypeId;
|
||||||
const cargoTypeId = isSurcharge
|
const keepsCargoType =
|
||||||
|
!isSurcharge ||
|
||||||
|
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||||
|
trigger === 'LASHING';
|
||||||
|
const cargoTypeId = !keepsCargoType
|
||||||
? null
|
? null
|
||||||
: dto.cargoTypeId !== undefined
|
: dto.cargoTypeId !== undefined
|
||||||
? dto.cargoTypeId
|
? dto.cargoTypeId
|
||||||
: existing.cargoTypeId;
|
: existing.cargoTypeId;
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
|
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||||
? dto.tradeDirection !== undefined
|
? dto.tradeDirection !== undefined
|
||||||
? dto.tradeDirection
|
? dto.tradeDirection
|
||||||
: existing.tradeDirection
|
: existing.tradeDirection
|
||||||
|
|||||||
@@ -416,10 +416,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
|||||||
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
||||||
// Cargo-securing / lashing — fires when the cargo type has hasLashing.
|
// Cargo-securing / lashing — bulk-only, fires when the cargo type has
|
||||||
// Bulk lashing seeds per ton; container lashing is per container type
|
// hasLashing. Sold per direction; commodity-wide catch-alls seeded here,
|
||||||
// and is configured by the rates team (no catch-all container seed).
|
// commodity-specific rates are configured by the rates team.
|
||||||
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "PER_TON" },
|
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "IMPORT", rateValue: 40, rateUnit: "PER_TON" },
|
||||||
|
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "EXPORT", rateValue: 40, rateUnit: "PER_TON" },
|
||||||
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
||||||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
|
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" },
|
||||||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },
|
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" },
|
||||||
|
|||||||
@@ -128,7 +128,10 @@ export function computeGlShipmentTotal(
|
|||||||
const qty = q.bulkQuantity;
|
const qty = q.bulkQuantity;
|
||||||
const rate =
|
const rate =
|
||||||
rateFor(
|
rateFor(
|
||||||
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance,
|
(i) =>
|
||||||
|
(i.unit === "per_ton" || i.unit === "per_item") &&
|
||||||
|
!i.isClearance &&
|
||||||
|
!i.conditionalOn,
|
||||||
) ?? items[0];
|
) ?? items[0];
|
||||||
if (rate && qty > 0) {
|
if (rate && qty > 0) {
|
||||||
lines.push({
|
lines.push({
|
||||||
@@ -165,6 +168,23 @@ export function computeGlShipmentTotal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
|
||||||
|
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
|
||||||
|
// depends on the wagon capacity the train stocks — shown at real pricing.
|
||||||
|
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
|
||||||
|
if (lashing && lashing.unit === "per_ton") {
|
||||||
|
const tons = q.bulkQuantity;
|
||||||
|
if (tons > 0) {
|
||||||
|
lines.push({
|
||||||
|
label: lashing.label,
|
||||||
|
unitPrice: lashing.unitPrice,
|
||||||
|
unit: lashing.unit,
|
||||||
|
quantity: tons,
|
||||||
|
amount: lashing.unitPrice * tons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Customs clearance service fee — billed on the booking invoice with the
|
// Customs clearance service fee — billed on the booking invoice with the
|
||||||
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
|
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
|
||||||
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
|
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ const RuleEngineFormDialog = ({
|
|||||||
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
||||||
if (name === "cargoKind") {
|
if (name === "cargoKind") {
|
||||||
next.containerTypeId = "";
|
next.containerTypeId = "";
|
||||||
|
next.cargoTypeId = "";
|
||||||
next.rateUnit = "";
|
next.rateUnit = "";
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -339,6 +340,10 @@ const RuleEngineFormDialog = ({
|
|||||||
value={resolveSelectValue(field, values)}
|
value={resolveSelectValue(field, values)}
|
||||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||||
disabled={selectOptionsLoading}
|
disabled={selectOptionsLoading}
|
||||||
|
// Native required blocks submit while a mandatory select is empty —
|
||||||
|
// without it the form posts and the API 400s (e.g. a container
|
||||||
|
// customs/lashing rate with no container type picked).
|
||||||
|
required={field.required}
|
||||||
data={options
|
data={options
|
||||||
.filter((opt) => opt.value !== "")
|
.filter((opt) => opt.value !== "")
|
||||||
.map((opt) => ({
|
.map((opt) => ({
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ const RATE_TRIGGERS = [
|
|||||||
},
|
},
|
||||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||||
{ label: "Penalty", value: "CONSOLIDATION" },
|
{ label: "Penalty", value: "CONSOLIDATION" },
|
||||||
{ label: "Lashing (per container type / bulk)", value: "LASHING" },
|
{ label: "Lashing (bulk, per cargo type)", value: "LASHING" },
|
||||||
{ label: "Cancellation", value: "CANCELLATION" },
|
{ label: "Cancellation", value: "CANCELLATION" },
|
||||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||||
@@ -229,11 +229,13 @@ const allowedRateUnits = (
|
|||||||
case "CANCELLATION":
|
case "CANCELLATION":
|
||||||
return ["FLAT", "PER_INVOICE"];
|
return ["FLAT", "PER_INVOICE"];
|
||||||
case "CUSTOMS_CLEARANCE":
|
case "CUSTOMS_CLEARANCE":
|
||||||
case "LASHING":
|
|
||||||
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||||
return cargoKind === "BULK"
|
return cargoKind === "BULK"
|
||||||
? ["PER_TON", "PER_WAGON"]
|
? ["PER_TON", "PER_WAGON"]
|
||||||
: ["PER_CONTAINER", "PER_WAGON"];
|
: ["PER_CONTAINER", "PER_WAGON"];
|
||||||
|
case "LASHING":
|
||||||
|
// Bulk-only cargo securing — per ton or per wagon.
|
||||||
|
return ["PER_TON", "PER_WAGON"];
|
||||||
case "CONSOLIDATION":
|
case "CONSOLIDATION":
|
||||||
case "SHIPPING_LINE":
|
case "SHIPPING_LINE":
|
||||||
case "PIL_EXTRA_FEE":
|
case "PIL_EXTRA_FEE":
|
||||||
@@ -722,10 +724,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
|
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING"].includes(
|
||||||
|
String(v.trigger ?? ""),
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
// ── Cargo kind — customs clearance and lashing are priced separately
|
// ── Cargo kind — customs clearance is priced separately for containers
|
||||||
// for containers (one rate per container type) and bulk ────────────────
|
// (one rate per container type) and bulk ───────────────────────────────
|
||||||
{
|
{
|
||||||
name: "cargoKind",
|
name: "cargoKind",
|
||||||
label: "Cargo kind",
|
label: "Cargo kind",
|
||||||
@@ -736,8 +740,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
description:
|
description:
|
||||||
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
|
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
v.appliesTo === "OTHER" &&
|
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
|
||||||
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")),
|
|
||||||
// Not a stored column: a container fee carries its containerTypeId, a
|
// Not a stored column: a container fee carries its containerTypeId, a
|
||||||
// bulk fee carries none.
|
// bulk fee carries none.
|
||||||
getInitialValue: (record) =>
|
getInitialValue: (record) =>
|
||||||
@@ -752,9 +755,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
placeholder: "Which container type this fee covers",
|
placeholder: "Which container type this fee covers",
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
v.appliesTo === "OTHER" &&
|
v.appliesTo === "OTHER" &&
|
||||||
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")) &&
|
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||||
v.cargoKind === "CONTAINER",
|
v.cargoKind === "CONTAINER",
|
||||||
},
|
},
|
||||||
|
// ── Bulk cargo type — the bulk customs fee names its commodity ────────
|
||||||
|
{
|
||||||
|
name: "cargoTypeId",
|
||||||
|
label: "Bulk cargo type",
|
||||||
|
type: "select",
|
||||||
|
required: true,
|
||||||
|
placeholder: "Which bulk commodity this fee covers",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "OTHER" &&
|
||||||
|
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||||
|
v.cargoKind === "BULK",
|
||||||
|
},
|
||||||
|
// ── Bulk cargo type — lashing is bulk-only; may narrow to one leaf
|
||||||
|
// commodity (specific wins over the commodity-wide catch-all) ──────────
|
||||||
|
{
|
||||||
|
name: "cargoTypeId",
|
||||||
|
label: "Bulk cargo type",
|
||||||
|
type: "select",
|
||||||
|
optional: true,
|
||||||
|
placeholder: "All lashing commodities (optional)",
|
||||||
|
description:
|
||||||
|
"Leave empty to cover every lashing commodity; a commodity-specific rate wins over the catch-all.",
|
||||||
|
showIf: (v) =>
|
||||||
|
v.appliesTo === "OTHER" && v.trigger === "LASHING",
|
||||||
|
},
|
||||||
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
|
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
|
||||||
{
|
{
|
||||||
name: "intercityKind",
|
name: "intercityKind",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Check,
|
Check,
|
||||||
@@ -218,6 +219,12 @@ export default function NewContractPage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 422 from generate-price = a required rate isn't configured for the chosen
|
||||||
|
// route — the customer can't fix it, so it's surfaced as a blocking modal.
|
||||||
|
const rateNotConfigured =
|
||||||
|
isAxiosError(persistAndPriceMutation.error) &&
|
||||||
|
persistAndPriceMutation.error.response?.status === 422;
|
||||||
|
|
||||||
const confirmMutation = useMutation({
|
const confirmMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!priceContractId) throw new Error("No contract to confirm");
|
if (!priceContractId) throw new Error("No contract to confirm");
|
||||||
@@ -759,7 +766,7 @@ export default function NewContractPage({
|
|||||||
<StepIndicator step={step} steps={visibleSteps} />
|
<StepIndicator step={step} steps={visibleSteps} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{persistAndPriceMutation.isError && (
|
{persistAndPriceMutation.isError && !rateNotConfigured && (
|
||||||
<Alert
|
<Alert
|
||||||
color="red"
|
color="red"
|
||||||
icon={<AlertCircle size={16} />}
|
icon={<AlertCircle size={16} />}
|
||||||
@@ -777,6 +784,28 @@ export default function NewContractPage({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pricing not configured (422) — a rate is missing on the chosen
|
||||||
|
route, nothing the customer can fix. Block with a modal. */}
|
||||||
|
<Modal
|
||||||
|
opened={rateNotConfigured}
|
||||||
|
onClose={() => persistAndPriceMutation.reset()}
|
||||||
|
title="Contract creation unavailable"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm">
|
||||||
|
You can't create a contract right now — pricing hasn't
|
||||||
|
been configured for the selected route yet. Please contact
|
||||||
|
support for assistance.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button onClick={() => persistAndPriceMutation.reset()}>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Step 0 — Setup: operation, contract, service, currency, miles. */}
|
{/* Step 0 — Setup: operation, contract, service, currency, miles. */}
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<StepCard>
|
<StepCard>
|
||||||
|
|||||||
@@ -111,7 +111,10 @@ export function computeShipmentTotal(
|
|||||||
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||||
const rate =
|
const rate =
|
||||||
rateFor(
|
rateFor(
|
||||||
(i) => (i.unit === "per_ton" || i.unit === "per_item") && !i.isClearance,
|
(i) =>
|
||||||
|
(i.unit === "per_ton" || i.unit === "per_item") &&
|
||||||
|
!i.isClearance &&
|
||||||
|
!i.conditionalOn,
|
||||||
) ?? items[0];
|
) ?? items[0];
|
||||||
if (rate && qty > 0) {
|
if (rate && qty > 0) {
|
||||||
lines.push({
|
lines.push({
|
||||||
@@ -150,6 +153,23 @@ export function computeShipmentTotal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
|
||||||
|
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
|
||||||
|
// depends on the wagon capacity the train stocks — shown at real pricing.
|
||||||
|
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
|
||||||
|
if (lashing && lashing.unit === "per_ton") {
|
||||||
|
const tons = Number(values.cargoWeightTons || 0);
|
||||||
|
if (tons > 0) {
|
||||||
|
lines.push({
|
||||||
|
label: lashing.label,
|
||||||
|
unitPrice: lashing.unitPrice,
|
||||||
|
unit: lashing.unit,
|
||||||
|
quantity: tons,
|
||||||
|
amount: lashing.unitPrice * tons,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Customs clearance service fee — billed on the booking invoice with the
|
// Customs clearance service fee — billed on the booking invoice with the
|
||||||
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
|
// freight. Container fees estimate per size (per box, or per wagon: two 20ft
|
||||||
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
|
// share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on
|
||||||
|
|||||||
Reference in New Issue
Block a user