This commit is contained in:
Marshal
2026-07-23 11:06:10 +00:00
parent 8f143b2341
commit 13609f8d59
26 changed files with 1717 additions and 115 deletions

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Scope the customs clearance service fee to a direction + route.
*
* The fee was a single global flat rate; the business sells it per lane —
* "import clearance, Djibouti → Adama, 300 USD". CUSTOMS_CLEARANCE rates now
* carry trade_direction + the yard pair, and contract pricing matches on them
* strictly (no route-less fallback).
*
* Existing route-less clearance rates cannot be backfilled (no way to know
* which lane each was meant for) — retired exactly like the base-freight
* retirement in AddRateYardScope: SUPERSEDED + soft-deleted, kept for
* snapshot history.
*/
export class CustomsClearanceRouteScope2820000000000 implements MigrationInterface {
name = 'CustomsClearanceRouteScope2820000000000';
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 rate_type = 'CUSTOMS_CLEARANCE'
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL);
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR "trigger" = 'CUSTOMS_CLEARANCE'
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Retired rates stay retired (their lanes were never recorded); down only
// restores the pre-customs constraint shape.
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
}

View File

@@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Scope the empty-container return surcharge to a direction + route +
* container type, like base freight (import-only for now — the box only goes
* back to the port on imports).
*
* Existing route-less RETURN_SURCHARGE rates cannot be backfilled — retired
* (SUPERSEDED + soft-deleted) exactly like base freight and customs clearance
* were, kept readable for snapshot history. Route-scoped replacements must be
* re-entered; a booking that asks for return with no matching rate hard-blocks.
*/
export class ReturnSurchargeRouteScope2830000000000 implements MigrationInterface {
name = 'ReturnSurchargeRouteScope2830000000000';
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 rate_type = 'RETURN_SURCHARGE'
AND (origin_yard_id IS NULL OR destination_yard_id IS NULL);
`);
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN')
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Retired rates stay retired; down only restores the customs-era shape.
await queryRunner.query(
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`,
);
await queryRunner.query(`
ALTER TABLE freight.rates
ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
deleted_at IS NOT NULL
OR status = 'SUPERSEDED'
OR CASE
WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
OR "trigger" = 'CUSTOMS_CLEARANCE'
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
END
);
`);
}
}

View File

@@ -165,8 +165,16 @@ export class BookingPricingService {
const usdAmount = mod.calculatedAmount;
const rate = rateById.get(mod.rateId);
const unit = rate?.rateUnit ?? 'FLAT';
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
// Derived/route-matched charges (import overweight, empty-container
// return) carry their own unit price + billing unit — bill and display
// those, not whatever the referenced rate row says.
const isDerived = mod.unitPriceUsd != null;
const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT';
const unitUsd = isDerived
? Number(mod.unitPriceUsd)
: rate
? Number(rate.rateValue)
: usdAmount;
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
// explicit trigger (e.g. overweight tons) wins when present; otherwise
// derive from total ÷ unit price (the live unit price — a count, not a
@@ -182,11 +190,11 @@ export class BookingPricingService {
// H15: bill the frozen contract surcharge rate (already in the booking
// currency) when this code has a snapshot; else keep the live amount.
const frozen = this.frozenRateByCode(
frozenRates,
mod.surchargeCode,
paymentCurrency,
);
// Derived charges skip the snapshot — import overweight prices off the
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
const frozen = isDerived
? null
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
const unitAmount = frozen
? Number(frozen.unitPrice)
: isEtbBooking
@@ -369,6 +377,8 @@ export class BookingPricingService {
isGovernment: booking.isGovernment,
allowConsolidation,
shippingLineId: booking.shippingLineId,
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
totalWagons,
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
// Container freight carries 0 here — its surcharges scale by container count.

View File

@@ -422,6 +422,8 @@ export class BookingsService {
isReefer?: boolean;
isGovernment?: boolean;
shippingLineId?: string | null;
originYardId?: string | null;
destinationYardId?: string | null;
bulkTons?: number;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
@@ -467,6 +469,8 @@ export class BookingsService {
isGovernment: dto.isGovernment ?? false,
allowConsolidation,
shippingLineId: dto.shippingLineId,
originYardId: dto.originYardId ?? null,
destinationYardId: dto.destinationYardId ?? null,
totalWagons,
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
containers,
@@ -788,6 +792,8 @@ export class BookingsService {
isReefer: dto.isReefer,
isGovernment,
shippingLineId: dto.shippingLineId,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
bulkTons: dto.cargoTotalWeightVgm,
containers,
});
@@ -998,6 +1004,8 @@ export class BookingsService {
isHazardous: dto.isHazardous ?? existing.isHazardous,
isReefer: dto.isReefer ?? existing.isReefer,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
originYardId: dto.originYardId ?? existing.originYardId,
destinationYardId: dto.destinationYardId ?? existing.destinationYardId,
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
containers,
});

View File

@@ -194,17 +194,49 @@ export class ContractPricingService {
contract.freightType === 'CONTAINER' &&
contract.equipmentReturn === 'WITH_RETURN'
) {
const withReturn = liveRates.find(
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
);
if (withReturn && Number(withReturn.rateValue) > 0) {
lineItems.push({
code: 'RETURN_SURCHARGE',
label: 'Empty container return',
unit: toContractUnit(withReturn.rateUnit),
unitPrice: convert(Number(withReturn.rateValue)),
conditionalOn: 'with_return',
// Return is sold per direction + route + container type (import-only) —
// one display line per contract size that has a configured rate. A size
// with no rate shows nothing here and hard-blocks at booking time.
// ponytail: bookings bill the live route rate, not a frozen snapshot.
const route = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
)[0];
const onLeg = route
? liveRates.filter(
(r) =>
r.rateType === 'RETURN_SURCHARGE' &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId,
)
: [];
if (onLeg.length > 0) {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s);
const { items: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 100,
});
for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20;
const matchedIds = new Set(
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
);
const rate =
onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ??
onLeg.find((r) => !r.containerTypeId);
if (!rate || Number(rate.rateValue) <= 0) continue;
lineItems.push({
code: 'RETURN_SURCHARGE',
label: `Empty container return (${size})`,
unit: toContractUnit(rate.rateUnit),
unitPrice: convert(Number(rate.rateValue)),
containerSize: size,
conditionalOn: 'with_return',
});
}
}
}
@@ -213,12 +245,24 @@ export class ContractPricingService {
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
// A customs contract may not proceed without a configured live rate.
if (contract.customsClearingEnabled) {
const clearance = liveRates.find(
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
);
// The fee is sold per direction + route — strict, no route-less fallback.
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
const route = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
)[0];
const clearance = route
? liveRates.find(
(r) =>
r.rateType === 'CUSTOMS_CLEARANCE' &&
r.currency === 'USD' &&
r.tradeDirection === contract.tradeDirection &&
r.originYardId === route.originYardId &&
r.destinationYardId === route.destinationYardId,
)
: undefined;
if (!clearance || Number(clearance.rateValue) <= 0) {
throw new UnprocessableEntityException(
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.',
);
}
lineItems.push({

View File

@@ -20,8 +20,13 @@ import { MinioService } from '../minio/minio.service';
import { FileRecord } from '../files/entities/file.entity';
import {
assertCanApproveContractStep,
assertFreightPermission,
canEditContractStep,
} from '../../common/freight-permission.util';
import {
FREIGHT_PERMS,
forFreightType,
} from '../../seed/freight-permissions.registry';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
@@ -239,8 +244,15 @@ export class ContractTransitionService {
actorId: string,
validityDays: number,
documentSnapshot?: ContractDocumentSnapshotInput | null,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
// The route guard passes on either arm; the contract's freight type decides
// which one is actually required (accept bulk ≠ accept container).
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType),
);
assertContractStatus(contract, ['SUBMITTED']);
if (!Number.isInteger(validityDays) || validityDays < 1) {
@@ -535,8 +547,13 @@ export class ContractTransitionService {
contractId: string,
note: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType),
);
assertContractStatus(contract, ['SUBMITTED']);
await this.contractsRepository.createReviewNote(
@@ -554,8 +571,17 @@ export class ContractTransitionService {
return updated;
}
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
async reject(
contractId: string,
reason: string,
actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType),
);
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
await this.contractsRepository.createReviewNote(

View File

@@ -34,7 +34,11 @@ import {
import { actorLabel } from '../warehouses/current-actor.util';
import { BookingStaff } from '../../common/booking-guards';
import { ContractDocumentHistoryService } from './contract-document-history.service';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import {
FREIGHT_PERMS,
bothFreightTypes,
forFreightType,
} from '../../seed/freight-permissions.registry';
import {
assertFreightPermission,
hasFreightPermission,
@@ -184,7 +188,10 @@ export class ContractsController {
@CurrentUser() user: TCurrentUser,
) {
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept);
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType),
);
}
return this.contractsService.create(dto, files ?? [], user?.id);
}
@@ -337,23 +344,25 @@ export class ContractsController {
}
@Post(':id/staff/accept')
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
// One-of guard; the service then requires the arm matching the contract's freight type.
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
staffAccept(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AcceptContractDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.staffAccept(
id,
resolveAuthUserId(user),
dto.validityDays,
dto.documentSnapshot,
user,
);
}
@Get(':id/document/draft')
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
@ApiOperation({
summary:
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
@@ -377,7 +386,7 @@ export class ContractsController {
}
@Put(':id/document/articles')
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
@ApiOperation({
summary:
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
@@ -396,29 +405,35 @@ export class ContractsController {
}
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges))
@ApiOperation({ summary: 'Staff return contract for customer updates' })
requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.requestChanges(
id,
dto.note,
resolveAuthUserId(user),
user,
);
}
@Post(':id/staff/reject')
@BookingStaff(FREIGHT_PERMS.contracts.reject)
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject))
@ApiOperation({ summary: 'Staff reject contract' })
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectContractDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user));
return this.transitionService.reject(
id,
dto.reason,
resolveAuthUserId(user),
user,
);
}
@Post(':id/approval-steps/:stepId/approve')

View File

@@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
@IsString()
@MaxLength(50)
rateType?: string;
@ApiPropertyOptional({
description: 'Filter by rate category — comma-separated appliesTo values (e.g. "CONTAINER" or "FIRST_MILE,LAST_MILE").',
})
@IsOptional()
@IsString()
@MaxLength(100)
appliesTo?: string;
@ApiPropertyOptional({
description: 'Filter by surcharge trigger — comma-separated trigger values (e.g. "CUSTOMS_CLEARANCE" or "HAZARDOUS,REEFER").',
})
@IsOptional()
@IsString()
@MaxLength(200)
trigger?: string;
}
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {

View File

@@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository {
if (query.rateType) {
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
}
// Category tabs on the admin page: comma-separated appliesTo / trigger
// lists, ANDed together (e.g. appliesTo=OTHER + trigger=CUSTOMS_CLEARANCE).
const csv = (v?: string) =>
(v ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const appliesTo = csv(query.appliesTo);
if (appliesTo.length > 0) {
qb.andWhere('rate.appliesTo IN (:...appliesTo)', { appliesTo });
}
const triggers = csv(query.trigger);
if (triggers.length > 0) {
qb.andWhere('rate.trigger IN (:...triggers)', { triggers });
}
if (query.search) {
qb.andWhere(
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',

View File

@@ -76,3 +76,191 @@ describe('RuleEngineService — requested service without a configured surcharge
expect(result.hardBlocked[0]).toContain('reefer');
});
});
describe('RuleEngineService — overweight surcharge by trade direction', () => {
const baseImportRate: Rate = {
id: 'rate-import-20',
rateType: 'CONTAINER_IMPORT',
trigger: 'ALWAYS',
rateValue: 1000,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
} as Rate;
const configuredOverweight: Rate = {
id: 'rate-ow',
rateType: 'OVERWEIGHT_PER_TON',
trigger: 'OVERWEIGHT',
rateValue: 10,
rateUnit: 'PER_TON',
currency: 'USD',
status: 'LIVE',
containerTypeId: null,
cargoTypeId: null,
} as Rate;
let service: RuleEngineService;
beforeEach(() => {
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{
findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]),
} as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
});
// One 20ft at 25 t against a 20 t limit → 5 t excess.
const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection,
isHazardous: false,
totalWagons: 1,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
containers: [
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
],
});
it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => {
const result = await service.evaluate(overweightInput('IMPORT'));
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
expect(ow).toHaveLength(1);
// 1000 / (2 × 20) = 25 USD/t on 5 excess tons.
expect(ow[0].unitPriceUsd).toBe(25);
expect(ow[0].calculatedAmount).toBe(125);
expect(ow[0].triggerValue).toBe(5);
expect(ow[0].rateId).toBe(baseImportRate.id);
});
it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => {
const result = await service.evaluate(overweightInput('EXPORT'));
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
expect(ow).toHaveLength(1);
expect(ow[0].rateId).toBe(configuredOverweight.id);
// 5 excess tons × the configured 10 USD/t.
expect(ow[0].calculatedAmount).toBe(50);
expect(ow[0].unitPriceUsd).toBeUndefined();
});
it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => {
const result = await service.evaluate({
...overweightInput('IMPORT'),
destinationYardId: 'yard-elsewhere',
});
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
expect(ow).toHaveLength(0);
});
});
describe('RuleEngineService — empty-container return per route + container type', () => {
const returnRate20: Rate = {
id: 'rate-return-20',
rateType: 'RETURN_SURCHARGE',
trigger: 'WITH_RETURN',
rateValue: 20,
rateUnit: 'PER_CONTAINER',
currency: 'USD',
status: 'LIVE',
containerTypeId: 'ct-20',
cargoTypeId: null,
tradeDirection: 'IMPORT',
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
} as Rate;
let service: RuleEngineService;
beforeEach(() => {
service = new RuleEngineService(
{ findById: jest.fn().mockResolvedValue(null) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{
findActiveByContainerTypeId: jest
.fn()
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
} as never,
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
{ findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never,
{ findById: jest.fn().mockResolvedValue(null) } as never,
{} as never,
);
});
const returnInput = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
serviceTypeId: 'svc-1',
paymentCurrency: 'USD',
tradeDirection: 'IMPORT',
isHazardous: false,
totalWagons: 1,
originYardId: 'yard-dj',
destinationYardId: 'yard-adama',
containers: [
{
containerTypeId: 'ct-20',
quantity: 4,
vgmPerUnitTons: 10,
totalVgmTons: 40,
returnQuantity: 2,
},
],
...overrides,
});
it('bills the route + type matched rate on the opted-in count', async () => {
const result = await service.evaluate(returnInput({}));
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
expect(result.hardBlocked).toHaveLength(0);
expect(ret).toHaveLength(1);
expect(ret[0].rateId).toBe(returnRate20.id);
expect(ret[0].triggerValue).toBe(2);
expect(ret[0].calculatedAmount).toBe(40);
expect(ret[0].billingUnit).toBe('PER_CONTAINER');
});
it('hard-blocks when the booking route has no matching return rate', async () => {
const result = await service.evaluate(
returnInput({ destinationYardId: 'yard-elsewhere' }),
);
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
expect(
result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'),
).toHaveLength(0);
});
it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => {
const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' }));
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
});
it('legacy booking-level flag bills every container at its type rate', async () => {
const result = await service.evaluate(
returnInput({
withReturn: true,
containers: [
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
],
}),
);
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
expect(ret).toHaveLength(1);
expect(ret[0].triggerValue).toBe(4);
expect(ret[0].calculatedAmount).toBe(80);
});
});

View File

@@ -67,6 +67,12 @@ export interface BookingEvaluationInput {
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
/**
* The booking's rail leg. Import overweight derives its per-ton price from
* this route's own container freight rate, so the engine needs the yards.
*/
originYardId?: string | null;
destinationYardId?: string | null;
/**
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
@@ -91,6 +97,15 @@ export interface AppliedCargoModifier {
triggerValue: number | null;
calculatedAmount: number;
currency: string;
/**
* Effective per-unit USD price when it differs from the rate row's own value
* — set by derived charges (import overweight: base freight ÷ 2×limit) so
* the breakdown shows the real per-ton figure, not the base container price.
* Any modifier carrying it also bypasses frozen contract snapshots.
*/
unitPriceUsd?: number | null;
/** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */
billingUnit?: string;
}
export interface ContainerWeightResult {
@@ -165,12 +180,16 @@ export class RuleEngineService {
...(await this.capacityViolations(input.containers, input.tradeDirection)),
);
// Per-container-line weight limit (maxVgmTons), index-aligned with
// containerWeightResults — the derived import overweight divides by it.
const lineMaxVgmTons: Array<number | null> = [];
for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
input.tradeDirection,
);
const rule = rules[0];
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
let isOverweight = container.isOverweight ?? false;
let excess = container.overweightExcessTons ?? null;
@@ -273,13 +292,6 @@ export class RuleEngineService {
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
label: 'refrigerated (reefer) cargo',
},
{
trigger: 'WITH_RETURN',
wanted:
truthy(input.withReturn) ||
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
label: 'empty-container return',
},
];
for (const svc of requestedServices) {
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
@@ -292,6 +304,14 @@ export class RuleEngineService {
}
for (const rate of surchargeRates) {
// Import overweight never bills the configured rate — its per-ton price
// derives from the route's base container freight (see below).
if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') {
continue;
}
// Empty-container return is sold per route + container type — billed by
// the route-matched block below, never by this route-agnostic loop.
if (rate.trigger === 'WITH_RETURN') continue;
const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous,
hasReefer,
@@ -384,6 +404,21 @@ export class RuleEngineService {
});
}
if (input.tradeDirection === 'IMPORT') {
appliedModifiers.push(
...this.derivedImportOverweight(
input,
containerWeightResults,
lineMaxVgmTons,
liveRates,
),
);
}
const withReturn = this.withReturnCharges(input, liveRates);
appliedModifiers.push(...withReturn.modifiers);
hardBlocked.push(...withReturn.blocked);
return {
priorityScore,
appliedModifiers,
@@ -394,6 +429,132 @@ export class RuleEngineService {
};
}
/**
* Import overweight — derived, never configured. Each overweight container
* line bills its excess tons at (its own base import freight on the booking's
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
* Note: derives from the LIVE route rate even for frozen-rate contract
* bookings — the frozen snapshot has no route-scoped container price to
* divide.
*/
private derivedImportOverweight(
input: BookingEvaluationInput,
weightResults: ContainerWeightResult[],
lineMaxVgmTons: Array<number | null>,
liveRates: Rate[],
): AppliedCargoModifier[] {
const modifiers: AppliedCargoModifier[] = [];
if (!input.originYardId || !input.destinationYardId) return modifiers;
for (let i = 0; i < weightResults.length; i++) {
const wr = weightResults[i];
const excess = Number(wr?.overweightExcessTons ?? 0);
const maxVgm = Number(lineMaxVgmTons[i] ?? 0);
if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue;
// Same precedence as base freight pricing: the rate scoped to this
// container type wins over the route's catch-all rate.
const onLeg = liveRates.filter(
(r) =>
r.rateType === 'CONTAINER_IMPORT' &&
r.currency === 'USD' &&
r.originYardId === input.originYardId &&
r.destinationYardId === input.destinationYardId,
);
const base =
onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ??
onLeg.find((r) => !r.containerTypeId);
// No base rate → the base-freight line hard-blocks this booking anyway.
if (!base) continue;
const perTon = Number(base.rateValue) / (2 * maxVgm);
const amount = excess * perTon;
if (!(amount > 0)) continue;
modifiers.push({
rateId: base.id,
surchargeCode: 'OVERWEIGHT_PER_TON',
triggerValue: excess,
calculatedAmount: amount,
currency: base.currency,
unitPriceUsd: perTon,
billingUnit: 'PER_TON',
});
}
return modifiers;
}
/**
* Empty-container return — sold per direction + route + container type, like
* base freight. Each container line that opted in (returnQuantity, or every
* container when only the legacy booking-level flag is set) bills the
* route-matched WITH_RETURN rate for its own container type; a line with no
* matching rate hard-blocks the booking instead of shipping the service for
* free. Rates are import-only for now, so an export booking that asks for
* return blocks too.
* ponytail: bills the LIVE route rate, not a frozen contract snapshot — one
* RETURN_SURCHARGE snapshot code can't hold per-size route prices.
*/
private withReturnCharges(
input: BookingEvaluationInput,
liveRates: Rate[],
): { modifiers: AppliedCargoModifier[]; blocked: string[] } {
const modifiers: AppliedCargoModifier[] = [];
const blocked: string[] = [];
const bookingLevel = truthy(input.withReturn);
const wanted =
bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0);
if (!wanted) return { modifiers, blocked };
const onLeg = liveRates.filter(
(r) =>
r.trigger === 'WITH_RETURN' &&
r.currency === 'USD' &&
r.tradeDirection === input.tradeDirection &&
r.originYardId === input.originYardId &&
r.destinationYardId === input.destinationYardId,
);
for (const container of input.containers) {
const qty =
Number(container.returnQuantity ?? 0) > 0
? Number(container.returnQuantity)
: bookingLevel
? Number(container.quantity || 0)
: 0;
if (!(qty > 0)) continue;
const rate =
onLeg.find((r) => r.containerTypeId === container.containerTypeId) ??
onLeg.find((r) => !r.containerTypeId);
if (!rate) {
blocked.push(
'No empty-container return rate is configured for this container ' +
'type on this route (return is import-only) — remove the return ' +
'option or ask EDR to configure its rate for this origin → destination.',
);
continue;
}
const rateValue = Number(rate.rateValue);
const amount = rate.rateUnit === 'FLAT' ? rateValue : qty * rateValue;
if (!(amount > 0)) continue;
modifiers.push({
rateId: rate.id,
surchargeCode: this.surchargeCode(rate),
triggerValue: qty,
calculatedAmount: amount,
currency: rate.currency,
unitPriceUsd: rateValue,
billingUnit: rate.rateUnit,
});
}
// Same block deduplicated — several lines missing the rate is one problem.
return { modifiers, blocked: [...new Set(blocked)] };
}
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking

View File

@@ -93,6 +93,19 @@ export class RatesService {
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
}
/**
* Rates sold per direction + route. Base freight always; customs clearance
* and empty-container return are the surcharges that are too — their fee
* depends on the lane (and, for returns, the container type).
*/
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
return (
this.isBaseFreight(appliesTo, trigger) ||
trigger === 'CUSTOMS_CLEARANCE' ||
trigger === 'WITH_RETURN'
);
}
/**
* Which country each end of the leg must sit in, given what the rate is for.
* The railway only sells three shapes: import lands at the Djibouti ports and
@@ -126,7 +139,7 @@ export class RatesService {
destinationYardId?: string | null;
}): Promise<YardScope> {
const { appliesTo, trigger, tradeDirection } = input;
if (!this.isBaseFreight(appliesTo, trigger)) {
if (!this.isRouteScoped(appliesTo, trigger)) {
return { originYardId: null, destinationYardId: null };
}
@@ -134,7 +147,7 @@ export class RatesService {
const destinationYardId = input.destinationYardId ?? null;
if (!originYardId || !destinationYardId) {
throw new BadRequestException(
'Base freight rates are priced per leg — pick both an origin and a destination yard.',
'This rate is priced per leg — pick both an origin and a destination yard.',
);
}
if (originYardId === destinationYardId) {
@@ -179,6 +192,25 @@ export class RatesService {
}): void {
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
const { containerTypeId, cargoTypeId } = input;
if (trigger === 'CUSTOMS_CLEARANCE') {
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
throw new BadRequestException(
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
);
}
return;
}
if (trigger === 'WITH_RETURN') {
// Returning the empty box only exists on imports (the box goes back to
// the port) — export return rates are rejected until the business sells
// that.
if (tradeDirection !== 'IMPORT') {
throw new BadRequestException(
'An empty container return rate is import-only for now.',
);
}
return;
}
if (!this.isBaseFreight(appliesTo, trigger)) return;
if (appliesTo === 'INTERCITY') {
@@ -247,13 +279,24 @@ export class RatesService {
const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction.
// Exceptions: customs clearance keeps a direction, and empty-container
// return keeps direction + container type — both are sold per lane.
const isSurcharge = trigger !== 'ALWAYS';
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
const containerTypeId =
trigger === 'WITH_RETURN'
? (dto.containerTypeId ?? null)
: isSurcharge
? null
: (dto.containerTypeId ?? null);
const cargoTypeId = 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 =
isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null);
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
? (dto.tradeDirection ?? null)
: isSurcharge || appliesTo === 'INTERCITY'
? null
: (dto.tradeDirection ?? null);
const intercityKind = dto.intercityKind ?? null;
this.assertScopeCoherent({
@@ -376,7 +419,8 @@ export class RatesService {
if (dto.appliesTo) updates.appliesTo = appliesTo;
if (dto.trigger) updates.trigger = trigger;
const containerTypeId = isSurcharge
const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN';
const containerTypeId = !keepsContainerType
? null
: dto.containerTypeId !== undefined
? dto.containerTypeId
@@ -387,11 +431,15 @@ export class RatesService {
? dto.cargoTypeId
: existing.cargoTypeId;
const tradeDirection =
isSurcharge || appliesTo === 'INTERCITY'
? null
: dto.tradeDirection !== undefined
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
? dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection;
: existing.tradeDirection
: isSurcharge || appliesTo === 'INTERCITY'
? null
: dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection;
updates.containerTypeId = containerTypeId ?? null;
updates.cargoTypeId = cargoTypeId ?? null;

View File

@@ -70,9 +70,15 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
*/
export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'),
perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'),
perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'),
perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'),
// Intake actions are split per freight type (bulk vs container) — fresh ids
// because the seeder upserts ON CONFLICT (key); reusing the old ids with new
// keys would PK-collide with the legacy staff_accept/request_changes/reject rows.
perm('a3000001-0001-4000-8000-000000000011', 'edr_freight_app:contracts:staff_accept:bulk', 'Accept bulk contract intake'),
perm('a3000001-0001-4000-8000-000000000012', 'edr_freight_app:contracts:staff_accept:container', 'Accept container contract intake'),
perm('a3000001-0001-4000-8000-000000000013', 'edr_freight_app:contracts:request_changes:bulk', 'Request bulk contract changes'),
perm('a3000001-0001-4000-8000-000000000014', 'edr_freight_app:contracts:request_changes:container', 'Request container contract changes'),
perm('a3000001-0001-4000-8000-000000000015', 'edr_freight_app:contracts:reject:bulk', 'Reject bulk contract'),
perm('a3000001-0001-4000-8000-000000000016', 'edr_freight_app:contracts:reject:container', 'Reject container contract'),
perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'),
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
@@ -373,9 +379,18 @@ export const FREIGHT_PERMS = {
},
contracts: {
view: 'edr_freight_app:contracts:view',
staffAccept: 'edr_freight_app:contracts:staff_accept',
requestChanges: 'edr_freight_app:contracts:request_changes',
reject: 'edr_freight_app:contracts:reject',
staffAccept: {
bulk: 'edr_freight_app:contracts:staff_accept:bulk',
container: 'edr_freight_app:contracts:staff_accept:container',
},
requestChanges: {
bulk: 'edr_freight_app:contracts:request_changes:bulk',
container: 'edr_freight_app:contracts:request_changes:container',
},
reject: {
bulk: 'edr_freight_app:contracts:reject:bulk',
container: 'edr_freight_app:contracts:reject:container',
},
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
approveDirector: 'edr_freight_app:contracts:approve_director',
approveCeo: 'edr_freight_app:contracts:approve_ceo',
@@ -661,6 +676,18 @@ export const FREIGHT_PERMS = {
},
} as const;
/** Both arms of a freight-type-split permission (for one-of route guards). */
export const bothFreightTypes = (p: { bulk: string; container: string }): string[] => [
p.bulk,
p.container,
];
/** The arm of a freight-type-split permission matching a contract's freightType. */
export const forFreightType = (
p: { bulk: string; container: string },
freightType: string,
): string => (freightType === 'BULK' ? p.bulk : p.container);
const allRuleEngineViewKeys = () =>
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
@@ -712,9 +739,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.rejectApproval,
FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.staffAccept,
FREIGHT_PERMS.contracts.requestChanges,
FREIGHT_PERMS.contracts.reject,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
FREIGHT_PERMS.contracts.approveLineStaff,
...allRuleEngineViewKeys(),
],
@@ -804,9 +831,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.finalizeClearance,
FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.staffAccept,
FREIGHT_PERMS.contracts.requestChanges,
FREIGHT_PERMS.contracts.reject,
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.generateContract,
FREIGHT_PERMS.contracts.signStaff,

View File

@@ -14,6 +14,8 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
@@ -48,8 +50,19 @@ export function ContractActionsToolbar({
onReviewClearance,
}: ContractActionsToolbarProps) {
const navigate = useNavigate();
const { user } = useAuth();
const { status } = contract;
// Intake permissions are split per freight type: an accept:bulk holder must
// not see the accept button on a container contract (API enforces the same).
const arm = contract.freightType === "BULK" ? "bulk" : "container";
const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]);
const mayRequestChanges = hasPermission(
user,
FREIGHT_PERMS.contracts.requestChanges[arm],
);
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
const [previewOpen, setPreviewOpen] = useState(false);
@@ -97,7 +110,8 @@ export function ContractActionsToolbar({
);
}
const canAccept = status === "SUBMITTED";
const canAccept =
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
// The document stays editable for the whole approval chain, but only by the
// approver whose turn it is. The server resolves that against the caller's
// position type; the client cannot derive it.
@@ -126,35 +140,41 @@ export function ContractActionsToolbar({
{canAccept && (
<>
<Button
fullWidth
color="edr-green"
leftSection={<Check size={16} />}
onClick={() => {
setEditorMode("accept");
setEditorOpen(true);
}}
>
Accept for approval
</Button>
<Button
fullWidth
variant="light"
color="orange"
leftSection={<MessageSquareWarning size={16} />}
onClick={() => setChangesOpen(true)}
>
Request changes
</Button>
<Button
fullWidth
variant="light"
color="red"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject contract
</Button>
{mayAccept && (
<Button
fullWidth
color="edr-green"
leftSection={<Check size={16} />}
onClick={() => {
setEditorMode("accept");
setEditorOpen(true);
}}
>
Accept for approval
</Button>
)}
{mayRequestChanges && (
<Button
fullWidth
variant="light"
color="orange"
leftSection={<MessageSquareWarning size={16} />}
onClick={() => setChangesOpen(true)}
>
Request changes
</Button>
)}
{mayReject && (
<Button
fullWidth
variant="light"
color="red"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject contract
</Button>
)}
</>
)}

View File

@@ -29,9 +29,19 @@ export const FREIGHT_PERMS = {
},
contracts: {
view: "edr_freight_app:contracts:view",
staffAccept: "edr_freight_app:contracts:staff_accept",
requestChanges: "edr_freight_app:contracts:request_changes",
reject: "edr_freight_app:contracts:reject",
// Intake actions are split per freight type — mirror of the API registry.
staffAccept: {
bulk: "edr_freight_app:contracts:staff_accept:bulk",
container: "edr_freight_app:contracts:staff_accept:container",
},
requestChanges: {
bulk: "edr_freight_app:contracts:request_changes:bulk",
container: "edr_freight_app:contracts:request_changes:container",
},
reject: {
bulk: "edr_freight_app:contracts:reject:bulk",
container: "edr_freight_app:contracts:reject:container",
},
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
approveDirector: "edr_freight_app:contracts:approve_director",
approveCeo: "edr_freight_app:contracts:approve_ceo",

View File

@@ -13,6 +13,7 @@ import {
Loader,
Modal,
Stack,
Tabs,
Text,
Tooltip,
} from "@mantine/core";
@@ -94,7 +95,14 @@ const yardOptionsForLegEnd = (
let country: string | undefined;
if (appliesTo === "INTERCITY") {
country = "Ethiopia";
} else if (appliesTo === "CONTAINER" || appliesTo === "BULK") {
} else if (
appliesTo === "CONTAINER" ||
appliesTo === "BULK" ||
// Customs clearance + empty-container return are sold per direction +
// route, so their yard dropdowns narrow exactly like base freight.
(appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
) {
const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set
// rather than defaulting to one and letting it read as a real choice.
@@ -124,6 +132,10 @@ const RuleEngineResourcePage = () => {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
// Category tabs (rates page): the active tab's filters go to the backend.
const [activeTab, setActiveTab] = useState<string>(
config?.listTabs?.[0]?.key ?? "",
);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
@@ -153,10 +165,13 @@ const RuleEngineResourcePage = () => {
sortOrder: "ASC" as const,
}
: {}),
...(config?.listTabs?.find((t) => t.key === activeTab)?.filters ?? {}),
}),
[
config?.orderConfig,
config?.supportsSearch,
config?.listTabs,
activeTab,
search,
pagination.pageIndex,
pagination.pageSize,
@@ -166,7 +181,8 @@ const RuleEngineResourcePage = () => {
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
}, [config?.slug, setPagination]);
setActiveTab(config?.listTabs?.[0]?.key ?? "");
}, [config?.slug, config?.listTabs, setPagination]);
const { data, isLoading, isError, error } = useRuleEngineList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
@@ -685,6 +701,25 @@ const RuleEngineResourcePage = () => {
<Card p={0}>
<Stack gap={0}>
{config.listTabs && (
<Tabs
value={activeTab}
onChange={(v) => {
setActiveTab(v ?? config.listTabs![0].key);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
px="md"
pt="sm"
>
<Tabs.List>
{config.listTabs.map((tab) => (
<Tabs.Tab key={tab.key} value={tab.key}>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
)}
<Box px="md" pt="md" pb="sm" w="100%">
<RuleEngineToolbar
search={search}

View File

@@ -84,6 +84,17 @@ export interface RuleEngineOrderConfig {
label: string;
}
/**
* A category tab above a resource list. The active tab's `filters` are sent to
* the list endpoint verbatim, so filtering happens server-side (values may be
* comma-separated lists, e.g. appliesTo: "FIRST_MILE,LAST_MILE").
*/
export interface RuleEngineListTab {
key: string;
label: string;
filters: { appliesTo?: string; trigger?: string };
}
export interface RuleEngineResourceConfig {
slug: RuleEngineResourceSlug;
label: string;
@@ -94,6 +105,8 @@ export interface RuleEngineResourceConfig {
formFields: FormFieldDef[];
supportsSearch?: boolean;
orderConfig?: RuleEngineOrderConfig;
/** Server-filtered category tabs rendered above the list (rates page). */
listTabs?: RuleEngineListTab[];
/** Primary line on card view (inferred from columns when omitted). */
cardTitleKey?: string;
/** Secondary line under title on card view (inferred when omitted). */
@@ -148,9 +161,15 @@ const RATE_APPLIES_TO = [
/** Surcharge triggers — only relevant when Applies to = Other. */
const RATE_TRIGGERS = [
{ label: "Hazardous cargo", value: "HAZARDOUS" },
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
{
label: "Overweight (export only — import derives from container price)",
value: "OVERWEIGHT",
},
{ label: "Reefer cargo", value: "REEFER" },
{ label: "Empty container return", value: "WITH_RETURN" },
{
label: "Empty container return (import, per route + container type)",
value: "WITH_RETURN",
},
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Consolidation", value: "CONSOLIDATION" },
{ label: "Lashing (flat, per booking)", value: "LASHING" },
@@ -175,6 +194,15 @@ const INTERCITY_KINDS = [
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
/**
* Rates priced per leg: base rail freight, plus the customs clearance fee and
* the empty-container return surcharge (sold per route + container type).
*/
const isRouteScopedRate = (values: Record<string, unknown>) =>
isBaseFreightRate(values) ||
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
/**
@@ -604,6 +632,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
subtitle: "Freight rates and approval workflow",
searchPlaceholder: "Search rates by type or status...",
supportsSearch: true,
// Category tabs — each filters server-side by appliesTo / trigger.
listTabs: [
{ key: "all", label: "All", filters: {} },
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
{
key: "trucking",
label: "First / Last mile",
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
},
{
key: "customs",
label: "Customs clearance",
filters: { trigger: "CUSTOMS_CLEARANCE" },
},
{
key: "return",
label: "Container return",
filters: { trigger: "WITH_RETURN" },
},
{
key: "surcharges",
label: "Surcharges",
filters: {
appliesTo: "OTHER",
trigger:
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE",
},
},
],
columns: [
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
@@ -640,14 +699,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "What makes this surcharge apply?",
showWhen: { field: "appliesTo", equals: ["OTHER"] },
},
// ── Trade direction — Bulk & Container only (intercity is domestic) ───
// ── Trade direction — Bulk & Container base freight, plus the route-
// scoped surcharges (customs clearance; empty-container return, which is
// import-only for now so export is not offered) ────────────────────────
{
name: "tradeDirection",
label: "Trade direction",
type: "select",
required: true,
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
optionsFromValues: (v: Record<string, unknown>) =>
String(v.trigger ?? "") === "WITH_RETURN" &&
String(v.appliesTo ?? "") === "OTHER"
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
},
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
{
@@ -664,7 +732,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
getInitialValue: (record) =>
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
},
// ── Container type — Container freight, and container-kind intercity ──
// ── Container type — Container freight, container-kind intercity, and
// the empty-container return surcharge (20ft vs 40ft price differently) ─
{
name: "containerTypeId",
label: "Container type",
@@ -673,7 +742,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Select container type (optional)",
showIf: (v) =>
v.appliesTo === "CONTAINER" ||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"),
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
},
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
{
@@ -696,7 +766,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
placeholder: "Where the leg starts",
showIf: isBaseFreightRate,
showIf: isRouteScopedRate,
},
{
name: "destinationYardId",
@@ -704,7 +774,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
placeholder: "Where the leg ends",
showIf: isBaseFreightRate,
showIf: isRouteScopedRate,
},
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight

View File

@@ -17,6 +17,9 @@ export interface RuleEngineListParams {
sortBy?: string;
sortOrder?: "ASC" | "DESC";
requiresDirectorApproval?: boolean;
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
appliesTo?: string;
trigger?: string;
}
export interface RuleEngineReorderPayload {
@@ -205,6 +208,8 @@ export const ruleEngineService = {
sortBy: params?.sortBy,
sortOrder: params?.sortOrder,
requiresDirectorApproval: params?.requiresDirectorApproval,
appliesTo: params?.appliesTo,
trigger: params?.trigger,
},
});
return normalizeList<T>(response.data, page, pageSize);