mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #935 from Tria-plc/freight_feature/usermanagement
changes
This commit is contained in:
@@ -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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -442,6 +442,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> {
|
||||||
@@ -487,6 +489,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,
|
||||||
@@ -808,6 +812,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,
|
||||||
});
|
});
|
||||||
@@ -1018,6 +1024,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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
140
e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts
Normal file
140
e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts
Normal 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 {};
|
||||||
136
e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts
Normal file
136
e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts
Normal 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 {};
|
||||||
179
e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts
Normal file
179
e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts
Normal 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 {};
|
||||||
269
e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts
Normal file
269
e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts
Normal 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 {};
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user