mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +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 rate = rateById.get(mod.rateId);
|
||||
const unit = rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
||||
// Derived/route-matched charges (import overweight, empty-container
|
||||
// return) carry their own unit price + billing unit — bill and display
|
||||
// those, not whatever the referenced rate row says.
|
||||
const isDerived = mod.unitPriceUsd != null;
|
||||
const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = isDerived
|
||||
? Number(mod.unitPriceUsd)
|
||||
: rate
|
||||
? Number(rate.rateValue)
|
||||
: usdAmount;
|
||||
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
||||
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
||||
// derive from total ÷ unit price (the live unit price — a count, not a
|
||||
@@ -182,11 +190,11 @@ export class BookingPricingService {
|
||||
|
||||
// H15: bill the frozen contract surcharge rate (already in the booking
|
||||
// currency) when this code has a snapshot; else keep the live amount.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
mod.surchargeCode,
|
||||
paymentCurrency,
|
||||
);
|
||||
// Derived charges skip the snapshot — import overweight prices off the
|
||||
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||
const frozen = isDerived
|
||||
? null
|
||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
@@ -369,6 +377,8 @@ export class BookingPricingService {
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
originYardId: booking.originYardId,
|
||||
destinationYardId: booking.destinationYardId,
|
||||
totalWagons,
|
||||
// Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge).
|
||||
// Container freight carries 0 here — its surcharges scale by container count.
|
||||
|
||||
@@ -442,6 +442,8 @@ export class BookingsService {
|
||||
isReefer?: boolean;
|
||||
isGovernment?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
bulkTons?: number;
|
||||
containers: CreateBookingContainerDto[];
|
||||
}): Promise<BookingEvaluationInput> {
|
||||
@@ -487,6 +489,8 @@ export class BookingsService {
|
||||
isGovernment: dto.isGovernment ?? false,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
originYardId: dto.originYardId ?? null,
|
||||
destinationYardId: dto.destinationYardId ?? null,
|
||||
totalWagons,
|
||||
bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0,
|
||||
containers,
|
||||
@@ -808,6 +812,8 @@ export class BookingsService {
|
||||
isReefer: dto.isReefer,
|
||||
isGovernment,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
bulkTons: dto.cargoTotalWeightVgm,
|
||||
containers,
|
||||
});
|
||||
@@ -1018,6 +1024,8 @@ export class BookingsService {
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
isReefer: dto.isReefer ?? existing.isReefer,
|
||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||
originYardId: dto.originYardId ?? existing.originYardId,
|
||||
destinationYardId: dto.destinationYardId ?? existing.destinationYardId,
|
||||
bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0),
|
||||
containers,
|
||||
});
|
||||
|
||||
@@ -194,17 +194,49 @@ export class ContractPricingService {
|
||||
contract.freightType === 'CONTAINER' &&
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
) {
|
||||
const withReturn = liveRates.find(
|
||||
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
|
||||
);
|
||||
if (withReturn && Number(withReturn.rateValue) > 0) {
|
||||
lineItems.push({
|
||||
code: 'RETURN_SURCHARGE',
|
||||
label: 'Empty container return',
|
||||
unit: toContractUnit(withReturn.rateUnit),
|
||||
unitPrice: convert(Number(withReturn.rateValue)),
|
||||
conditionalOn: 'with_return',
|
||||
// Return is sold per direction + route + container type (import-only) —
|
||||
// one display line per contract size that has a configured rate. A size
|
||||
// with no rate shows nothing here and hard-blocks at booking time.
|
||||
// ponytail: bookings bill the live route rate, not a frozen snapshot.
|
||||
const route = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
)[0];
|
||||
const onLeg = route
|
||||
? liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'RETURN_SURCHARGE' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === contract.tradeDirection &&
|
||||
r.originYardId === route.originYardId &&
|
||||
r.destinationYardId === route.destinationYardId,
|
||||
)
|
||||
: [];
|
||||
if (onLeg.length > 0) {
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s);
|
||||
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||
isActive: true,
|
||||
pageSize: 100,
|
||||
});
|
||||
for (const size of sizes) {
|
||||
const sizeFt = size === '40ft' ? 40 : 20;
|
||||
const matchedIds = new Set(
|
||||
containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id),
|
||||
);
|
||||
const rate =
|
||||
onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
if (!rate || Number(rate.rateValue) <= 0) continue;
|
||||
lineItems.push({
|
||||
code: 'RETURN_SURCHARGE',
|
||||
label: `Empty container return (${size})`,
|
||||
unit: toContractUnit(rate.rateUnit),
|
||||
unitPrice: convert(Number(rate.rateValue)),
|
||||
containerSize: size,
|
||||
conditionalOn: 'with_return',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,12 +245,24 @@ export class ContractPricingService {
|
||||
// ONE_TIME, per shipment request for GENERAL. Excluded from booking totals.
|
||||
// A customs contract may not proceed without a configured live rate.
|
||||
if (contract.customsClearingEnabled) {
|
||||
const clearance = liveRates.find(
|
||||
(r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD',
|
||||
);
|
||||
// The fee is sold per direction + route — strict, no route-less fallback.
|
||||
// ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots.
|
||||
const route = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
)[0];
|
||||
const clearance = route
|
||||
? liveRates.find(
|
||||
(r) =>
|
||||
r.rateType === 'CUSTOMS_CLEARANCE' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === contract.tradeDirection &&
|
||||
r.originYardId === route.originYardId &&
|
||||
r.destinationYardId === route.destinationYardId,
|
||||
)
|
||||
: undefined;
|
||||
if (!clearance || Number(clearance.rateValue) <= 0) {
|
||||
throw new UnprocessableEntityException(
|
||||
'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.',
|
||||
'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.',
|
||||
);
|
||||
}
|
||||
lineItems.push({
|
||||
|
||||
@@ -20,8 +20,13 @@ import { MinioService } from '../minio/minio.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import {
|
||||
assertCanApproveContractStep,
|
||||
assertFreightPermission,
|
||||
canEditContractStep,
|
||||
} from '../../common/freight-permission.util';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
forFreightType,
|
||||
} from '../../seed/freight-permissions.registry';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
@@ -239,8 +244,15 @@ export class ContractTransitionService {
|
||||
actorId: string,
|
||||
validityDays: number,
|
||||
documentSnapshot?: ContractDocumentSnapshotInput | null,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
// The route guard passes on either arm; the contract's freight type decides
|
||||
// which one is actually required (accept bulk ≠ accept container).
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType),
|
||||
);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
|
||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||
@@ -535,8 +547,13 @@ export class ContractTransitionService {
|
||||
contractId: string,
|
||||
note: string,
|
||||
actorId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType),
|
||||
);
|
||||
assertContractStatus(contract, ['SUBMITTED']);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
@@ -554,8 +571,17 @@ export class ContractTransitionService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async reject(contractId: string, reason: string, actorId: string): Promise<Contract> {
|
||||
async reject(
|
||||
contractId: string,
|
||||
reason: string,
|
||||
actorId: string,
|
||||
user?: TCurrentUser | null,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType),
|
||||
);
|
||||
assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
|
||||
@@ -34,7 +34,11 @@ import {
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
bothFreightTypes,
|
||||
forFreightType,
|
||||
} from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
assertFreightPermission,
|
||||
hasFreightPermission,
|
||||
@@ -184,7 +188,10 @@ export class ContractsController {
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept);
|
||||
assertFreightPermission(
|
||||
user,
|
||||
forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType),
|
||||
);
|
||||
}
|
||||
return this.contractsService.create(dto, files ?? [], user?.id);
|
||||
}
|
||||
@@ -337,23 +344,25 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
// One-of guard; the service then requires the arm matching the contract's freight type.
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
|
||||
@ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' })
|
||||
staffAccept(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.staffAccept(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.validityDays,
|
||||
dto.documentSnapshot,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/document/draft')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog',
|
||||
@@ -377,7 +386,7 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Put(':id/document/articles')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.staffAccept)
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept))
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)',
|
||||
@@ -396,29 +405,35 @@ export class ContractsController {
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.requestChanges)
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges))
|
||||
@ApiOperation({ summary: 'Staff return contract for customer updates' })
|
||||
requestChanges(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestChangesDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.requestChanges(
|
||||
id,
|
||||
dto.note,
|
||||
resolveAuthUserId(user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.reject)
|
||||
@BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject))
|
||||
@ApiOperation({ summary: 'Staff reject contract' })
|
||||
reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user));
|
||||
return this.transitionService.reject(
|
||||
id,
|
||||
dto.reason,
|
||||
resolveAuthUserId(user),
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
|
||||
@@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
rateType?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by rate category — comma-separated appliesTo values (e.g. "CONTAINER" or "FIRST_MILE,LAST_MILE").',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
appliesTo?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filter by surcharge trigger — comma-separated trigger values (e.g. "CUSTOMS_CLEARANCE" or "HAZARDOUS,REEFER").',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
||||
|
||||
@@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository {
|
||||
if (query.rateType) {
|
||||
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
|
||||
}
|
||||
// Category tabs on the admin page: comma-separated appliesTo / trigger
|
||||
// lists, ANDed together (e.g. appliesTo=OTHER + trigger=CUSTOMS_CLEARANCE).
|
||||
const csv = (v?: string) =>
|
||||
(v ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const appliesTo = csv(query.appliesTo);
|
||||
if (appliesTo.length > 0) {
|
||||
qb.andWhere('rate.appliesTo IN (:...appliesTo)', { appliesTo });
|
||||
}
|
||||
const triggers = csv(query.trigger);
|
||||
if (triggers.length > 0) {
|
||||
qb.andWhere('rate.trigger IN (:...triggers)', { triggers });
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere(
|
||||
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
||||
|
||||
@@ -76,3 +76,191 @@ describe('RuleEngineService — requested service without a configured surcharge
|
||||
expect(result.hardBlocked[0]).toContain('reefer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — overweight surcharge by trade direction', () => {
|
||||
const baseImportRate: Rate = {
|
||||
id: 'rate-import-20',
|
||||
rateType: 'CONTAINER_IMPORT',
|
||||
trigger: 'ALWAYS',
|
||||
rateValue: 1000,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: 'ct-20',
|
||||
cargoTypeId: null,
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
} as Rate;
|
||||
|
||||
const configuredOverweight: Rate = {
|
||||
id: 'rate-ow',
|
||||
rateType: 'OVERWEIGHT_PER_TON',
|
||||
trigger: 'OVERWEIGHT',
|
||||
rateValue: 10,
|
||||
rateUnit: 'PER_TON',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
} as Rate;
|
||||
|
||||
let service: RuleEngineService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{
|
||||
findActiveByContainerTypeId: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||
} as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{
|
||||
findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]),
|
||||
} as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
// One 20ft at 25 t against a 20 t limit → 5 t excess.
|
||||
const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection,
|
||||
isHazardous: false,
|
||||
totalWagons: 1,
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
|
||||
],
|
||||
});
|
||||
|
||||
it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => {
|
||||
const result = await service.evaluate(overweightInput('IMPORT'));
|
||||
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||
expect(ow).toHaveLength(1);
|
||||
// 1000 / (2 × 20) = 25 USD/t on 5 excess tons.
|
||||
expect(ow[0].unitPriceUsd).toBe(25);
|
||||
expect(ow[0].calculatedAmount).toBe(125);
|
||||
expect(ow[0].triggerValue).toBe(5);
|
||||
expect(ow[0].rateId).toBe(baseImportRate.id);
|
||||
});
|
||||
|
||||
it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => {
|
||||
const result = await service.evaluate(overweightInput('EXPORT'));
|
||||
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||
expect(ow).toHaveLength(1);
|
||||
expect(ow[0].rateId).toBe(configuredOverweight.id);
|
||||
// 5 excess tons × the configured 10 USD/t.
|
||||
expect(ow[0].calculatedAmount).toBe(50);
|
||||
expect(ow[0].unitPriceUsd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => {
|
||||
const result = await service.evaluate({
|
||||
...overweightInput('IMPORT'),
|
||||
destinationYardId: 'yard-elsewhere',
|
||||
});
|
||||
const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
|
||||
expect(ow).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuleEngineService — empty-container return per route + container type', () => {
|
||||
const returnRate20: Rate = {
|
||||
id: 'rate-return-20',
|
||||
rateType: 'RETURN_SURCHARGE',
|
||||
trigger: 'WITH_RETURN',
|
||||
rateValue: 20,
|
||||
rateUnit: 'PER_CONTAINER',
|
||||
currency: 'USD',
|
||||
status: 'LIVE',
|
||||
containerTypeId: 'ct-20',
|
||||
cargoTypeId: null,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
} as Rate;
|
||||
|
||||
let service: RuleEngineService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new RuleEngineService(
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{
|
||||
findActiveByContainerTypeId: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
|
||||
} as never,
|
||||
{ findAllActive: jest.fn().mockResolvedValue([]) } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never,
|
||||
{ findById: jest.fn().mockResolvedValue(null) } as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
const returnInput = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
|
||||
serviceTypeId: 'svc-1',
|
||||
paymentCurrency: 'USD',
|
||||
tradeDirection: 'IMPORT',
|
||||
isHazardous: false,
|
||||
totalWagons: 1,
|
||||
originYardId: 'yard-dj',
|
||||
destinationYardId: 'yard-adama',
|
||||
containers: [
|
||||
{
|
||||
containerTypeId: 'ct-20',
|
||||
quantity: 4,
|
||||
vgmPerUnitTons: 10,
|
||||
totalVgmTons: 40,
|
||||
returnQuantity: 2,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('bills the route + type matched rate on the opted-in count', async () => {
|
||||
const result = await service.evaluate(returnInput({}));
|
||||
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||||
expect(result.hardBlocked).toHaveLength(0);
|
||||
expect(ret).toHaveLength(1);
|
||||
expect(ret[0].rateId).toBe(returnRate20.id);
|
||||
expect(ret[0].triggerValue).toBe(2);
|
||||
expect(ret[0].calculatedAmount).toBe(40);
|
||||
expect(ret[0].billingUnit).toBe('PER_CONTAINER');
|
||||
});
|
||||
|
||||
it('hard-blocks when the booking route has no matching return rate', async () => {
|
||||
const result = await service.evaluate(
|
||||
returnInput({ destinationYardId: 'yard-elsewhere' }),
|
||||
);
|
||||
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
|
||||
expect(
|
||||
result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => {
|
||||
const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' }));
|
||||
expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
|
||||
});
|
||||
|
||||
it('legacy booking-level flag bills every container at its type rate', async () => {
|
||||
const result = await service.evaluate(
|
||||
returnInput({
|
||||
withReturn: true,
|
||||
containers: [
|
||||
{ containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
|
||||
expect(ret).toHaveLength(1);
|
||||
expect(ret[0].triggerValue).toBe(4);
|
||||
expect(ret[0].calculatedAmount).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,12 @@ export interface BookingEvaluationInput {
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
/**
|
||||
* The booking's rail leg. Import overweight derives its per-ton price from
|
||||
* this route's own container freight rate, so the engine needs the yards.
|
||||
*/
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
/**
|
||||
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
|
||||
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
|
||||
@@ -91,6 +97,15 @@ export interface AppliedCargoModifier {
|
||||
triggerValue: number | null;
|
||||
calculatedAmount: number;
|
||||
currency: string;
|
||||
/**
|
||||
* Effective per-unit USD price when it differs from the rate row's own value
|
||||
* — set by derived charges (import overweight: base freight ÷ 2×limit) so
|
||||
* the breakdown shows the real per-ton figure, not the base container price.
|
||||
* Any modifier carrying it also bypasses frozen contract snapshots.
|
||||
*/
|
||||
unitPriceUsd?: number | null;
|
||||
/** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */
|
||||
billingUnit?: string;
|
||||
}
|
||||
|
||||
export interface ContainerWeightResult {
|
||||
@@ -165,12 +180,16 @@ export class RuleEngineService {
|
||||
...(await this.capacityViolations(input.containers, input.tradeDirection)),
|
||||
);
|
||||
|
||||
// Per-container-line weight limit (maxVgmTons), index-aligned with
|
||||
// containerWeightResults — the derived import overweight divides by it.
|
||||
const lineMaxVgmTons: Array<number | null> = [];
|
||||
for (const container of input.containers) {
|
||||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
||||
container.containerTypeId,
|
||||
input.tradeDirection,
|
||||
);
|
||||
const rule = rules[0];
|
||||
lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null);
|
||||
let isOverweight = container.isOverweight ?? false;
|
||||
let excess = container.overweightExcessTons ?? null;
|
||||
|
||||
@@ -273,13 +292,6 @@ export class RuleEngineService {
|
||||
input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0),
|
||||
label: 'refrigerated (reefer) cargo',
|
||||
},
|
||||
{
|
||||
trigger: 'WITH_RETURN',
|
||||
wanted:
|
||||
truthy(input.withReturn) ||
|
||||
input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0),
|
||||
label: 'empty-container return',
|
||||
},
|
||||
];
|
||||
for (const svc of requestedServices) {
|
||||
if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) {
|
||||
@@ -292,6 +304,14 @@ export class RuleEngineService {
|
||||
}
|
||||
|
||||
for (const rate of surchargeRates) {
|
||||
// Import overweight never bills the configured rate — its per-ton price
|
||||
// derives from the route's base container freight (see below).
|
||||
if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') {
|
||||
continue;
|
||||
}
|
||||
// Empty-container return is sold per route + container type — billed by
|
||||
// the route-matched block below, never by this route-agnostic loop.
|
||||
if (rate.trigger === 'WITH_RETURN') continue;
|
||||
const triggered = this.matchesTrigger(rate.trigger, {
|
||||
isHazardous: input.isHazardous,
|
||||
hasReefer,
|
||||
@@ -384,6 +404,21 @@ export class RuleEngineService {
|
||||
});
|
||||
}
|
||||
|
||||
if (input.tradeDirection === 'IMPORT') {
|
||||
appliedModifiers.push(
|
||||
...this.derivedImportOverweight(
|
||||
input,
|
||||
containerWeightResults,
|
||||
lineMaxVgmTons,
|
||||
liveRates,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const withReturn = this.withReturnCharges(input, liveRates);
|
||||
appliedModifiers.push(...withReturn.modifiers);
|
||||
hardBlocked.push(...withReturn.blocked);
|
||||
|
||||
return {
|
||||
priorityScore,
|
||||
appliedModifiers,
|
||||
@@ -394,6 +429,132 @@ export class RuleEngineService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import overweight — derived, never configured. Each overweight container
|
||||
* line bills its excess tons at (its own base import freight on the booking's
|
||||
* route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit →
|
||||
* 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate.
|
||||
* Note: derives from the LIVE route rate even for frozen-rate contract
|
||||
* bookings — the frozen snapshot has no route-scoped container price to
|
||||
* divide.
|
||||
*/
|
||||
private derivedImportOverweight(
|
||||
input: BookingEvaluationInput,
|
||||
weightResults: ContainerWeightResult[],
|
||||
lineMaxVgmTons: Array<number | null>,
|
||||
liveRates: Rate[],
|
||||
): AppliedCargoModifier[] {
|
||||
const modifiers: AppliedCargoModifier[] = [];
|
||||
if (!input.originYardId || !input.destinationYardId) return modifiers;
|
||||
|
||||
for (let i = 0; i < weightResults.length; i++) {
|
||||
const wr = weightResults[i];
|
||||
const excess = Number(wr?.overweightExcessTons ?? 0);
|
||||
const maxVgm = Number(lineMaxVgmTons[i] ?? 0);
|
||||
if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue;
|
||||
|
||||
// Same precedence as base freight pricing: the rate scoped to this
|
||||
// container type wins over the route's catch-all rate.
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === 'CONTAINER_IMPORT' &&
|
||||
r.currency === 'USD' &&
|
||||
r.originYardId === input.originYardId &&
|
||||
r.destinationYardId === input.destinationYardId,
|
||||
);
|
||||
const base =
|
||||
onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
// No base rate → the base-freight line hard-blocks this booking anyway.
|
||||
if (!base) continue;
|
||||
|
||||
const perTon = Number(base.rateValue) / (2 * maxVgm);
|
||||
const amount = excess * perTon;
|
||||
if (!(amount > 0)) continue;
|
||||
|
||||
modifiers.push({
|
||||
rateId: base.id,
|
||||
surchargeCode: 'OVERWEIGHT_PER_TON',
|
||||
triggerValue: excess,
|
||||
calculatedAmount: amount,
|
||||
currency: base.currency,
|
||||
unitPriceUsd: perTon,
|
||||
billingUnit: 'PER_TON',
|
||||
});
|
||||
}
|
||||
return modifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty-container return — sold per direction + route + container type, like
|
||||
* base freight. Each container line that opted in (returnQuantity, or every
|
||||
* container when only the legacy booking-level flag is set) bills the
|
||||
* route-matched WITH_RETURN rate for its own container type; a line with no
|
||||
* matching rate hard-blocks the booking instead of shipping the service for
|
||||
* free. Rates are import-only for now, so an export booking that asks for
|
||||
* return blocks too.
|
||||
* ponytail: bills the LIVE route rate, not a frozen contract snapshot — one
|
||||
* RETURN_SURCHARGE snapshot code can't hold per-size route prices.
|
||||
*/
|
||||
private withReturnCharges(
|
||||
input: BookingEvaluationInput,
|
||||
liveRates: Rate[],
|
||||
): { modifiers: AppliedCargoModifier[]; blocked: string[] } {
|
||||
const modifiers: AppliedCargoModifier[] = [];
|
||||
const blocked: string[] = [];
|
||||
const bookingLevel = truthy(input.withReturn);
|
||||
const wanted =
|
||||
bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0);
|
||||
if (!wanted) return { modifiers, blocked };
|
||||
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
r.trigger === 'WITH_RETURN' &&
|
||||
r.currency === 'USD' &&
|
||||
r.tradeDirection === input.tradeDirection &&
|
||||
r.originYardId === input.originYardId &&
|
||||
r.destinationYardId === input.destinationYardId,
|
||||
);
|
||||
|
||||
for (const container of input.containers) {
|
||||
const qty =
|
||||
Number(container.returnQuantity ?? 0) > 0
|
||||
? Number(container.returnQuantity)
|
||||
: bookingLevel
|
||||
? Number(container.quantity || 0)
|
||||
: 0;
|
||||
if (!(qty > 0)) continue;
|
||||
|
||||
const rate =
|
||||
onLeg.find((r) => r.containerTypeId === container.containerTypeId) ??
|
||||
onLeg.find((r) => !r.containerTypeId);
|
||||
if (!rate) {
|
||||
blocked.push(
|
||||
'No empty-container return rate is configured for this container ' +
|
||||
'type on this route (return is import-only) — remove the return ' +
|
||||
'option or ask EDR to configure its rate for this origin → destination.',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rateValue = Number(rate.rateValue);
|
||||
const amount = rate.rateUnit === 'FLAT' ? rateValue : qty * rateValue;
|
||||
if (!(amount > 0)) continue;
|
||||
modifiers.push({
|
||||
rateId: rate.id,
|
||||
surchargeCode: this.surchargeCode(rate),
|
||||
triggerValue: qty,
|
||||
calculatedAmount: amount,
|
||||
currency: rate.currency,
|
||||
unitPriceUsd: rateValue,
|
||||
billingUnit: rate.rateUnit,
|
||||
});
|
||||
}
|
||||
|
||||
// Same block deduplicated — several lines missing the rate is one problem.
|
||||
return { modifiers, blocked: [...new Set(blocked)] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Messages for container lines whose total weight exceeds the hard capacity
|
||||
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
|
||||
|
||||
@@ -93,6 +93,19 @@ export class RatesService {
|
||||
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rates sold per direction + route. Base freight always; customs clearance
|
||||
* and empty-container return are the surcharges that are too — their fee
|
||||
* depends on the lane (and, for returns, the container type).
|
||||
*/
|
||||
private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
return (
|
||||
this.isBaseFreight(appliesTo, trigger) ||
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which country each end of the leg must sit in, given what the rate is for.
|
||||
* The railway only sells three shapes: import lands at the Djibouti ports and
|
||||
@@ -126,7 +139,7 @@ export class RatesService {
|
||||
destinationYardId?: string | null;
|
||||
}): Promise<YardScope> {
|
||||
const { appliesTo, trigger, tradeDirection } = input;
|
||||
if (!this.isBaseFreight(appliesTo, trigger)) {
|
||||
if (!this.isRouteScoped(appliesTo, trigger)) {
|
||||
return { originYardId: null, destinationYardId: null };
|
||||
}
|
||||
|
||||
@@ -134,7 +147,7 @@ export class RatesService {
|
||||
const destinationYardId = input.destinationYardId ?? null;
|
||||
if (!originYardId || !destinationYardId) {
|
||||
throw new BadRequestException(
|
||||
'Base freight rates are priced per leg — pick both an origin and a destination yard.',
|
||||
'This rate is priced per leg — pick both an origin and a destination yard.',
|
||||
);
|
||||
}
|
||||
if (originYardId === destinationYardId) {
|
||||
@@ -179,6 +192,25 @@ export class RatesService {
|
||||
}): void {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind } = input;
|
||||
const { containerTypeId, cargoTypeId } = input;
|
||||
if (trigger === 'CUSTOMS_CLEARANCE') {
|
||||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException(
|
||||
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (trigger === 'WITH_RETURN') {
|
||||
// Returning the empty box only exists on imports (the box goes back to
|
||||
// the port) — export return rates are rejected until the business sells
|
||||
// that.
|
||||
if (tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException(
|
||||
'An empty container return rate is import-only for now.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!this.isBaseFreight(appliesTo, trigger)) return;
|
||||
|
||||
if (appliesTo === 'INTERCITY') {
|
||||
@@ -247,13 +279,24 @@ export class RatesService {
|
||||
const trigger = dto.trigger as Rate['trigger'];
|
||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||
// the engine never accidentally narrows a surcharge by container/direction.
|
||||
// Exceptions: customs clearance keeps a direction, and empty-container
|
||||
// return keeps direction + container type — both are sold per lane.
|
||||
const isSurcharge = trigger !== 'ALWAYS';
|
||||
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
||||
const containerTypeId =
|
||||
trigger === 'WITH_RETURN'
|
||||
? (dto.containerTypeId ?? null)
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.containerTypeId ?? null);
|
||||
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
||||
// Intercity never leaves Ethiopia, so it has no trade direction to store —
|
||||
// its yard pair already says where it runs.
|
||||
const tradeDirection =
|
||||
isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null);
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
|
||||
? (dto.tradeDirection ?? null)
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
: (dto.tradeDirection ?? null);
|
||||
|
||||
const intercityKind = dto.intercityKind ?? null;
|
||||
this.assertScopeCoherent({
|
||||
@@ -376,7 +419,8 @@ export class RatesService {
|
||||
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
||||
if (dto.trigger) updates.trigger = trigger;
|
||||
|
||||
const containerTypeId = isSurcharge
|
||||
const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN';
|
||||
const containerTypeId = !keepsContainerType
|
||||
? null
|
||||
: dto.containerTypeId !== undefined
|
||||
? dto.containerTypeId
|
||||
@@ -387,11 +431,15 @@ export class RatesService {
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
const tradeDirection =
|
||||
isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
: dto.tradeDirection !== undefined
|
||||
trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN'
|
||||
? dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection;
|
||||
: existing.tradeDirection
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
: dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection;
|
||||
|
||||
updates.containerTypeId = containerTypeId ?? null;
|
||||
updates.cargoTypeId = cargoTypeId ?? null;
|
||||
|
||||
@@ -70,9 +70,15 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
*/
|
||||
export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'),
|
||||
perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'),
|
||||
perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'),
|
||||
perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'),
|
||||
// Intake actions are split per freight type (bulk vs container) — fresh ids
|
||||
// because the seeder upserts ON CONFLICT (key); reusing the old ids with new
|
||||
// keys would PK-collide with the legacy staff_accept/request_changes/reject rows.
|
||||
perm('a3000001-0001-4000-8000-000000000011', 'edr_freight_app:contracts:staff_accept:bulk', 'Accept bulk contract intake'),
|
||||
perm('a3000001-0001-4000-8000-000000000012', 'edr_freight_app:contracts:staff_accept:container', 'Accept container contract intake'),
|
||||
perm('a3000001-0001-4000-8000-000000000013', 'edr_freight_app:contracts:request_changes:bulk', 'Request bulk contract changes'),
|
||||
perm('a3000001-0001-4000-8000-000000000014', 'edr_freight_app:contracts:request_changes:container', 'Request container contract changes'),
|
||||
perm('a3000001-0001-4000-8000-000000000015', 'edr_freight_app:contracts:reject:bulk', 'Reject bulk contract'),
|
||||
perm('a3000001-0001-4000-8000-000000000016', 'edr_freight_app:contracts:reject:container', 'Reject container contract'),
|
||||
perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'),
|
||||
perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'),
|
||||
perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'),
|
||||
@@ -373,9 +379,18 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
contracts: {
|
||||
view: 'edr_freight_app:contracts:view',
|
||||
staffAccept: 'edr_freight_app:contracts:staff_accept',
|
||||
requestChanges: 'edr_freight_app:contracts:request_changes',
|
||||
reject: 'edr_freight_app:contracts:reject',
|
||||
staffAccept: {
|
||||
bulk: 'edr_freight_app:contracts:staff_accept:bulk',
|
||||
container: 'edr_freight_app:contracts:staff_accept:container',
|
||||
},
|
||||
requestChanges: {
|
||||
bulk: 'edr_freight_app:contracts:request_changes:bulk',
|
||||
container: 'edr_freight_app:contracts:request_changes:container',
|
||||
},
|
||||
reject: {
|
||||
bulk: 'edr_freight_app:contracts:reject:bulk',
|
||||
container: 'edr_freight_app:contracts:reject:container',
|
||||
},
|
||||
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
|
||||
approveDirector: 'edr_freight_app:contracts:approve_director',
|
||||
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
||||
@@ -661,6 +676,18 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Both arms of a freight-type-split permission (for one-of route guards). */
|
||||
export const bothFreightTypes = (p: { bulk: string; container: string }): string[] => [
|
||||
p.bulk,
|
||||
p.container,
|
||||
];
|
||||
|
||||
/** The arm of a freight-type-split permission matching a contract's freightType. */
|
||||
export const forFreightType = (
|
||||
p: { bulk: string; container: string },
|
||||
freightType: string,
|
||||
): string => (freightType === 'BULK' ? p.bulk : p.container);
|
||||
|
||||
const allRuleEngineViewKeys = () =>
|
||||
RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s));
|
||||
|
||||
@@ -712,9 +739,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.rejectApproval,
|
||||
FREIGHT_PERMS.bookings.cancel,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.staffAccept,
|
||||
FREIGHT_PERMS.contracts.requestChanges,
|
||||
FREIGHT_PERMS.contracts.reject,
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
...allRuleEngineViewKeys(),
|
||||
],
|
||||
@@ -804,9 +831,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
FREIGHT_PERMS.bookings.reviewDocuments,
|
||||
FREIGHT_PERMS.bookings.finalizeClearance,
|
||||
FREIGHT_PERMS.contracts.view,
|
||||
FREIGHT_PERMS.contracts.staffAccept,
|
||||
FREIGHT_PERMS.contracts.requestChanges,
|
||||
FREIGHT_PERMS.contracts.reject,
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept),
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges),
|
||||
...bothFreightTypes(FREIGHT_PERMS.contracts.reject),
|
||||
FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
FREIGHT_PERMS.contracts.generateContract,
|
||||
FREIGHT_PERMS.contracts.signStaff,
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
@@ -48,8 +50,19 @@ export function ContractActionsToolbar({
|
||||
onReviewClearance,
|
||||
}: ContractActionsToolbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { status } = contract;
|
||||
|
||||
// Intake permissions are split per freight type: an accept:bulk holder must
|
||||
// not see the accept button on a container contract (API enforces the same).
|
||||
const arm = contract.freightType === "BULK" ? "bulk" : "container";
|
||||
const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]);
|
||||
const mayRequestChanges = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.contracts.requestChanges[arm],
|
||||
);
|
||||
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
@@ -97,7 +110,8 @@ export function ContractActionsToolbar({
|
||||
);
|
||||
}
|
||||
|
||||
const canAccept = status === "SUBMITTED";
|
||||
const canAccept =
|
||||
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
|
||||
// The document stays editable for the whole approval chain, but only by the
|
||||
// approver whose turn it is. The server resolves that against the caller's
|
||||
// position type; the client cannot derive it.
|
||||
@@ -126,35 +140,41 @@ export function ContractActionsToolbar({
|
||||
|
||||
{canAccept && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("accept");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<MessageSquareWarning size={16} />}
|
||||
onClick={() => setChangesOpen(true)}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject contract
|
||||
</Button>
|
||||
{mayAccept && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("accept");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
)}
|
||||
{mayRequestChanges && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<MessageSquareWarning size={16} />}
|
||||
onClick={() => setChangesOpen(true)}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
)}
|
||||
{mayReject && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject contract
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -29,9 +29,19 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
contracts: {
|
||||
view: "edr_freight_app:contracts:view",
|
||||
staffAccept: "edr_freight_app:contracts:staff_accept",
|
||||
requestChanges: "edr_freight_app:contracts:request_changes",
|
||||
reject: "edr_freight_app:contracts:reject",
|
||||
// Intake actions are split per freight type — mirror of the API registry.
|
||||
staffAccept: {
|
||||
bulk: "edr_freight_app:contracts:staff_accept:bulk",
|
||||
container: "edr_freight_app:contracts:staff_accept:container",
|
||||
},
|
||||
requestChanges: {
|
||||
bulk: "edr_freight_app:contracts:request_changes:bulk",
|
||||
container: "edr_freight_app:contracts:request_changes:container",
|
||||
},
|
||||
reject: {
|
||||
bulk: "edr_freight_app:contracts:reject:bulk",
|
||||
container: "edr_freight_app:contracts:reject:container",
|
||||
},
|
||||
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
@@ -94,7 +95,14 @@ const yardOptionsForLegEnd = (
|
||||
let country: string | undefined;
|
||||
if (appliesTo === "INTERCITY") {
|
||||
country = "Ethiopia";
|
||||
} else if (appliesTo === "CONTAINER" || appliesTo === "BULK") {
|
||||
} else if (
|
||||
appliesTo === "CONTAINER" ||
|
||||
appliesTo === "BULK" ||
|
||||
// Customs clearance + empty-container return are sold per direction +
|
||||
// route, so their yard dropdowns narrow exactly like base freight.
|
||||
(appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
|
||||
) {
|
||||
const direction = String(values.tradeDirection ?? "");
|
||||
// Direction is what decides the countries, so offer nothing until it is set
|
||||
// rather than defaulting to one and letting it read as a real choice.
|
||||
@@ -124,6 +132,10 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
// Category tabs (rates page): the active tab's filters go to the backend.
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
config?.listTabs?.[0]?.key ?? "",
|
||||
);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
|
||||
@@ -153,10 +165,13 @@ const RuleEngineResourcePage = () => {
|
||||
sortOrder: "ASC" as const,
|
||||
}
|
||||
: {}),
|
||||
...(config?.listTabs?.find((t) => t.key === activeTab)?.filters ?? {}),
|
||||
}),
|
||||
[
|
||||
config?.orderConfig,
|
||||
config?.supportsSearch,
|
||||
config?.listTabs,
|
||||
activeTab,
|
||||
search,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
@@ -166,7 +181,8 @@ const RuleEngineResourcePage = () => {
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
setSearch("");
|
||||
}, [config?.slug, setPagination]);
|
||||
setActiveTab(config?.listTabs?.[0]?.key ?? "");
|
||||
}, [config?.slug, config?.listTabs, setPagination]);
|
||||
|
||||
const { data, isLoading, isError, error } = useRuleEngineList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
@@ -685,6 +701,25 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
{config.listTabs && (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(v) => {
|
||||
setActiveTab(v ?? config.listTabs![0].key);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
px="md"
|
||||
pt="sm"
|
||||
>
|
||||
<Tabs.List>
|
||||
{config.listTabs.map((tab) => (
|
||||
<Tabs.Tab key={tab.key} value={tab.key}>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
)}
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<RuleEngineToolbar
|
||||
search={search}
|
||||
|
||||
@@ -84,6 +84,17 @@ export interface RuleEngineOrderConfig {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A category tab above a resource list. The active tab's `filters` are sent to
|
||||
* the list endpoint verbatim, so filtering happens server-side (values may be
|
||||
* comma-separated lists, e.g. appliesTo: "FIRST_MILE,LAST_MILE").
|
||||
*/
|
||||
export interface RuleEngineListTab {
|
||||
key: string;
|
||||
label: string;
|
||||
filters: { appliesTo?: string; trigger?: string };
|
||||
}
|
||||
|
||||
export interface RuleEngineResourceConfig {
|
||||
slug: RuleEngineResourceSlug;
|
||||
label: string;
|
||||
@@ -94,6 +105,8 @@ export interface RuleEngineResourceConfig {
|
||||
formFields: FormFieldDef[];
|
||||
supportsSearch?: boolean;
|
||||
orderConfig?: RuleEngineOrderConfig;
|
||||
/** Server-filtered category tabs rendered above the list (rates page). */
|
||||
listTabs?: RuleEngineListTab[];
|
||||
/** Primary line on card view (inferred from columns when omitted). */
|
||||
cardTitleKey?: string;
|
||||
/** Secondary line under title on card view (inferred when omitted). */
|
||||
@@ -148,9 +161,15 @@ const RATE_APPLIES_TO = [
|
||||
/** Surcharge triggers — only relevant when Applies to = Other. */
|
||||
const RATE_TRIGGERS = [
|
||||
{ label: "Hazardous cargo", value: "HAZARDOUS" },
|
||||
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
|
||||
{
|
||||
label: "Overweight (export only — import derives from container price)",
|
||||
value: "OVERWEIGHT",
|
||||
},
|
||||
{ label: "Reefer cargo", value: "REEFER" },
|
||||
{ label: "Empty container return", value: "WITH_RETURN" },
|
||||
{
|
||||
label: "Empty container return (import, per route + container type)",
|
||||
value: "WITH_RETURN",
|
||||
},
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||
{ label: "Consolidation", value: "CONSOLIDATION" },
|
||||
{ label: "Lashing (flat, per booking)", value: "LASHING" },
|
||||
@@ -175,6 +194,15 @@ const INTERCITY_KINDS = [
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
|
||||
/**
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fee and
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
isBaseFreightRate(values) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
|
||||
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
/**
|
||||
@@ -604,6 +632,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
subtitle: "Freight rates and approval workflow",
|
||||
searchPlaceholder: "Search rates by type or status...",
|
||||
supportsSearch: true,
|
||||
// Category tabs — each filters server-side by appliesTo / trigger.
|
||||
listTabs: [
|
||||
{ key: "all", label: "All", filters: {} },
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
|
||||
{
|
||||
key: "trucking",
|
||||
label: "First / Last mile",
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
|
||||
},
|
||||
{
|
||||
key: "customs",
|
||||
label: "Customs clearance",
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE" },
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
label: "Container return",
|
||||
filters: { trigger: "WITH_RETURN" },
|
||||
},
|
||||
{
|
||||
key: "surcharges",
|
||||
label: "Surcharges",
|
||||
filters: {
|
||||
appliesTo: "OTHER",
|
||||
trigger:
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE",
|
||||
},
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||
@@ -640,14 +699,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
||||
},
|
||||
// ── Trade direction — Bulk & Container only (intercity is domestic) ───
|
||||
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||
// scoped surcharges (customs clearance; empty-container return, which is
|
||||
// import-only for now so export is not offered) ────────────────────────
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
|
||||
optionsFromValues: (v: Record<string, unknown>) =>
|
||||
String(v.trigger ?? "") === "WITH_RETURN" &&
|
||||
String(v.appliesTo ?? "") === "OTHER"
|
||||
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
|
||||
},
|
||||
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
|
||||
{
|
||||
@@ -664,7 +732,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
getInitialValue: (record) =>
|
||||
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
// ── Container type — Container freight, and container-kind intercity ──
|
||||
// ── Container type — Container freight, container-kind intercity, and
|
||||
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
@@ -673,7 +742,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Select container type (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"),
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
|
||||
},
|
||||
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
|
||||
{
|
||||
@@ -696,7 +766,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Where the leg starts",
|
||||
showIf: isBaseFreightRate,
|
||||
showIf: isRouteScopedRate,
|
||||
},
|
||||
{
|
||||
name: "destinationYardId",
|
||||
@@ -704,7 +774,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Where the leg ends",
|
||||
showIf: isBaseFreightRate,
|
||||
showIf: isRouteScopedRate,
|
||||
},
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface RuleEngineListParams {
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
requiresDirectorApproval?: boolean;
|
||||
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
export interface RuleEngineReorderPayload {
|
||||
@@ -205,6 +208,8 @@ export const ruleEngineService = {
|
||||
sortBy: params?.sortBy,
|
||||
sortOrder: params?.sortOrder,
|
||||
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||
appliesTo: params?.appliesTo,
|
||||
trigger: params?.trigger,
|
||||
},
|
||||
});
|
||||
return normalizeList<T>(response.data, page, pageSize);
|
||||
|
||||
@@ -73,11 +73,16 @@ describe("bulk import: dead first cycle — expire all, reopen, book again", { r
|
||||
["BRA", "BRB"].forEach((suffix) => forceReservationExpiry(suffix));
|
||||
withSchedule(DEPARTURE, (s) => {
|
||||
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>(
|
||||
"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`,
|
||||
[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";
|
||||
originCode?: 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) {
|
||||
@@ -127,6 +131,8 @@ export function seedImportContract(opts: SeedContractOpts) {
|
||||
const customs = opts.customs ?? false;
|
||||
const direction = opts.direction ?? "IMPORT";
|
||||
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.
|
||||
const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED";
|
||||
db(
|
||||
@@ -146,11 +152,11 @@ export function seedImportContract(opts: SeedContractOpts) {
|
||||
ELSE 1
|
||||
END
|
||||
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),
|
||||
$3, $4,
|
||||
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'
|
||||
FROM freight.companies comp
|
||||
WHERE comp.tin = $5
|
||||
@@ -175,8 +181,10 @@ export function seedImportContract(opts: SeedContractOpts) {
|
||||
RETURNING id
|
||||
), scope_container AS (
|
||||
INSERT INTO freight.contract_cargo_scope
|
||||
(contract_id, container_size, cargo_free_text)
|
||||
SELECT c.id, v.size, 'E2E import corridor cargo'
|
||||
(contract_id, container_size, quantity_cap, cargo_free_text)
|
||||
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)
|
||||
WHERE $9::text = 'CONTAINER'
|
||||
), scope_bulk AS (
|
||||
@@ -203,6 +211,9 @@ export function seedImportContract(opts: SeedContractOpts) {
|
||||
opts.suffix,
|
||||
freight,
|
||||
boundary,
|
||||
kind,
|
||||
status,
|
||||
opts.cap20 ?? null,
|
||||
],
|
||||
);
|
||||
// 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). */
|
||||
export function acceptOperation(suffix: string) {
|
||||
withBooking(suffix, (b) => {
|
||||
|
||||
@@ -111,11 +111,14 @@ describe("mixed import: dead mixed cycle, mixed recovery, mixed ride-alongs", {
|
||||
["MRC", "MRB"].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 reopens (PRE_WINDOW)",
|
||||
"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?.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.
|
||||
-- (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
|
||||
(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,
|
||||
'importer', 'done', true
|
||||
'done', true
|
||||
FROM iam.users u
|
||||
JOIN freight.companies c ON c.tin = '0102030405'
|
||||
WHERE u.email = 'user@gmail.com'
|
||||
|
||||
Reference in New Issue
Block a user