add e2e test

This commit is contained in:
Marshal
2026-07-22 23:44:32 +00:00
parent b0f561a935
commit 2481f43f1f
32 changed files with 4702 additions and 125 deletions

View File

@@ -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 steps 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,
);
});
});

View File

@@ -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( export function assertCanApproveBookingStep(
user: TCurrentUser | MeLikeUser | null | undefined, user: TCurrentUser | MeLikeUser | null | undefined,
requiredRole: string, requiredRole: string,

View File

@@ -18,7 +18,10 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractViewModel } from '../../contracts/contract-view-model.builder'; import { ContractViewModel } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service'; import { MinioService } from '../minio/minio.service';
import { FileRecord } from '../files/entities/file.entity'; 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 { ContractDocumentHistoryService } from './contract-document-history.service';
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
@@ -431,12 +434,11 @@ export class ContractTransitionService {
if (!next) return false; if (!next) return false;
if (!user) return false; if (!user) return false;
try { // Strict match: ONLY the approver whose turn it is (the next pending step's
assertCanApproveContractStep(user, next.requiredRole); // role) may edit. Using the looser approve gate here let any approver who
return true; // held a contract-approve permission keep the edit button after acting —
} catch { // approval must hand edit rights to the next approver, not share them.
return false; return canEditContractStep(user, next.requiredRole);
}
} }
/** The role that currently holds editing rights, for UI messaging. */ /** The role that currently holds editing rights, for UI messaging. */

View File

@@ -1,7 +1,7 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; 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 { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
@@ -9,18 +9,22 @@ import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives') @ApiTags('locomotives')
@ApiBearerAuth() @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') @Controller('locomotives')
@FleetView()
export class LocomotivesController { export class LocomotivesController {
constructor(private readonly locomotivesService: LocomotivesService) {} constructor(private readonly locomotivesService: LocomotivesService) {}
@Get() @Get()
@StaffReference()
@ApiOperation({ summary: 'List locomotives' }) @ApiOperation({ summary: 'List locomotives' })
findAll(@Query() filter: FilterLocomotivesDto) { findAll(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAll(filter); return this.locomotivesService.findAll(filter);
} }
@Get(':id') @Get(':id')
@StaffReference()
@ApiOperation({ summary: 'Get a locomotive by ID' }) @ApiOperation({ summary: 'Get a locomotive by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) { findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.findById(id); return this.locomotivesService.findById(id);

View File

@@ -12,7 +12,7 @@ import {
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; 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 { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.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'; import { WagonsService } from './wagons.service';
@ApiTags('wagons') @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') @Controller('wagons')
@FleetView()
export class WagonsController { export class WagonsController {
constructor(private readonly wagonsService: WagonsService) {} constructor(private readonly wagonsService: WagonsService) {}
@@ -36,18 +38,21 @@ export class WagonsController {
} }
@Get() @Get()
@StaffReference()
@ApiOperation({ summary: 'List all wagons' }) @ApiOperation({ summary: 'List all wagons' })
findAll(@Query() query: ListWagonsQueryDto) { findAll(@Query() query: ListWagonsQueryDto) {
return this.wagonsService.findAll(query); return this.wagonsService.findAll(query);
} }
@Get(':id') @Get(':id')
@StaffReference()
@ApiOperation({ summary: 'Get a wagon by ID' }) @ApiOperation({ summary: 'Get a wagon by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) { findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.findById(id); return this.wagonsService.findById(id);
} }
@Get(':id/movements') @Get(':id/movements')
@StaffReference()
@ApiOperation({ @ApiOperation({
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first", summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
}) })

View File

@@ -581,6 +581,16 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/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 => const isEtClearanceItem = (item: SidebarItem): boolean =>
item.href === ET_CLEARANCE_HREF; item.href === ET_CLEARANCE_HREF;
const isDjClearanceItem = (item: SidebarItem): boolean => const isDjClearanceItem = (item: SidebarItem): boolean =>
@@ -714,7 +724,11 @@ const DashboardShell = () => {
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE; document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
}, [location.pathname, sidebarSections]); }, [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 <Navigate to={glClearanceHome} replace />; return <Navigate to={glClearanceHome} replace />;
} }

View File

@@ -6,7 +6,6 @@ import {
LayoutGrid, LayoutGrid,
Milestone, Milestone,
Package, Package,
ShieldCheck,
} from "lucide-react"; } from "lucide-react";
import { import {
Container, Container,
@@ -36,7 +35,6 @@ import {
BookingCompanyCard, BookingCompanyCard,
BookingContractSummaryCard, BookingContractSummaryCard,
BookingContainerUnitsCard, BookingContainerUnitsCard,
ClearanceReviewSection,
BookingDocumentsPanel, BookingDocumentsPanel,
ContractOrdersPanel, ContractOrdersPanel,
} from "@/components/bookings/detail"; } from "@/components/bookings/detail";
@@ -129,13 +127,9 @@ export default function BookingRequestDetailPage() {
const row = toBookingListRow(booking); const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status); const statusMeta = getStatusMeta(booking.status);
// Non-customs clearance is reviewed here by Marketing in its own tab; customs // Clearance review + finalize now lives solely on the Operations "Clearance
// bookings are handled in the Global Logistics clearance queue instead. // Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
const showClearanceTab = // no clearance tab is embedded here anymore.
!booking.customsClearingEnabled &&
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
booking.status,
);
// A general contract drives an "Orders" tab: each drawdown order spawns a // A general contract drives an "Orders" tab: each drawdown order spawns a
// child booking that staff manage (clearance/approval) independently. // child booking that staff manage (clearance/approval) independently.
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT"; const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
@@ -143,9 +137,7 @@ export default function BookingRequestDetailPage() {
// customs-workflow, invoice or notice files — so the tab bar always renders. // customs-workflow, invoice or notice files — so the tab bar always renders.
const requestedTab = searchParams.get("tab"); const requestedTab = searchParams.get("tab");
const activeTab = const activeTab =
requestedTab === "clearance" && showClearanceTab requestedTab === "orders" && isGeneralContract
? "clearance"
: requestedTab === "orders" && isGeneralContract
? "orders" ? "orders"
: requestedTab === "documents" : requestedTab === "documents"
? "documents" ? "documents"
@@ -209,14 +201,6 @@ export default function BookingRequestDetailPage() {
Orders Orders
</Tabs.Tab> </Tabs.Tab>
)} )}
{showClearanceTab && (
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Customer clearance
</Tabs.Tab>
)}
<Tabs.Tab <Tabs.Tab
value="documents" value="documents"
leftSection={<FolderOpen size={16} />} leftSection={<FolderOpen size={16} />}
@@ -236,14 +220,6 @@ export default function BookingRequestDetailPage() {
/> />
</Tabs.Panel> </Tabs.Panel>
)} )}
{showClearanceTab && (
<Tabs.Panel value="clearance">
<ClearanceReviewSection
bookingId={booking.id}
onChanged={() => refetch()}
/>
</Tabs.Panel>
)}
<Tabs.Panel value="documents"> <Tabs.Panel value="documents">
<BookingDocumentsPanel bookingId={booking.id} /> <BookingDocumentsPanel bookingId={booking.id} />
</Tabs.Panel> </Tabs.Panel>

View File

@@ -138,9 +138,14 @@ export default function ClearanceDocumentsPage() {
const generalQuery = useQuery({ const generalQuery = useQuery({
queryKey: ["clearance-documents", "general", bookingStatuses, page, search], queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
queryFn: () => 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({ bookingsService.list({
statuses: bookingStatuses, statuses: bookingStatuses,
bookingType: "GENERAL_CONTRACT",
customsClearingEnabled: "false", customsClearingEnabled: "false",
page, page,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,

View File

@@ -93,6 +93,11 @@ export default function ContractClearanceDetailPage() {
}, [clearance]); }, [clearance]);
const reference = contract?.reference ?? "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 = const phasedCustoms =
contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled); contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
const docsPhaseComplete = const docsPhaseComplete =
@@ -338,7 +343,7 @@ export default function ContractClearanceDetailPage() {
<ContractClearanceReviewSection <ContractClearanceReviewSection
contractId={id!} contractId={id!}
hideSummary hideSummary
selfClear={false} selfClear={selfClear}
readOnly={reviewReadOnly} readOnly={reviewReadOnly}
approvalsLocked={phasedCustoms && docReviewLocked} approvalsLocked={phasedCustoms && docReviewLocked}
queriesLocked={queriesLocked} queriesLocked={queriesLocked}

View File

@@ -19,7 +19,6 @@ import {
Receipt, Receipt,
RefreshCw, RefreshCw,
Route as RouteIcon, Route as RouteIcon,
ShieldCheck,
Snowflake, Snowflake,
Users, Users,
} from "lucide-react"; } from "lucide-react";
@@ -51,7 +50,6 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper"; import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar"; import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard"; import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline"; import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
import { import {
@@ -74,15 +72,14 @@ import {
import type { CustomerDocument } from "@/types/customer"; import type { CustomerDocument } from "@/types/customer";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
// Clearance phase — staff can still ACT (approve / query / finalize). // Clearance phase — actionable (docs approve / query / finalize on the hub).
const CLEARANCE_ACTIVE_STATUSES = [ const CLEARANCE_ACTIVE_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS", "AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW", "CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING", "CLEARANCE_READY_FOR_BOOKING",
]; ];
// Clearance is done — the tab stays visible but READ-ONLY so staff/customer can // Clearance is done — its documents are still worth loading (read-only record).
// see which documents were approved, by whom, and when.
const CLEARANCE_DONE_STATUSES = [ const CLEARANCE_DONE_STATUSES = [
"ACTIVE_SHIPMENT_IN_PROGRESS", "ACTIVE_SHIPMENT_IN_PROGRESS",
"FULLY_EXECUTED", "FULLY_EXECUTED",
@@ -91,7 +88,9 @@ const CLEARANCE_DONE_STATUSES = [
"EXPIRED", "EXPIRED",
]; ];
// Show the Clearance Review tab in either phase (active or done). // Contract is in (or past) its clearance phase — load the clearance view so the
// Documents tab can show customs workflow files, and surface the "Review
// clearance" deep-link to the Operations hub.
const CLEARANCE_REVIEW_STATUSES = [ const CLEARANCE_REVIEW_STATUSES = [
...CLEARANCE_ACTIVE_STATUSES, ...CLEARANCE_ACTIVE_STATUSES,
...CLEARANCE_DONE_STATUSES, ...CLEARANCE_DONE_STATUSES,
@@ -151,13 +150,13 @@ export default function ContractRequestDetailPage() {
} }
}; };
const showClearanceTabQuery = Boolean( const hasClearancePhase = Boolean(
contract && CLEARANCE_REVIEW_STATUSES.includes(contract.status), contract && CLEARANCE_REVIEW_STATUSES.includes(contract.status),
); );
const { data: clearanceView } = useQuery({ const { data: clearanceView } = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""), queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
queryFn: () => contractsService.getClearance(id!), queryFn: () => contractsService.getClearance(id!),
enabled: Boolean(id) && showClearanceTabQuery, enabled: Boolean(id) && hasClearancePhase,
}); });
// Customer profile documents (national ID, TIN, import/business license) for // 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 === "APPROVED_PENDING_SIGNATURE" ||
contract.status === "REJECTED"; contract.status === "REJECTED";
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status); // Clearance review + finalize now lives solely on the Operations "Clearance
const phasedCustoms = // Documents" hub. The Staff-actions "Review clearance" button deep-links there
contract.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled); // while the contract is in a clearance-review status — no embedded tab here.
const docsPhaseComplete = const inClearanceReview = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
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;
const files = contract.files ?? []; const files = contract.files ?? [];
const contractPdf = files.find((f) => f.code === "contract"); const contractPdf = files.find((f) => f.code === "contract");
// Signature files (code `signature_<role>`) are baked into the contract PDF — // Signature files (code `signature_<role>`) are baked into the contract PDF —
@@ -303,8 +294,6 @@ export default function ContractRequestDetailPage() {
? "documents" ? "documents"
: requestedTab === "customer" : requestedTab === "customer"
? "customer" ? "customer"
: requestedTab === "clearance" && showClearanceTab
? "clearance"
: "details"; : "details";
const customerLabel = contract.isGovernment const customerLabel = contract.isGovernment
@@ -485,39 +474,13 @@ export default function ContractRequestDetailPage() {
<Tabs.Tab value="customer" leftSection={<Users size={16} />}> <Tabs.Tab value="customer" leftSection={<Users size={16} />}>
Customer Customer
</Tabs.Tab> </Tabs.Tab>
{showClearanceTab && (
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Clearance Review
</Tabs.Tab>
)}
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
<Grid gap="lg"> <Grid gap="lg">
{/* LEFT — primary content */} {/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}> <Grid.Col span={{ base: 12, lg: 8 }}>
{currentTab === "clearance" ? ( {currentTab === "documents" ? (
<Stack gap="lg">
<ContractClearanceReviewSection
contractId={id!}
selfClear={selfClear}
readOnly={clearanceReadOnly}
phasedCustoms={phasedCustoms}
approvalsLocked={clearanceApprovalsLocked}
onChanged={() => refetch()}
/>
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
<ClearanceWorkflowFilesPanel
files={clearanceView!.workflowFiles!}
onView={view}
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
/>
) : null}
</Stack>
) : currentTab === "documents" ? (
<Stack gap="lg"> <Stack gap="lg">
<ContractDocumentsCard <ContractDocumentsCard
files={contractDocuments} files={contractDocuments}
@@ -727,7 +690,12 @@ export default function ContractRequestDetailPage() {
contract={contract} contract={contract}
mutations={mutations} mutations={mutations}
onReviewClearance={ onReviewClearance={
showClearanceTab ? () => setTab("clearance") : undefined inClearanceReview
? () =>
navigate(
`/dashboard/contracts/clearance-documents/${contract.id}`,
)
: undefined
} }
/> />
{showApprovalCard && ( {showApprovalCard && (

View File

@@ -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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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<ScheduleRow>(
"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<ScheduleRow>(
"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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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<ScheduleRow>(
"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<ScheduleRow>(
"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 {};

View File

@@ -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<string, number> = {
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<ScheduleRow>(
"window FULL + DONE",
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
);
db<{ n: string }>(
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
FROM freight.wagon_booking_allocations wba
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
[s.id],
).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54));
});
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 {};

View File

@@ -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<ScheduleRow>(
"window FULL + DONE",
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
);
});
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 {};

View File

@@ -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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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<ScheduleRow>(
"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<ScheduleRow>(
"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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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 {};

View File

@@ -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 CORRIDOR = ["DJIB_PORT", "NAGAD", "DIRE_DAWA", "E2E_AWASH", "MOJO", "KALITY"] as const;
export const ORIGIN = "DJIB_PORT"; export const ORIGIN = "DJIB_PORT";
export const DEST = "KALITY"; 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; export const apiUrl = () => Cypress.env("apiUrl") as string;
@@ -113,7 +116,8 @@ export interface SeedContractOpts {
reference: string; reference: string;
currency?: "ETB" | "USD"; currency?: "ETB" | "USD";
customs?: boolean; customs?: boolean;
direction?: "IMPORT" | "DOMESTIC"; direction?: "IMPORT" | "EXPORT" | "DOMESTIC";
freight?: "CONTAINER" | "BULK";
originCode?: string; originCode?: string;
destCode?: string; destCode?: string;
} }
@@ -122,6 +126,9 @@ export function seedImportContract(opts: SeedContractOpts) {
const currency = opts.currency ?? "ETB"; const currency = opts.currency ?? "ETB";
const customs = opts.customs ?? false; const customs = opts.customs ?? false;
const direction = opts.direction ?? "IMPORT"; const direction = opts.direction ?? "IMPORT";
const freight = opts.freight ?? "CONTAINER";
// Pre-booking boundary milestone for Path B contracts differs by direction.
const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED";
db( db(
`WITH c AS ( `WITH c AS (
INSERT INTO freight.contracts INSERT INTO freight.contracts
@@ -133,9 +140,13 @@ export function seedImportContract(opts: SeedContractOpts) {
SELECT $1, comp.id, SELECT $1, comp.id,
(SELECT p.id FROM freight.company_profiles p (SELECT p.id FROM freight.company_profiles p
WHERE p.company_id = comp.id AND p.deleted_at IS NULL 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), 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), (SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1),
$3, $4, $3, $4,
CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END, CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END,
@@ -162,17 +173,24 @@ export function seedImportContract(opts: SeedContractOpts) {
JOIN freight.yards o ON o.code = $6 JOIN freight.yards o ON o.code = $6
JOIN freight.yards d ON d.code = $7 JOIN freight.yards d ON d.code = $7
RETURNING id RETURNING id
), scope AS ( ), scope_container AS (
INSERT INTO freight.contract_cargo_scope INSERT INTO freight.contract_cargo_scope
(contract_id, container_size, cargo_free_text) (contract_id, container_size, cargo_free_text)
SELECT c.id, v.size, 'E2E import corridor cargo' SELECT c.id, v.size, 'E2E import corridor cargo'
FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size) FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size)
WHERE $9::text = 'CONTAINER'
), 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 -- 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 INSERT INTO freight.clearance_milestones
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order) (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`, FROM c WHERE $4`,
[ [
opts.reference, opts.reference,
@@ -183,6 +201,8 @@ export function seedImportContract(opts: SeedContractOpts) {
opts.originCode ?? ORIGIN, opts.originCode ?? ORIGIN,
opts.destCode ?? DEST, opts.destCode ?? DEST,
opts.suffix, opts.suffix,
freight,
boundary,
], ],
); );
// Backfill the boundary milestone when the insert above was skipped because // Backfill the boundary milestone when the insert above was skipped because
@@ -191,15 +211,15 @@ export function seedImportContract(opts: SeedContractOpts) {
db( db(
`INSERT INTO freight.clearance_milestones `INSERT INTO freight.clearance_milestones
(contract_id, milestone_code, milestone_label, status, triggered_at, sort_order) (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 FROM freight.contracts ct
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND ct.deleted_at IS NULL WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND ct.deleted_at IS NULL
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM freight.clearance_milestones m 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 AND m.deleted_at IS NULL
)`, )`,
[opts.suffix], [opts.suffix, boundary],
); );
} }
} }
@@ -292,7 +312,7 @@ export function bookContainers(opts: {
forty?: number; forty?: number;
scheduledDate?: string; // omit for DOMESTIC (intercity) scheduledDate?: string; // omit for DOMESTIC (intercity)
vgmTons?: number; 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 vgm = opts.vgmTons ?? 10;
const lines: Array<Record<string, unknown>> = []; const lines: Array<Record<string, unknown>> = [];
@@ -338,7 +358,55 @@ export function bookContainers(opts: {
).then((res) => { ).then((res) => {
if (opts.expectFailure) { if (opts.expectFailure) {
expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422); 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); 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 { } else {
expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]); expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
} }
@@ -470,6 +538,40 @@ export function forceReservationExpiry(suffix: string) {
pollBookingStatus(suffix, "EXPIRED"); 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) { export function pollAllocations(suffix: string, minWagons = 1) {
withBooking(suffix, (b) => withBooking(suffix, (b) =>
pollDb<{ n: string }>( pollDb<{ n: string }>(
@@ -533,7 +635,7 @@ export function ensureCorridorRoute() {
* scope CTR-IMP-% only — never touches other suites' data). A wiped schedule * scope CTR-IMP-% only — never touches other suites' data). A wiped schedule
* must never leave bookings pointing at it (ghost refs break assign). * 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( db(
`WITH stale AS ( `WITH stale AS (
SELECT ts.id FROM freight.train_schedules ts 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() UPDATE freight.train_schedules SET deleted_at = now()
WHERE id IN (SELECT id FROM stale)`, 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 // Prior-run pool leftovers (never reserved, so no schedule ref) would
// contaminate this run's batch — a stale high-priority booking steals the // 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 { export interface ScheduleRow {
id: string; id: string;
status: 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`; ts.payment_phase_ends_at, ts.scheduled_departure_date`;
/** The corridor schedule departing within ±1h of `departure` (12:00 EAT pin). */ /** 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<ScheduleRow>( return db<ScheduleRow>(
`SELECT ${SCHEDULE_COLS} `SELECT ${SCHEDULE_COLS}
FROM freight.train_schedules ts FROM freight.train_schedules ts
@@ -599,7 +743,7 @@ export function dbSchedule(departure: Date, destCode = DEST) {
WHERE ts.deleted_at IS NULL WHERE ts.deleted_at IS NULL
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600 AND abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600
ORDER BY ts.created_at DESC LIMIT 1`, 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 * Ops creates an import schedule on the corridor via the API — loco-pair mode
* (no built train): capacity comes from maxWagonsPerTrain (54, the corridor * (no built train): capacity comes from maxWagonsPerTrain (54, the corridor
@@ -619,17 +771,22 @@ export function createImportSchedule(opts: {
departure: Date; departure: Date;
locoPair: [string, string]; locoPair: [string, string];
maxWagons?: number; 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; if (rows.length > 0) return;
dbRouteId().then(({ rows: routes }) => { dbRouteId(originCode, destCode).then(({ rows: routes }) => {
expect(routes, "corridor route").to.have.length(1); expect(routes, "corridor route").to.have.length(1);
db<{ id: string }>( db<{ id: string }>(
`SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`, `SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`,
[opts.locoPair], [opts.locoPair],
).then(({ rows: locos }) => { ).then(({ rows: locos }) => {
expect(locos, `locomotives ${opts.locoPair.join(",")}`).to.have.length(2); 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, routeId: routes[0].id,
scheduleDate: opts.departure.toISOString(), scheduleDate: opts.departure.toISOString(),
locomotiveIds: locos.map((l) => l.id), locomotiveIds: locos.map((l) => l.id),
@@ -640,8 +797,9 @@ export function createImportSchedule(opts: {
}); });
}); });
}); });
withSchedule(opts.departure, (s) => { dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => {
expect(s.max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54); expect(rows, "created schedule").to.have.length(1);
expect(rows[0].max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54);
}); });
} }

View File

@@ -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<ScheduleRow>(
"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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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<ScheduleRow>(
"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<ScheduleRow>(
"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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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<ScheduleRow>(
"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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"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 {};

View File

@@ -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<ScheduleRow>(
"window FULL + DONE",
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
);
db<{ n: string }>(
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
FROM freight.wagon_booking_allocations wba
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
[s.id],
).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54));
});
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 {};

View File

@@ -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<ScheduleRow>(
"window FULL + DONE",
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
);
});
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 {};

View File

@@ -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'), 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-5'), ('LOCO-IMP-6'), ('LOCO-IMP-7'), ('LOCO-IMP-8'),
('LOCO-IMP-9'), ('LOCO-IMP-10'), ('LOCO-IMP-11'), ('LOCO-IMP-12'), ('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) AS v(code)
JOIN freight.yards y ON y.code = 'DJIB_PORT' JOIN freight.yards y ON y.code = 'DJIB_PORT'
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code); WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
@@ -109,6 +112,70 @@ FROM (
) pick ) pick
WHERE w.id = pick.id; 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). -- 6. Segment distances for every consecutive corridor pair (symmetric rows).
INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km) INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km)
SELECT gen_random_uuid(), a.id, b.id, v.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) 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 -- 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). -- ride-along leg (rates are configured in USD and converted per booking).
INSERT INTO freight.rates INSERT INTO freight.rates
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status, (id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
origin_yard_id, destination_yard_id, proposed_by_staff_id) origin_yard_id, destination_yard_id, proposed_by_staff_id)
SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value, 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 FROM (VALUES
('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'KALITY', 800), ('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'KALITY', 800, 'PER_CONTAINER'),
('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'MOJO', 700), ('CONTAINER_IMPORT', 'CONTAINER', 'DJIB_PORT', 'MOJO', 700, 'PER_CONTAINER'),
('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'KALITY', 650), ('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'KALITY', 650, 'PER_CONTAINER'),
('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'MOJO', 600), ('CONTAINER_IMPORT', 'CONTAINER', 'NAGAD', 'MOJO', 600, 'PER_CONTAINER'),
('INTERCITY_CONTAINER', 'INTERCITY', 'MOJO', 'KALITY', 200) ('INTERCITY_CONTAINER', 'INTERCITY', 'MOJO', 'KALITY', 200, 'PER_CONTAINER'),
) AS v(rate_type, applies_to, from_code, to_code, value) ('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 a ON a.code = v.from_code
JOIN freight.yards b ON b.code = v.to_code JOIN freight.yards b ON b.code = v.to_code
JOIN iam.users u ON u.email = 'operation@edr.local' JOIN iam.users u ON u.email = 'operation@edr.local'