Merge pull request #943 from Tria-plc/freight_feature/usermanagement

changes
This commit is contained in:
marshal
2026-07-23 17:20:24 +03:00
committed by GitHub
15 changed files with 362 additions and 160 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => ({

View File

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

View File

@@ -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&apos;t create a contract right now pricing hasn&apos;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>

View File

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