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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const service = makeService({
|
||||
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
|
||||
// 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 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) {
|
||||
blocked.push(missingRateMessage('bulk cargo'));
|
||||
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
|
||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||
if (
|
||||
@@ -295,18 +325,26 @@ export class ContractPricingService {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Bulk fee = the route's type-less customs rate (per ton / per wagon).
|
||||
const rate = onLeg.find((r) => !r.containerTypeId);
|
||||
// Bulk fee — the rate scoped to the contract's commodity wins; a
|
||||
// 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) {
|
||||
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({
|
||||
code: 'CUSTOMS_CLEARANCE',
|
||||
label: 'Customs clearance service (bulk)',
|
||||
label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
cargoTypeCode: scope?.cargoType?.code ?? null,
|
||||
isClearance: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export class CreateRateDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: CARGO_KINDS,
|
||||
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()
|
||||
@IsIn([...CARGO_KINDS])
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
||||
export function allowedRateUnits(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
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;
|
||||
}): RateUnit[] {
|
||||
const { appliesTo, trigger } = input;
|
||||
@@ -37,12 +37,14 @@ export function allowedRateUnits(input: {
|
||||
case 'CANCELLATION':
|
||||
return ['FLAT', 'PER_INVOICE'];
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
case 'LASHING':
|
||||
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||
// fees per ton or per wagon. Billed on the booking invoice.
|
||||
return input.cargoKind === 'BULK'
|
||||
? ['PER_TON', 'PER_WAGON']
|
||||
: ['PER_CONTAINER', 'PER_WAGON'];
|
||||
case 'LASHING':
|
||||
// Bulk-only cargo securing — per ton or per wagon.
|
||||
return ['PER_TON', 'PER_WAGON'];
|
||||
case 'CONSOLIDATION':
|
||||
return ['PER_CONTAINER', 'FLAT'];
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -308,70 +308,43 @@ describe('RuleEngineService — empty-container return per route + container typ
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — lashing per cargo kind', () => {
|
||||
const lashing20: Rate = {
|
||||
id: 'rate-lash-20',
|
||||
describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => {
|
||||
const lashingBulkImport: Rate = {
|
||||
id: 'rate-lash-bulk',
|
||||
rateType: 'LASHING',
|
||||
trigger: 'LASHING',
|
||||
rateValue: 30,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
rateValue: 2,
|
||||
rateUnit: 'PER_TON',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: 'ct-20',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
tradeDirection: null,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: null,
|
||||
destinationYardId: null,
|
||||
} as Rate;
|
||||
|
||||
const lashingBulk: Rate = {
|
||||
...lashing20,
|
||||
id: 'rate-lash-bulk',
|
||||
rateValue: 2,
|
||||
rateUnit: 'PER_TON',
|
||||
containerTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
const buildService = (rates: Rate[]): RuleEngineService =>
|
||||
new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{
|
||||
findActiveByContainerTypeId: jest
|
||||
findById: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||
.mockResolvedValue({ hasLashing: true, 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(rates) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } 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 => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
isHazardous: false,
|
||||
hasLashing: true,
|
||||
cargoTypeId: 'cargo-sugar',
|
||||
totalWagons: 0,
|
||||
bulkTons: 100,
|
||||
bulkWagons: 3,
|
||||
@@ -382,53 +355,70 @@ describe('RuleEngineService — lashing per cargo kind', () => {
|
||||
const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
|
||||
result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');
|
||||
|
||||
it('bills each container line at its own container type rate', async () => {
|
||||
const result = await buildService([lashing20]).evaluate(containerInput());
|
||||
it('bulk lashing bills per ton on the direction-matched rate', async () => {
|
||||
const result = await buildService([lashingBulkImport]).evaluate(bulkInput());
|
||||
const mods = lashingMods(result);
|
||||
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].calculatedAmount).toBe(200);
|
||||
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 () => {
|
||||
const result = await buildService([
|
||||
{ ...lashingBulk, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
|
||||
{ ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
|
||||
]).evaluate(bulkInput());
|
||||
const mods = lashingMods(result);
|
||||
expect(mods[0].triggerValue).toBe(3);
|
||||
expect(mods[0].calculatedAmount).toBe(75);
|
||||
});
|
||||
|
||||
it('no lashing charge when the cargo does not need lashing', async () => {
|
||||
const result = await buildService([lashing20]).evaluate(
|
||||
containerInput({ hasLashing: false }),
|
||||
it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => {
|
||||
const result = await buildService([
|
||||
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);
|
||||
});
|
||||
|
||||
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
|
||||
* fee. Container bookings bill each container line at its own container
|
||||
* type's lashing rate (PER_CONTAINER × boxes, or PER_WAGON × the wagons the
|
||||
* line occupies); bulk bookings bill the type-less rate (PER_TON × tonnage,
|
||||
* or PER_WAGON × the wagons the bulk occupies). An unconfigured rate simply
|
||||
* bills nothing — same leniency as hazard/reefer.
|
||||
* Cargo securing / lashing — BULK only, sold per trade direction, optionally
|
||||
* narrowed to one leaf commodity (the commodity-scoped rate wins over the
|
||||
* commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the
|
||||
* wagons the bulk occupies. Container bookings never incur lashing, and an
|
||||
* unconfigured rate simply bills nothing — same leniency as hazard/reefer.
|
||||
*/
|
||||
private lashingCharges(
|
||||
input: BookingEvaluationInput,
|
||||
liveRates: Rate[],
|
||||
): AppliedCargoModifier[] {
|
||||
const modifiers: AppliedCargoModifier[] = [];
|
||||
const lashingRates = liveRates.filter(
|
||||
(r) => r.trigger === 'LASHING' && r.currency === 'USD',
|
||||
if (input.containers.length > 0) return modifiers; // bulk-only service
|
||||
|
||||
const onDirection = liveRates.filter(
|
||||
(r) =>
|
||||
r.trigger === 'LASHING' &&
|
||||
r.currency === 'USD' &&
|
||||
!r.containerTypeId &&
|
||||
r.tradeDirection === input.tradeDirection,
|
||||
);
|
||||
if (lashingRates.length === 0) return modifiers;
|
||||
|
||||
const push = (rate: Rate, billedQty: number): void => {
|
||||
const rateValue = Number(rate.rateValue);
|
||||
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);
|
||||
const rate =
|
||||
(input.cargoTypeId
|
||||
? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId)
|
||||
: undefined) ?? onDirection.find((r) => !r.cargoTypeId);
|
||||
if (!rate) return modifiers;
|
||||
|
||||
const billedQty =
|
||||
rate.rateUnit === 'PER_TON'
|
||||
? Math.max(0, Number(input.bulkTons ?? 0))
|
||||
: rate.rateUnit === 'PER_WAGON'
|
||||
? Math.max(0, Number(input.bulkWagons ?? 0))
|
||||
: 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -194,14 +194,8 @@ export class RatesService {
|
||||
}): void {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||
const { containerTypeId, cargoTypeId } = input;
|
||||
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') {
|
||||
const label = trigger === 'CUSTOMS_CLEARANCE' ? 'customs clearance' : 'lashing';
|
||||
// Customs is sold per direction; lashing applies both ways.
|
||||
if (
|
||||
trigger === 'CUSTOMS_CLEARANCE' &&
|
||||
tradeDirection !== 'IMPORT' &&
|
||||
tradeDirection !== 'EXPORT'
|
||||
) {
|
||||
if (trigger === 'CUSTOMS_CLEARANCE') {
|
||||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException(
|
||||
'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.
|
||||
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
||||
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) {
|
||||
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) {
|
||||
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;
|
||||
@@ -309,22 +330,27 @@ export class RatesService {
|
||||
// container type — both are sold per lane (and per container type).
|
||||
const isSurcharge = trigger !== 'ALWAYS';
|
||||
const cargoKind =
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING'
|
||||
trigger === 'CUSTOMS_CLEARANCE'
|
||||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||
: null;
|
||||
const containerTypeId =
|
||||
trigger === 'WITH_RETURN' ||
|
||||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
|
||||
cargoKind === 'CONTAINER')
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
|
||||
? (dto.containerTypeId ?? null)
|
||||
: isSurcharge
|
||||
? 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 —
|
||||
// its yard pair already says where it runs.
|
||||
const tradeDirection =
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||
? (dto.tradeDirection ?? null)
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
@@ -456,7 +482,7 @@ export class RatesService {
|
||||
// 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).
|
||||
const cargoKind =
|
||||
trigger !== 'CUSTOMS_CLEARANCE' && trigger !== 'LASHING'
|
||||
trigger !== 'CUSTOMS_CLEARANCE'
|
||||
? null
|
||||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||
@@ -464,20 +490,23 @@ export class RatesService {
|
||||
const keepsContainerType =
|
||||
!isSurcharge ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
((trigger === 'CUSTOMS_CLEARANCE' || trigger === 'LASHING') &&
|
||||
cargoKind === 'CONTAINER');
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
|
||||
const containerTypeId = !keepsContainerType
|
||||
? null
|
||||
: dto.containerTypeId !== undefined
|
||||
? dto.containerTypeId
|
||||
: existing.containerTypeId;
|
||||
const cargoTypeId = isSurcharge
|
||||
const keepsCargoType =
|
||||
!isSurcharge ||
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING';
|
||||
const cargoTypeId = !keepsCargoType
|
||||
? null
|
||||
: dto.cargoTypeId !== undefined
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
const tradeDirection =
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING'
|
||||
? dto.tradeDirection !== undefined
|
||||
? dto.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: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
||||
// Cargo-securing / lashing — fires when the cargo type has hasLashing.
|
||||
// Bulk lashing seeds per ton; container lashing is per container type
|
||||
// and is configured by the rates team (no catch-all container seed).
|
||||
{ appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "PER_TON" },
|
||||
// Cargo-securing / lashing — bulk-only, fires when the cargo type has
|
||||
// hasLashing. Sold per direction; commodity-wide catch-alls seeded here,
|
||||
// commodity-specific rates are configured by the rates team.
|
||||
{ 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 ──
|
||||
{ appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, 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 rate =
|
||||
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];
|
||||
if (rate && qty > 0) {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -213,6 +213,7 @@ const RuleEngineFormDialog = ({
|
||||
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
||||
if (name === "cargoKind") {
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
return next;
|
||||
@@ -339,6 +340,10 @@ const RuleEngineFormDialog = ({
|
||||
value={resolveSelectValue(field, values)}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
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
|
||||
.filter((opt) => opt.value !== "")
|
||||
.map((opt) => ({
|
||||
|
||||
@@ -172,7 +172,7 @@ const RATE_TRIGGERS = [
|
||||
},
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||
{ 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: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||
@@ -229,11 +229,13 @@ const allowedRateUnits = (
|
||||
case "CANCELLATION":
|
||||
return ["FLAT", "PER_INVOICE"];
|
||||
case "CUSTOMS_CLEARANCE":
|
||||
case "LASHING":
|
||||
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||
return cargoKind === "BULK"
|
||||
? ["PER_TON", "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 "SHIPPING_LINE":
|
||||
case "PIL_EXTRA_FEE":
|
||||
@@ -722,10 +724,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
showIf: (v) =>
|
||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(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
|
||||
// for containers (one rate per container type) and bulk ────────────────
|
||||
// ── Cargo kind — customs clearance is priced separately for containers
|
||||
// (one rate per container type) and bulk ───────────────────────────────
|
||||
{
|
||||
name: "cargoKind",
|
||||
label: "Cargo kind",
|
||||
@@ -736,8 +740,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
description:
|
||||
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")),
|
||||
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
|
||||
// Not a stored column: a container fee carries its containerTypeId, a
|
||||
// bulk fee carries none.
|
||||
getInitialValue: (record) =>
|
||||
@@ -752,9 +755,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Which container type this fee covers",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "LASHING"].includes(String(v.trigger ?? "")) &&
|
||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
||||
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) ─
|
||||
{
|
||||
name: "intercityKind",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertCircle,
|
||||
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({
|
||||
mutationFn: async () => {
|
||||
if (!priceContractId) throw new Error("No contract to confirm");
|
||||
@@ -759,7 +766,7 @@ export default function NewContractPage({
|
||||
<StepIndicator step={step} steps={visibleSteps} />
|
||||
</Box>
|
||||
|
||||
{persistAndPriceMutation.isError && (
|
||||
{persistAndPriceMutation.isError && !rateNotConfigured && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertCircle size={16} />}
|
||||
@@ -777,6 +784,28 @@ export default function NewContractPage({
|
||||
</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 && (
|
||||
<StepCard>
|
||||
|
||||
@@ -111,7 +111,10 @@ export function computeShipmentTotal(
|
||||
const qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||
const rate =
|
||||
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];
|
||||
if (rate && qty > 0) {
|
||||
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
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user