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 e5fc63b5c..e1c4de1a2 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 => @@ -716,7 +726,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'