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

View File

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

View File

@@ -194,17 +194,49 @@ export class ContractPricingService {
contract.freightType === 'CONTAINER' && contract.freightType === 'CONTAINER' &&
contract.equipmentReturn === 'WITH_RETURN' contract.equipmentReturn === 'WITH_RETURN'
) { ) {
const withReturn = liveRates.find( // Return is sold per direction + route + container type (import-only) —
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD', // 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.
if (withReturn && Number(withReturn.rateValue) > 0) { // ponytail: bookings bill the live route rate, not a frozen snapshot.
lineItems.push({ const route = [...(contract.routes ?? [])].sort(
code: 'RETURN_SURCHARGE', (a, b) => a.sortOrder - b.sortOrder,
label: 'Empty container return', )[0];
unit: toContractUnit(withReturn.rateUnit), const onLeg = route
unitPrice: convert(Number(withReturn.rateValue)), ? liveRates.filter(
conditionalOn: 'with_return', (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. // ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
// A customs contract may not proceed without a configured live rate. // A customs contract may not proceed without a configured live rate.
if (contract.customsClearingEnabled) { if (contract.customsClearingEnabled) {
const clearance = liveRates.find( // The fee is sold per direction + route — strict, no route-less fallback.
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD', // 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) { if (!clearance || Number(clearance.rateValue) <= 0) {
throw new UnprocessableEntityException( 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({ lineItems.push({

View File

@@ -20,8 +20,13 @@ import { MinioService } from '../minio/minio.service';
import { FileRecord } from '../files/entities/file.entity'; import { FileRecord } from '../files/entities/file.entity';
import { import {
assertCanApproveContractStep, assertCanApproveContractStep,
assertFreightPermission,
canEditContractStep, canEditContractStep,
} from '../../common/freight-permission.util'; } from '../../common/freight-permission.util';
import {
FREIGHT_PERMS,
forFreightType,
} from '../../seed/freight-permissions.registry';
import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
@@ -239,8 +244,15 @@ export class ContractTransitionService {
actorId: string, actorId: string,
validityDays: number, validityDays: number,
documentSnapshot?: ContractDocumentSnapshotInput | null, documentSnapshot?: ContractDocumentSnapshotInput | null,
user?: TCurrentUser | null,
): Promise<Contract> { ): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); 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']); assertContractStatus(contract, ['SUBMITTED']);
if (!Number.isInteger(validityDays) || validityDays < 1) { if (!Number.isInteger(validityDays) || validityDays < 1) {
@@ -535,8 +547,13 @@ export class ContractTransitionService {
contractId: string, contractId: string,
note: string, note: string,
actorId: string, actorId: string,
user?: TCurrentUser | null,
): Promise<Contract> { ): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); const contract = await this.contractsService.findById(contractId);
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType),
);
assertContractStatus(contract, ['SUBMITTED']); assertContractStatus(contract, ['SUBMITTED']);
await this.contractsRepository.createReviewNote( await this.contractsRepository.createReviewNote(
@@ -554,8 +571,17 @@ export class ContractTransitionService {
return updated; 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); const contract = await this.contractsService.findById(contractId);
assertFreightPermission(
user,
forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType),
);
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']); assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
await this.contractsRepository.createReviewNote( await this.contractsRepository.createReviewNote(

View File

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

View File

@@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
@IsString() @IsString()
@MaxLength(50) @MaxLength(50)
rateType?: string; 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 { export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {

View File

@@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository {
if (query.rateType) { if (query.rateType) {
qb.andWhere('rate.rateType = :rateType', { rateType: 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) { if (query.search) {
qb.andWhere( qb.andWhere(
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)', '(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'); 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; isGovernment?: boolean;
allowConsolidation?: boolean; allowConsolidation?: boolean;
shippingLineId?: string | null; 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 * Booking's cargo type needs EDR-provided lashing/securing (cargoType
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the * hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
@@ -91,6 +97,15 @@ export interface AppliedCargoModifier {
triggerValue: number | null; triggerValue: number | null;
calculatedAmount: number; calculatedAmount: number;
currency: string; 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 { export interface ContainerWeightResult {
@@ -165,12 +180,16 @@ export class RuleEngineService {
...(await this.capacityViolations(input.containers, input.tradeDirection)), ...(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) { for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId, container.containerTypeId,
input.tradeDirection, input.tradeDirection,
); );
const rule = rules[0]; const rule = rules[0];
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
let isOverweight = container.isOverweight ?? false; let isOverweight = container.isOverweight ?? false;
let excess = container.overweightExcessTons ?? null; let excess = container.overweightExcessTons ?? null;
@@ -273,13 +292,6 @@ export class RuleEngineService {
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0), input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
label: 'refrigerated (reefer) cargo', 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) { for (const svc of requestedServices) {
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) { if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
@@ -292,6 +304,14 @@ export class RuleEngineService {
} }
for (const rate of surchargeRates) { 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, { const triggered = this.matchesTrigger(rate.trigger, {
isHazardous: input.isHazardous, isHazardous: input.isHazardous,
hasReefer, 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 { return {
priorityScore, priorityScore,
appliedModifiers, 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 * Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking * 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); 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. * 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 * The railway only sells three shapes: import lands at the Djibouti ports and
@@ -126,7 +139,7 @@ export class RatesService {
destinationYardId?: string | null; destinationYardId?: string | null;
}): Promise<YardScope> { }): Promise<YardScope> {
const { appliesTo, trigger, tradeDirection } = input; const { appliesTo, trigger, tradeDirection } = input;
if (!this.isBaseFreight(appliesTo, trigger)) { if (!this.isRouteScoped(appliesTo, trigger)) {
return { originYardId: null, destinationYardId: null }; return { originYardId: null, destinationYardId: null };
} }
@@ -134,7 +147,7 @@ export class RatesService {
const destinationYardId = input.destinationYardId ?? null; const destinationYardId = input.destinationYardId ?? null;
if (!originYardId || !destinationYardId) { if (!originYardId || !destinationYardId) {
throw new BadRequestException( 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) { if (originYardId === destinationYardId) {
@@ -179,6 +192,25 @@ export class RatesService {
}): void { }): void {
const { appliesTo, trigger, tradeDirection, intercityKind } = input; const { appliesTo, trigger, tradeDirection, intercityKind } = input;
const { containerTypeId, cargoTypeId } = 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 (!this.isBaseFreight(appliesTo, trigger)) return;
if (appliesTo === 'INTERCITY') { if (appliesTo === 'INTERCITY') {
@@ -247,13 +279,24 @@ export class RatesService {
const trigger = dto.trigger as Rate['trigger']; const trigger = dto.trigger as Rate['trigger'];
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
// the engine never accidentally narrows a surcharge by container/direction. // 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 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); const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
// Intercity never leaves Ethiopia, so it has no trade direction to store — // Intercity never leaves Ethiopia, so it has no trade direction to store —
// its yard pair already says where it runs. // its yard pair already says where it runs.
const tradeDirection = const tradeDirection =
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; const intercityKind = dto.intercityKind ?? null;
this.assertScopeCoherent({ this.assertScopeCoherent({
@@ -376,7 +419,8 @@ export class RatesService {
if (dto.appliesTo) updates.appliesTo = appliesTo; if (dto.appliesTo) updates.appliesTo = appliesTo;
if (dto.trigger) updates.trigger = trigger; if (dto.trigger) updates.trigger = trigger;
const containerTypeId = isSurcharge const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN';
const containerTypeId = !keepsContainerType
? null ? null
: dto.containerTypeId !== undefined : dto.containerTypeId !== undefined
? dto.containerTypeId ? dto.containerTypeId
@@ -387,11 +431,15 @@ export class RatesService {
? dto.cargoTypeId ? dto.cargoTypeId
: existing.cargoTypeId; : existing.cargoTypeId;
const tradeDirection = const tradeDirection =
isSurcharge || appliesTo === 'INTERCITY' trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
? null ? dto.tradeDirection !== undefined
: dto.tradeDirection !== undefined
? dto.tradeDirection ? dto.tradeDirection
: existing.tradeDirection; : existing.tradeDirection
: isSurcharge || appliesTo === 'INTERCITY'
? null
: dto.tradeDirection !== undefined
? dto.tradeDirection
: existing.tradeDirection;
updates.containerTypeId = containerTypeId ?? null; updates.containerTypeId = containerTypeId ?? null;
updates.cargoTypeId = cargoTypeId ?? null; updates.cargoTypeId = cargoTypeId ?? null;

View File

@@ -70,9 +70,15 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
*/ */
export const CONTRACT_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-000000000001', 'edr_freight_app:contracts:view', 'View contracts'),
perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'), // Intake actions are split per freight type (bulk vs container) — fresh ids
perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'), // because the seeder upserts ON CONFLICT (key); reusing the old ids with new
perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'), // 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-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-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'), 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: { contracts: {
view: 'edr_freight_app:contracts:view', view: 'edr_freight_app:contracts:view',
staffAccept: 'edr_freight_app:contracts:staff_accept', staffAccept: {
requestChanges: 'edr_freight_app:contracts:request_changes', bulk: 'edr_freight_app:contracts:staff_accept:bulk',
reject: 'edr_freight_app:contracts:reject', 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', approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
approveDirector: 'edr_freight_app:contracts:approve_director', approveDirector: 'edr_freight_app:contracts:approve_director',
approveCeo: 'edr_freight_app:contracts:approve_ceo', approveCeo: 'edr_freight_app:contracts:approve_ceo',
@@ -661,6 +676,18 @@ export const FREIGHT_PERMS = {
}, },
} as const; } 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 = () => const allRuleEngineViewKeys = () =>
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s)); 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.rejectApproval,
FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.cancel,
FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.staffAccept, ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
FREIGHT_PERMS.contracts.requestChanges, ...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
FREIGHT_PERMS.contracts.reject, ...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.approveLineStaff,
...allRuleEngineViewKeys(), ...allRuleEngineViewKeys(),
], ],
@@ -804,9 +831,9 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.reviewDocuments,
FREIGHT_PERMS.bookings.finalizeClearance, FREIGHT_PERMS.bookings.finalizeClearance,
FREIGHT_PERMS.contracts.view, FREIGHT_PERMS.contracts.view,
FREIGHT_PERMS.contracts.staffAccept, ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
FREIGHT_PERMS.contracts.requestChanges, ...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
FREIGHT_PERMS.contracts.reject, ...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.approveLineStaff,
FREIGHT_PERMS.contracts.generateContract, FREIGHT_PERMS.contracts.generateContract,
FREIGHT_PERMS.contracts.signStaff, FREIGHT_PERMS.contracts.signStaff,

View File

@@ -14,6 +14,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service"; import { contractsService } from "@/services/contracts.service";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
@@ -48,8 +50,19 @@ export function ContractActionsToolbar({
onReviewClearance, onReviewClearance,
}: ContractActionsToolbarProps) { }: ContractActionsToolbarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
const { status } = contract; 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 [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
const [previewOpen, setPreviewOpen] = useState(false); 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 // 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 // approver whose turn it is. The server resolves that against the caller's
// position type; the client cannot derive it. // position type; the client cannot derive it.
@@ -126,35 +140,41 @@ export function ContractActionsToolbar({
{canAccept && ( {canAccept && (
<> <>
<Button {mayAccept && (
fullWidth <Button
color="edr-green" fullWidth
leftSection={<Check size={16} />} color="edr-green"
onClick={() => { leftSection={<Check size={16} />}
setEditorMode("accept"); onClick={() => {
setEditorOpen(true); setEditorMode("accept");
}} setEditorOpen(true);
> }}
Accept for approval >
</Button> Accept for approval
<Button </Button>
fullWidth )}
variant="light" {mayRequestChanges && (
color="orange" <Button
leftSection={<MessageSquareWarning size={16} />} fullWidth
onClick={() => setChangesOpen(true)} variant="light"
> color="orange"
Request changes leftSection={<MessageSquareWarning size={16} />}
</Button> onClick={() => setChangesOpen(true)}
<Button >
fullWidth Request changes
variant="light" </Button>
color="red" )}
leftSection={<XCircle size={16} />} {mayReject && (
onClick={() => setRejectOpen(true)} <Button
> fullWidth
Reject contract variant="light"
</Button> color="red"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject contract
</Button>
)}
</> </>
)} )}

View File

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

View File

@@ -13,6 +13,7 @@ import {
Loader, Loader,
Modal, Modal,
Stack, Stack,
Tabs,
Text, Text,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
@@ -94,7 +95,14 @@ const yardOptionsForLegEnd = (
let country: string | undefined; let country: string | undefined;
if (appliesTo === "INTERCITY") { if (appliesTo === "INTERCITY") {
country = "Ethiopia"; 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 ?? ""); const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set // 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. // 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 { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState(""); 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 [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<RuleEngineRecord | null>(null); const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>( const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
@@ -153,10 +165,13 @@ const RuleEngineResourcePage = () => {
sortOrder: "ASC" as const, sortOrder: "ASC" as const,
} }
: {}), : {}),
...(config?.listTabs?.find((t) => t.key === activeTab)?.filters ?? {}),
}), }),
[ [
config?.orderConfig, config?.orderConfig,
config?.supportsSearch, config?.supportsSearch,
config?.listTabs,
activeTab,
search, search,
pagination.pageIndex, pagination.pageIndex,
pagination.pageSize, pagination.pageSize,
@@ -166,7 +181,8 @@ const RuleEngineResourcePage = () => {
useEffect(() => { useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize })); setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch(""); setSearch("");
}, [config?.slug, setPagination]); setActiveTab(config?.listTabs?.[0]?.key ?? "");
}, [config?.slug, config?.listTabs, setPagination]);
const { data, isLoading, isError, error } = useRuleEngineList( const { data, isLoading, isError, error } = useRuleEngineList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG, config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
@@ -685,6 +701,25 @@ const RuleEngineResourcePage = () => {
<Card p={0}> <Card p={0}>
<Stack gap={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%"> <Box px="md" pt="md" pb="sm" w="100%">
<RuleEngineToolbar <RuleEngineToolbar
search={search} search={search}

View File

@@ -84,6 +84,17 @@ export interface RuleEngineOrderConfig {
label: string; 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 { export interface RuleEngineResourceConfig {
slug: RuleEngineResourceSlug; slug: RuleEngineResourceSlug;
label: string; label: string;
@@ -94,6 +105,8 @@ export interface RuleEngineResourceConfig {
formFields: FormFieldDef[]; formFields: FormFieldDef[];
supportsSearch?: boolean; supportsSearch?: boolean;
orderConfig?: RuleEngineOrderConfig; orderConfig?: RuleEngineOrderConfig;
/** Server-filtered category tabs rendered above the list (rates page). */
listTabs?: RuleEngineListTab[];
/** Primary line on card view (inferred from columns when omitted). */ /** Primary line on card view (inferred from columns when omitted). */
cardTitleKey?: string; cardTitleKey?: string;
/** Secondary line under title on card view (inferred when omitted). */ /** 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. */ /** Surcharge triggers — only relevant when Applies to = Other. */
const RATE_TRIGGERS = [ const RATE_TRIGGERS = [
{ label: "Hazardous cargo", value: "HAZARDOUS" }, { 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: "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: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Consolidation", value: "CONSOLIDATION" }, { label: "Consolidation", value: "CONSOLIDATION" },
{ label: "Lashing (flat, per booking)", value: "LASHING" }, { label: "Lashing (flat, per booking)", value: "LASHING" },
@@ -175,6 +194,15 @@ const INTERCITY_KINDS = [
const isBaseFreightRate = (values: Record<string, unknown>) => const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? "")); ["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 }); 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", subtitle: "Freight rates and approval workflow",
searchPlaceholder: "Search rates by type or status...", searchPlaceholder: "Search rates by type or status...",
supportsSearch: true, 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: [ columns: [
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" }, { id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" }, { id: "trigger", header: "Trigger", accessorKey: "trigger" },
@@ -640,14 +699,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "What makes this surcharge apply?", placeholder: "What makes this surcharge apply?",
showWhen: { field: "appliesTo", equals: ["OTHER"] }, 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", name: "tradeDirection",
label: "Trade direction", label: "Trade direction",
type: "select", type: "select",
required: true, required: true,
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"), optionsFromValues: (v: Record<string, unknown>) =>
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] }, 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) ─ // ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
{ {
@@ -664,7 +732,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
getInitialValue: (record) => getInitialValue: (record) =>
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER", 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", name: "containerTypeId",
label: "Container type", label: "Container type",
@@ -673,7 +742,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Select container type (optional)", placeholder: "Select container type (optional)",
showIf: (v) => showIf: (v) =>
v.appliesTo === "CONTAINER" || 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 ─ // ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
{ {
@@ -696,7 +766,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select", type: "select",
required: true, required: true,
placeholder: "Where the leg starts", placeholder: "Where the leg starts",
showIf: isBaseFreightRate, showIf: isRouteScopedRate,
}, },
{ {
name: "destinationYardId", name: "destinationYardId",
@@ -704,7 +774,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select", type: "select",
required: true, required: true,
placeholder: "Where the leg ends", placeholder: "Where the leg ends",
showIf: isBaseFreightRate, showIf: isRouteScopedRate,
}, },
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" }, { name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight // Unit choices are driven by the rate shape (appliesTo + trigger). Overweight

View File

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

View File

@@ -73,11 +73,16 @@ describe("bulk import: dead first cycle — expire all, reopen, book again", { r
["BRA", "BRB"].forEach((suffix) => forceReservationExpiry(suffix)); ["BRA", "BRB"].forEach((suffix) => forceReservationExpiry(suffix));
withSchedule(DEPARTURE, (s) => { withSchedule(DEPARTURE, (s) => {
endPaymentPhase(s.id); endPaymentPhase(s.id);
// When the reopen instant falls inside office hours, the 10s tick's
// guard-loop chains PRE_WINDOW straight into OPEN within the SAME tick
// (booking-window.service.ts advanceSchedule), so PRE_WINDOW is not a
// reliably observable resting state — assert the cycle left PAYMENT
// without concluding FULL/DONE, whichever phase it lands on.
pollDb<ScheduleRow>( pollDb<ScheduleRow>(
"window reopens (PRE_WINDOW, cycle 2 pending)", "window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)",
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`, `SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[s.id], [s.id],
(row) => row?.window_phase === "PRE_WINDOW", (row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE",
); );
}); });
}); });

View File

@@ -0,0 +1,140 @@
/**
* GENERAL drawdown ledger (D+25) — one GENERAL contract capped at 60×20ft
* draws down across many bookings; the cap is enforced at CREATE, releases
* when a booking dies, and the contract stays CONTRACT_ACTIVE throughout:
*
* B1 30 → B2 20 → B3 asking 20 REJECTED ("only 10 remain") → B3' 10 → cap
* exhausted → B4 2 REJECTED → B3' expires → its 10 return → B5 10 accepted.
*
* Sequential steps of one journey — retries off.
*/
import {
bookContainers,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureCorridorRoute,
forceWindowOpen,
pollBookingStatus,
resetCorridorDay,
seedImportContract,
withSchedule,
} from "./import-utils";
const DEPARTURE = departureAt(25);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const SUFFIX = "GDCAP";
const REF = `CTR-IMP-${stamp}-${SUFFIX}`;
describe("GENERAL drawdown: a 60×20ft cap across many bookings", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
seedImportContract({ suffix: SUFFIX, reference: REF, kind: "GENERAL", cap20: 60 });
});
it("operations prepares the corridor train with an open window (bookings need an open day)", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-17", "LOCO-IMP-18"] });
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("B1 draws 30 and B2 draws 20 — 50 of 60 held", () => {
bookContainers({
suffix: SUFFIX,
runStamp: stamp,
isoSeed: 18_000,
twenty: 30,
scheduledDate: BOOKING_DAY,
});
bookContainers({
suffix: SUFFIX,
runStamp: stamp,
isoSeed: 18_100,
twenty: 20,
scheduledDate: BOOKING_DAY,
});
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.bookings b
JOIN freight.contracts ct ON ct.id = b.contract_id
WHERE ct.reference = $1`,
[REF],
).then(({ rows }) => expect(Number(rows[0].n), "two live drawdowns").to.eq(2));
});
it("B3 asking 20 is REJECTED — only 10 of 60 remain", () => {
bookContainers({
suffix: SUFFIX,
runStamp: stamp,
isoSeed: 18_200,
twenty: 20,
scheduledDate: BOOKING_DAY,
expectFailure: "remain on this contract",
});
});
it("B3' takes exactly the remaining 10 — the contract COMPLETES and B4 is rejected", () => {
bookContainers({
suffix: SUFFIX,
runStamp: stamp,
isoSeed: 18_300,
twenty: 10,
scheduledDate: BOOKING_DAY,
});
// Booking the last of the cap flips the GENERAL contract to
// CONTRACT_CLOSED (fully drawn) — the next booking is rejected as such.
db<{ status: string }>(
`SELECT status FROM freight.contracts WHERE reference = $1`,
[REF],
).then(({ rows }) =>
expect(rows[0].status, "fully drawn contract completes").to.eq("CONTRACT_CLOSED"),
);
bookContainers({
suffix: SUFFIX,
runStamp: stamp,
isoSeed: 18_400,
twenty: 2,
scheduledDate: BOOKING_DAY,
expectFailure: "completed",
});
});
it("an EXPIRED booking releases its draw — B5 books the freed 10 and the ledger closes again", () => {
// Kill the newest live drawdown (B3', 10×20ft) — expiry releases its hold.
db(
`UPDATE freight.bookings b SET status = 'EXPIRED'
FROM freight.contracts ct
WHERE ct.id = b.contract_id AND ct.reference = $1
AND b.id = (
SELECT b2.id FROM freight.bookings b2
JOIN freight.contracts c2 ON c2.id = b2.contract_id
WHERE c2.reference = $1 AND b2.status NOT IN ('EXPIRED','CANCELLED','REJECTED')
ORDER BY b2.created_at DESC LIMIT 1
)`,
[REF],
);
bookContainers({
suffix: SUFFIX,
runStamp: stamp,
isoSeed: 18_500,
twenty: 10,
scheduledDate: BOOKING_DAY,
});
pollBookingStatus(SUFFIX, "AWAITING_DOCUMENTS", 5);
// Completion tracks the outstanding quantity: the released 10 were
// rebooked, so the ledger is full again and the contract stays CLOSED.
db<{ status: string }>(
`SELECT status FROM freight.contracts WHERE reference = $1`,
[REF],
).then(({ rows }) =>
expect(rows[0].status, "re-drawn contract closed").to.eq("CONTRACT_CLOSED"),
);
});
});
export {};

View File

@@ -0,0 +1,136 @@
/**
* GENERAL mixed reopen (D+26) — the same two GENERAL contracts (one
* container, one bulk) book AGAIN after their first bookings die: cycle 1
* reserves a container + a bulk drawdown, nobody pays, both expire, the
* window reopens — and the SAME contracts issue fresh drawdowns in cycle 2
* that pay and allocate typed. GENERAL contracts survive dead bookings and
* dead cycles alike.
*
* Sequential steps of one journey — retries off.
*/
import {
bookBulk,
bookContainers,
clearGeneralBooking,
closeBookingWindow,
completeDocReview,
createImportSchedule,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(26);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
describe("GENERAL mixed reopen: the same contracts book again after a dead cycle", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
seedImportContract({ suffix: "GMC", reference: stampedRef("GMC"), kind: "GENERAL" });
seedImportContract({
suffix: "GMB",
reference: stampedRef("GMB"),
kind: "GENERAL",
freight: "BULK",
});
});
it("operations prepares the corridor train — first window opens (cycle 1)", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"] });
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 1").to.eq(1));
});
it("cycle 1: a container and a bulk GENERAL drawdown clear per booking and are reserved", () => {
bookContainers({
suffix: "GMC",
runStamp: stamp,
isoSeed: 19_000,
twenty: 12, // 6 wagons
scheduledDate: BOOKING_DAY,
});
clearGeneralBooking("GMC", BOOKING_DAY);
bookBulk({ suffix: "GMB", tons: 700, scheduledDate: BOOKING_DAY }); // 10 wagons
clearGeneralBooking("GMB", BOOKING_DAY);
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
["GMC", "GMB"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
});
it("nobody pays — both drawdowns expire and the window reopens", () => {
["GMC", "GMB"].forEach((suffix) => forceReservationExpiry(suffix));
withSchedule(DEPARTURE, (s) => {
endPaymentPhase(s.id);
// Same-tick guard-loop can chain PRE_WINDOW straight into OPEN when the
// reopen instant falls inside office hours — assert it left PAYMENT
// without concluding FULL/DONE, whichever phase it lands on.
pollDb<ScheduleRow>(
"window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)",
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE",
);
});
});
it("cycle 2: the SAME contracts issue fresh drawdowns that pay and allocate typed", () => {
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 2").to.eq(2));
bookContainers({
suffix: "GMC",
runStamp: stamp,
isoSeed: 19_200,
twenty: 12,
scheduledDate: BOOKING_DAY,
});
clearGeneralBooking("GMC", BOOKING_DAY);
bookBulk({ suffix: "GMB", tons: 700, scheduledDate: BOOKING_DAY });
clearGeneralBooking("GMB", BOOKING_DAY);
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
["GMC", "GMB"].forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
markPaid("GMC");
pollAllocations("GMC", 6);
expectWagonType("GMC", "NW5", 6);
markPaid("GMB");
pollAllocations("GMB", 10);
expectWagonType("GMB", "CW4", 10);
// The cycle-1 corpses stay dead; both contracts remain ACTIVE.
["GMC", "GMB"].forEach((suffix) =>
withBooking(suffix, (b) => expect(b.status, `${suffix} cycle-2 rides`).to.eq("PAID")),
);
});
});
export {};

View File

@@ -0,0 +1,179 @@
/**
* GENERAL rush hour — 20 "users" (20 GENERAL contracts) book one 54-wagon
* import train in the SAME first window (D+23). Every GENERAL booking clears
* PER BOOKING (upload → GL approve → finalize → proceed → ops accept) before
* it may enter the pool. Then:
*
* a 21st booking whose operation request is never accepted is EXPIRED by
* the batch at doc-review end (it can no longer make the train)
* the batch reserves the top 9 (9 × 6w = 54/54); 11 wait
* the payment race: 5 pay, 4 miss their deadline → the freed 24 wagons
* promote the next 4 waiters live (payment phase extends); they pay →
* the train still departs FULL
* the 7 left-over waiters expire in the day-end sweep; every contract
* stays CONTRACT_ACTIVE (GENERAL survives its bookings)
*
* Sequential steps of one journey — retries off.
*/
import {
bookContainers,
clearGeneralBooking,
closeBookingWindow,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(23);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
/** 20 virtual users — one GENERAL contract each, 12×20ft = 6 wagons per booking. */
const USERS = Array.from({ length: 20 }, (_, i) =>
`GR${String(i + 1).padStart(2, "0")}`,
);
const SELECTED = USERS.slice(0, 9); // 9 × 6w = 54
const PAYERS = SELECTED.slice(0, 5);
const DEFAULTERS = SELECTED.slice(5, 9);
const PROMOTED = USERS.slice(9, 13); // take the defaulters' 24 wagons
const LEFTOVER = USERS.slice(13); // 7 expire with the day
describe("GENERAL rush hour: 20 users, one train, one window", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
[...USERS, "GRNA"].forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), kind: "GENERAL" }),
);
});
it("operations prepares the corridor train with an open first window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-11", "LOCO-IMP-12"] });
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("20 users book at once — each GENERAL booking clears PER BOOKING into the pool", () => {
USERS.forEach((suffix, i) => {
bookContainers({
suffix,
runStamp: stamp,
isoSeed: 16_000 + i * 15,
twenty: 12,
scheduledDate: BOOKING_DAY,
});
clearGeneralBooking(suffix, BOOKING_DAY);
});
USERS.forEach((suffix, i) => setPriority(suffix, i + 1));
});
it("a 21st booking never accepted by operations is expired when doc review ends", () => {
bookContainers({
suffix: "GRNA",
runStamp: stamp,
isoSeed: 16_500,
twenty: 12,
scheduledDate: BOOKING_DAY,
});
// Clearance done, operation requested — but ops never accept it.
withBooking("GRNA", (b) => {
cy.log(`GRNA ${b.reference} stays OPERATION_REQUEST_PENDING`);
});
db(
`UPDATE freight.bookings b SET status = 'OPERATION_REQUEST_PENDING'
FROM freight.contracts ct
WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%-GRNA'
AND b.status = 'AWAITING_DOCUMENTS'`,
[],
);
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
// Never-accepted bookings are swept BEFORE the batch runs.
pollBookingStatus("GRNA", "EXPIRED");
});
it("the batch reserves the top 9 (54/54); 11 wait with no pay window", () => {
SELECTED.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
[...PROMOTED, ...LEFTOVER].forEach((suffix) =>
withBooking(suffix, (b) => {
expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED");
expect(b.payment_deadline, `${suffix} no pay window yet`).to.be.null;
}),
);
});
it("payment race: 5 pay, 4 default — the freed 24 wagons promote the next 4 waiters live", () => {
PAYERS.forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, 6);
});
DEFAULTERS.forEach((suffix) => forceReservationExpiry(suffix));
PROMOTED.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
});
it("the promoted 4 pay — the train departs FULL at 54/54, typed", () => {
PROMOTED.forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, 6);
expectWagonType(suffix, "NW5", 6);
});
withSchedule(DEPARTURE, (s) => {
endPaymentPhase(s.id);
pollDb<ScheduleRow>(
"window FULL + DONE",
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
);
db<{ n: string }>(
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
FROM freight.wagon_booking_allocations wba
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
[s.id],
).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54));
});
});
it("the 7 leftover waiters expire in ONE day-end sweep; every contract stays ACTIVE", () => {
LEFTOVER.forEach((suffix) => pollBookingStatus(suffix, "EXPIRED"));
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.contracts
WHERE reference LIKE 'CTR-IMP-%-GR%' AND status = 'CONTRACT_ACTIVE'
AND deleted_at IS NULL AND created_at > now() - interval '1 hour'`,
[],
).then(({ rows }) =>
expect(Number(rows[0].n), "GENERAL contracts survive their bookings").to.be.at.least(21),
);
});
});
export {};

View File

@@ -0,0 +1,269 @@
/**
* GENERAL two-trains-one-day — 20 GENERAL bookings against TWO 54-wagon
* trains sharing one route-day (D+24): ONE window timeline, ONE batch
* release, overflow cascading earliest-departure-first, and a full DUAL
* lifecycle (both trains dispatch, run the corridor and arrive the same day).
*
* both schedules share the group window (identical clocks); one
* doc-review-complete releases the WHOLE day
* 20 × 6w = 120w demand vs 108: train 1 takes 9 bookings, the overflow
* lands on train 2 (9 more), 2 wait
* all 18 pay → 54/54 on EACH train; the 2 waiters expire only after BOTH
* trains conclude (the sweep defers while a sibling is open)
* both trains finalize, gate-pass, dispatch, checkpoint and arrive —
* every booking ARRIVED under its own train, movements ledger per train
*
* Sequential steps of one journey — retries off.
*/
import {
apiPost,
bookContainers,
clearGeneralBooking,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureCorridorRoute,
forceReservationExpiry,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
withBooking,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE_1 = departureAt(24);
const DEPARTURE_2 = new Date(DEPARTURE_1.getTime() + 2 * 3_600_000); // same EAT day
const BOOKING_DAY = eatDayStr(DEPARTURE_1);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const USERS = Array.from({ length: 20 }, (_, i) =>
`GT${String(i + 1).padStart(2, "0")}`,
);
const RIDERS = USERS.slice(0, 18); // 9 per train
const WAITERS = USERS.slice(18); // 2 expire after both trains conclude
interface DayScheduleRow {
id: string;
window_phase: string;
booking_window_status: string;
status: string;
scheduled_departure_date: string;
}
/** Both schedules of the day, earliest departure first. */
function dayShedules() {
return db<DayScheduleRow>(
`SELECT ts.id, ts.window_phase, ts.booking_window_status, ts.status,
ts.scheduled_departure_date
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = 'DJIB_PORT'
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = 'KALITY'
WHERE ts.deleted_at IS NULL
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $1::timestamptz))) < 14400
ORDER BY ts.scheduled_departure_date ASC`,
[DEPARTURE_1.toISOString()],
);
}
function withBothSchedules(fn: (first: DayScheduleRow, second: DayScheduleRow) => void) {
dayShedules().then(({ rows }) => {
expect(rows, "two schedules on the day").to.have.length(2);
fn(rows[0], rows[1]);
});
}
describe("GENERAL two trains, one day: shared window, overflow, dual lifecycle", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
USERS.forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), kind: "GENERAL" }),
);
});
it("operations schedules TWO 54-wagon trains on one day — one shared window, forced open", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE_1);
createImportSchedule({ departure: DEPARTURE_1, locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"] });
createImportSchedule({ departure: DEPARTURE_2, locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"] });
// One clock for the whole day: force IDENTICAL window timestamps on both
// siblings (the group rule's shared timeline, arranged deterministically).
withBothSchedules((first, second) => {
db(
`UPDATE freight.train_schedules
SET window_opens_at = now() - interval '1 minute',
window_closes_at = now() + interval '60 minutes'
WHERE id = ANY($1::uuid[])`,
[[first.id, second.id]],
);
[first.id, second.id].forEach((id) =>
pollDb<ScheduleRow>(
`schedule ${id} OPEN`,
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[id],
(row) => row?.window_phase === "OPEN" && row?.booking_window_status === "OPEN",
),
);
});
});
it("20 users book into the day (never a specific train); every booking clears per booking", () => {
USERS.forEach((suffix, i) => {
bookContainers({
suffix,
runStamp: stamp,
isoSeed: 17_000 + i * 15,
twenty: 12,
scheduledDate: BOOKING_DAY,
});
clearGeneralBooking(suffix, BOOKING_DAY);
});
USERS.forEach((suffix, i) => setPriority(suffix, i + 1));
});
it("ONE doc-review-complete releases the whole day — 18 reserved across both trains, 2 wait", () => {
withBothSchedules((first, second) => {
db(
`UPDATE freight.train_schedules
SET window_closes_at = now() - interval '1 second'
WHERE id = ANY($1::uuid[]) AND window_phase = 'OPEN'`,
[[first.id, second.id]],
);
[first.id, second.id].forEach((id) =>
pollDb<ScheduleRow>(
`schedule ${id} DOC_REVIEW`,
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[id],
(row) => row?.window_phase === "DOC_REVIEW",
),
);
// Staff complete doc review on ONE train — the group stamp releases both.
completeDocReview(first.id);
pollDb<ScheduleRow>(
"sibling released by the same action",
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[second.id],
(row) => row?.window_phase === "PAYMENT" || row?.window_phase === "DONE",
);
});
RIDERS.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
WAITERS.forEach((suffix) =>
withBooking(suffix, (b) => {
expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED");
}),
);
});
it("all 18 pay — 54/54 on EACH train, overflow filled earliest-departure-first", () => {
RIDERS.forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, 6);
});
withBothSchedules((first, second) => {
[first.id, second.id].forEach((id) => {
pollDb<{ n: string }>(
`train ${id} carries 9 bookings`,
`SELECT count(*) AS n FROM freight.train_schedule_bookings
WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
[id],
(row) => Number(row?.n) === 9,
15,
);
db<{ n: string }>(
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
FROM freight.wagon_booking_allocations wba
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
[id],
).then(({ rows }) => expect(Number(rows[0].n), "54 wagons").to.eq(54));
});
// The top-priority bookings ride the EARLIEST departure.
withBooking("GT01", (b) =>
expect(b.train_schedule_id, "GT01 on the first train").to.eq(first.id),
);
});
});
it("both trains conclude FULL — only then do the 2 waiters expire (sweep defers for siblings)", () => {
withBothSchedules((first, second) => {
db(
`UPDATE freight.train_schedules
SET payment_phase_ends_at = now() - interval '1 second'
WHERE id = ANY($1::uuid[]) AND window_phase = 'PAYMENT'`,
[[first.id, second.id]],
);
[first.id, second.id].forEach((id) =>
pollDb<ScheduleRow>(
`schedule ${id} FULL + DONE`,
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
),
);
});
WAITERS.forEach((suffix) => pollBookingStatus(suffix, "EXPIRED"));
});
it("dual lifecycle: both trains gate-pass, dispatch, run the corridor and arrive", () => {
withBothSchedules((first, second) => {
[first.id, second.id].forEach((id) => {
pollDb<ScheduleRow>(
`schedule ${id} finalized`,
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(row) => row?.status === "SCHEDULED",
10,
);
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
.its("status")
.should("be.oneOf", [200, 201]);
[1, 2, 3, 4].forEach((seq) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, {
sequenceNo: seq,
kind: "PASSED",
})
.its("status")
.should("be.oneOf", [200, 201]);
});
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, {
sequenceNo: 5,
kind: "ARRIVED",
})
.its("status")
.should("be.oneOf", [200, 201]);
pollDb<ScheduleRow>(
`schedule ${id} ARRIVED`,
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(row) => row?.status === "ARRIVED",
20,
);
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`,
[id],
).then(({ rows }) =>
expect(Number(rows[0].n), `train ${id} movements`).to.be.at.least(54),
);
});
});
RIDERS.forEach((suffix) => pollBookingStatus(suffix, "ARRIVED", 20));
});
});
export {};

View File

@@ -120,6 +120,10 @@ export interface SeedContractOpts {
freight?: "CONTAINER" | "BULK"; freight?: "CONTAINER" | "BULK";
originCode?: string; originCode?: string;
destCode?: string; destCode?: string;
/** GENERAL = multi-booking drawdown contract (status CONTRACT_ACTIVE). */
kind?: "ONE_TIME" | "GENERAL";
/** GENERAL only: quantity cap on the 20ft scope row (40ft stays uncapped). */
cap20?: number;
} }
export function seedImportContract(opts: SeedContractOpts) { export function seedImportContract(opts: SeedContractOpts) {
@@ -127,6 +131,8 @@ export function seedImportContract(opts: SeedContractOpts) {
const customs = opts.customs ?? false; const customs = opts.customs ?? false;
const direction = opts.direction ?? "IMPORT"; const direction = opts.direction ?? "IMPORT";
const freight = opts.freight ?? "CONTAINER"; const freight = opts.freight ?? "CONTAINER";
const kind = opts.kind ?? "ONE_TIME";
const status = kind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED";
// Pre-booking boundary milestone for Path B contracts differs by direction. // Pre-booking boundary milestone for Path B contracts differs by direction.
const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED"; const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED";
db( db(
@@ -146,11 +152,11 @@ export function seedImportContract(opts: SeedContractOpts) {
ELSE 1 ELSE 1
END END
LIMIT 1), LIMIT 1),
'ONE_TIME', $2::text, $9::text, $11::text, $2::text, $9::text,
(SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1), (SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
$3, $4, $3, $4,
CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END, CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END,
'FULLY_EXECUTED', now(), now() - interval '1 day', $12::text, now(), now() - interval '1 day',
now() + interval '60 days', 'E2E import-corridor fixture contract' now() + interval '60 days', 'E2E import-corridor fixture contract'
FROM freight.companies comp FROM freight.companies comp
WHERE comp.tin = $5 WHERE comp.tin = $5
@@ -175,8 +181,10 @@ export function seedImportContract(opts: SeedContractOpts) {
RETURNING id RETURNING id
), scope_container AS ( ), scope_container AS (
INSERT INTO freight.contract_cargo_scope INSERT INTO freight.contract_cargo_scope
(contract_id, container_size, cargo_free_text) (contract_id, container_size, quantity_cap, cargo_free_text)
SELECT c.id, v.size, 'E2E import corridor cargo' SELECT c.id, v.size,
CASE WHEN v.size = '20ft' THEN $13::numeric END,
'E2E import corridor cargo'
FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size) FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size)
WHERE $9::text = 'CONTAINER' WHERE $9::text = 'CONTAINER'
), scope_bulk AS ( ), scope_bulk AS (
@@ -203,6 +211,9 @@ export function seedImportContract(opts: SeedContractOpts) {
opts.suffix, opts.suffix,
freight, freight,
boundary, boundary,
kind,
status,
opts.cap20 ?? null,
], ],
); );
// Backfill the boundary milestone when the insert above was skipped because // Backfill the boundary milestone when the insert above was skipped because
@@ -414,6 +425,34 @@ export function bookBulk(opts: {
}); });
} }
/**
* Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A):
* AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize →
* CLEARANCE_READY → customer proceeds with the shipment day →
* OPERATION_REQUEST_PENDING → ops accept → FULLY_EXECUTED (pool). The e2e
* seed configures no required documents, so one ad-hoc doc satisfies the
* 100%-approved gate.
*/
export function clearGeneralBooking(suffix: string, scheduledDate: string) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS");
glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e");
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, {
fileKey: "custom_e2e",
status: "APPROVED",
})
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`)
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(customer, `/api/bookings/${b.id}/clearance/proceed`, { scheduledDate })
.its("status")
.should("be.oneOf", [200, 201]);
});
acceptOperation(suffix);
}
/** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */ /** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */
export function acceptOperation(suffix: string) { export function acceptOperation(suffix: string) {
withBooking(suffix, (b) => { withBooking(suffix, (b) => {

View File

@@ -111,11 +111,14 @@ describe("mixed import: dead mixed cycle, mixed recovery, mixed ride-alongs", {
["MRC", "MRB"].forEach((suffix) => forceReservationExpiry(suffix)); ["MRC", "MRB"].forEach((suffix) => forceReservationExpiry(suffix));
withSchedule(DEPARTURE, (s) => { withSchedule(DEPARTURE, (s) => {
endPaymentPhase(s.id); endPaymentPhase(s.id);
// Same-tick guard-loop can chain PRE_WINDOW straight into OPEN when the
// reopen instant falls inside office hours — assert it left PAYMENT
// without concluding FULL/DONE, whichever phase it lands on.
pollDb<ScheduleRow>( pollDb<ScheduleRow>(
"window reopens (PRE_WINDOW)", "window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)",
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`, `SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[s.id], [s.id],
(row) => row?.window_phase === "PRE_WINDOW", (row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE",
); );
}); });
}); });

View File

@@ -38,11 +38,13 @@ WHERE c.tin = '0102030405'
); );
-- 2c. Link the demo portal user to the company, onboarding already done. -- 2c. Link the demo portal user to the company, onboarding already done.
-- (active_profile_type was dropped by migration 2450 — the "active mode"
-- column no longer exists; bookings resolve the profile per shipment.)
INSERT INTO freight.external_profiles INSERT INTO freight.external_profiles
(id, user_id, company_id, first_name, last_name, is_primary_contact, (id, user_id, company_id, first_name, last_name, is_primary_contact,
active_profile_type, onboarding_step, onboarding_completed) onboarding_step, onboarding_completed)
SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User', true, SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User', true,
'importer', 'done', true 'done', true
FROM iam.users u FROM iam.users u
JOIN freight.companies c ON c.tin = '0102030405' JOIN freight.companies c ON c.tin = '0102030405'
WHERE u.email = 'user@gmail.com' WHERE u.email = 'user@gmail.com'