diff --git a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts
new file mode 100644
index 000000000..d0d01535a
--- /dev/null
+++ b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts
@@ -0,0 +1,51 @@
+import {
+ assertCanApproveContractStep,
+ canEditContractStep,
+} from './freight-permission.util';
+import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
+
+// The document-edit gate (canEditContractStep) must be STRICT: only the approver
+// whose turn it is may edit. This is the fix for a previous approver keeping the
+// "Edit contract articles" button after acting, because the approve gate lets
+// through anyone holding any contract-approve permission.
+describe('canEditContractStep (strict per-step edit gate)', () => {
+ const director = {
+ employee: { position: { positionType: { key: '-marketing-director-' } } },
+ };
+ // A line staff who already approved their own step but still holds a
+ // contract-approve permission — the exact actor that leaked edit rights.
+ const officerWithApprovePerm = {
+ employee: {
+ position: {
+ positionType: { key: '-marketing-officer-' },
+ permissions: [{ key: FREIGHT_PERMS.contracts.approveLineStaff }],
+ },
+ },
+ };
+ const superAdmin = { roles: [{ key: 'super_admin' }] };
+
+ it('lets the step’s own approver edit', () => {
+ expect(canEditContractStep(director, '-marketing-director-')).toBe(true);
+ });
+
+ it('lets an approval admin edit any step', () => {
+ expect(canEditContractStep(superAdmin, '-marketing-director-')).toBe(true);
+ });
+
+ it('does NOT let a different approver edit just because they hold an approve permission', () => {
+ expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
+ false,
+ );
+ });
+
+ it('stays intentionally stricter than the approve gate (which keeps the blanket fallback)', () => {
+ // The approve gate passes the officer via the any-permission blanket…
+ expect(() =>
+ assertCanApproveContractStep(officerWithApprovePerm, '-marketing-director-'),
+ ).not.toThrow();
+ // …but the edit gate does not — that divergence IS the fix.
+ expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
+ false,
+ );
+ });
+});
diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts
index ced6e3c46..429c910d3 100644
--- a/apps/edr-freight-api/src/common/freight-permission.util.ts
+++ b/apps/edr-freight-api/src/common/freight-permission.util.ts
@@ -201,6 +201,34 @@ export function assertCanApproveContractStep(
);
}
+/**
+ * Strict "is it exactly this caller's turn?" test — mirrors the backoffice
+ * `canApproveContractStep`. Same passes as {@link assertCanApproveContractStep}
+ * EXCEPT the blanket "holds any contract-approve permission" fallback is
+ * dropped: a line-staff holding `approveLineStaff` must NOT read as the director
+ * for a director step. Used to gate contract-document editing so approval hands
+ * edit rights to the NEXT approver only — a previous approver who already acted
+ * (but still holds an approve permission) loses the edit button, as required.
+ *
+ * (Kept separate from the approve/reject gate, which keeps the blanket fallback
+ * so delegates whose token omits a position type can still action their step.)
+ */
+export function canEditContractStep(
+ user: TCurrentUser | MeLikeUser | null | undefined,
+ requiredRole: string,
+): boolean {
+ if (isFreightApprovalAdmin(user)) return true;
+
+ const positionTypes = collectPositionTypeKeys(user);
+ if (positionTypes.includes(requiredRole)) return true;
+
+ const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
+ if (aliases.some((alias) => positionTypes.includes(alias))) return true;
+
+ const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
+ return Boolean(legacyPermission && hasFreightPermission(user, legacyPermission));
+}
+
export function assertCanApproveBookingStep(
user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string,
diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
index ba4fe442f..e2e433c70 100644
--- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
+++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts
@@ -18,7 +18,10 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractViewModel } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service';
import { FileRecord } from '../files/entities/file.entity';
-import { assertCanApproveContractStep } from '../../common/freight-permission.util';
+import {
+ assertCanApproveContractStep,
+ canEditContractStep,
+} from '../../common/freight-permission.util';
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';
@@ -431,12 +434,11 @@ export class ContractTransitionService {
if (!next) return false;
if (!user) return false;
- try {
- assertCanApproveContractStep(user, next.requiredRole);
- return true;
- } catch {
- return false;
- }
+ // Strict match: ONLY the approver whose turn it is (the next pending step's
+ // role) may edit. Using the looser approve gate here let any approver who
+ // held a contract-approve permission keep the edit button after acting —
+ // approval must hand edit rights to the next approver, not share them.
+ return canEditContractStep(user, next.requiredRole);
}
/** The role that currently holds editing rights, for UI messaging. */
diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts
index c907af717..fc80b238a 100644
--- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts
+++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts
@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
-import { FleetManage, FleetView } from '../../common/booking-guards';
+import { FleetManage, StaffReference } from '../../common/booking-guards';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
@@ -9,18 +9,22 @@ import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives')
@ApiBearerAuth()
+// No class-level guard: reads are login-only reference data (any staff can
+// fetch a locomotive for a cross-flow view without the fleet:view that drives
+// the Fleet sidebar). Every mutation carries its own @FleetManage().
@Controller('locomotives')
-@FleetView()
export class LocomotivesController {
constructor(private readonly locomotivesService: LocomotivesService) {}
@Get()
+ @StaffReference()
@ApiOperation({ summary: 'List locomotives' })
findAll(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAll(filter);
}
@Get(':id')
+ @StaffReference()
@ApiOperation({ summary: 'Get a locomotive by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.findById(id);
diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts
index 556907cde..cd501362d 100644
--- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts
+++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts
@@ -12,7 +12,7 @@ import {
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
-import { FleetManage, FleetView } from '../../common/booking-guards';
+import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
@@ -23,8 +23,10 @@ import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { WagonsService } from './wagons.service';
@ApiTags('wagons')
+// No class-level guard: reads (list, by-id, movements) are login-only reference
+// data — any staff can fetch wagon data for a cross-flow view without the
+// fleet:view that drives the Fleet sidebar. Every mutation has its @FleetManage().
@Controller('wagons')
-@FleetView()
export class WagonsController {
constructor(private readonly wagonsService: WagonsService) {}
@@ -36,18 +38,21 @@ export class WagonsController {
}
@Get()
+ @StaffReference()
@ApiOperation({ summary: 'List all wagons' })
findAll(@Query() query: ListWagonsQueryDto) {
return this.wagonsService.findAll(query);
}
@Get(':id')
+ @StaffReference()
@ApiOperation({ summary: 'Get a wagon by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.findById(id);
}
@Get(':id/movements')
+ @StaffReference()
@ApiOperation({
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
})
diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx
index 77ff95c2d..caef6f14e 100644
--- a/apps/edr-freight-web/backoffice/src/App.tsx
+++ b/apps/edr-freight-web/backoffice/src/App.tsx
@@ -581,6 +581,16 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
+// Routes a GL officer may reach beyond their clearance hub. Path B booking is
+// part of their job (create/rebook under a cleared contract, then view that
+// booking's clearance), but those routes live outside the clearance prefix —
+// without this allowlist the single-prefix lock bounces them out of their own
+// workflow. Matched against location.pathname (no query string).
+const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
+ /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
+ /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
+];
+
const isEtClearanceItem = (item: SidebarItem): boolean =>
item.href === ET_CLEARANCE_HREF;
const isDjClearanceItem = (item: SidebarItem): boolean =>
@@ -714,7 +724,11 @@ const DashboardShell = () => {
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
}, [location.pathname, sidebarSections]);
- if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
+ if (
+ glClearanceHome &&
+ !location.pathname.startsWith(glClearanceHome) &&
+ !GL_WORKFLOW_PATH_PATTERNS.some((re) => re.test(location.pathname))
+ ) {
return ;
}
diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
index 01e2368bd..fbd851d73 100644
--- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx
@@ -6,7 +6,6 @@ import {
LayoutGrid,
Milestone,
Package,
- ShieldCheck,
} from "lucide-react";
import {
Container,
@@ -36,7 +35,6 @@ import {
BookingCompanyCard,
BookingContractSummaryCard,
BookingContainerUnitsCard,
- ClearanceReviewSection,
BookingDocumentsPanel,
ContractOrdersPanel,
} from "@/components/bookings/detail";
@@ -129,13 +127,9 @@ export default function BookingRequestDetailPage() {
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
- // Non-customs clearance is reviewed here by Marketing in its own tab; customs
- // bookings are handled in the Global Logistics clearance queue instead.
- const showClearanceTab =
- !booking.customsClearingEnabled &&
- ["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
- booking.status,
- );
+ // Clearance review + finalize now lives solely on the Operations "Clearance
+ // Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
+ // no clearance tab is embedded here anymore.
// A general contract drives an "Orders" tab: each drawdown order spawns a
// child booking that staff manage (clearance/approval) independently.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
@@ -143,13 +137,11 @@ export default function BookingRequestDetailPage() {
// customs-workflow, invoice or notice files — so the tab bar always renders.
const requestedTab = searchParams.get("tab");
const activeTab =
- requestedTab === "clearance" && showClearanceTab
- ? "clearance"
- : requestedTab === "orders" && isGeneralContract
- ? "orders"
- : requestedTab === "documents"
- ? "documents"
- : "overview";
+ requestedTab === "orders" && isGeneralContract
+ ? "orders"
+ : requestedTab === "documents"
+ ? "documents"
+ : "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -209,14 +201,6 @@ export default function BookingRequestDetailPage() {
Orders
)}
- {showClearanceTab && (
- }
- >
- Customer clearance
-
- )}
}
@@ -236,14 +220,6 @@ export default function BookingRequestDetailPage() {
/>
)}
- {showClearanceTab && (
-
- refetch()}
- />
-
- )}
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
index 60d5a56f8..a4ae9f880 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx
@@ -138,9 +138,14 @@ export default function ClearanceDocumentsPage() {
const generalQuery = useQuery({
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
queryFn: () =>
+ // Per-booking self-clearance instances are drawdowns under GENERAL
+ // non-customs contracts: they carry bookingType=ONE_TIME (each shipment
+ // is one-time) with contractKind=GENERAL, so filtering on
+ // bookingType=GENERAL_CONTRACT returned nothing. customsClearingEnabled
+ // =false + the three per-booking clearance statuses already isolate
+ // exactly this worklist — the same set the old booking-request tab showed.
bookingsService.list({
statuses: bookingStatuses,
- bookingType: "GENERAL_CONTRACT",
customsClearingEnabled: "false",
page,
pageSize: PAGE_SIZE,
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx
index 5ac798250..1ed7d71ec 100644
--- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx
@@ -93,6 +93,11 @@ export default function ContractClearanceDetailPage() {
}, [clearance]);
const reference = contract?.reference ?? "Clearance";
+ // Path A (non-customs) → Operations reviews & finalizes; Path B (customs) → GL.
+ // This page serves BOTH hubs (Ops "Clearance Documents" + GL "Document
+ // Clearance"), so the reviewer is decided by the contract, not the hub — a
+ // hardcoded value routes non-customs finalize to the GL endpoint and 409s.
+ const selfClear = !contract?.customsClearingEnabled;
const phasedCustoms =
contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
const docsPhaseComplete =
@@ -338,7 +343,7 @@ export default function ContractClearanceDetailPage() {
contractsService.getClearance(id!),
- enabled: Boolean(id) && showClearanceTabQuery,
+ enabled: Boolean(id) && hasClearancePhase,
});
// Customer profile documents (national ID, TIN, import/business license) for
@@ -270,18 +269,10 @@ export default function ContractRequestDetailPage() {
contract.status === "APPROVED_PENDING_SIGNATURE" ||
contract.status === "REJECTED";
- const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
- const phasedCustoms =
- contract.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
- const docsPhaseComplete =
- clearanceView?.milestones?.some(
- (m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
- ) ?? false;
- const clearanceApprovalsLocked = phasedCustoms && docsPhaseComplete;
- // Once clearance is finalized the tab is informational only — no approve/query.
- const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
- // Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
- const selfClear = !contract.customsClearingEnabled;
+ // Clearance review + finalize now lives solely on the Operations "Clearance
+ // Documents" hub. The Staff-actions "Review clearance" button deep-links there
+ // while the contract is in a clearance-review status — no embedded tab here.
+ const inClearanceReview = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
const files = contract.files ?? [];
const contractPdf = files.find((f) => f.code === "contract");
// Signature files (code `signature_`) are baked into the contract PDF —
@@ -303,9 +294,7 @@ export default function ContractRequestDetailPage() {
? "documents"
: requestedTab === "customer"
? "customer"
- : requestedTab === "clearance" && showClearanceTab
- ? "clearance"
- : "details";
+ : "details";
const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government")
@@ -485,39 +474,13 @@ export default function ContractRequestDetailPage() {
}>
Customer
- {showClearanceTab && (
- }
- >
- Clearance Review
-
- )}
{/* LEFT — primary content */}
- {currentTab === "clearance" ? (
-
- refetch()}
- />
- {(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
- void handleDownloadFile({ id: f.id, name: f.name } as never)}
- />
- ) : null}
-
- ) : currentTab === "documents" ? (
+ {currentTab === "documents" ? (
setTab("clearance") : undefined
+ inClearanceReview
+ ? () =>
+ navigate(
+ `/dashboard/contracts/clearance-documents/${contract.id}`,
+ )
+ : undefined
}
/>
{showApprovalCard && (
diff --git a/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts
new file mode 100644
index 000000000..25213bcfb
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts
@@ -0,0 +1,215 @@
+/**
+ * BULK IMPORT critical-scenario matrix — bulk-specific corridor edge cases:
+ *
+ * 1. gate: a wheat booking on a day with no open window is rejected
+ * 2. sub-corridor bulk (NAGAD → MOJO, 700 T) rides the through-train next
+ * to a DJIB_PORT → KALITY 1 400 T booking — corridor-aware batch
+ * 3. bulk intercity ride-along (MOJO → KALITY, DOMESTIC, 140 T): dateless
+ * booking, staff accept onto the import train's free leg, pay window
+ * opens, paid + linked
+ * 4. WHOLE-TRAIN giant: a 4 000 T booking (58 wagons worth) alone on a
+ * 54-wagon train → partial offer of the FULL consist (3 780 T), gateway
+ * settle applies the split, the train is FULL from ONE booking, and the
+ * 220 T outstanding must be rebooked EXACTLY on a later train.
+ *
+ * Sequential steps — retries off.
+ */
+
+import {
+ acceptOperation,
+ apiPost,
+ bookBulk,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ forceWindowOpen,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ settleViaGateway,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(15);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const GIANT_DEPARTURE = departureAt(16);
+const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
+const REMAINDER_DEPARTURE = departureAt(17);
+const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
+const NO_WINDOW_DAY = eatDayStr(departureAt(19)); // no schedule exists there
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+describe("bulk critical matrix: gates, sub-corridor, bulk intercity, whole-train giant", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ seedImportContract({ suffix: "BM1", reference: stampedRef("BM1"), freight: "BULK" });
+ seedImportContract({
+ suffix: "BMSUB",
+ reference: stampedRef("BMSUB"),
+ freight: "BULK",
+ originCode: "NAGAD",
+ destCode: "MOJO",
+ });
+ seedImportContract({
+ suffix: "BMIC",
+ reference: stampedRef("BMIC"),
+ freight: "BULK",
+ direction: "DOMESTIC",
+ originCode: "MOJO",
+ destCode: "KALITY",
+ });
+ seedImportContract({ suffix: "BMG", reference: stampedRef("BMG"), freight: "BULK" });
+ });
+
+ it("operations prepares the corridor and the D+15 bulk train with an open window", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ resetCorridorDay(GIANT_DEPARTURE);
+ resetCorridorDay(REMAINDER_DEPARTURE);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
+ kind: "bulk",
+ });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+ });
+
+ it("gate: a wheat booking on a day with no open window is rejected", () => {
+ bookBulk({
+ suffix: "BM1",
+ tons: 140,
+ scheduledDate: NO_WINDOW_DAY,
+ expectFailure: "booking window",
+ });
+ });
+
+ it("a through-corridor 1 400 T booking and a NAGAD→MOJO 700 T booking share the train", () => {
+ bookBulk({ suffix: "BM1", tons: 1400, scheduledDate: BOOKING_DAY });
+ acceptOperation("BM1");
+ bookBulk({ suffix: "BMSUB", tons: 700, scheduledDate: BOOKING_DAY });
+ acceptOperation("BMSUB");
+
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ ["BM1", "BMSUB"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ markPaid("BM1");
+ pollAllocations("BM1", 20);
+ markPaid("BMSUB");
+ pollAllocations("BMSUB", 10);
+
+ withSchedule(DEPARTURE, (s) => {
+ withBooking("BM1", (b) => expect(b.train_schedule_id).to.eq(s.id));
+ withBooking("BMSUB", (b) => expect(b.train_schedule_id).to.eq(s.id));
+ });
+ });
+
+ it("bulk intercity ride-along: dateless DOMESTIC wheat accepted onto the import train's free leg", () => {
+ bookBulk({ suffix: "BMIC", tons: 140 }); // 2 wagons MOJO → KALITY, dateless
+ acceptOperation("BMIC");
+
+ withSchedule(DEPARTURE, (s) => {
+ withBooking("BMIC", (b) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
+ bookingIds: [b.id],
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ });
+ pollBookingStatus("BMIC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
+ withBooking("BMIC", (b) => {
+ expect(b.payment_deadline, "ride-along pay window opened").to.be.a("string");
+ });
+ markPaid("BMIC");
+ withSchedule(DEPARTURE, (s) => {
+ withBooking("BMIC", (b) => {
+ expect(b.train_schedule_id, "linked to the import train").to.eq(s.id);
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.train_schedule_bookings
+ WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
+ [b.id, s.id],
+ ).then(({ rows }) => expect(Number(rows[0].n), "link row").to.eq(1));
+ });
+ });
+ });
+
+ it("whole-train giant: 4 000 T alone gets a FULL-consist partial offer (3 780 T) and fills the train", () => {
+ createImportSchedule({
+ departure: GIANT_DEPARTURE,
+ locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
+ kind: "bulk",
+ });
+ withSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+
+ bookBulk({ suffix: "BMG", tons: 4000, scheduledDate: GIANT_DAY });
+ acceptOperation("BMG");
+ withSchedule(GIANT_DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ pollBookingStatus("BMG", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
+ withBooking("BMG", (b) => {
+ pollDb<{ status: string }>(
+ "BMG open partial offer",
+ `SELECT status FROM freight.booking_batch_offers
+ WHERE booking_id = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [b.id],
+ (row) => row?.status === "OFFERED",
+ 10,
+ );
+ });
+
+ settleViaGateway("BMG");
+ pollAllocations("BMG", 54);
+ withBooking("BMG", (b) => {
+ expect(b.is_split, "BMG is split").to.eq(true);
+ });
+ withSchedule(GIANT_DEPARTURE, (s) => {
+ endPaymentPhase(s.id);
+ pollDb(
+ "giant train FULL + DONE from one booking",
+ `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",
+ );
+ });
+ });
+
+ it("the giant's 220 T outstanding must be rebooked EXACTLY on a later train", () => {
+ createImportSchedule({
+ departure: REMAINDER_DEPARTURE,
+ locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
+ kind: "bulk",
+ });
+ withSchedule(REMAINDER_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+
+ bookBulk({
+ suffix: "BMG",
+ tons: 100,
+ scheduledDate: REMAINDER_DAY,
+ expectFailure: "must take the whole",
+ });
+ bookBulk({ suffix: "BMG", tons: 220, scheduledDate: REMAINDER_DAY });
+ pollBookingStatus("BMG", "OPERATION_REQUEST_PENDING", 5);
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/bulk_export_fcfs_space.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_export_fcfs_space.cy.ts
new file mode 100644
index 000000000..878b862e9
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/bulk_export_fcfs_space.cy.ts
@@ -0,0 +1,171 @@
+/**
+ * BULK EXPORT — FCFS capacity truth (mirror of export_fcfs_space):
+ *
+ * D+9: three wheat bookings (1 400 + 1 400 + 980 T = 54 wagons) accept
+ * first and hold the train before any payment; three late 700 T exporters
+ * are REJECTED AT SUBMISSION by the whole-train space gate. The three pay
+ * → FULL.
+ *
+ * D+10: the whole-or-nothing giant — 4 060 T (58 wagons) rejected against
+ * the empty train; rebooked at exactly 3 780 T (54 wagons) it reserves the
+ * whole consist alone, pays, allocates 54/54 → FULL; a 70 T afterthought
+ * bounces.
+ *
+ * Sequential steps — retries off.
+ */
+
+import {
+ acceptExport,
+ bookBulk,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(9);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const GIANT_DEPARTURE = departureAt(10);
+const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const FIRST = [
+ { suffix: "YA", tons: 1400, wagons: 20 },
+ { suffix: "YB", tons: 1400, wagons: 20 },
+ { suffix: "YC", tons: 980, wagons: 14 },
+];
+const LATE = ["YL1", "YL2", "YL3"];
+
+function seedBulkExport(suffix: string) {
+ seedImportContract({
+ suffix,
+ reference: stampedRef(suffix),
+ freight: "BULK",
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+}
+
+describe("bulk export FCFS: reservations hold capacity, whole-or-nothing gate", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ [...FIRST.map((b) => b.suffix), ...LATE, "YG", "YS"].forEach(seedBulkExport);
+ });
+
+ it("operations prepares the export corridor and the D+9 CW4 train with an open window", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ resetCorridorDay(GIANT_DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
+ kind: "bulk",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+ });
+
+ it("three exporters book wheat and are accepted — 54 wagons reserved BEFORE any payment", () => {
+ FIRST.forEach((b) => {
+ bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
+ acceptExport(b.suffix);
+ });
+ });
+
+ it("three late exporters are rejected at submission — the space gate reports no room", () => {
+ LATE.forEach((suffix) => {
+ bookBulk({
+ suffix,
+ tons: 700,
+ scheduledDate: BOOKING_DAY,
+ expectFailure: /space|window/i,
+ });
+ });
+ });
+
+ it("the three reserved pay — 54/54 allocated and the export window flips FULL", () => {
+ FIRST.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ });
+ withExportSchedule(DEPARTURE, (s) => {
+ pollDb(
+ "export window FULL",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.booking_window_status === "FULL",
+ );
+ });
+ });
+
+ it("whole-or-nothing: a 4 060 T giant is rejected against the empty D+10 train", () => {
+ createImportSchedule({
+ departure: GIANT_DEPARTURE,
+ locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
+ kind: "bulk",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+
+ bookBulk({
+ suffix: "YG",
+ tons: 4060, // 58 wagons > 54 — export never splits
+ scheduledDate: GIANT_DAY,
+ expectFailure: /space|window/i,
+ });
+ });
+
+ it("rebooked at exactly 3 780 T the giant reserves the whole train alone, pays, fills it", () => {
+ bookBulk({ suffix: "YG", tons: 3780, scheduledDate: GIANT_DAY });
+ acceptExport("YG");
+ markPaid("YG");
+ pollAllocations("YG", 54);
+ withExportSchedule(GIANT_DEPARTURE, (s) => {
+ pollDb(
+ "giant train FULL from one booking",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.booking_window_status === "FULL",
+ );
+ withBooking("YG", (b) => {
+ expect(b.train_schedule_id, "giant rides its train").to.eq(s.id);
+ });
+ 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("a 70 T afterthought bounces off the FULL train", () => {
+ bookBulk({
+ suffix: "YS",
+ tons: 70,
+ scheduledDate: GIANT_DAY,
+ expectFailure: /space|window/i,
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts
new file mode 100644
index 000000000..bb8e30b55
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts
@@ -0,0 +1,251 @@
+/**
+ * BULK EXPORT journey — six wheat bookings fill the 54-wagon CW4 train on the
+ * reversed corridor KALITY → MOJO → E2E_AWASH → DIRE_DAWA → NAGAD →
+ * DJIB_PORT, all inside the ONE FCFS export window, then the full life of the
+ * train to Djibouti Port and the export customs tail.
+ *
+ * The six bookings (70 T per CW4 wagon — Σ = 54 wagons / 3 780 T):
+ * XBF1 customs + USD 560 T = 8 wagons
+ * XBF2 customs + ETB 420 T = 6 wagons
+ * XBF3 self + ETB 420 T = 6 wagons
+ * XBF4 self + ETB 420 T = 6 wagons
+ * XBF5 customs + USD 1 540 T = 22 wagons (the ≥22-wagon giant)
+ * XBF6 self + USD 420 T = 6 wagons
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptExport,
+ apiPost,
+ bookBulk,
+ completeBookingMilestone,
+ createImportSchedule,
+ db,
+ dbBooking,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ expectMilestoneDone,
+ forceWindowOpen,
+ glUpload,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(8);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const BOOKINGS: Array<{
+ suffix: string;
+ customs: boolean;
+ currency: "ETB" | "USD";
+ tons: number;
+ wagons: number;
+}> = [
+ { suffix: "XBF1", customs: true, currency: "USD", tons: 560, wagons: 8 },
+ { suffix: "XBF2", customs: true, currency: "ETB", tons: 420, wagons: 6 },
+ { suffix: "XBF3", customs: false, currency: "ETB", tons: 420, wagons: 6 },
+ { suffix: "XBF4", customs: false, currency: "ETB", tons: 420, wagons: 6 },
+ { suffix: "XBF5", customs: true, currency: "USD", tons: 1540, wagons: 22 },
+ { suffix: "XBF6", customs: false, currency: "USD", tons: 420, wagons: 6 },
+];
+const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
+const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
+
+function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
+ withExportSchedule(DEPARTURE, (s) => fn(s.id, s));
+}
+
+describe("bulk export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ for (const b of BOOKINGS) {
+ seedImportContract({
+ suffix: b.suffix,
+ reference: stampedRef(b.suffix),
+ currency: b.currency,
+ customs: b.customs,
+ freight: "BULK",
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ }
+ });
+
+ it("operations prepares the export corridor and a 54-wagon CW4 train — window forced open", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
+ kind: "bulk",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withScheduleId((id) => forceWindowOpen(id, 60));
+ });
+
+ it("six exporters book wheat inside the one window", () => {
+ BOOKINGS.forEach((b) => {
+ bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
+ pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
+ });
+ });
+
+ it("each accept reserves FCFS immediately — pay deadlines clamped to the window close", () => {
+ BOOKINGS.forEach((b) => acceptExport(b.suffix));
+ withScheduleId((_, s) => {
+ BOOKINGS.forEach((b) => {
+ withBooking(b.suffix, (row) => {
+ expect(row.payment_deadline, `${b.suffix} pay deadline`).to.be.a("string");
+ expect(
+ new Date(row.payment_deadline!).getTime(),
+ `${b.suffix} deadline never outlives the window close`,
+ ).to.be.at.most(new Date(s.window_closes_at!).getTime());
+ });
+ });
+ });
+ BOOKINGS.forEach((b) => {
+ withBooking(b.suffix, (row) => {
+ pollDb<{ currency: string }>(
+ `${b.suffix} invoice`,
+ `SELECT currency FROM freight.invoices
+ WHERE source_id = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [row.id],
+ (inv) => inv?.currency === b.currency,
+ 10,
+ );
+ });
+ });
+ });
+
+ it("all six pay — allocated 54/54, the export window flips FULL, staff finalize", () => {
+ BOOKINGS.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ });
+ withScheduleId((id) => {
+ pollDb(
+ "export window FULL",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.booking_window_status === "FULL",
+ );
+ 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 allocated").to.eq(54));
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/finalize`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollDb(
+ "schedule SCHEDULED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "SCHEDULED",
+ 10,
+ );
+ });
+ });
+
+ it("gate pass + T1 uploads, the train dispatches and runs the corridor to Djibouti Port", () => {
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ glUpload(`/api/contracts/bookings/${b.id}/transport-document`);
+ });
+ });
+
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollDb(
+ "schedule DISPATCHED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "DISPATCHED",
+ 10,
+ );
+ [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(
+ "schedule ARRIVED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "ARRIVED",
+ 20,
+ );
+ });
+ BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
+ });
+
+ it("GL Djibouti closes the export tail (T1 close → offloaded) on every customs booking", () => {
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ completeBookingMilestone(suffix, "OFFLOADED");
+ expectMilestoneDone(suffix, "T1_CLOSED");
+ expectMilestoneDone(suffix, "OFFLOADED");
+ });
+ });
+
+ it("the self-clearance bookings arrived clean — no customs tail required", () => {
+ SELF_CLEAR.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ expect(b.status, `${suffix} final status`).to.eq("ARRIVED");
+ });
+ dbBooking(suffix).then(({ rows }) => {
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.clearance_milestones
+ WHERE booking_id = $1 AND milestone_code = 'T1_CLOSED'
+ AND status = 'COMPLETED' AND deleted_at IS NULL`,
+ [rows[0].id],
+ ).then(({ rows: ms }) =>
+ expect(Number(ms[0].n), `${suffix} has no T1 tail`).to.eq(0),
+ );
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/bulk_export_matrix.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_export_matrix.cy.ts
new file mode 100644
index 000000000..f4d6fe572
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/bulk_export_matrix.cy.ts
@@ -0,0 +1,193 @@
+/**
+ * BULK EXPORT critical matrix (reversed corridor, D+13):
+ *
+ * 1. gate: a wheat booking on a day with no open window is rejected
+ * 2. sub-corridor bulk export (DIRE_DAWA → DJIB_PORT, 980 T) boards
+ * mid-route and shares the train with a KALITY 2 800 T through-booking
+ * 3. directional FULL: through 40w + sub 14w commit the border edges → the
+ * export window flips FULL while the home leg still has 14 free wagons
+ * 4. bulk intercity ride-along on the FULL export train's free home leg
+ * (KALITY → MOJO, DOMESTIC, 140 T, dateless) — accepted, pay window
+ * clamped to the EXPORT close, paid + linked; the window stays FULL
+ * 5. same-day sibling export train keeps its OWN window (no group rule)
+ *
+ * Sequential steps — retries off.
+ */
+
+import {
+ acceptExport,
+ apiPost,
+ bookBulk,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ forceWindowOpen,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(13);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const NO_WINDOW_DAY = eatDayStr(departureAt(20)); // no schedule exists there
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+describe("bulk export matrix: sub-corridor, directional FULL, bulk intercity, own windows", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ seedImportContract({
+ suffix: "YM1",
+ reference: stampedRef("YM1"),
+ freight: "BULK",
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ seedImportContract({
+ suffix: "YMSUB",
+ reference: stampedRef("YMSUB"),
+ freight: "BULK",
+ direction: "EXPORT",
+ originCode: "DIRE_DAWA",
+ destCode: EXP_DEST,
+ });
+ seedImportContract({
+ suffix: "YMIC",
+ reference: stampedRef("YMIC"),
+ freight: "BULK",
+ direction: "DOMESTIC",
+ originCode: EXP_ORIGIN,
+ destCode: "MOJO",
+ });
+ });
+
+ it("operations prepares the export corridor and the D+13 CW4 train with an open window", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"],
+ kind: "bulk",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 90));
+ });
+
+ it("gate: a wheat booking on a day with no open window is rejected", () => {
+ bookBulk({
+ suffix: "YM1",
+ tons: 140,
+ scheduledDate: NO_WINDOW_DAY,
+ expectFailure: "booking window",
+ });
+ });
+
+ it("a KALITY through-booking (2 800 T) and a DIRE_DAWA sub-corridor booking (980 T) share the train", () => {
+ bookBulk({ suffix: "YM1", tons: 2800, scheduledDate: BOOKING_DAY });
+ acceptExport("YM1");
+ bookBulk({ suffix: "YMSUB", tons: 980, scheduledDate: BOOKING_DAY });
+ acceptExport("YMSUB");
+
+ markPaid("YM1");
+ pollAllocations("YM1", 40);
+ markPaid("YMSUB");
+ pollAllocations("YMSUB", 14);
+
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("YM1", (b) => expect(b.train_schedule_id).to.eq(s.id));
+ withBooking("YMSUB", (b) => expect(b.train_schedule_id).to.eq(s.id));
+ });
+ });
+
+ it("the border edges are committed — the export window flips FULL (home leg still free)", () => {
+ withExportSchedule(DEPARTURE, (s) => {
+ pollDb(
+ "directional FULL",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.booking_window_status === "FULL",
+ );
+ });
+ });
+
+ it("bulk intercity ride-along boards the FULL train's free home leg — clamped, paid, linked", () => {
+ bookBulk({ suffix: "YMIC", tons: 140 }); // 2 wagons KALITY → MOJO, dateless
+ withBooking("YMIC", (b) => {
+ apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ pollBookingStatus("YMIC", "FULLY_EXECUTED", 10);
+
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("YMIC", (b) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
+ bookingIds: [b.id],
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ });
+ pollBookingStatus("YMIC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("YMIC", (b) => {
+ expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
+ new Date(s.window_closes_at!).getTime(),
+ );
+ });
+ });
+ markPaid("YMIC");
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("YMIC", (b) => {
+ expect(b.train_schedule_id, "linked to the export train").to.eq(s.id);
+ });
+ expect(s.booking_window_status, "window stays FULL").to.eq("FULL");
+ });
+ });
+
+ it("a same-day sibling export train keeps its OWN window — no route-day group for export", () => {
+ const sibling = new Date(DEPARTURE.getTime() + 90 * 60_000);
+ createImportSchedule({
+ departure: sibling,
+ locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
+ kind: "bulk",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(DEPARTURE, (anchor) => {
+ db<{ id: string; window_phase: string; window_closes_at: string }>(
+ `SELECT ts.id, ts.window_phase, ts.window_closes_at
+ FROM freight.train_schedules ts
+ JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
+ JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
+ WHERE ts.deleted_at IS NULL AND ts.id <> $3
+ AND abs(extract(epoch FROM (ts.scheduled_departure_date - $4::timestamptz))) < 7200
+ ORDER BY ts.created_at DESC LIMIT 1`,
+ [EXP_ORIGIN, EXP_DEST, anchor.id, DEPARTURE.toISOString()],
+ ).then(({ rows }) => {
+ expect(rows, "sibling export schedule").to.have.length(1);
+ expect(rows[0].window_phase, "own fresh window").to.eq("PRE_WINDOW");
+ expect(
+ new Date(rows[0].window_closes_at).getTime(),
+ "own close, anchored to its own departure",
+ ).to.not.eq(new Date(anchor.window_closes_at!).getTime());
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/bulk_export_pay_or_lose.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_export_pay_or_lose.cy.ts
new file mode 100644
index 000000000..283425cc6
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/bulk_export_pay_or_lose.cy.ts
@@ -0,0 +1,171 @@
+/**
+ * BULK EXPORT — pay-or-lose (mirror of export_pay_or_lose):
+ *
+ * D+11: ZA + ZB reserve 40 wagons of wheat and pay. ZC reserves the last 14
+ * (980 T) but never pays; late ZD is rejected for space while ZC's hold
+ * lives; ZC expires → ZD immediately books the freed 980 T, pays, allocates.
+ *
+ * D+12: five bookings reserve the whole train (840×4 + 420 T), only three
+ * pay. The window CLOSE passes → phase DONE, the two unpaid expire, export
+ * never reopens (cycle counter stays 1).
+ *
+ * Sequential steps — retries off.
+ */
+
+import {
+ acceptExport,
+ bookBulk,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ forceReservationExpiry,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(11);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const CLOSE_DEPARTURE = departureAt(12);
+const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+function seedBulkExport(suffix: string) {
+ seedImportContract({
+ suffix,
+ reference: stampedRef(suffix),
+ freight: "BULK",
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+}
+
+const CLOSERS = [
+ { suffix: "ZQA", tons: 840, wagons: 12, pays: true },
+ { suffix: "ZQB", tons: 840, wagons: 12, pays: true },
+ { suffix: "ZQC", tons: 840, wagons: 12, pays: true },
+ { suffix: "ZQD", tons: 840, wagons: 12, pays: false },
+ { suffix: "ZQE", tons: 420, wagons: 6, pays: false },
+];
+
+describe("bulk export pay-or-lose: expiry frees space; close expires the unpaid", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ ["ZA", "ZB", "ZC", "ZD", ...CLOSERS.map((c) => c.suffix)].forEach(seedBulkExport);
+ });
+
+ it("operations prepares the export corridor and the D+11 CW4 train with an open window", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ resetCorridorDay(CLOSE_DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
+ kind: "bulk",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+ });
+
+ it("ZA and ZB reserve and pay 40 wagons; ZC reserves the last 14 unpaid (clamped)", () => {
+ bookBulk({ suffix: "ZA", tons: 1400, scheduledDate: BOOKING_DAY });
+ acceptExport("ZA");
+ markPaid("ZA");
+ pollAllocations("ZA", 20);
+
+ bookBulk({ suffix: "ZB", tons: 1400, scheduledDate: BOOKING_DAY });
+ acceptExport("ZB");
+ markPaid("ZB");
+ pollAllocations("ZB", 20);
+
+ bookBulk({ suffix: "ZC", tons: 980, scheduledDate: BOOKING_DAY });
+ acceptExport("ZC");
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("ZC", (b) => {
+ expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
+ new Date(s.window_closes_at!).getTime(),
+ );
+ });
+ });
+ });
+
+ it("a late exporter is rejected while ZC's unpaid reservation holds the space", () => {
+ bookBulk({
+ suffix: "ZD",
+ tons: 980,
+ scheduledDate: BOOKING_DAY,
+ expectFailure: /space|window/i,
+ });
+ });
+
+ it("ZC misses its pay window — EXPIRED — and ZD immediately books the freed wagons", () => {
+ forceReservationExpiry("ZC");
+ bookBulk({ suffix: "ZD", tons: 980, scheduledDate: BOOKING_DAY });
+ acceptExport("ZD");
+ markPaid("ZD");
+ pollAllocations("ZD", 14);
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("ZD", (b) => expect(b.train_schedule_id, "ZD took ZC's seat").to.eq(s.id));
+ });
+ });
+
+ it("window-close day: five reservations, three payments", () => {
+ createImportSchedule({
+ departure: CLOSE_DEPARTURE,
+ locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
+ kind: "bulk",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(CLOSE_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+
+ CLOSERS.forEach((c) => {
+ bookBulk({ suffix: c.suffix, tons: c.tons, scheduledDate: CLOSE_DAY });
+ acceptExport(c.suffix);
+ });
+ CLOSERS.filter((c) => c.pays).forEach((c) => {
+ markPaid(c.suffix);
+ pollAllocations(c.suffix, c.wagons);
+ });
+ });
+
+ it("the window CLOSES — phase DONE, the two unpaid expire, and export never reopens", () => {
+ withExportSchedule(CLOSE_DEPARTURE, (s) => {
+ db(
+ `UPDATE freight.train_schedules
+ SET window_closes_at = now() - interval '1 second'
+ WHERE id = $1 AND window_phase = 'OPEN'`,
+ [s.id],
+ );
+ pollDb(
+ "export window DONE (no reopen)",
+ `SELECT window_phase, booking_cycle_no FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.window_phase === "DONE" && Number(row?.booking_cycle_no) === 1,
+ );
+ });
+ // Forced close ⇒ force the matching deadline clamp on the unpaid pair.
+ CLOSERS.filter((c) => !c.pays).forEach((c) => forceReservationExpiry(c.suffix));
+ CLOSERS.filter((c) => !c.pays).forEach((c) => pollBookingStatus(c.suffix, "EXPIRED"));
+ CLOSERS.filter((c) => c.pays).forEach((c) =>
+ withBooking(c.suffix, (b) => expect(b.status, `${c.suffix} rides`).to.eq("PAID")),
+ );
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts
new file mode 100644
index 000000000..0ffdb37f3
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts
@@ -0,0 +1,286 @@
+/**
+ * BULK IMPORT journey 1 — six wheat bookings fill a 54-wagon CW4 train on the
+ * long corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY,
+ * all in the FIRST booking window, then the full life of the train: payment,
+ * allocation, gate pass, T1, dispatch, checkpoint-by-checkpoint movement,
+ * arrival, and the post-arrival customs tail.
+ *
+ * The six bookings (70 T per CW4 wagon — Σ = 54 wagons / 3 780 T):
+ * BF1 customs + USD 560 T = 8 wagons
+ * BF2 customs + ETB 420 T = 6 wagons
+ * BF3 self + ETB 420 T = 6 wagons
+ * BF4 self + ETB 420 T = 6 wagons
+ * BF5 customs + USD 1 540 T = 22 wagons (the ≥22-wagon giant)
+ * BF6 self + USD 420 T = 6 wagons
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptOperation,
+ apiPost,
+ bookBulk,
+ closeBookingWindow,
+ completeBookingMilestone,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ dbBooking,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ expectMilestoneDone,
+ forceWindowOpen,
+ glUpload,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(10);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const BOOKINGS: Array<{
+ suffix: string;
+ customs: boolean;
+ currency: "ETB" | "USD";
+ tons: number;
+ wagons: number;
+}> = [
+ { suffix: "BF1", customs: true, currency: "USD", tons: 560, wagons: 8 },
+ { suffix: "BF2", customs: true, currency: "ETB", tons: 420, wagons: 6 },
+ { suffix: "BF3", customs: false, currency: "ETB", tons: 420, wagons: 6 },
+ { suffix: "BF4", customs: false, currency: "ETB", tons: 420, wagons: 6 },
+ { suffix: "BF5", customs: true, currency: "USD", tons: 1540, wagons: 22 },
+ { suffix: "BF6", customs: false, currency: "USD", tons: 420, wagons: 6 },
+];
+const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
+const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
+
+function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
+ withSchedule(DEPARTURE, (s) => fn(s.id, s));
+}
+
+describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ for (const b of BOOKINGS) {
+ seedImportContract({
+ suffix: b.suffix,
+ reference: stampedRef(b.suffix),
+ currency: b.currency,
+ customs: b.customs,
+ freight: "BULK",
+ });
+ }
+ });
+
+ it("operations prepares the corridor and a 54-wagon BULK train — first window forced open", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
+ kind: "bulk",
+ });
+ withScheduleId((id) => forceWindowOpen(id, 45));
+ withScheduleId((_, s) => {
+ expect(s.booking_cycle_no, "FIRST window cycle").to.eq(1);
+ });
+ });
+
+ it("customer books all six wheat shipments inside the first window", () => {
+ BOOKINGS.forEach((b) => {
+ bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
+ pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
+ });
+ });
+
+ it("operations accepts all six — the whole pool is FULLY_EXECUTED (in window)", () => {
+ BOOKINGS.forEach((b) => acceptOperation(b.suffix));
+ });
+
+ it("window closes, doc review completes — the batch reserves ALL six (they fit exactly)", () => {
+ withScheduleId((id) => {
+ closeBookingWindow(id);
+ completeDocReview(id);
+ });
+ BOOKINGS.forEach((b) =>
+ pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ BOOKINGS.forEach((b) => {
+ withBooking(b.suffix, (row) => {
+ expect(row.payment_deadline, `${b.suffix} pay deadline`).to.be.a("string");
+ pollDb<{ currency: string }>(
+ `${b.suffix} invoice`,
+ `SELECT currency FROM freight.invoices
+ WHERE source_id = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [row.id],
+ (inv) => inv?.currency === b.currency,
+ 10,
+ );
+ });
+ });
+ });
+
+ it("all six pay — allocated onto the train, 54/54 wagons, window FULL and schedule finalized", () => {
+ BOOKINGS.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ });
+ withScheduleId((id) => {
+ endPaymentPhase(id);
+ pollDb(
+ "schedule FULL + DONE + finalized",
+ `SELECT window_phase, booking_window_status, status
+ FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) =>
+ s?.booking_window_status === "FULL" &&
+ s?.window_phase === "DONE" &&
+ s?.status === "SCHEDULED",
+ );
+ 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 allocated").to.eq(54));
+ });
+ });
+
+ it("GL Djibouti: gate pass granted, T1 documents uploaded for the customs bookings", () => {
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ glUpload(`/api/contracts/bookings/${b.id}/t1-documents`);
+ });
+ });
+ });
+
+ it("the train dispatches and runs the corridor checkpoint by checkpoint to the terminal", () => {
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollDb(
+ "schedule DISPATCHED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "DISPATCHED",
+ 10,
+ );
+ });
+ BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "IN_TRANSIT", 10));
+
+ withScheduleId((id) => {
+ [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(
+ "schedule ARRIVED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "ARRIVED",
+ 20,
+ );
+ });
+ BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
+ withScheduleId((id) => {
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.wagon_movements
+ WHERE train_schedule_id = $1`,
+ [id],
+ ).then(({ rows }) =>
+ expect(Number(rows[0].n), "wagon movement ledger rows").to.be.at.least(54),
+ );
+ });
+ });
+
+ it("GL runs the customs tail on every customs booking (T1 close → risk → second duty → release → final invoice)", () => {
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/risk`, {
+ riskLevel: "GREEN",
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ glUpload(
+ `/api/contracts/bookings/${b.id}/second-duty`,
+ { dutyRequired: "false" },
+ "attachment",
+ );
+ glUpload(
+ `/api/contracts/bookings/${b.id}/final-invoice`,
+ { amount: "1000", description: "e2e final invoice" },
+ "file",
+ );
+ glUpload(`/api/contracts/bookings/${b.id}/final-invoice-slip`, {}, "file");
+ apiPost(
+ "superadmin@tria.com",
+ `/api/contracts/bookings/${b.id}/final-invoice/confirm`,
+ )
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ completeBookingMilestone(suffix, "IMPORT_RELEASE_GRANTED");
+ completeBookingMilestone(suffix, "IMPORT_PROCESS_COMPLETED");
+ expectMilestoneDone(suffix, "T1_CLOSED");
+ expectMilestoneDone(suffix, "RISK_ASSIGNED");
+ expectMilestoneDone(suffix, "IMPORT_RELEASE_GRANTED");
+ expectMilestoneDone(suffix, "IMPORT_PROCESS_COMPLETED");
+ });
+ });
+
+ it("the self-clearance bookings arrived clean — no customs tail required", () => {
+ SELF_CLEAR.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ expect(b.status, `${suffix} final status`).to.eq("ARRIVED");
+ });
+ dbBooking(suffix).then(({ rows }) => {
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.clearance_milestones
+ WHERE booking_id = $1 AND milestone_code = 'T1_CLOSED'
+ AND status = 'COMPLETED' AND deleted_at IS NULL`,
+ [rows[0].id],
+ ).then(({ rows: ms }) =>
+ expect(Number(ms[0].n), `${suffix} has no T1 tail`).to.eq(0),
+ );
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts
new file mode 100644
index 000000000..163bb7d79
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts
@@ -0,0 +1,199 @@
+/**
+ * BULK IMPORT journey 3 — split offer, exact-remainder rebooking, pay-window
+ * expiry and priority-ordered waiting-list promotion on one 54-wagon CW4
+ * train (bulk splits are FULL-WAGONS-ONLY at the base 70 T cap):
+ *
+ * reserved (priority order): BSA 1 400 T = 20w, BSB 980 T = 14w,
+ * BSD 840 T = 12w → 46w. BSC 1 680 T = 24w does NOT fit whole → PARTIAL
+ * offer of the remaining 8 wagons = 560 T. BSC settles via the real payment
+ * pipeline → split applies (is_split + snapshot); the outstanding 1 120 T
+ * must later be rebooked EXACTLY (a wrong tonnage is rejected).
+ * BSD never pays → EXPIRES; the freed 12 wagons promote BS1 (420 T = 6w)
+ * and BS2 (420 T = 6w); BS3 (1 400 T = 20w) never fits and expires.
+ *
+ * Final consist: 20 + 14 + 8 + 6 + 6 = 54/54.
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptOperation,
+ bookBulk,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ forceReservationExpiry,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ setPriority,
+ settleViaGateway,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(12);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const REMAINDER_DEPARTURE = departureAt(14);
+const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const ORDER = ["BSA", "BSB", "BSD", "BSC", "BS1", "BS2", "BS3"] as const;
+const TONS: Record = {
+ BSA: 1400,
+ BSB: 980,
+ BSD: 840,
+ BSC: 1680,
+ BS1: 420,
+ BS2: 420,
+ BS3: 1400,
+};
+
+describe("bulk import: split offer, remainder rebooking, expiry + promotion", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ ORDER.forEach((suffix) =>
+ seedImportContract({ suffix, reference: stampedRef(suffix), freight: "BULK" }),
+ );
+ });
+
+ it("operations prepares the corridor bulk train with an open first window", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ resetCorridorDay(REMAINDER_DEPARTURE);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"],
+ kind: "bulk",
+ });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ });
+
+ it("seven customers book wheat in the first window; operations accepts them in priority order", () => {
+ ORDER.forEach((suffix) => {
+ bookBulk({ suffix, tons: TONS[suffix], scheduledDate: BOOKING_DAY });
+ acceptOperation(suffix);
+ });
+ ORDER.forEach((suffix, i) => setPriority(suffix, i + 1));
+ });
+
+ it("the batch reserves BSA/BSB/BSD whole and offers BSC a PARTIAL for the last 8 wagons (560 T)", () => {
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ ["BSA", "BSB", "BSD", "BSC"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ withBooking("BSC", (b) => {
+ pollDb<{ status: string }>(
+ "BSC open partial offer",
+ `SELECT status FROM freight.booking_batch_offers
+ WHERE booking_id = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [b.id],
+ (row) => row?.status === "OFFERED",
+ 10,
+ );
+ });
+ ["BS1", "BS2", "BS3"].forEach((suffix) =>
+ withBooking(suffix, (b) => {
+ expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED");
+ }),
+ );
+ });
+
+ it("BSA and BSB pay; BSC settles via the gateway — the split applies (full wagons only)", () => {
+ markPaid("BSA");
+ pollAllocations("BSA", 20);
+ markPaid("BSB");
+ pollAllocations("BSB", 14);
+
+ settleViaGateway("BSC");
+ pollAllocations("BSC", 8);
+ withBooking("BSC", (b) => {
+ expect(b.is_split, "BSC is split").to.eq(true);
+ db<{ pre_split_quantities: unknown }>(
+ `SELECT pre_split_quantities FROM freight.bookings WHERE id = $1`,
+ [b.id],
+ ).then(({ rows }) => {
+ expect(rows[0].pre_split_quantities, "pre-split snapshot").to.not.be.null;
+ });
+ });
+ });
+
+ it("BSD misses its pay window — EXPIRED, and the freed wagons promote BS1 + BS2 (payment sent)", () => {
+ forceReservationExpiry("BSD");
+ ["BS1", "BS2"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ ["BS1", "BS2"].forEach((suffix) =>
+ withBooking(suffix, (b) => {
+ expect(b.payment_deadline, `${suffix} got a pay window`).to.be.a("string");
+ }),
+ );
+ withBooking("BS3", (b) => {
+ expect(b.status, "BS3 still has no seat").to.eq("FULLY_EXECUTED");
+ });
+ });
+
+ it("BS1 and BS2 pay — the train is FULL at 54; BS3 expires with the day", () => {
+ markPaid("BS1");
+ pollAllocations("BS1", 6);
+ markPaid("BS2");
+ pollAllocations("BS2", 6);
+
+ withSchedule(DEPARTURE, (s) => {
+ endPaymentPhase(s.id);
+ pollDb(
+ "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));
+ });
+ pollBookingStatus("BS3", "EXPIRED");
+ });
+
+ it("the split customer must rebook EXACTLY the whole 1 120 T remainder — wrong tonnage rejected, exact accepted", () => {
+ createImportSchedule({
+ departure: REMAINDER_DEPARTURE,
+ locoPair: ["LOCO-IMP-21", "LOCO-IMP-22"],
+ kind: "bulk",
+ });
+ withSchedule(REMAINDER_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+
+ // 1 680 booked − 560 shipped-by-split = 1 120 T outstanding. 560 ≠ 1 120.
+ bookBulk({
+ suffix: "BSC",
+ tons: 560,
+ scheduledDate: REMAINDER_DAY,
+ expectFailure: "must take the whole",
+ });
+
+ bookBulk({ suffix: "BSC", tons: 1120, scheduledDate: REMAINDER_DAY });
+ pollBookingStatus("BSC", "OPERATION_REQUEST_PENDING", 5);
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/bulk_import_waiting_expiry.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_import_waiting_expiry.cy.ts
new file mode 100644
index 000000000..772124810
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/bulk_import_waiting_expiry.cy.ts
@@ -0,0 +1,133 @@
+/**
+ * BULK IMPORT journey 2 — the CW4 train fills from THREE wheat bookings;
+ * three more sit in the waiting pool of the same (first) window. The three
+ * selected pay and allocate; when the cycle concludes FULL the three waiting
+ * bookings expire with the day.
+ *
+ * Tonnage (70 T per CW4 wagon, 54-wagon consist):
+ * selected: BWA 1 400 T = 20w, BWB 1 400 T = 20w, BWC 980 T = 14w → Σ 54
+ * waiting: BW1/BW2/BW3 700 T = 10w each
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptOperation,
+ bookBulk,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ setPriority,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(11);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const SELECTED = [
+ { suffix: "BWA", tons: 1400, wagons: 20 },
+ { suffix: "BWB", tons: 1400, wagons: 20 },
+ { suffix: "BWC", tons: 980, wagons: 14 },
+];
+const WAITING = [
+ { suffix: "BW1", tons: 700, wagons: 10 },
+ { suffix: "BW2", tons: 700, wagons: 10 },
+ { suffix: "BW3", tons: 700, wagons: 10 },
+];
+const ALL = [...SELECTED, ...WAITING];
+
+describe("bulk import: 3 bookings fill the train, 3 wait and expire", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ ALL.forEach((b) =>
+ seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix), freight: "BULK" }),
+ );
+ });
+
+ it("operations prepares the corridor and a 54-wagon bulk train with an open first window", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-IMP-17", "LOCO-IMP-18"],
+ kind: "bulk",
+ });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ });
+
+ it("six customers book wheat in the first window; operations accepts all six", () => {
+ ALL.forEach((b) => {
+ bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
+ acceptOperation(b.suffix);
+ });
+ ALL.forEach((b, i) => setPriority(b.suffix, i + 1));
+ });
+
+ it("the batch selects exactly the three that fill 54 wagons; the rest keep waiting", () => {
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ SELECTED.forEach((b) =>
+ pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ WAITING.forEach((b) =>
+ withBooking(b.suffix, (row) => {
+ expect(row.status, `${b.suffix} still waiting`).to.eq("FULLY_EXECUTED");
+ expect(row.payment_deadline, `${b.suffix} has no pay deadline`).to.be.null;
+ }),
+ );
+ });
+
+ it("the three selected bookings pay and allocate — 54/54", () => {
+ SELECTED.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ });
+ withSchedule(DEPARTURE, (s) => {
+ 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 cycle concludes FULL — the three waiting bookings expire with the day", () => {
+ withSchedule(DEPARTURE, (s) => {
+ endPaymentPhase(s.id);
+ pollDb(
+ "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",
+ );
+ });
+ WAITING.forEach((b) => pollBookingStatus(b.suffix, "EXPIRED"));
+ SELECTED.forEach((b) =>
+ withBooking(b.suffix, (row) => expect(row.status, `${b.suffix} stays PAID`).to.eq("PAID")),
+ );
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts
new file mode 100644
index 000000000..6304f78f9
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts
@@ -0,0 +1,110 @@
+/**
+ * BULK IMPORT journey 4 — nobody pays in the first window cycle: both
+ * reserved wheat bookings expire, the cycle concludes NOT-full and the window
+ * REOPENS for a second cycle on the same bulk train. A fresh 700 T booking
+ * arrives in cycle 2, pays, and allocates.
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptOperation,
+ bookBulk,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ forceReservationExpiry,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(13);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+describe("bulk import: dead first cycle — expire all, reopen, book again", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ ["BRA", "BRB", "BRC"].forEach((suffix) =>
+ seedImportContract({ suffix, reference: stampedRef(suffix), freight: "BULK" }),
+ );
+ });
+
+ it("operations prepares the corridor bulk train — first window opens (cycle 1)", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-IMP-23", "LOCO-IMP-24"],
+ kind: "bulk",
+ });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 1").to.eq(1));
+ });
+
+ it("two customers book wheat and are reserved in cycle 1", () => {
+ bookBulk({ suffix: "BRA", tons: 1400, scheduledDate: BOOKING_DAY });
+ bookBulk({ suffix: "BRB", tons: 1400, scheduledDate: BOOKING_DAY });
+ ["BRA", "BRB"].forEach((suffix) => acceptOperation(suffix));
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ ["BRA", "BRB"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ });
+
+ it("nobody pays — both reservations expire and the cycle concludes not-full", () => {
+ ["BRA", "BRB"].forEach((suffix) => forceReservationExpiry(suffix));
+ withSchedule(DEPARTURE, (s) => {
+ endPaymentPhase(s.id);
+ pollDb(
+ "window reopens (PRE_WINDOW, cycle 2 pending)",
+ `SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.window_phase === "PRE_WINDOW",
+ );
+ });
+ });
+
+ it("the second window opens (cycle 2) and a fresh 700 T booking pays and allocates", () => {
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 2").to.eq(2));
+
+ bookBulk({ suffix: "BRC", tons: 700, scheduledDate: BOOKING_DAY });
+ acceptOperation("BRC");
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ pollBookingStatus("BRC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
+ markPaid("BRC");
+ pollAllocations("BRC", 10);
+
+ ["BRA", "BRB"].forEach((suffix) =>
+ withBooking(suffix, (b) => expect(b.status, `${suffix} stays expired`).to.eq("EXPIRED")),
+ );
+ withSchedule(DEPARTURE, (s) => {
+ withBooking("BRC", (b) => {
+ expect(b.train_schedule_id, "BRC rides the reopened train").to.eq(s.id);
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/export_fcfs_space.cy.ts b/e2e/freight/cypress/e2e/flows/export_fcfs_space.cy.ts
new file mode 100644
index 000000000..aef8b9b60
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/export_fcfs_space.cy.ts
@@ -0,0 +1,190 @@
+/**
+ * EXPORT journeys E2 + E5 — FCFS capacity truth on the reversed corridor:
+ *
+ * E2 (D+3): three bookings (20+20+14 wagons) accept first and hold ALL 54
+ * wagons before anyone pays. Three late exporters then try to book the same
+ * day → each is REJECTED AT SUBMISSION by the whole-train space gate
+ * ("exports ride whole or not at all" — no waiting list, no split). The
+ * three reserved pay → FULL.
+ *
+ * E5 (D+4): the whole-or-nothing giant. A 58-wagon booking (116×20ft) is
+ * rejected against the empty 54-wagon train; rebooked at exactly 54 wagons
+ * it reserves the ENTIRE train alone, pays, allocates 54/54 → FULL — and a
+ * 1-wagon afterthought bounces off the FULL train.
+ *
+ * Sequential steps — retries off.
+ */
+
+import {
+ acceptExport,
+ bookContainers,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(3);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const GIANT_DEPARTURE = departureAt(4);
+const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const FIRST = [
+ { suffix: "XA", twenty: 40, forty: 0, wagons: 20 },
+ { suffix: "XB", twenty: 0, forty: 20, wagons: 20 },
+ { suffix: "XC", twenty: 28, forty: 0, wagons: 14 },
+];
+const LATE = ["XL1", "XL2", "XL3"];
+
+function seedExport(suffix: string) {
+ seedImportContract({
+ suffix,
+ reference: stampedRef(suffix),
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+}
+
+describe("export FCFS: reservations hold capacity, whole-or-nothing space gate", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ [...FIRST.map((b) => b.suffix), ...LATE, "XG", "XS"].forEach(seedExport);
+ });
+
+ it("operations prepares the export corridor and the D+3 train with an open window", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ resetCorridorDay(GIANT_DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+ });
+
+ it("three exporters book and are accepted — 54 wagons reserved BEFORE any payment", () => {
+ let isoSeed = 6500;
+ FIRST.forEach((b) => {
+ bookContainers({
+ suffix: b.suffix,
+ runStamp: stamp,
+ isoSeed,
+ twenty: b.twenty,
+ forty: b.forty,
+ scheduledDate: BOOKING_DAY,
+ });
+ isoSeed += b.twenty + b.forty;
+ acceptExport(b.suffix);
+ });
+ });
+
+ it("three late exporters are rejected at submission — the space gate reports no room", () => {
+ LATE.forEach((suffix, i) => {
+ bookContainers({
+ suffix,
+ runStamp: stamp,
+ isoSeed: 6700 + i * 30,
+ twenty: 20, // 10 wagons — but reservations already hold all 54
+ scheduledDate: BOOKING_DAY,
+ expectFailure: /space|window/i,
+ });
+ });
+ });
+
+ it("the three reserved pay — 54/54 allocated and the export window flips FULL", () => {
+ FIRST.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ });
+ withExportSchedule(DEPARTURE, (s) => {
+ pollDb(
+ "export window FULL",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.booking_window_status === "FULL",
+ );
+ 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("whole-or-nothing: a 58-wagon giant is rejected against the empty D+4 train", () => {
+ createImportSchedule({
+ departure: GIANT_DEPARTURE,
+ locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(GIANT_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+
+ bookContainers({
+ suffix: "XG",
+ runStamp: stamp,
+ isoSeed: 7000,
+ twenty: 116, // 58 wagons > 54 — export never splits
+ scheduledDate: GIANT_DAY,
+ expectFailure: /space|window/i,
+ });
+ });
+
+ it("rebooked at exactly 54 wagons the giant reserves the whole train alone, pays, fills it", () => {
+ bookContainers({
+ suffix: "XG",
+ runStamp: stamp,
+ isoSeed: 7200,
+ twenty: 108, // 54 wagons — the whole consist
+ scheduledDate: GIANT_DAY,
+ });
+ acceptExport("XG");
+ markPaid("XG");
+ pollAllocations("XG", 54);
+ withExportSchedule(GIANT_DEPARTURE, (s) => {
+ pollDb(
+ "giant train FULL from one booking",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.booking_window_status === "FULL",
+ );
+ withBooking("XG", (b) => {
+ expect(b.train_schedule_id, "giant rides its train").to.eq(s.id);
+ });
+ });
+ });
+
+ it("a 1-wagon afterthought bounces off the FULL train", () => {
+ bookContainers({
+ suffix: "XS",
+ runStamp: stamp,
+ isoSeed: 7400,
+ twenty: 2,
+ scheduledDate: GIANT_DAY,
+ expectFailure: /space|window/i,
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts
new file mode 100644
index 000000000..d7c2c728d
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/export_full_train.cy.ts
@@ -0,0 +1,285 @@
+/**
+ * EXPORT journey E1 — six container bookings fill the 54-wagon train on the
+ * reversed corridor KALITY → MOJO → E2E_AWASH → DIRE_DAWA → NAGAD →
+ * DJIB_PORT, all inside the ONE FCFS export window, then the full life of the
+ * train to Djibouti Port and the export customs tail.
+ *
+ * Export mechanics under test (vs import): no cycles, no doc-review/payment
+ * phases, no batch — the ops ACCEPT itself reserves the wagons FCFS and opens
+ * a pay window CLAMPED to the window close.
+ *
+ * The six bookings (Σ = 54 wagons):
+ * EF1 customs + USD 16×20ft = 8 wagons
+ * EF2 customs + ETB 6×40ft = 6 wagons
+ * EF3 self + ETB 12×20ft = 6 wagons
+ * EF4 self + ETB 6×40ft = 6 wagons
+ * EF5 customs + USD 44×20ft = 22 wagons (the ≥22-wagon giant)
+ * EF6 self + USD 4×40ft + 4×20ft = 6 wagons
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptExport,
+ apiPost,
+ bookContainers,
+ createImportSchedule,
+ db,
+ dbBooking,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ expectMilestoneDone,
+ completeBookingMilestone,
+ forceWindowOpen,
+ glUpload,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(2);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const BOOKINGS: Array<{
+ suffix: string;
+ customs: boolean;
+ currency: "ETB" | "USD";
+ twenty: number;
+ forty: number;
+ wagons: number;
+}> = [
+ { suffix: "EF1", customs: true, currency: "USD", twenty: 16, forty: 0, wagons: 8 },
+ { suffix: "EF2", customs: true, currency: "ETB", twenty: 0, forty: 6, wagons: 6 },
+ { suffix: "EF3", customs: false, currency: "ETB", twenty: 12, forty: 0, wagons: 6 },
+ { suffix: "EF4", customs: false, currency: "ETB", twenty: 0, forty: 6, wagons: 6 },
+ { suffix: "EF5", customs: true, currency: "USD", twenty: 44, forty: 0, wagons: 22 },
+ { suffix: "EF6", customs: false, currency: "USD", twenty: 4, forty: 4, wagons: 6 },
+];
+const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
+const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
+
+function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
+ withExportSchedule(DEPARTURE, (s) => fn(s.id, s));
+}
+
+describe("export: six bookings fill the 54-wagon corridor train (FCFS)", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ for (const b of BOOKINGS) {
+ seedImportContract({
+ suffix: b.suffix,
+ reference: stampedRef(b.suffix),
+ currency: b.currency,
+ customs: b.customs,
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ }
+ });
+
+ it("operations ensures the reversed export corridor exists (direction frozen EXPORT)", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ });
+
+ it("operations schedules the 54-wagon export train — its single FCFS window forced open", () => {
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withScheduleId((id) => forceWindowOpen(id, 60));
+ });
+
+ it("six exporters book inside the one window", () => {
+ let isoSeed = 6000;
+ BOOKINGS.forEach((b) => {
+ bookContainers({
+ suffix: b.suffix,
+ runStamp: stamp,
+ isoSeed,
+ twenty: b.twenty,
+ forty: b.forty,
+ scheduledDate: BOOKING_DAY,
+ });
+ isoSeed += b.twenty + b.forty;
+ pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
+ });
+ });
+
+ it("each accept reserves FCFS immediately — pay deadlines clamped to the window close", () => {
+ BOOKINGS.forEach((b) => acceptExport(b.suffix));
+ withScheduleId((_, s) => {
+ BOOKINGS.forEach((b) => {
+ withBooking(b.suffix, (row) => {
+ expect(row.payment_deadline, `${b.suffix} pay deadline`).to.be.a("string");
+ expect(
+ new Date(row.payment_deadline!).getTime(),
+ `${b.suffix} deadline never outlives the window close`,
+ ).to.be.at.most(new Date(s.window_closes_at!).getTime());
+ });
+ });
+ });
+ // Reservation invoices carry the CONTRACT currency.
+ BOOKINGS.forEach((b) => {
+ withBooking(b.suffix, (row) => {
+ pollDb<{ currency: string }>(
+ `${b.suffix} invoice`,
+ `SELECT currency FROM freight.invoices
+ WHERE source_id = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [row.id],
+ (inv) => inv?.currency === b.currency,
+ 10,
+ );
+ });
+ });
+ });
+
+ it("all six pay — allocated 54/54, the export window flips FULL, staff finalize", () => {
+ BOOKINGS.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ });
+ withScheduleId((id) => {
+ pollDb(
+ "export window FULL",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.booking_window_status === "FULL",
+ );
+ 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 allocated").to.eq(54));
+ // Export has no auto-finalize conclude step — staff finalize explicitly.
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/finalize`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollDb(
+ "schedule SCHEDULED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "SCHEDULED",
+ 10,
+ );
+ });
+ });
+
+ it("gate pass granted at the Djibouti end; T1 documents uploaded for the customs bookings", () => {
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ glUpload(`/api/contracts/bookings/${b.id}/transport-document`);
+ });
+ });
+ });
+
+ it("the train dispatches — every booking boards at KALITY (IN_TRANSIT)", () => {
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollDb(
+ "schedule DISPATCHED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "DISPATCHED",
+ 10,
+ );
+ });
+ BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "IN_TRANSIT", 10));
+ });
+
+ it("the train runs the corridor and arrives at Djibouti Port — every booking ARRIVED", () => {
+ withScheduleId((id) => {
+ [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(
+ "schedule ARRIVED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "ARRIVED",
+ 20,
+ );
+ });
+ BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
+ withScheduleId((id) => {
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.wagon_movements
+ WHERE train_schedule_id = $1`,
+ [id],
+ ).then(({ rows }) =>
+ expect(Number(rows[0].n), "wagon movement ledger rows").to.be.at.least(54),
+ );
+ });
+ });
+
+ it("GL Djibouti closes the export tail (T1 close → offloaded) on every customs booking", () => {
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ completeBookingMilestone(suffix, "OFFLOADED");
+ expectMilestoneDone(suffix, "T1_CLOSED");
+ expectMilestoneDone(suffix, "OFFLOADED");
+ });
+ });
+
+ it("the self-clearance bookings arrived clean — no customs tail required", () => {
+ SELF_CLEAR.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ expect(b.status, `${suffix} final status`).to.eq("ARRIVED");
+ });
+ dbBooking(suffix).then(({ rows }) => {
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.clearance_milestones
+ WHERE booking_id = $1 AND milestone_code = 'T1_CLOSED'
+ AND status = 'COMPLETED' AND deleted_at IS NULL`,
+ [rows[0].id],
+ ).then(({ rows: ms }) =>
+ expect(Number(ms[0].n), `${suffix} has no T1 tail`).to.eq(0),
+ );
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/export_matrix.cy.ts b/e2e/freight/cypress/e2e/flows/export_matrix.cy.ts
new file mode 100644
index 000000000..34821bd5c
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/export_matrix.cy.ts
@@ -0,0 +1,245 @@
+/**
+ * EXPORT critical-scenario matrix (reversed corridor, D+7):
+ *
+ * 1. sub-corridor export: a DIRE_DAWA → DJIB_PORT booking boards mid-route
+ * and shares the train with a KALITY through-booking
+ * 2. directional FULL: through 40w + sub-corridor 14w commit every wagon on
+ * the border edges → the export window flips FULL while the KALITY→MOJO
+ * home leg still has 14 free wagons
+ * 3. intercity ride-along on the FULL export train's free home leg
+ * (KALITY → MOJO, DOMESTIC, dateless) — accepted, pay deadline clamped
+ * to the EXPORT window close, paid + linked; the window stays FULL
+ * 4. same-day sibling export train keeps its OWN window — export is
+ * excluded from the import route-day group rule
+ * 5. duplicate ISO container number across two bookings of the same
+ * route-day is rejected
+ *
+ * Sequential steps — retries off.
+ */
+
+import {
+ acceptExport,
+ apiPost,
+ bookContainers,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ forceWindowOpen,
+ isoNumber,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(7);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+describe("export matrix: sub-corridor, directional FULL, intercity on FULL train, own windows", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ seedImportContract({
+ suffix: "EM1",
+ reference: stampedRef("EM1"),
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ seedImportContract({
+ suffix: "EMSUB",
+ reference: stampedRef("EMSUB"),
+ direction: "EXPORT",
+ originCode: "DIRE_DAWA",
+ destCode: EXP_DEST,
+ });
+ seedImportContract({
+ suffix: "EMIC",
+ reference: stampedRef("EMIC"),
+ direction: "DOMESTIC",
+ originCode: EXP_ORIGIN,
+ destCode: "MOJO",
+ });
+ seedImportContract({
+ suffix: "EMDUP",
+ reference: stampedRef("EMDUP"),
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ });
+
+ it("operations prepares the export corridor and the D+7 train with an open window", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 90));
+ });
+
+ it("a KALITY through-booking (40w) and a DIRE_DAWA sub-corridor booking (14w) share the train", () => {
+ bookContainers({
+ suffix: "EM1",
+ runStamp: stamp,
+ isoSeed: 8600,
+ twenty: 80, // 40 wagons, full corridor
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptExport("EM1");
+
+ // While the window is still OPEN (14 wagons free): a booking reusing one
+ // of EM1's ISO container numbers on the same route-day is rejected.
+ db<{ id: string }>(
+ `SELECT id FROM freight.contracts
+ WHERE reference LIKE 'CTR-IMP-%-' || $1
+ ORDER BY created_at DESC LIMIT 1`,
+ ["EMDUP"],
+ ).then(({ rows }) => {
+ apiPost(
+ "user@gmail.com",
+ `/api/contracts/${rows[0].id}/bookings`,
+ {
+ scheduledDate: BOOKING_DAY,
+ containers: [
+ {
+ containerSize: "20ft",
+ quantity: 2,
+ units: [
+ { containerNumber: isoNumber(stamp, 8600), vgmTons: 10 },
+ { containerNumber: isoNumber(stamp, 9990), vgmTons: 10 },
+ ],
+ },
+ ],
+ },
+ false,
+ ).then((res) => {
+ expect(res.status, "cross-booking ISO clash rejected").to.be.within(400, 422);
+ expect(JSON.stringify(res.body).toLowerCase()).to.include("container");
+ });
+ });
+
+ bookContainers({
+ suffix: "EMSUB",
+ runStamp: stamp,
+ isoSeed: 8700,
+ twenty: 28, // 14 wagons, boards mid-route at DIRE_DAWA
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptExport("EMSUB");
+
+ markPaid("EM1");
+ pollAllocations("EM1", 40);
+ markPaid("EMSUB");
+ pollAllocations("EMSUB", 14);
+
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("EM1", (b) => expect(b.train_schedule_id).to.eq(s.id));
+ withBooking("EMSUB", (b) => expect(b.train_schedule_id).to.eq(s.id));
+ });
+ });
+
+ it("the border edges are committed — the export window flips FULL (home leg still empty)", () => {
+ withExportSchedule(DEPARTURE, (s) => {
+ pollDb(
+ "directional FULL",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.booking_window_status === "FULL",
+ );
+ });
+ });
+
+ it("intercity ride-along boards the FULL train's free home leg — deadline clamped to the export close", () => {
+ bookContainers({
+ suffix: "EMIC",
+ runStamp: stamp,
+ isoSeed: 8800,
+ twenty: 4, // 2 wagons KALITY → MOJO, dateless
+ });
+ withBooking("EMIC", (b) => {
+ apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ pollBookingStatus("EMIC", "FULLY_EXECUTED", 10);
+
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("EMIC", (b) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
+ bookingIds: [b.id],
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ });
+ pollBookingStatus("EMIC", ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("EMIC", (b) => {
+ // Export parity: the ride-along's pay window never outlives the close.
+ expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
+ new Date(s.window_closes_at!).getTime(),
+ );
+ });
+ });
+ markPaid("EMIC");
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("EMIC", (b) => {
+ expect(b.train_schedule_id, "linked to the export train").to.eq(s.id);
+ });
+ // The ride-along never reopens the export window.
+ expect(s.booking_window_status, "window stays FULL").to.eq("FULL");
+ });
+ });
+
+ it("a same-day sibling export train keeps its OWN window — no route-day group for export", () => {
+ const sibling = new Date(DEPARTURE.getTime() + 90 * 60_000);
+ createImportSchedule({
+ departure: sibling,
+ locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(DEPARTURE, (anchor) => {
+ db<{ id: string; window_phase: string; window_closes_at: string }>(
+ `SELECT ts.id, ts.window_phase, ts.window_closes_at
+ FROM freight.train_schedules ts
+ JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
+ JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
+ WHERE ts.deleted_at IS NULL AND ts.id <> $3
+ AND abs(extract(epoch FROM (ts.scheduled_departure_date - $4::timestamptz))) < 7200
+ ORDER BY ts.created_at DESC LIMIT 1`,
+ [EXP_ORIGIN, EXP_DEST, anchor.id, DEPARTURE.toISOString()],
+ ).then(({ rows }) => {
+ expect(rows, "sibling export schedule").to.have.length(1);
+ // The anchor's window was forced/consumed (FULL); a joiner under the
+ // import group rule would copy its live state. Export siblings don't:
+ // this one starts its own PRE_WINDOW timeline anchored to its OWN
+ // departure.
+ expect(rows[0].window_phase, "own fresh window").to.eq("PRE_WINDOW");
+ expect(
+ new Date(rows[0].window_closes_at).getTime(),
+ "own close, clamped to its own departure",
+ ).to.not.eq(new Date(anchor.window_closes_at!).getTime());
+ });
+ });
+ });
+
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/export_pay_or_lose.cy.ts b/e2e/freight/cypress/e2e/flows/export_pay_or_lose.cy.ts
new file mode 100644
index 000000000..a0ea7b254
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/export_pay_or_lose.cy.ts
@@ -0,0 +1,207 @@
+/**
+ * EXPORT journeys E3 + E4 — pay-or-lose on the reversed corridor:
+ *
+ * E3 (D+5): PA + PB reserve 40 wagons and pay. PC reserves the last 14 but
+ * never pays; a late exporter PD is rejected for space while PC's hold
+ * lives. PC's pay deadline passes → EXPIRED → the freed 14 wagons are
+ * instantly FCFS-bookable again: PD rebooks, accepts, pays, allocates.
+ *
+ * E4 (D+6): five bookings reserve the whole train, only three pay. The
+ * window CLOSE passes → phase DONE, and the day sweep expires the two
+ * unpaid reservations. Export days never reopen — no second cycle, the
+ * cycle counter stays at 1 and the three paid bookings ride.
+ *
+ * Sequential steps — retries off.
+ */
+
+import {
+ acceptExport,
+ bookContainers,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ forceReservationExpiry,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(5);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const CLOSE_DEPARTURE = departureAt(6);
+const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+function seedExport(suffix: string) {
+ seedImportContract({
+ suffix,
+ reference: stampedRef(suffix),
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+}
+
+/** QA..QE reserve 12+12+12+12+6 = 54; only QA/QB/QC pay. */
+const CLOSERS = [
+ { suffix: "QA", forty: 12, wagons: 12, pays: true },
+ { suffix: "QB", forty: 12, wagons: 12, pays: true },
+ { suffix: "QC", forty: 12, wagons: 12, pays: true },
+ { suffix: "QD", forty: 12, wagons: 12, pays: false },
+ { suffix: "QE", forty: 6, wagons: 6, pays: false },
+];
+
+describe("export pay-or-lose: expiry frees space; window close expires the unpaid", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ ["PA", "PB", "PC", "PD", ...CLOSERS.map((c) => c.suffix)].forEach(seedExport);
+ });
+
+ it("operations prepares the export corridor and the D+5 train with an open window", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ resetCorridorDay(CLOSE_DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+ });
+
+ it("PA and PB reserve and pay 40 wagons; PC reserves the last 14 unpaid", () => {
+ bookContainers({
+ suffix: "PA",
+ runStamp: stamp,
+ isoSeed: 7600,
+ twenty: 40,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptExport("PA");
+ markPaid("PA");
+ pollAllocations("PA", 20);
+
+ bookContainers({
+ suffix: "PB",
+ runStamp: stamp,
+ isoSeed: 7700,
+ forty: 20,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptExport("PB");
+ markPaid("PB");
+ pollAllocations("PB", 20);
+
+ bookContainers({
+ suffix: "PC",
+ runStamp: stamp,
+ isoSeed: 7800,
+ twenty: 28,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptExport("PC");
+ // Clamp: PC's deadline never outlives the window close.
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("PC", (b) => {
+ expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
+ new Date(s.window_closes_at!).getTime(),
+ );
+ });
+ });
+ });
+
+ it("a late exporter is rejected while PC's unpaid reservation holds the space", () => {
+ bookContainers({
+ suffix: "PD",
+ runStamp: stamp,
+ isoSeed: 7900,
+ twenty: 28,
+ scheduledDate: BOOKING_DAY,
+ expectFailure: /space|window/i,
+ });
+ });
+
+ it("PC misses its pay window — EXPIRED — and PD immediately books the freed 14 wagons", () => {
+ forceReservationExpiry("PC");
+ bookContainers({
+ suffix: "PD",
+ runStamp: stamp,
+ isoSeed: 8000,
+ twenty: 28,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptExport("PD");
+ markPaid("PD");
+ pollAllocations("PD", 14);
+ withExportSchedule(DEPARTURE, (s) => {
+ withBooking("PD", (b) => expect(b.train_schedule_id, "PD took PC's seat").to.eq(s.id));
+ });
+ });
+
+ it("window-close day: five reservations, three payments", () => {
+ createImportSchedule({
+ departure: CLOSE_DEPARTURE,
+ locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(CLOSE_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+
+ let isoSeed = 8200;
+ CLOSERS.forEach((c) => {
+ bookContainers({
+ suffix: c.suffix,
+ runStamp: stamp,
+ isoSeed,
+ forty: c.forty,
+ scheduledDate: CLOSE_DAY,
+ });
+ isoSeed += c.forty;
+ acceptExport(c.suffix);
+ });
+ CLOSERS.filter((c) => c.pays).forEach((c) => {
+ markPaid(c.suffix);
+ pollAllocations(c.suffix, c.wagons);
+ });
+ });
+
+ it("the window CLOSES — phase DONE, the two unpaid expire, and export never reopens", () => {
+ withExportSchedule(CLOSE_DEPARTURE, (s) => {
+ db(
+ `UPDATE freight.train_schedules
+ SET window_closes_at = now() - interval '1 second'
+ WHERE id = $1 AND window_phase = 'OPEN'`,
+ [s.id],
+ );
+ pollDb(
+ "export window DONE (no reopen)",
+ `SELECT window_phase, booking_cycle_no FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.window_phase === "DONE" && Number(row?.booking_cycle_no) === 1,
+ );
+ });
+ // The unpaid deadlines were clamped to the ORIGINAL close; forcing the
+ // close earlier means forcing their deadlines with it (same semantics).
+ CLOSERS.filter((c) => !c.pays).forEach((c) => forceReservationExpiry(c.suffix));
+ CLOSERS.filter((c) => !c.pays).forEach((c) => pollBookingStatus(c.suffix, "EXPIRED"));
+ CLOSERS.filter((c) => c.pays).forEach((c) =>
+ withBooking(c.suffix, (b) => expect(b.status, `${c.suffix} rides`).to.eq("PAID")),
+ );
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts
index d164280fd..62ba74635 100644
--- a/e2e/freight/cypress/e2e/flows/import-utils.ts
+++ b/e2e/freight/cypress/e2e/flows/import-utils.ts
@@ -27,6 +27,9 @@ export const superAdmin = "superadmin@tria.com";
export const CORRIDOR = ["DJIB_PORT", "NAGAD", "DIRE_DAWA", "E2E_AWASH", "MOJO", "KALITY"] as const;
export const ORIGIN = "DJIB_PORT";
export const DEST = "KALITY";
+/** Export rides the same corridor reversed (ET → DJ). */
+export const EXP_ORIGIN = "KALITY";
+export const EXP_DEST = "DJIB_PORT";
export const apiUrl = () => Cypress.env("apiUrl") as string;
@@ -113,7 +116,8 @@ export interface SeedContractOpts {
reference: string;
currency?: "ETB" | "USD";
customs?: boolean;
- direction?: "IMPORT" | "DOMESTIC";
+ direction?: "IMPORT" | "EXPORT" | "DOMESTIC";
+ freight?: "CONTAINER" | "BULK";
originCode?: string;
destCode?: string;
}
@@ -122,6 +126,9 @@ export function seedImportContract(opts: SeedContractOpts) {
const currency = opts.currency ?? "ETB";
const customs = opts.customs ?? false;
const direction = opts.direction ?? "IMPORT";
+ const freight = opts.freight ?? "CONTAINER";
+ // Pre-booking boundary milestone for Path B contracts differs by direction.
+ const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED";
db(
`WITH c AS (
INSERT INTO freight.contracts
@@ -133,9 +140,13 @@ export function seedImportContract(opts: SeedContractOpts) {
SELECT $1, comp.id,
(SELECT p.id FROM freight.company_profiles p
WHERE p.company_id = comp.id AND p.deleted_at IS NULL
- ORDER BY CASE WHEN p.type = 'importer' THEN 0 ELSE 1 END
+ ORDER BY CASE
+ WHEN $2::text = 'EXPORT' AND p.type = 'exporter' THEN 0
+ WHEN $2::text <> 'EXPORT' AND p.type = 'importer' THEN 0
+ ELSE 1
+ END
LIMIT 1),
- 'ONE_TIME', $2, 'CONTAINER',
+ 'ONE_TIME', $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,
@@ -162,17 +173,24 @@ export function seedImportContract(opts: SeedContractOpts) {
JOIN freight.yards o ON o.code = $6
JOIN freight.yards d ON d.code = $7
RETURNING id
- ), scope AS (
+ ), 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'
FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size)
+ WHERE $9::text = 'CONTAINER'
+ ), scope_bulk AS (
+ INSERT INTO freight.contract_cargo_scope
+ (contract_id, cargo_type_id, cargo_free_text)
+ SELECT c.id, ct.id, 'E2E import wheat'
+ FROM c JOIN freight.cargo_types ct ON ct.code = 'E2E_IMP_WHEAT'
+ WHERE $9::text = 'BULK'
)
-- Path B gate: ONE_TIME customs bookings require the pre-booking boundary
- -- milestone (IMPORT → DO_COLLECTED) COMPLETED at contract level.
+ -- milestone (IMPORT → DO_COLLECTED, EXPORT → EXPORT_RELEASED) COMPLETED.
INSERT INTO freight.clearance_milestones
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
- SELECT c.id, 'DO_COLLECTED', 'Delivery order collected', 'COMPLETED', now(), 0
+ SELECT c.id, $10, 'Pre-booking clearance boundary', 'COMPLETED', now(), 0
FROM c WHERE $4`,
[
opts.reference,
@@ -183,6 +201,8 @@ export function seedImportContract(opts: SeedContractOpts) {
opts.originCode ?? ORIGIN,
opts.destCode ?? DEST,
opts.suffix,
+ freight,
+ boundary,
],
);
// Backfill the boundary milestone when the insert above was skipped because
@@ -191,15 +211,15 @@ export function seedImportContract(opts: SeedContractOpts) {
db(
`INSERT INTO freight.clearance_milestones
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order)
- SELECT ct.id, 'DO_COLLECTED', 'Delivery order collected', 'COMPLETED', now(), 0
+ SELECT ct.id, $2::text, 'Pre-booking clearance boundary', 'COMPLETED', now(), 0
FROM freight.contracts ct
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND ct.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM freight.clearance_milestones m
- WHERE m.contract_id = ct.id AND m.milestone_code = 'DO_COLLECTED'
+ WHERE m.contract_id = ct.id AND m.milestone_code = $2::text
AND m.deleted_at IS NULL
)`,
- [opts.suffix],
+ [opts.suffix, boundary],
);
}
}
@@ -292,7 +312,7 @@ export function bookContainers(opts: {
forty?: number;
scheduledDate?: string; // omit for DOMESTIC (intercity)
vgmTons?: number;
- expectFailure?: string; // substring of the expected 4xx error message
+ expectFailure?: string | RegExp; // substring/regex of the expected 4xx error
}) {
const vgm = opts.vgmTons ?? 10;
const lines: Array> = [];
@@ -338,7 +358,55 @@ export function bookContainers(opts: {
).then((res) => {
if (opts.expectFailure) {
expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
- expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
+ if (opts.expectFailure instanceof RegExp) {
+ expect(JSON.stringify(res.body)).to.match(opts.expectFailure);
+ } else {
+ expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
+ }
+ } else {
+ expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
+ }
+ });
+ });
+}
+
+/**
+ * Book bulk tons under a seeded BULK contract via the API. Wagon demand =
+ * ceil(tons / 70) on CW4 covered gondolas.
+ */
+export function bookBulk(opts: {
+ suffix: string;
+ tons: number;
+ scheduledDate?: string; // omit for DOMESTIC (intercity)
+ expectFailure?: string | RegExp;
+}) {
+ db<{ id: string; customs_clearing_enabled: boolean; cargo_type_id: string }>(
+ `SELECT ct.id, ct.customs_clearing_enabled,
+ (SELECT t.id FROM freight.cargo_types t WHERE t.code = 'E2E_IMP_WHEAT') AS cargo_type_id
+ FROM freight.contracts ct
+ WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
+ ORDER BY ct.created_at DESC LIMIT 1`,
+ [opts.suffix],
+ ).then(({ rows }) => {
+ expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
+ const actor = rows[0].customs_clearing_enabled ? superAdmin : customer;
+ apiPost(
+ actor,
+ `/api/contracts/${rows[0].id}/bookings`,
+ {
+ ...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
+ bulkLines: [{ cargoTypeId: rows[0].cargo_type_id, cargoWeightTons: opts.tons }],
+ cargoFreeText: "E2E import wheat",
+ },
+ !opts.expectFailure,
+ ).then((res) => {
+ if (opts.expectFailure) {
+ expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
+ if (opts.expectFailure instanceof RegExp) {
+ expect(JSON.stringify(res.body)).to.match(opts.expectFailure);
+ } else {
+ expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
+ }
} else {
expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
}
@@ -470,6 +538,40 @@ export function forceReservationExpiry(suffix: string) {
pollBookingStatus(suffix, "EXPIRED");
}
+/**
+ * Type-integrity assert for mixed trains: every wagon slot allocated to the
+ * booking is of ONE expected wagon type (containers → NW5, bulk → CW4), and
+ * the slot count matches. No wheat on a flat wagon, no box in a gondola.
+ */
+export function expectWagonType(suffix: string, code: string, wagons: number) {
+ withBooking(suffix, (b) =>
+ pollDb<{ code: string; n: string }>(
+ `${suffix} rides ${wagons}× ${code}`,
+ `SELECT wt.code, count(*) AS n
+ FROM freight.wagon_booking_allocations wba
+ JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
+ JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
+ WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL
+ GROUP BY wt.code`,
+ [b.id],
+ (row) => row?.code === code && Number(row?.n) === wagons,
+ 20,
+ ),
+ );
+ // A grouped second row would mean mixed wagon types under one booking.
+ withBooking(suffix, (b) =>
+ db<{ k: string }>(
+ `SELECT count(DISTINCT tsw.wagon_type_id) AS k
+ FROM freight.wagon_booking_allocations wba
+ JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
+ WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL`,
+ [b.id],
+ ).then(({ rows }) =>
+ expect(Number(rows[0].k), `${suffix} single wagon type`).to.eq(1),
+ ),
+ );
+}
+
export function pollAllocations(suffix: string, minWagons = 1) {
withBooking(suffix, (b) =>
pollDb<{ n: string }>(
@@ -533,7 +635,7 @@ export function ensureCorridorRoute() {
* scope CTR-IMP-% only — never touches other suites' data). A wiped schedule
* must never leave bookings pointing at it (ghost refs break assign).
*/
-export function resetCorridorDay(departure: Date, destCode = DEST) {
+export function resetCorridorDay(departure: Date, destCode = DEST, originCode = ORIGIN) {
db(
`WITH stale AS (
SELECT ts.id FROM freight.train_schedules ts
@@ -556,7 +658,7 @@ export function resetCorridorDay(departure: Date, destCode = DEST) {
)
UPDATE freight.train_schedules SET deleted_at = now()
WHERE id IN (SELECT id FROM stale)`,
- [ORIGIN, destCode, departure.toISOString()],
+ [originCode, destCode, departure.toISOString()],
);
// Prior-run pool leftovers (never reserved, so no schedule ref) would
// contaminate this run's batch — a stale high-priority booking steals the
@@ -572,6 +674,48 @@ export function resetCorridorDay(departure: Date, destCode = DEST) {
);
}
+/** Create the reversed 6-stop corridor (ET → DJ = EXPORT) if missing. */
+export function ensureExportRoute() {
+ dbRouteId(EXP_ORIGIN, EXP_DEST).then(({ rows }) => {
+ if (rows.length > 0) return;
+ const stops = [...CORRIDOR].reverse();
+ db<{ id: string; code: string }>(
+ `SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`,
+ [stops],
+ ).then(({ rows: yards }) => {
+ expect(yards, "corridor yards").to.have.length(stops.length);
+ const byCode = new Map(yards.map((y) => [y.code, y.id]));
+ apiPost(opsStaff, "/api/routes", {
+ milestones: stops.map((code) => ({ yardId: byCode.get(code) })),
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ db<{ direction: string }>(
+ `SELECT r.direction FROM freight.routes r
+ JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = $1
+ JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = $2
+ WHERE r.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 1`,
+ [EXP_ORIGIN, EXP_DEST],
+ ).then(({ rows: created }) => {
+ expect(created[0]?.direction, "export corridor direction").to.eq("EXPORT");
+ });
+ });
+}
+
+/**
+ * Ops accepts an EXPORT operation request — FCFS: the accept itself reserves
+ * the train slot and opens a pay window clamped to the window close.
+ */
+export function acceptExport(suffix: string) {
+ withBooking(suffix, (b) => {
+ apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 10);
+}
+
export interface ScheduleRow {
id: string;
status: string;
@@ -590,7 +734,7 @@ const SCHEDULE_COLS = `ts.id, ts.status, ts.window_phase, ts.booking_window_stat
ts.payment_phase_ends_at, ts.scheduled_departure_date`;
/** The corridor schedule departing within ±1h of `departure` (12:00 EAT pin). */
-export function dbSchedule(departure: Date, destCode = DEST) {
+export function dbSchedule(departure: Date, destCode = DEST, originCode = ORIGIN) {
return db(
`SELECT ${SCHEDULE_COLS}
FROM freight.train_schedules ts
@@ -599,7 +743,7 @@ export function dbSchedule(departure: Date, destCode = DEST) {
WHERE ts.deleted_at IS NULL
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
ORDER BY ts.created_at DESC LIMIT 1`,
- [ORIGIN, destCode, departure.toISOString()],
+ [originCode, destCode, departure.toISOString()],
);
}
@@ -610,6 +754,14 @@ export function withSchedule(departure: Date, fn: (s: ScheduleRow) => void) {
});
}
+/** Export-corridor (KALITY → DJIB_PORT) variant of withSchedule. */
+export function withExportSchedule(departure: Date, fn: (s: ScheduleRow) => void) {
+ dbSchedule(departure, EXP_DEST, EXP_ORIGIN).then(({ rows }) => {
+ expect(rows, `export schedule departing ${departure.toISOString()}`).to.have.length(1);
+ fn(rows[0]);
+ });
+}
+
/**
* Ops creates an import schedule on the corridor via the API — loco-pair mode
* (no built train): capacity comes from maxWagonsPerTrain (54, the corridor
@@ -619,17 +771,22 @@ export function createImportSchedule(opts: {
departure: Date;
locoPair: [string, string];
maxWagons?: number;
+ kind?: "container" | "bulk";
+ originCode?: string;
+ destCode?: string;
}) {
- dbSchedule(opts.departure).then(({ rows }) => {
+ const originCode = opts.originCode ?? ORIGIN;
+ const destCode = opts.destCode ?? DEST;
+ dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
if (rows.length > 0) return;
- dbRouteId().then(({ rows: routes }) => {
+ dbRouteId(originCode, destCode).then(({ rows: routes }) => {
expect(routes, "corridor route").to.have.length(1);
db<{ id: string }>(
`SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
[opts.locoPair],
).then(({ rows: locos }) => {
expect(locos, `locomotives ${opts.locoPair.join(",")}`).to.have.length(2);
- apiPost(opsStaff, "/api/train-scheduling/container/schedules", {
+ apiPost(opsStaff, `/api/train-scheduling/${opts.kind ?? "container"}/schedules`, {
routeId: routes[0].id,
scheduleDate: opts.departure.toISOString(),
locomotiveIds: locos.map((l) => l.id),
@@ -640,8 +797,9 @@ export function createImportSchedule(opts: {
});
});
});
- withSchedule(opts.departure, (s) => {
- expect(s.max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
+ dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
+ expect(rows, "created schedule").to.have.length(1);
+ expect(rows[0].max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
});
}
diff --git a/e2e/freight/cypress/e2e/flows/mixed_export_close_intercity.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_export_close_intercity.cy.ts
new file mode 100644
index 000000000..95fbf57df
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/mixed_export_close_intercity.cy.ts
@@ -0,0 +1,253 @@
+/**
+ * MIXED EXPORT journeys XM4 + XM5 — mixed window close, and a FULL mixed
+ * train carrying a DOUBLE intercity ride-along:
+ *
+ * XM4 (D+17): five mixed reservations (2 container + 3 bulk = 54w), only
+ * one container + one bulk pay. The window CLOSE passes → phase DONE, the
+ * three unpaid (both kinds) expire in ONE sweep, export never reopens.
+ *
+ * XM5 (D+18): through container 40w + sub-corridor bulk (DIRE_DAWA → port)
+ * 14w commit the border edges → directional FULL. Then a container AND a
+ * bulk intercity ride-along (KALITY → MOJO, dateless) are accepted onto the
+ * FULL train's free home leg in ONE staff action — pay windows clamped to
+ * the EXPORT close, both pay, both linked; the export window stays FULL.
+ *
+ * Sequential steps — retries off.
+ */
+
+import {
+ forceReservationExpiry,
+ acceptExport,
+ acceptOperation,
+ apiPost,
+ bookBulk,
+ bookContainers,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ expectWagonType,
+ forceWindowOpen,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const CLOSE_DEPARTURE = departureAt(17);
+const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE);
+const FULL_DEPARTURE = departureAt(18);
+const FULL_DAY = eatDayStr(FULL_DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+/** 2 container + 3 bulk = 54 wagons; only QMC1 + QMB1 pay. */
+const CLOSERS: Array<{
+ suffix: string;
+ freight: "CONTAINER" | "BULK";
+ twenty?: number;
+ tons?: number;
+ wagons: number;
+ pays: boolean;
+}> = [
+ { suffix: "QMC1", freight: "CONTAINER", twenty: 24, wagons: 12, pays: true },
+ { suffix: "QMB1", freight: "BULK", tons: 840, wagons: 12, pays: true },
+ { suffix: "QMC2", freight: "CONTAINER", twenty: 24, wagons: 12, pays: false },
+ { suffix: "QMB2", freight: "BULK", tons: 840, wagons: 12, pays: false },
+ { suffix: "QMB3", freight: "BULK", tons: 420, wagons: 6, pays: false },
+];
+
+describe("mixed export: mixed close sweep + FULL train with double intercity", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ CLOSERS.forEach((c) =>
+ seedImportContract({
+ suffix: c.suffix,
+ reference: stampedRef(c.suffix),
+ freight: c.freight,
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ }),
+ );
+ seedImportContract({
+ suffix: "FMTH",
+ reference: stampedRef("FMTH"),
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ seedImportContract({
+ suffix: "FMSB",
+ reference: stampedRef("FMSB"),
+ freight: "BULK",
+ direction: "EXPORT",
+ originCode: "DIRE_DAWA",
+ destCode: EXP_DEST,
+ });
+ seedImportContract({
+ suffix: "MEIC",
+ reference: stampedRef("MEIC"),
+ direction: "DOMESTIC",
+ originCode: EXP_ORIGIN,
+ destCode: "MOJO",
+ });
+ seedImportContract({
+ suffix: "MEIB",
+ reference: stampedRef("MEIB"),
+ freight: "BULK",
+ direction: "DOMESTIC",
+ originCode: EXP_ORIGIN,
+ destCode: "MOJO",
+ });
+ });
+
+ it("mixed close day: five mixed reservations, one container + one bulk pay", () => {
+ ensureExportRoute();
+ resetCorridorDay(CLOSE_DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ resetCorridorDay(FULL_DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: CLOSE_DEPARTURE,
+ locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(CLOSE_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+
+ let isoSeed = 15_000;
+ CLOSERS.forEach((c) => {
+ if (c.freight === "CONTAINER") {
+ bookContainers({
+ suffix: c.suffix,
+ runStamp: stamp,
+ isoSeed,
+ twenty: c.twenty,
+ scheduledDate: CLOSE_DAY,
+ });
+ isoSeed += c.twenty ?? 0;
+ } else {
+ bookBulk({ suffix: c.suffix, tons: c.tons!, scheduledDate: CLOSE_DAY });
+ }
+ acceptExport(c.suffix);
+ });
+ CLOSERS.filter((c) => c.pays).forEach((c) => {
+ markPaid(c.suffix);
+ pollAllocations(c.suffix, c.wagons);
+ expectWagonType(c.suffix, c.freight === "CONTAINER" ? "NW5" : "CW4", c.wagons);
+ });
+ });
+
+ it("the window CLOSES — the three unpaid of BOTH kinds expire in one sweep, no reopen", () => {
+ withExportSchedule(CLOSE_DEPARTURE, (s) => {
+ db(
+ `UPDATE freight.train_schedules
+ SET window_closes_at = now() - interval '1 second'
+ WHERE id = $1 AND window_phase = 'OPEN'`,
+ [s.id],
+ );
+ pollDb(
+ "export window DONE (no reopen)",
+ `SELECT window_phase, booking_cycle_no FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.window_phase === "DONE" && Number(row?.booking_cycle_no) === 1,
+ );
+ });
+ // Forced close ⇒ force the matching deadline clamp on the unpaid trio.
+ CLOSERS.filter((c) => !c.pays).forEach((c) => forceReservationExpiry(c.suffix));
+ CLOSERS.filter((c) => !c.pays).forEach((c) => pollBookingStatus(c.suffix, "EXPIRED"));
+ CLOSERS.filter((c) => c.pays).forEach((c) =>
+ withBooking(c.suffix, (b) => expect(b.status, `${c.suffix} rides`).to.eq("PAID")),
+ );
+ });
+
+ it("FULL-train day: through container 40w + sub-corridor bulk 14w flip the window FULL", () => {
+ createImportSchedule({
+ departure: FULL_DEPARTURE,
+ locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(FULL_DEPARTURE, (s) => forceWindowOpen(s.id, 90));
+
+ bookContainers({
+ suffix: "FMTH",
+ runStamp: stamp,
+ isoSeed: 15_500,
+ twenty: 80, // 40 wagons, full corridor
+ scheduledDate: FULL_DAY,
+ });
+ acceptExport("FMTH");
+ bookBulk({ suffix: "FMSB", tons: 980, scheduledDate: FULL_DAY });
+ acceptExport("FMSB");
+
+ markPaid("FMTH");
+ pollAllocations("FMTH", 40);
+ expectWagonType("FMTH", "NW5", 40);
+ markPaid("FMSB");
+ pollAllocations("FMSB", 14);
+ expectWagonType("FMSB", "CW4", 14);
+
+ withExportSchedule(FULL_DEPARTURE, (s) => {
+ pollDb(
+ "directional FULL",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.booking_window_status === "FULL",
+ );
+ });
+ });
+
+ it("a container AND a bulk intercity ride-along join the FULL train in ONE accept — clamped, paid, linked", () => {
+ bookContainers({ suffix: "MEIC", runStamp: stamp, isoSeed: 15_700, twenty: 4 });
+ acceptOperation("MEIC");
+ bookBulk({ suffix: "MEIB", tons: 140 });
+ acceptOperation("MEIB");
+
+ withExportSchedule(FULL_DEPARTURE, (s) => {
+ withBooking("MEIC", (bc) => {
+ withBooking("MEIB", (bb) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
+ bookingIds: [bc.id, bb.id],
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ });
+ });
+ ["MEIC", "MEIB"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ withExportSchedule(FULL_DEPARTURE, (s) => {
+ ["MEIC", "MEIB"].forEach((suffix) =>
+ withBooking(suffix, (b) => {
+ // Export parity: ride-along pay windows never outlive the close.
+ expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
+ new Date(s.window_closes_at!).getTime(),
+ );
+ }),
+ );
+ });
+ markPaid("MEIC");
+ markPaid("MEIB");
+ withExportSchedule(FULL_DEPARTURE, (s) => {
+ ["MEIC", "MEIB"].forEach((suffix) =>
+ withBooking(suffix, (b) => {
+ expect(b.train_schedule_id, `${suffix} linked`).to.eq(s.id);
+ }),
+ );
+ expect(s.booking_window_status, "export window stays FULL").to.eq("FULL");
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/mixed_export_fcfs.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_export_fcfs.cy.ts
new file mode 100644
index 000000000..04907b685
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/mixed_export_fcfs.cy.ts
@@ -0,0 +1,203 @@
+/**
+ * MIXED EXPORT journeys XM2 + XM3 — one FCFS ledger for two cargo kinds:
+ *
+ * XM2 (D+15): container 20w, bulk 14w, container 20w accept in order — 54
+ * wagons held before any payment. A late bulk (700 T) AND a late container
+ * (10w) both bounce off the SAME space gate. The three reserved pay →
+ * typed allocation, FULL. One shared reservation account, no per-type quota.
+ *
+ * XM3 (D+16): cross-type pay-or-lose. Container 20w pays, bulk 14w pays,
+ * a second container 20w never pays → expires at its clamped deadline →
+ * a BULK 1 400 T booking takes the container's freed slots (slots are
+ * type-blind, wagons stay typed: the wheat lands on CW4). The train is
+ * FULL again — a 1-wagon container afterthought bounces.
+ *
+ * Sequential steps — retries off.
+ */
+
+import {
+ acceptExport,
+ bookBulk,
+ bookContainers,
+ createImportSchedule,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ expectWagonType,
+ forceReservationExpiry,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(15);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const PAYLOSE_DEPARTURE = departureAt(16);
+const PAYLOSE_DAY = eatDayStr(PAYLOSE_DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+function seedMixedExport(suffix: string, freight: "CONTAINER" | "BULK") {
+ seedImportContract({
+ suffix,
+ reference: stampedRef(suffix),
+ freight,
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+}
+
+describe("mixed export FCFS: one shared ledger, cross-type pay-or-lose", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ seedMixedExport("FMC1", "CONTAINER");
+ seedMixedExport("FMB1", "BULK");
+ seedMixedExport("FMC2", "CONTAINER");
+ seedMixedExport("FML1", "BULK"); // late bulk
+ seedMixedExport("FML2", "CONTAINER"); // late container
+ seedMixedExport("PLC1", "CONTAINER");
+ seedMixedExport("PLB1", "BULK");
+ seedMixedExport("PLC2", "CONTAINER");
+ seedMixedExport("PLB2", "BULK"); // takes PLC2's freed slots
+ seedMixedExport("PLS", "CONTAINER"); // afterthought
+ });
+
+ it("operations prepares the export corridor and the D+15 train with an open window", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ resetCorridorDay(PAYLOSE_DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+ });
+
+ it("container, bulk, container accept in order — 54 wagons held before any payment", () => {
+ bookContainers({
+ suffix: "FMC1",
+ runStamp: stamp,
+ isoSeed: 14_000,
+ twenty: 40,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptExport("FMC1");
+ bookBulk({ suffix: "FMB1", tons: 980, scheduledDate: BOOKING_DAY });
+ acceptExport("FMB1");
+ bookContainers({
+ suffix: "FMC2",
+ runStamp: stamp,
+ isoSeed: 14_100,
+ twenty: 40,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptExport("FMC2");
+ });
+
+ it("a late bulk AND a late container bounce off the same space gate", () => {
+ bookBulk({
+ suffix: "FML1",
+ tons: 700,
+ scheduledDate: BOOKING_DAY,
+ expectFailure: /space|window/i,
+ });
+ bookContainers({
+ suffix: "FML2",
+ runStamp: stamp,
+ isoSeed: 14_200,
+ twenty: 20,
+ scheduledDate: BOOKING_DAY,
+ expectFailure: /space|window/i,
+ });
+ });
+
+ it("the three reserved pay — typed allocation 54/54, window FULL", () => {
+ markPaid("FMC1");
+ pollAllocations("FMC1", 20);
+ expectWagonType("FMC1", "NW5", 20);
+ markPaid("FMB1");
+ pollAllocations("FMB1", 14);
+ expectWagonType("FMB1", "CW4", 14);
+ markPaid("FMC2");
+ pollAllocations("FMC2", 20);
+ expectWagonType("FMC2", "NW5", 20);
+
+ withExportSchedule(DEPARTURE, (s) => {
+ pollDb(
+ "export window FULL",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.booking_window_status === "FULL",
+ );
+ });
+ });
+
+ it("pay-or-lose day: container pays, bulk pays, the second container reserves unpaid", () => {
+ createImportSchedule({
+ departure: PAYLOSE_DEPARTURE,
+ locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withExportSchedule(PAYLOSE_DEPARTURE, (s) => forceWindowOpen(s.id, 60));
+
+ bookContainers({
+ suffix: "PLC1",
+ runStamp: stamp,
+ isoSeed: 14_400,
+ twenty: 40,
+ scheduledDate: PAYLOSE_DAY,
+ });
+ acceptExport("PLC1");
+ markPaid("PLC1");
+ pollAllocations("PLC1", 20);
+
+ bookBulk({ suffix: "PLB1", tons: 980, scheduledDate: PAYLOSE_DAY });
+ acceptExport("PLB1");
+ markPaid("PLB1");
+ pollAllocations("PLB1", 14);
+
+ bookContainers({
+ suffix: "PLC2",
+ runStamp: stamp,
+ isoSeed: 14_500,
+ twenty: 40,
+ scheduledDate: PAYLOSE_DAY,
+ });
+ acceptExport("PLC2");
+ });
+
+ it("the unpaid container expires — a BULK booking takes its freed slots (typed wagons)", () => {
+ forceReservationExpiry("PLC2");
+ bookBulk({ suffix: "PLB2", tons: 1400, scheduledDate: PAYLOSE_DAY });
+ acceptExport("PLB2");
+ markPaid("PLB2");
+ pollAllocations("PLB2", 20);
+ // Slots are type-blind; the physical wagons are not — wheat rides CW4.
+ expectWagonType("PLB2", "CW4", 20);
+ });
+
+ it("the train is FULL again — a 1-wagon container afterthought bounces", () => {
+ bookContainers({
+ suffix: "PLS",
+ runStamp: stamp,
+ isoSeed: 14_600,
+ twenty: 2,
+ scheduledDate: PAYLOSE_DAY,
+ expectFailure: /space|window/i,
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts
new file mode 100644
index 000000000..df458fc65
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts
@@ -0,0 +1,257 @@
+/**
+ * MIXED EXPORT journey XM1 — containers AND wheat share ONE 54-wagon export
+ * train (KALITY → … → DJIB_PORT) inside ONE FCFS window: every accept
+ * reserves instantly with a close-clamped pay deadline; allocation is typed
+ * (NW5 under boxes, CW4 under wheat); then the full journey to Djibouti Port
+ * and both export customs tails.
+ *
+ * The six bookings (Σ = 54 wagons):
+ * XMC1 container customs + USD 16×20ft = 8 × NW5
+ * XMB1 bulk customs + USD 560 T = 8 × CW4
+ * XMC2 container self + ETB 6×40ft = 6 × NW5
+ * XMB2 bulk self + ETB 980 T = 14 × CW4
+ * XMC3 container customs + ETB 16×20ft = 8 × NW5
+ * XMB3 bulk self + USD 700 T = 10 × CW4
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptExport,
+ apiPost,
+ bookBulk,
+ bookContainers,
+ completeBookingMilestone,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ ensureExportRoute,
+ EXP_DEST,
+ EXP_ORIGIN,
+ expectMilestoneDone,
+ expectWagonType,
+ forceWindowOpen,
+ glUpload,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withExportSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(14);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const BOOKINGS: Array<{
+ suffix: string;
+ freight: "CONTAINER" | "BULK";
+ customs: boolean;
+ currency: "ETB" | "USD";
+ twenty?: number;
+ forty?: number;
+ tons?: number;
+ wagons: number;
+}> = [
+ { suffix: "XMC1", freight: "CONTAINER", customs: true, currency: "USD", twenty: 16, wagons: 8 },
+ { suffix: "XMB1", freight: "BULK", customs: true, currency: "USD", tons: 560, wagons: 8 },
+ { suffix: "XMC2", freight: "CONTAINER", customs: false, currency: "ETB", forty: 6, wagons: 6 },
+ { suffix: "XMB2", freight: "BULK", customs: false, currency: "ETB", tons: 980, wagons: 14 },
+ { suffix: "XMC3", freight: "CONTAINER", customs: true, currency: "ETB", twenty: 16, wagons: 8 },
+ { suffix: "XMB3", freight: "BULK", customs: false, currency: "USD", tons: 700, wagons: 10 },
+];
+const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
+const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
+
+function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
+ withExportSchedule(DEPARTURE, (s) => fn(s.id, s));
+}
+
+describe("mixed export: containers and wheat share one 54-wagon FCFS train", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ for (const b of BOOKINGS) {
+ seedImportContract({
+ suffix: b.suffix,
+ reference: stampedRef(b.suffix),
+ currency: b.currency,
+ customs: b.customs,
+ freight: b.freight,
+ direction: "EXPORT",
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ }
+ });
+
+ it("operations prepares the export corridor and one 54-wagon train — window forced open", () => {
+ ensureExportRoute();
+ resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
+ createImportSchedule({
+ departure: DEPARTURE,
+ locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
+ originCode: EXP_ORIGIN,
+ destCode: EXP_DEST,
+ });
+ withScheduleId((id) => forceWindowOpen(id, 60));
+ });
+
+ it("three container and three bulk exporters book inside the one window", () => {
+ let isoSeed = 13_000;
+ BOOKINGS.forEach((b) => {
+ if (b.freight === "CONTAINER") {
+ bookContainers({
+ suffix: b.suffix,
+ runStamp: stamp,
+ isoSeed,
+ twenty: b.twenty,
+ forty: b.forty,
+ scheduledDate: BOOKING_DAY,
+ });
+ isoSeed += (b.twenty ?? 0) + (b.forty ?? 0);
+ } else {
+ bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
+ }
+ pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
+ });
+ });
+
+ it("each accept reserves FCFS instantly — both kinds share one clamped ledger", () => {
+ BOOKINGS.forEach((b) => acceptExport(b.suffix));
+ withScheduleId((_, s) => {
+ BOOKINGS.forEach((b) => {
+ withBooking(b.suffix, (row) => {
+ expect(
+ new Date(row.payment_deadline!).getTime(),
+ `${b.suffix} deadline never outlives the window close`,
+ ).to.be.at.most(new Date(s.window_closes_at!).getTime());
+ });
+ });
+ });
+ BOOKINGS.forEach((b) => {
+ withBooking(b.suffix, (row) => {
+ pollDb<{ currency: string }>(
+ `${b.suffix} invoice`,
+ `SELECT currency FROM freight.invoices
+ WHERE source_id = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [row.id],
+ (inv) => inv?.currency === b.currency,
+ 10,
+ );
+ });
+ });
+ });
+
+ it("all six pay — 54/54 STRICTLY typed (boxes on NW5, wheat on CW4), FULL, finalized", () => {
+ BOOKINGS.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ });
+ BOOKINGS.forEach((b) =>
+ expectWagonType(b.suffix, b.freight === "CONTAINER" ? "NW5" : "CW4", b.wagons),
+ );
+ withScheduleId((id) => {
+ pollDb(
+ "export window FULL",
+ `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.booking_window_status === "FULL",
+ );
+ 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 allocated").to.eq(54));
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/finalize`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollDb(
+ "schedule SCHEDULED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "SCHEDULED",
+ 10,
+ );
+ });
+ });
+
+ it("transport documents uploaded for customs bookings of BOTH kinds; the train runs to Djibouti", () => {
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ glUpload(`/api/contracts/bookings/${b.id}/transport-document`);
+ });
+ });
+
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollDb(
+ "schedule DISPATCHED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "DISPATCHED",
+ 10,
+ );
+ [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(
+ "schedule ARRIVED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "ARRIVED",
+ 20,
+ );
+ });
+ BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
+ });
+
+ it("container AND bulk export tails close side by side (T1 close → offloaded)", () => {
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ completeBookingMilestone(suffix, "OFFLOADED");
+ expectMilestoneDone(suffix, "T1_CLOSED");
+ expectMilestoneDone(suffix, "OFFLOADED");
+ });
+ SELF_CLEAR.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ expect(b.status, `${suffix} arrived clean`).to.eq("ARRIVED");
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts
new file mode 100644
index 000000000..e1b779109
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts
@@ -0,0 +1,266 @@
+/**
+ * MIXED IMPORT journey M1 — containers AND bulk wheat share ONE 54-wagon
+ * import train on the corridor DJIB_PORT → … → KALITY. The route-day pool is
+ * type-blind: one window, one doc-review, ONE batch reserves all six; only
+ * wagon allocation cares about the physical type — containers ride NW5 flat
+ * wagons, wheat rides CW4 covered gondolas, never crossed.
+ *
+ * The six bookings (Σ = 54 wagons):
+ * MC1 container customs + USD 16×20ft = 8 × NW5
+ * MB1 bulk customs + USD 560 T = 8 × CW4
+ * MC2 container self + ETB 6×40ft = 6 × NW5
+ * MB2 bulk self + ETB 980 T = 14 × CW4
+ * MC3 container customs + ETB 16×20ft = 8 × NW5
+ * MB3 bulk self + USD 700 T = 10 × CW4
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptOperation,
+ apiPost,
+ bookBulk,
+ bookContainers,
+ closeBookingWindow,
+ completeBookingMilestone,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ expectMilestoneDone,
+ expectWagonType,
+ forceWindowOpen,
+ glUpload,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(18);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const BOOKINGS: Array<{
+ suffix: string;
+ freight: "CONTAINER" | "BULK";
+ customs: boolean;
+ currency: "ETB" | "USD";
+ twenty?: number;
+ forty?: number;
+ tons?: number;
+ wagons: number;
+}> = [
+ { suffix: "MC1", freight: "CONTAINER", customs: true, currency: "USD", twenty: 16, wagons: 8 },
+ { suffix: "MB1", freight: "BULK", customs: true, currency: "USD", tons: 560, wagons: 8 },
+ { suffix: "MC2", freight: "CONTAINER", customs: false, currency: "ETB", forty: 6, wagons: 6 },
+ { suffix: "MB2", freight: "BULK", customs: false, currency: "ETB", tons: 980, wagons: 14 },
+ { suffix: "MC3", freight: "CONTAINER", customs: true, currency: "ETB", twenty: 16, wagons: 8 },
+ { suffix: "MB3", freight: "BULK", customs: false, currency: "USD", tons: 700, wagons: 10 },
+];
+const CUSTOMS = BOOKINGS.filter((b) => b.customs).map((b) => b.suffix);
+const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs).map((b) => b.suffix);
+
+function withScheduleId(fn: (id: string, s: ScheduleRow) => void) {
+ withSchedule(DEPARTURE, (s) => fn(s.id, s));
+}
+
+describe("mixed import: containers and wheat share one 54-wagon train", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ for (const b of BOOKINGS) {
+ seedImportContract({
+ suffix: b.suffix,
+ reference: stampedRef(b.suffix),
+ currency: b.currency,
+ customs: b.customs,
+ freight: b.freight,
+ });
+ }
+ });
+
+ it("operations prepares the corridor and one 54-wagon train — first window forced open", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-1", "LOCO-IMP-2"] });
+ withScheduleId((id) => forceWindowOpen(id, 45));
+ });
+
+ it("three container and three bulk shipments book inside the same first window", () => {
+ let isoSeed = 9000;
+ BOOKINGS.forEach((b) => {
+ if (b.freight === "CONTAINER") {
+ bookContainers({
+ suffix: b.suffix,
+ runStamp: stamp,
+ isoSeed,
+ twenty: b.twenty,
+ forty: b.forty,
+ scheduledDate: BOOKING_DAY,
+ });
+ isoSeed += (b.twenty ?? 0) + (b.forty ?? 0);
+ } else {
+ bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
+ }
+ pollBookingStatus(b.suffix, "OPERATION_REQUEST_PENDING", 5);
+ });
+ });
+
+ it("operations accepts all six — one type-blind pool", () => {
+ BOOKINGS.forEach((b) => acceptOperation(b.suffix));
+ });
+
+ it("ONE doc-review-complete releases ONE batch that reserves both cargo kinds together", () => {
+ withScheduleId((id) => {
+ closeBookingWindow(id);
+ completeDocReview(id);
+ });
+ BOOKINGS.forEach((b) =>
+ pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ // Currency mix from one batch: USD and ETB invoices side by side.
+ BOOKINGS.forEach((b) => {
+ withBooking(b.suffix, (row) => {
+ pollDb<{ currency: string }>(
+ `${b.suffix} invoice`,
+ `SELECT currency FROM freight.invoices
+ WHERE source_id = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [row.id],
+ (inv) => inv?.currency === b.currency,
+ 10,
+ );
+ });
+ });
+ });
+
+ it("all six pay — 54/54 with STRICT wagon-type integrity (containers on NW5, wheat on CW4)", () => {
+ BOOKINGS.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ });
+ BOOKINGS.forEach((b) =>
+ expectWagonType(b.suffix, b.freight === "CONTAINER" ? "NW5" : "CW4", b.wagons),
+ );
+ withScheduleId((id) => {
+ endPaymentPhase(id);
+ pollDb(
+ "schedule FULL + DONE + finalized",
+ `SELECT window_phase, booking_window_status, status
+ FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) =>
+ s?.booking_window_status === "FULL" &&
+ s?.window_phase === "DONE" &&
+ s?.status === "SCHEDULED",
+ );
+ 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 allocated").to.eq(54));
+ });
+ });
+
+ it("gate pass + T1 uploads for the customs bookings of BOTH kinds", () => {
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ glUpload(`/api/contracts/bookings/${b.id}/t1-documents`);
+ });
+ });
+ });
+
+ it("the mixed train dispatches, runs the corridor and arrives — both kinds ARRIVED", () => {
+ withScheduleId((id) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ pollDb(
+ "schedule DISPATCHED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "DISPATCHED",
+ 10,
+ );
+ [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(
+ "schedule ARRIVED",
+ `SELECT status FROM freight.train_schedules WHERE id = $1`,
+ [id],
+ (s) => s?.status === "ARRIVED",
+ 20,
+ );
+ });
+ BOOKINGS.forEach((b) => pollBookingStatus(b.suffix, "ARRIVED", 20));
+ withScheduleId((id) => {
+ db<{ n: string }>(
+ `SELECT count(*) AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`,
+ [id],
+ ).then(({ rows }) =>
+ expect(Number(rows[0].n), "wagon movement ledger rows").to.be.at.least(54),
+ );
+ });
+ });
+
+ it("the customs tails of container AND bulk bookings complete side by side", () => {
+ CUSTOMS.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/t1-close`)
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ apiPost("superadmin@tria.com", `/api/contracts/bookings/${b.id}/risk`, {
+ riskLevel: "GREEN",
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ glUpload(
+ `/api/contracts/bookings/${b.id}/second-duty`,
+ { dutyRequired: "false" },
+ "attachment",
+ );
+ });
+ completeBookingMilestone(suffix, "IMPORT_RELEASE_GRANTED");
+ expectMilestoneDone(suffix, "T1_CLOSED");
+ expectMilestoneDone(suffix, "IMPORT_RELEASE_GRANTED");
+ });
+ SELF_CLEAR.forEach((suffix) => {
+ withBooking(suffix, (b) => {
+ expect(b.status, `${suffix} arrived clean`).to.eq("ARRIVED");
+ });
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts
new file mode 100644
index 000000000..4d73acd81
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts
@@ -0,0 +1,212 @@
+/**
+ * MIXED IMPORT journey M4 + matrix — dead mixed cycle, mixed recovery, and
+ * mixed ride-alongs on one train (D+21):
+ *
+ * 1. cycle 1: one container (20w) and one bulk (1 400 T) reserved, neither
+ * pays → both expire, the window REOPENS (mixed cycle bookkeeping)
+ * 2. cycle 2: a fresh container (10w) AND a fresh bulk (730 T → ceil = 11
+ * wagons, the rounding case) book, pay, allocate typed on the recovered
+ * train
+ * 3. mixed intercity: a container ride-along AND a bulk ride-along
+ * (KALITY-bound legs are free) join the same import train — accepted,
+ * paid, linked
+ * 4. mixed sub-corridor: a bulk NAGAD→MOJO booking rides the through-train
+ * beside the cycle-2 pair
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptOperation,
+ apiPost,
+ bookBulk,
+ bookContainers,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ expectWagonType,
+ forceReservationExpiry,
+ forceWindowOpen,
+ markPaid,
+ opsStaff,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(21);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+describe("mixed import: dead mixed cycle, mixed recovery, mixed ride-alongs", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ seedImportContract({ suffix: "MRC", reference: stampedRef("MRC") });
+ seedImportContract({ suffix: "MRB", reference: stampedRef("MRB"), freight: "BULK" });
+ seedImportContract({ suffix: "MRC2", reference: stampedRef("MRC2") });
+ seedImportContract({ suffix: "MRB2", reference: stampedRef("MRB2"), freight: "BULK" });
+ seedImportContract({
+ suffix: "MRSUB",
+ reference: stampedRef("MRSUB"),
+ freight: "BULK",
+ originCode: "NAGAD",
+ destCode: "MOJO",
+ });
+ seedImportContract({
+ suffix: "MRIC",
+ reference: stampedRef("MRIC"),
+ direction: "DOMESTIC",
+ originCode: "MOJO",
+ destCode: "KALITY",
+ });
+ seedImportContract({
+ suffix: "MRIB",
+ reference: stampedRef("MRIB"),
+ freight: "BULK",
+ direction: "DOMESTIC",
+ originCode: "MOJO",
+ destCode: "KALITY",
+ });
+ });
+
+ it("operations prepares the corridor train — first window opens (cycle 1)", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-9", "LOCO-IMP-10"] });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 1").to.eq(1));
+ });
+
+ it("cycle 1: one container and one bulk reserved — neither pays, both expire, window reopens", () => {
+ bookContainers({
+ suffix: "MRC",
+ runStamp: stamp,
+ isoSeed: 12_000,
+ twenty: 40,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptOperation("MRC");
+ bookBulk({ suffix: "MRB", tons: 1400, scheduledDate: BOOKING_DAY });
+ acceptOperation("MRB");
+
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ ["MRC", "MRB"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ ["MRC", "MRB"].forEach((suffix) => forceReservationExpiry(suffix));
+ withSchedule(DEPARTURE, (s) => {
+ endPaymentPhase(s.id);
+ pollDb(
+ "window reopens (PRE_WINDOW)",
+ `SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
+ [s.id],
+ (row) => row?.window_phase === "PRE_WINDOW",
+ );
+ });
+ });
+
+ it("cycle 2: a fresh container (10w) and a fresh bulk (730 T → 11 wagons) 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: "MRC2",
+ runStamp: stamp,
+ isoSeed: 12_200,
+ twenty: 20,
+ scheduledDate: BOOKING_DAY,
+ });
+ acceptOperation("MRC2");
+ // ceil(730 / 70) = 11 — the bulk rounding case rides beside containers.
+ bookBulk({ suffix: "MRB2", tons: 730, scheduledDate: BOOKING_DAY });
+ acceptOperation("MRB2");
+ // Mixed sub-corridor: bulk boards NAGAD, alights MOJO, same train.
+ bookBulk({ suffix: "MRSUB", tons: 700, scheduledDate: BOOKING_DAY });
+ acceptOperation("MRSUB");
+
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ ["MRC2", "MRB2", "MRSUB"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ markPaid("MRC2");
+ pollAllocations("MRC2", 10);
+ expectWagonType("MRC2", "NW5", 10);
+ markPaid("MRB2");
+ pollAllocations("MRB2", 11);
+ expectWagonType("MRB2", "CW4", 11);
+ markPaid("MRSUB");
+ pollAllocations("MRSUB", 10);
+ expectWagonType("MRSUB", "CW4", 10);
+
+ ["MRC", "MRB"].forEach((suffix) =>
+ withBooking(suffix, (b) => expect(b.status, `${suffix} stays expired`).to.eq("EXPIRED")),
+ );
+ withSchedule(DEPARTURE, (s) => {
+ ["MRC2", "MRB2", "MRSUB"].forEach((suffix) =>
+ withBooking(suffix, (b) =>
+ expect(b.train_schedule_id, `${suffix} rides the recovered train`).to.eq(s.id),
+ ),
+ );
+ });
+ });
+
+ it("mixed intercity: a container AND a bulk ride-along join the same import train", () => {
+ bookContainers({ suffix: "MRIC", runStamp: stamp, isoSeed: 12_400, twenty: 4 });
+ acceptOperation("MRIC");
+ bookBulk({ suffix: "MRIB", tons: 140 });
+ acceptOperation("MRIB");
+
+ withSchedule(DEPARTURE, (s) => {
+ withBooking("MRIC", (bc) => {
+ withBooking("MRIB", (bb) => {
+ apiPost(opsStaff, `/api/train-scheduling/schedules/${s.id}/intercity/accept`, {
+ bookingIds: [bc.id, bb.id],
+ })
+ .its("status")
+ .should("be.oneOf", [200, 201]);
+ });
+ });
+ });
+ ["MRIC", "MRIB"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ markPaid("MRIC");
+ markPaid("MRIB");
+ withSchedule(DEPARTURE, (s) => {
+ ["MRIC", "MRIB"].forEach((suffix) =>
+ withBooking(suffix, (b) => {
+ expect(b.train_schedule_id, `${suffix} linked`).to.eq(s.id);
+ // The link row is written by the async allocate step — poll it.
+ pollDb<{ n: string }>(
+ `${suffix} link row`,
+ `SELECT count(*) AS n FROM freight.train_schedule_bookings
+ WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
+ [b.id, s.id],
+ (row) => Number(row?.n ?? 0) === 1,
+ 15,
+ );
+ }),
+ );
+ });
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts
new file mode 100644
index 000000000..46f27d9a1
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts
@@ -0,0 +1,216 @@
+/**
+ * MIXED IMPORT journey M3 — a container split and a CROSS-TYPE promotion:
+ * container-freed wagons hand the train to the waiting bulk queue.
+ *
+ * Queue reality: containers order by staff priority, bulk is rule-recomputed
+ * and orders by acceptance. Fill (54 slots):
+ * MSC container 40×20ft = 20w (prio 1) — pays
+ * MSD container 40×20ft = 20w (prio 2) — reserved, never pays
+ * MSX container 48×20ft = 24w (prio 3) — only 14 left → PARTIAL offer 14w;
+ * settles via the real payment pipeline → split applies (28 boxes ride,
+ * 20×20ft outstanding)
+ * bulk queue (acceptance order): MSB 840 T = 12w, MSW2 560 T = 8w,
+ * MSW3 700 T = 10w — no room at fill, ALL wait.
+ * MSD expires → its 20 container wagons promote the BULK queue: MSB (12w)
+ * and MSW2 (8w) get pay windows; both pay → CW4 gondolas under the wheat →
+ * 54/54 = 20 NW5 + 14 NW5 + 12 CW4 + 8 CW4. MSW3 expires with the day, and
+ * the split customer must later rebook EXACTLY the 20×20ft remainder.
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptOperation,
+ bookBulk,
+ bookContainers,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ db,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ expectWagonType,
+ forceReservationExpiry,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ setPriority,
+ settleViaGateway,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(20);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+const REMAINDER_DEPARTURE = departureAt(22);
+const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const CONTAINERS = [
+ { suffix: "MSC", twenty: 40, wagons: 20 },
+ { suffix: "MSD", twenty: 40, wagons: 20 },
+ { suffix: "MSX", twenty: 48, wagons: 24 },
+];
+const BULKS = [
+ { suffix: "MSB", tons: 840, wagons: 12 },
+ { suffix: "MSW2", tons: 560, wagons: 8 },
+ { suffix: "MSW3", tons: 700, wagons: 10 },
+];
+
+describe("mixed import: container split; container-freed wagons promote the bulk queue", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ CONTAINERS.forEach((b) =>
+ seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix) }),
+ );
+ BULKS.forEach((b) =>
+ seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix), freight: "BULK" }),
+ );
+ });
+
+ it("operations prepares the corridor train with an open first window", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ resetCorridorDay(REMAINDER_DEPARTURE);
+ createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-5", "LOCO-IMP-6"] });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ });
+
+ it("six mixed shipments book in the first window; accepted in queue order", () => {
+ let isoSeed = 10_000;
+ CONTAINERS.forEach((b) => {
+ bookContainers({
+ suffix: b.suffix,
+ runStamp: stamp,
+ isoSeed,
+ twenty: b.twenty,
+ scheduledDate: BOOKING_DAY,
+ });
+ isoSeed += b.twenty;
+ acceptOperation(b.suffix);
+ });
+ BULKS.forEach((b) => {
+ bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
+ acceptOperation(b.suffix);
+ });
+ CONTAINERS.forEach((b, i) => setPriority(b.suffix, i + 1));
+ });
+
+ it("the batch reserves the containers (MSX gets a 14w PARTIAL); the bulk queue waits whole", () => {
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ CONTAINERS.forEach((b) =>
+ pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ withBooking("MSX", (b) => {
+ pollDb<{ status: string }>(
+ "MSX open partial offer",
+ `SELECT status FROM freight.booking_batch_offers
+ WHERE booking_id = $1 AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ [b.id],
+ (row) => row?.status === "OFFERED",
+ 10,
+ );
+ });
+ BULKS.forEach((b) =>
+ withBooking(b.suffix, (row) => {
+ expect(row.status, `${b.suffix} waiting`).to.eq("FULLY_EXECUTED");
+ }),
+ );
+ });
+
+ it("MSC pays; MSX settles via the gateway — the split applies (14 × NW5, 28 boxes ride)", () => {
+ markPaid("MSC");
+ pollAllocations("MSC", 20);
+ expectWagonType("MSC", "NW5", 20);
+
+ settleViaGateway("MSX");
+ pollAllocations("MSX", 14);
+ expectWagonType("MSX", "NW5", 14);
+ withBooking("MSX", (b) => {
+ expect(b.is_split, "MSX is split").to.eq(true);
+ db<{ q: string }>(
+ `SELECT sum(quantity) AS q FROM freight.booking_container
+ WHERE booking_id = $1 AND deleted_at IS NULL`,
+ [b.id],
+ ).then(({ rows }) => expect(Number(rows[0].q), "MSX shrank to 28 boxes").to.eq(28));
+ });
+ });
+
+ it("the unpaid container expires — its wagons promote the BULK queue (MSB + MSW2 get pay windows)", () => {
+ forceReservationExpiry("MSD");
+ ["MSB", "MSW2"].forEach((suffix) =>
+ pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ withBooking("MSW3", (b) => {
+ expect(b.status, "the 10w bulk still has no seat").to.eq("FULLY_EXECUTED");
+ });
+ });
+
+ it("both promoted bulks pay — 54/54 typed (NW5 under boxes, CW4 under wheat); MSW3 expires", () => {
+ markPaid("MSB");
+ pollAllocations("MSB", 12);
+ expectWagonType("MSB", "CW4", 12);
+ markPaid("MSW2");
+ pollAllocations("MSW2", 8);
+ expectWagonType("MSW2", "CW4", 8);
+
+ withSchedule(DEPARTURE, (s) => {
+ endPaymentPhase(s.id);
+ pollDb(
+ "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));
+ });
+ pollBookingStatus("MSW3", "EXPIRED");
+ });
+
+ it("the split container customer must rebook EXACTLY the 20×20ft remainder", () => {
+ createImportSchedule({
+ departure: REMAINDER_DEPARTURE,
+ locoPair: ["LOCO-IMP-7", "LOCO-IMP-8"],
+ });
+ withSchedule(REMAINDER_DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+
+ bookContainers({
+ suffix: "MSX",
+ runStamp: stamp,
+ isoSeed: 11_000,
+ twenty: 8,
+ scheduledDate: REMAINDER_DAY,
+ expectFailure: "must take the whole remainder",
+ });
+ bookContainers({
+ suffix: "MSX",
+ runStamp: stamp,
+ isoSeed: 11_100,
+ twenty: 20,
+ scheduledDate: REMAINDER_DAY,
+ });
+ pollBookingStatus("MSX", "OPERATION_REQUEST_PENDING", 5);
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/e2e/flows/mixed_import_waiting_expiry.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_import_waiting_expiry.cy.ts
new file mode 100644
index 000000000..ebbcbf00f
--- /dev/null
+++ b/e2e/freight/cypress/e2e/flows/mixed_import_waiting_expiry.cy.ts
@@ -0,0 +1,149 @@
+/**
+ * MIXED IMPORT journey M2 — one queue, two cargo kinds. Engine ordering rule
+ * under test: CONTAINER bookings keep their staff-set priority scores, BULK
+ * priorities are RECOMPUTED from the rule engine at batch time and then order
+ * among themselves by acceptance — so the queue is containers-by-priority
+ * first, bulk-by-acceptance after. The train fills across both kinds
+ * (20w + 20w containers + the first-accepted 14w bulk = 54); the remaining
+ * bulk trio waits with no pay window and expires when the day concludes FULL.
+ *
+ * Sequential steps of one journey — retries off.
+ */
+
+import {
+ acceptOperation,
+ bookBulk,
+ bookContainers,
+ closeBookingWindow,
+ completeDocReview,
+ createImportSchedule,
+ departureAt,
+ eatDayStr,
+ endPaymentPhase,
+ ensureCorridorRoute,
+ expectWagonType,
+ forceWindowOpen,
+ markPaid,
+ pollAllocations,
+ pollBookingStatus,
+ pollDb,
+ resetCorridorDay,
+ seedImportContract,
+ setPriority,
+ withBooking,
+ withSchedule,
+ type ScheduleRow,
+} from "./import-utils";
+
+const DEPARTURE = departureAt(19);
+const BOOKING_DAY = eatDayStr(DEPARTURE);
+
+const stamp = String(Date.now());
+const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
+
+const SELECTED: Array<{
+ suffix: string;
+ freight: "CONTAINER" | "BULK";
+ twenty?: number;
+ tons?: number;
+ wagons: number;
+}> = [
+ { suffix: "MWC1", freight: "CONTAINER", twenty: 40, wagons: 20 },
+ { suffix: "MWC2", freight: "CONTAINER", twenty: 40, wagons: 20 },
+ { suffix: "MWB1", freight: "BULK", tons: 980, wagons: 14 },
+];
+const WAITING = [
+ { suffix: "MWB2", tons: 700 },
+ { suffix: "MWB3", tons: 700 },
+ { suffix: "MWB4", tons: 700 },
+];
+
+describe("mixed import: containers + first bulk fill the train, bulk trio waits and expires", { retries: 0 }, () => {
+ before(() => {
+ cy.task("db:seedFile", "seed-import-corridor.sql");
+ SELECTED.forEach((b) =>
+ seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix), freight: b.freight }),
+ );
+ WAITING.forEach((b) =>
+ seedImportContract({ suffix: b.suffix, reference: stampedRef(b.suffix), freight: "BULK" }),
+ );
+ });
+
+ it("operations prepares the corridor train with an open first window", () => {
+ ensureCorridorRoute();
+ resetCorridorDay(DEPARTURE);
+ createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-3", "LOCO-IMP-4"] });
+ withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45));
+ });
+
+ it("six mixed shipments book; operations accepts — MWB1 is the FIRST-accepted bulk", () => {
+ let isoSeed = 9500;
+ // Containers first (manual priority), then bulk in acceptance order —
+ // MWB1 accepted before the waiters so the recomputed-bulk tie-break
+ // (fully_executed_at ASC) puts it at the head of the bulk queue.
+ SELECTED.forEach((b) => {
+ if (b.freight === "CONTAINER") {
+ bookContainers({
+ suffix: b.suffix,
+ runStamp: stamp,
+ isoSeed,
+ twenty: b.twenty,
+ scheduledDate: BOOKING_DAY,
+ });
+ isoSeed += b.twenty ?? 0;
+ } else {
+ bookBulk({ suffix: b.suffix, tons: b.tons!, scheduledDate: BOOKING_DAY });
+ }
+ acceptOperation(b.suffix);
+ });
+ WAITING.forEach((b) => {
+ bookBulk({ suffix: b.suffix, tons: b.tons, scheduledDate: BOOKING_DAY });
+ acceptOperation(b.suffix);
+ });
+ // Manual priority holds for CONTAINER bookings only (bulk is recomputed).
+ setPriority("MWC1", 1);
+ setPriority("MWC2", 2);
+ });
+
+ it("the batch fills 54 across both kinds; the bulk trio keeps waiting with no pay window", () => {
+ withSchedule(DEPARTURE, (s) => {
+ closeBookingWindow(s.id);
+ completeDocReview(s.id);
+ });
+ SELECTED.forEach((b) =>
+ pollBookingStatus(b.suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
+ );
+ WAITING.forEach((b) =>
+ withBooking(b.suffix, (row) => {
+ expect(row.status, `${b.suffix} still waiting`).to.eq("FULLY_EXECUTED");
+ expect(row.payment_deadline, `${b.suffix} has no pay deadline`).to.be.null;
+ }),
+ );
+ });
+
+ it("the three selected pay — typed allocation 54/54", () => {
+ SELECTED.forEach((b) => {
+ markPaid(b.suffix);
+ pollAllocations(b.suffix, b.wagons);
+ expectWagonType(b.suffix, b.freight === "CONTAINER" ? "NW5" : "CW4", b.wagons);
+ });
+ });
+
+ it("the day concludes FULL — the waiting bulk trio expires in ONE sweep", () => {
+ withSchedule(DEPARTURE, (s) => {
+ endPaymentPhase(s.id);
+ pollDb(
+ "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",
+ );
+ });
+ WAITING.forEach((b) => pollBookingStatus(b.suffix, "EXPIRED"));
+ SELECTED.forEach((b) =>
+ withBooking(b.suffix, (row) => expect(row.status, `${b.suffix} stays PAID`).to.eq("PAID")),
+ );
+ });
+});
+
+export {};
diff --git a/e2e/freight/cypress/fixtures/seed-import-corridor.sql b/e2e/freight/cypress/fixtures/seed-import-corridor.sql
index 07e7f5f78..8ce7766f8 100644
--- a/e2e/freight/cypress/fixtures/seed-import-corridor.sql
+++ b/e2e/freight/cypress/fixtures/seed-import-corridor.sql
@@ -76,7 +76,10 @@ SELECT gen_random_uuid(), v.code, 9000, 760, y.id
FROM (VALUES ('LOCO-IMP-1'), ('LOCO-IMP-2'), ('LOCO-IMP-3'), ('LOCO-IMP-4'),
('LOCO-IMP-5'), ('LOCO-IMP-6'), ('LOCO-IMP-7'), ('LOCO-IMP-8'),
('LOCO-IMP-9'), ('LOCO-IMP-10'), ('LOCO-IMP-11'), ('LOCO-IMP-12'),
- ('LOCO-IMP-13'), ('LOCO-IMP-14'))
+ ('LOCO-IMP-13'), ('LOCO-IMP-14'), ('LOCO-IMP-15'), ('LOCO-IMP-16'),
+ ('LOCO-IMP-17'), ('LOCO-IMP-18'), ('LOCO-IMP-19'), ('LOCO-IMP-20'),
+ ('LOCO-IMP-21'), ('LOCO-IMP-22'), ('LOCO-IMP-23'), ('LOCO-IMP-24'),
+ ('LOCO-IMP-25'), ('LOCO-IMP-26'), ('LOCO-IMP-27'), ('LOCO-IMP-28'))
AS v(code)
JOIN freight.yards y ON y.code = 'DJIB_PORT'
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
@@ -109,6 +112,70 @@ FROM (
) pick
WHERE w.id = pick.id;
+-- 5b. Export-corridor rolling stock: the reversed corridor (KALITY →
+-- DJIB_PORT) loads at KALITY, sub-corridor exports board at DIRE_DAWA, and
+-- twelve dedicated locomotives live at KALITY.
+UPDATE freight.wagons w
+SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'KALITY')
+FROM (
+ SELECT w2.id
+ FROM freight.wagons w2
+ JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5'
+ JOIN freight.yards y ON y.id = w2.current_yard_id AND y.code = 'DJIB_PORT'
+ WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
+ ORDER BY w2.wagon_number ASC
+ LIMIT 120
+) pick
+WHERE w.id = pick.id;
+
+UPDATE freight.wagons w
+SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'DIRE_DAWA')
+FROM (
+ SELECT w2.id
+ FROM freight.wagons w2
+ JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5'
+ JOIN freight.yards y ON y.id = w2.current_yard_id AND y.code = 'DJIB_PORT'
+ WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
+ ORDER BY w2.wagon_number ASC
+ LIMIT 20
+) pick
+WHERE w.id = pick.id;
+
+INSERT INTO freight.locomotives
+ (id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
+SELECT gen_random_uuid(), v.code, 9000, 760, y.id
+FROM (VALUES ('LOCO-EXP-1'), ('LOCO-EXP-2'), ('LOCO-EXP-3'), ('LOCO-EXP-4'),
+ ('LOCO-EXP-5'), ('LOCO-EXP-6'), ('LOCO-EXP-7'), ('LOCO-EXP-8'),
+ ('LOCO-EXP-9'), ('LOCO-EXP-10'), ('LOCO-EXP-11'), ('LOCO-EXP-12'))
+ AS v(code)
+JOIN freight.yards y ON y.code = 'KALITY'
+WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
+
+-- 5b2. Export-bulk rolling stock: the boot CW4 fleet (110) is already spoken
+-- for by the import-bulk specs at DJIB_PORT — mint dedicated e2e CW4 wagons
+-- for the export side: 80 at KALITY (full trains) + 20 at DIRE_DAWA
+-- (sub-corridor boarding).
+INSERT INTO freight.wagons (id, wagon_number, wagon_type_id, current_yard_id)
+SELECT gen_random_uuid(), 'ECW' || lpad(g::text, 4, '0'), wt.id,
+ (SELECT id FROM freight.yards
+ WHERE code = CASE WHEN g <= 80 THEN 'KALITY' ELSE 'DIRE_DAWA' END)
+FROM generate_series(1, 100) AS g
+JOIN freight.wagon_types wt ON wt.code = 'CW4'
+WHERE NOT EXISTS (
+ SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0')
+);
+
+-- 5c. Approved exporter profile — export contracts bill against it
+-- (seed-company.sql only creates the importer).
+INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
+SELECT gen_random_uuid(), c.id, 'exporter', 'active', 'EXP-E2E-0002'
+FROM freight.companies c
+WHERE c.tin = '0102030405'
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.company_profiles p
+ WHERE p.company_id = c.id AND p.type = 'exporter' AND p.deleted_at IS NULL
+ );
+
-- 6. Segment distances for every consecutive corridor pair (symmetric rows).
INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km)
SELECT gen_random_uuid(), a.id, b.id, v.km
@@ -127,20 +194,102 @@ WHERE NOT EXISTS (
OR (d.from_yard_id = b.id AND d.to_yard_id = a.id)
);
+-- 6b. BULK import cargo hierarchy: group "E2E Import Grains" → commodity
+-- "E2E Import Wheat", carried on CW4 covered gondolas (13.976 m — the only
+-- bulk-capable type whose 54-wagon consist still fits the 760 m locos).
+INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active)
+SELECT gen_random_uuid(), 'E2E_IMP_GRAINS', 'E2E Import Grains', true
+WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_IMP_GRAINS');
+
+INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, is_active)
+SELECT gen_random_uuid(), 'E2E_IMP_WHEAT', 'E2E Import Wheat', g.id, true
+FROM freight.cargo_types g
+WHERE g.code = 'E2E_IMP_GRAINS'
+ AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_IMP_WHEAT');
+
+INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
+SELECT ct.id, wt.id
+FROM freight.cargo_types ct
+JOIN freight.wagon_types wt ON wt.code = 'CW4'
+WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT')
+ AND NOT EXISTS (
+ SELECT 1 FROM freight.cargo_type_wagon_types x
+ WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
+ );
+
+-- 6c. Park the free CW4 fleet for the bulk specs: bulk trains load at
+-- Djibouti Port, a NAGAD pocket serves the sub-corridor scenario and a MOJO
+-- pocket the bulk intercity ride-along. The e2e-minted ECW% wagons are the
+-- EXPORT pocket (KALITY / DIRE_DAWA) — never sweep them to Djibouti.
+UPDATE freight.wagons w
+SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'DJIB_PORT')
+FROM freight.wagon_types wt
+WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
+ AND w.train_id IS NULL AND w.deleted_at IS NULL
+ AND w.wagon_number NOT LIKE 'ECW%';
+
+-- Re-park the export CW4 pocket every seed (the mint above is insert-guarded).
+UPDATE freight.wagons w
+SET current_yard_id = (SELECT id FROM freight.yards
+ WHERE code = CASE WHEN substring(w.wagon_number FROM 4)::int <= 80
+ THEN 'KALITY' ELSE 'DIRE_DAWA' END)
+FROM freight.wagon_types wt
+WHERE wt.id = w.wagon_type_id AND wt.code = 'CW4'
+ AND w.wagon_number LIKE 'ECW%'
+ AND w.train_id IS NULL AND w.deleted_at IS NULL;
+
+UPDATE freight.wagons w
+SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'NAGAD')
+FROM (
+ SELECT w2.id
+ FROM freight.wagons w2
+ JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4'
+ WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
+ ORDER BY w2.wagon_number DESC
+ LIMIT 10
+) pick
+WHERE w.id = pick.id;
+
+UPDATE freight.wagons w
+SET current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
+FROM (
+ SELECT w2.id
+ FROM freight.wagons w2
+ JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4'
+ JOIN freight.yards y ON y.id = w2.current_yard_id AND y.code = 'DJIB_PORT'
+ WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
+ ORDER BY w2.wagon_number ASC
+ LIMIT 6
+) pick
+WHERE w.id = pick.id;
+
-- 7. LIVE import rates on every leg the specs book, plus the intercity
-- ride-along leg (rates are configured in USD and converted per booking).
INSERT INTO freight.rates
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
origin_yard_id, destination_yard_id, proposed_by_staff_id)
SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value,
- 'PER_CONTAINER', 'LIVE', a.id, b.id, u.id
+ v.unit, 'LIVE', a.id, b.id, u.id
FROM (VALUES
- ('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'KALITY', 800),
- ('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'MOJO', 700),
- ('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'KALITY', 650),
- ('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'MOJO', 600),
- ('INTERCITY_CONTAINER', 'INTERCITY', 'MOJO', 'KALITY', 200)
- ) AS v(rate_type, applies_to, from_code, to_code, value)
+ ('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'KALITY', 800, 'PER_CONTAINER'),
+ ('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'MOJO', 700, 'PER_CONTAINER'),
+ ('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'KALITY', 650, 'PER_CONTAINER'),
+ ('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'MOJO', 600, 'PER_CONTAINER'),
+ ('INTERCITY_CONTAINER', 'INTERCITY', 'MOJO', 'KALITY', 200, 'PER_CONTAINER'),
+ ('BULK_IMPORT', 'BULK', 'DJIB_PORT', 'KALITY', 30, 'PER_TON'),
+ ('BULK_IMPORT', 'BULK', 'DJIB_PORT', 'MOJO', 28, 'PER_TON'),
+ ('BULK_IMPORT', 'BULK', 'NAGAD', 'KALITY', 26, 'PER_TON'),
+ ('BULK_IMPORT', 'BULK', 'NAGAD', 'MOJO', 25, 'PER_TON'),
+ ('INTERCITY_BULK', 'INTERCITY', 'MOJO', 'KALITY', 10, 'PER_TON'),
+ ('CONTAINER_EXPORT', 'CONTAINER', 'KALITY', 'DJIB_PORT', 800, 'PER_CONTAINER'),
+ ('CONTAINER_EXPORT', 'CONTAINER', 'MOJO', 'DJIB_PORT', 700, 'PER_CONTAINER'),
+ ('CONTAINER_EXPORT', 'CONTAINER', 'DIRE_DAWA', 'DJIB_PORT', 500, 'PER_CONTAINER'),
+ ('INTERCITY_CONTAINER', 'INTERCITY', 'KALITY', 'MOJO', 200, 'PER_CONTAINER'),
+ ('BULK_EXPORT', 'BULK', 'KALITY', 'DJIB_PORT', 30, 'PER_TON'),
+ ('BULK_EXPORT', 'BULK', 'MOJO', 'DJIB_PORT', 25, 'PER_TON'),
+ ('BULK_EXPORT', 'BULK', 'DIRE_DAWA', 'DJIB_PORT', 20, 'PER_TON'),
+ ('INTERCITY_BULK', 'INTERCITY', 'KALITY', 'MOJO', 10, 'PER_TON')
+ ) AS v(rate_type, applies_to, from_code, to_code, value, unit)
JOIN freight.yards a ON a.code = v.from_code
JOIN freight.yards b ON b.code = v.to_code
JOIN iam.users u ON u.email = 'operation@edr.local'