mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add e2e test
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
assertCanApproveContractStep,
|
||||||
|
canEditContractStep,
|
||||||
|
} from './freight-permission.util';
|
||||||
|
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||||
|
|
||||||
|
// The document-edit gate (canEditContractStep) must be STRICT: only the approver
|
||||||
|
// whose turn it is may edit. This is the fix for a previous approver keeping the
|
||||||
|
// "Edit contract articles" button after acting, because the approve gate lets
|
||||||
|
// through anyone holding any contract-approve permission.
|
||||||
|
describe('canEditContractStep (strict per-step edit gate)', () => {
|
||||||
|
const director = {
|
||||||
|
employee: { position: { positionType: { key: '-marketing-director-' } } },
|
||||||
|
};
|
||||||
|
// A line staff who already approved their own step but still holds a
|
||||||
|
// contract-approve permission — the exact actor that leaked edit rights.
|
||||||
|
const officerWithApprovePerm = {
|
||||||
|
employee: {
|
||||||
|
position: {
|
||||||
|
positionType: { key: '-marketing-officer-' },
|
||||||
|
permissions: [{ key: FREIGHT_PERMS.contracts.approveLineStaff }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const superAdmin = { roles: [{ key: 'super_admin' }] };
|
||||||
|
|
||||||
|
it('lets the step’s own approver edit', () => {
|
||||||
|
expect(canEditContractStep(director, '-marketing-director-')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets an approval admin edit any step', () => {
|
||||||
|
expect(canEditContractStep(superAdmin, '-marketing-director-')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT let a different approver edit just because they hold an approve permission', () => {
|
||||||
|
expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays intentionally stricter than the approve gate (which keeps the blanket fallback)', () => {
|
||||||
|
// The approve gate passes the officer via the any-permission blanket…
|
||||||
|
expect(() =>
|
||||||
|
assertCanApproveContractStep(officerWithApprovePerm, '-marketing-director-'),
|
||||||
|
).not.toThrow();
|
||||||
|
// …but the edit gate does not — that divergence IS the fix.
|
||||||
|
expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +137,11 @@ 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"
|
? "orders"
|
||||||
: requestedTab === "orders" && isGeneralContract
|
: requestedTab === "documents"
|
||||||
? "orders"
|
? "documents"
|
||||||
: requestedTab === "documents"
|
: "overview";
|
||||||
? "documents"
|
|
||||||
: "overview";
|
|
||||||
const setActiveTab = (tab: string | null) => {
|
const setActiveTab = (tab: string | null) => {
|
||||||
const next = new URLSearchParams(searchParams);
|
const next = new URLSearchParams(searchParams);
|
||||||
if (tab && tab !== "overview") next.set("tab", tab);
|
if (tab && tab !== "overview") next.set("tab", tab);
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,9 +294,7 @@ export default function ContractRequestDetailPage() {
|
|||||||
? "documents"
|
? "documents"
|
||||||
: requestedTab === "customer"
|
: requestedTab === "customer"
|
||||||
? "customer"
|
? "customer"
|
||||||
: requestedTab === "clearance" && showClearanceTab
|
: "details";
|
||||||
? "clearance"
|
|
||||||
: "details";
|
|
||||||
|
|
||||||
const customerLabel = contract.isGovernment
|
const customerLabel = contract.isGovernment
|
||||||
? (contract.governmentInstitution ?? "Government")
|
? (contract.governmentInstitution ?? "Government")
|
||||||
@@ -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 && (
|
||||||
|
|||||||
215
e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts
Normal file
215
e2e/freight/cypress/e2e/flows/bulk_critical_matrix.cy.ts
Normal 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 {};
|
||||||
171
e2e/freight/cypress/e2e/flows/bulk_export_fcfs_space.cy.ts
Normal file
171
e2e/freight/cypress/e2e/flows/bulk_export_fcfs_space.cy.ts
Normal 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 {};
|
||||||
251
e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts
Normal file
251
e2e/freight/cypress/e2e/flows/bulk_export_full_train.cy.ts
Normal 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 {};
|
||||||
193
e2e/freight/cypress/e2e/flows/bulk_export_matrix.cy.ts
Normal file
193
e2e/freight/cypress/e2e/flows/bulk_export_matrix.cy.ts
Normal 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 {};
|
||||||
171
e2e/freight/cypress/e2e/flows/bulk_export_pay_or_lose.cy.ts
Normal file
171
e2e/freight/cypress/e2e/flows/bulk_export_pay_or_lose.cy.ts
Normal 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 {};
|
||||||
286
e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts
Normal file
286
e2e/freight/cypress/e2e/flows/bulk_import_full_train.cy.ts
Normal 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 {};
|
||||||
199
e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts
Normal file
199
e2e/freight/cypress/e2e/flows/bulk_import_split_promote.cy.ts
Normal 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 {};
|
||||||
133
e2e/freight/cypress/e2e/flows/bulk_import_waiting_expiry.cy.ts
Normal file
133
e2e/freight/cypress/e2e/flows/bulk_import_waiting_expiry.cy.ts
Normal 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 {};
|
||||||
110
e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts
Normal file
110
e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts
Normal 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 {};
|
||||||
190
e2e/freight/cypress/e2e/flows/export_fcfs_space.cy.ts
Normal file
190
e2e/freight/cypress/e2e/flows/export_fcfs_space.cy.ts
Normal 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 {};
|
||||||
285
e2e/freight/cypress/e2e/flows/export_full_train.cy.ts
Normal file
285
e2e/freight/cypress/e2e/flows/export_full_train.cy.ts
Normal 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 {};
|
||||||
245
e2e/freight/cypress/e2e/flows/export_matrix.cy.ts
Normal file
245
e2e/freight/cypress/e2e/flows/export_matrix.cy.ts
Normal 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 {};
|
||||||
207
e2e/freight/cypress/e2e/flows/export_pay_or_lose.cy.ts
Normal file
207
e2e/freight/cypress/e2e/flows/export_pay_or_lose.cy.ts
Normal 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 {};
|
||||||
@@ -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);
|
||||||
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
|
if (opts.expectFailure instanceof RegExp) {
|
||||||
|
expect(JSON.stringify(res.body)).to.match(opts.expectFailure);
|
||||||
|
} else {
|
||||||
|
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Book bulk tons under a seeded BULK contract via the API. Wagon demand =
|
||||||
|
* ceil(tons / 70) on CW4 covered gondolas.
|
||||||
|
*/
|
||||||
|
export function bookBulk(opts: {
|
||||||
|
suffix: string;
|
||||||
|
tons: number;
|
||||||
|
scheduledDate?: string; // omit for DOMESTIC (intercity)
|
||||||
|
expectFailure?: string | RegExp;
|
||||||
|
}) {
|
||||||
|
db<{ id: string; customs_clearing_enabled: boolean; cargo_type_id: string }>(
|
||||||
|
`SELECT ct.id, ct.customs_clearing_enabled,
|
||||||
|
(SELECT t.id FROM freight.cargo_types t WHERE t.code = 'E2E_IMP_WHEAT') AS cargo_type_id
|
||||||
|
FROM freight.contracts ct
|
||||||
|
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1
|
||||||
|
ORDER BY ct.created_at DESC LIMIT 1`,
|
||||||
|
[opts.suffix],
|
||||||
|
).then(({ rows }) => {
|
||||||
|
expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1);
|
||||||
|
const actor = rows[0].customs_clearing_enabled ? superAdmin : customer;
|
||||||
|
apiPost(
|
||||||
|
actor,
|
||||||
|
`/api/contracts/${rows[0].id}/bookings`,
|
||||||
|
{
|
||||||
|
...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}),
|
||||||
|
bulkLines: [{ cargoTypeId: rows[0].cargo_type_id, cargoWeightTons: opts.tons }],
|
||||||
|
cargoFreeText: "E2E import wheat",
|
||||||
|
},
|
||||||
|
!opts.expectFailure,
|
||||||
|
).then((res) => {
|
||||||
|
if (opts.expectFailure) {
|
||||||
|
expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422);
|
||||||
|
if (opts.expectFailure instanceof RegExp) {
|
||||||
|
expect(JSON.stringify(res.body)).to.match(opts.expectFailure);
|
||||||
|
} else {
|
||||||
|
expect(JSON.stringify(res.body)).to.include(opts.expectFailure);
|
||||||
|
}
|
||||||
} else {
|
} 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
253
e2e/freight/cypress/e2e/flows/mixed_export_close_intercity.cy.ts
Normal file
253
e2e/freight/cypress/e2e/flows/mixed_export_close_intercity.cy.ts
Normal 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 {};
|
||||||
203
e2e/freight/cypress/e2e/flows/mixed_export_fcfs.cy.ts
Normal file
203
e2e/freight/cypress/e2e/flows/mixed_export_fcfs.cy.ts
Normal 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 {};
|
||||||
257
e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts
Normal file
257
e2e/freight/cypress/e2e/flows/mixed_export_full_train.cy.ts
Normal 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 {};
|
||||||
266
e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts
Normal file
266
e2e/freight/cypress/e2e/flows/mixed_import_full_train.cy.ts
Normal 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 {};
|
||||||
212
e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts
Normal file
212
e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts
Normal 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 {};
|
||||||
216
e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts
Normal file
216
e2e/freight/cypress/e2e/flows/mixed_import_split_promote.cy.ts
Normal 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 {};
|
||||||
149
e2e/freight/cypress/e2e/flows/mixed_import_waiting_expiry.cy.ts
Normal file
149
e2e/freight/cypress/e2e/flows/mixed_import_waiting_expiry.cy.ts
Normal 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 {};
|
||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user