/** * Shared helpers for the IMPORT corridor flow specs. * * Corridor (A→B→C→D→E→T, DJ→ET = IMPORT): * DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY * * Philosophy (same as segment_weight.cy.ts): these specs test the * scheduling/window/clearance ENGINE, not the contract wizard — contracts are * seeded FULLY_EXECUTED in SQL with stamped references; bookings, staff * reviews, window phases, payment, dispatch and clearance run through the real * API + UI. Window *timestamps* are arranged via db:query (the specs arrange * window state, they don't test the wall clock), and every transition is then * performed by the app's own 10s window tick or its staff endpoints. * * No module-level state besides constants: Cypress re-evaluates the spec * bundle on cross-origin reloads, so helpers look rows up by stamped-reference * SUFFIX + newest row, never by captured ids. */ export const customer = "user@gmail.com"; export const companyTin = "0102030405"; // seed-company.sql export const opsStaff = "operation@edr.local"; /** isSuperAdmin bypasses assertFreightPermission — used for GL endpoints so a * missing preset permission never masks an engine regression. */ export const superAdmin = "superadmin@tria.com"; export const CORRIDOR = ["DJIB_PORT", "NAGAD", "DIRE_DAWA", "E2E_AWASH", "MOJO", "KALITY"] as const; export const ORIGIN = "DJIB_PORT"; export const DEST = "KALITY"; /** Export rides the same corridor reversed (ET → DJ). */ export const EXP_ORIGIN = "KALITY"; export const EXP_DEST = "DJIB_PORT"; export const apiUrl = () => Cypress.env("apiUrl") as string; // --------------------------------------------------------------------------- // small generic plumbing // --------------------------------------------------------------------------- export type Row = Record; export function db(sql: string, params: unknown[] = []) { return cy.task<{ rowCount: number; rows: T[] }>("db:query", { sql, params }, { log: false }); } /** Bearer token for a staff/customer account (portal users use the demo pwd). */ export function tokenFor(email: string): Cypress.Chainable { const pass = email.endsWith("@gmail.com") ? (Cypress.env("demoPassword") as string) : undefined; return cy.apiLogin(email, pass).then(({ token }) => cy.wrap(token, { log: false })); } export function apiPost( email: string, path: string, body?: unknown, failOnStatusCode = true, ) { return tokenFor(email).then((token) => cy.request({ method: "POST", url: `${apiUrl()}${path}`, headers: { Authorization: `Bearer ${token}` }, body: body ?? {}, failOnStatusCode, }), ); } /** Poll a 1-row query until `check` passes (10s window tick ⇒ 3s cadence). */ export function pollDb( label: string, sql: string, params: unknown[], check: (row: T | undefined) => boolean, attempts = 40, ) { const read = (attempt: number): void => { db(sql, params).then(({ rows }) => { if (check(rows[0])) return; expect(attempt, label).to.be.lessThan(attempts); cy.wait(3000, { log: false }).then(() => read(attempt + 1)); }); }; read(0); } // --------------------------------------------------------------------------- // time — departures pinned to 12:00 EAT so the EAT day key is unambiguous // --------------------------------------------------------------------------- export function departureAt(dayOffset: number): Date { const eatNow = new Date(Date.now() + 3 * 3_600_000); return new Date( Date.UTC( eatNow.getUTCFullYear(), eatNow.getUTCMonth(), eatNow.getUTCDate() + dayOffset, 9, // 09:00 UTC = 12:00 EAT 0, 0, ), ); } /** The EAT calendar day (`YYYY-MM-DD`) of an instant — the booking day key. */ export const eatDayStr = (d: Date) => new Date(d.getTime() + 3 * 3_600_000).toISOString().slice(0, 10); // --------------------------------------------------------------------------- // contracts — seeded FULLY_EXECUTED (see file header) // --------------------------------------------------------------------------- export interface SeedContractOpts { suffix: string; reference: string; currency?: "ETB" | "USD"; customs?: boolean; direction?: "IMPORT" | "EXPORT" | "DOMESTIC"; freight?: "CONTAINER" | "BULK"; originCode?: string; destCode?: string; /** GENERAL = multi-booking drawdown contract (status CONTRACT_ACTIVE). */ kind?: "ONE_TIME" | "GENERAL"; /** GENERAL only: quantity cap on the 20ft scope row (40ft stays uncapped). */ cap20?: number; } export function seedImportContract(opts: SeedContractOpts) { const currency = opts.currency ?? "ETB"; const customs = opts.customs ?? false; const direction = opts.direction ?? "IMPORT"; const freight = opts.freight ?? "CONTAINER"; const kind = opts.kind ?? "ONE_TIME"; const status = kind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED"; // Pre-booking boundary milestone for Path B contracts differs by direction. const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED"; db( `WITH c AS ( INSERT INTO freight.contracts (reference, company_id, company_profile_id, contract_kind, trade_direction, freight_type, service_type_id, payment_currency, customs_clearing_enabled, clearance_status, status, fully_executed_at, contract_valid_from, contract_valid_until, contract_summary) SELECT $1, comp.id, (SELECT p.id FROM freight.company_profiles p WHERE p.company_id = comp.id AND p.deleted_at IS NULL ORDER BY CASE WHEN $2::text = 'EXPORT' AND p.type = 'exporter' THEN 0 WHEN $2::text <> 'EXPORT' AND p.type = 'importer' THEN 0 ELSE 1 END LIMIT 1), $11::text, $2::text, $9::text, (SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1), $3, $4, CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END, $12::text, now(), now() - interval '1 day', now() + interval '60 days', 'E2E import-corridor fixture contract' FROM freight.companies comp WHERE comp.tin = $5 -- before() re-runs on cross-origin reloads: keep one stable fresh row -- per suffix (skip when this run already seeded an unbooked one). AND NOT EXISTS ( SELECT 1 FROM freight.contracts c2 WHERE c2.reference LIKE 'CTR-IMP-%-' || $8 AND c2.deleted_at IS NULL AND c2.created_at > now() - interval '30 minutes' AND NOT EXISTS ( SELECT 1 FROM freight.bookings b2 WHERE b2.contract_id = c2.id ) ) RETURNING id ), r AS ( INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, sort_order) SELECT c.id, o.id, d.id, 0 FROM c JOIN freight.yards o ON o.code = $6 JOIN freight.yards d ON d.code = $7 RETURNING id ), scope_container AS ( INSERT INTO freight.contract_cargo_scope (contract_id, container_size, quantity_cap, cargo_free_text) SELECT c.id, v.size, CASE WHEN v.size = '20ft' THEN $13::numeric END, 'E2E import corridor cargo' FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size) WHERE $9::text = 'CONTAINER' ), scope_bulk AS ( INSERT INTO freight.contract_cargo_scope (contract_id, cargo_type_id, cargo_free_text) SELECT c.id, ct.id, 'E2E import wheat' FROM c JOIN freight.cargo_types ct ON ct.code = 'E2E_IMP_WHEAT' WHERE $9::text = 'BULK' ) -- Path B gate: ONE_TIME customs bookings require the pre-booking boundary -- milestone (IMPORT → DO_COLLECTED, EXPORT → EXPORT_RELEASED) COMPLETED. INSERT INTO freight.clearance_milestones (contract_id, milestone_code, milestone_label, status, triggered_at, sort_order) SELECT c.id, $10, 'Pre-booking clearance boundary', 'COMPLETED', now(), 0 FROM c WHERE $4`, [ opts.reference, direction, currency, customs, companyTin, opts.originCode ?? ORIGIN, opts.destCode ?? DEST, opts.suffix, freight, boundary, kind, status, opts.cap20 ?? null, ], ); // Backfill the boundary milestone when the insert above was skipped because // a prior run's still-unbooked contract row is being reused. if (customs) { db( `INSERT INTO freight.clearance_milestones (contract_id, milestone_code, milestone_label, status, triggered_at, sort_order) SELECT ct.id, $2::text, 'Pre-booking clearance boundary', 'COMPLETED', now(), 0 FROM freight.contracts ct WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND ct.deleted_at IS NULL AND NOT EXISTS ( SELECT 1 FROM freight.clearance_milestones m WHERE m.contract_id = ct.id AND m.milestone_code = $2::text AND m.deleted_at IS NULL )`, [opts.suffix, boundary], ); } } /** Newest seeded contract for a suffix — stamp-agnostic. */ export function dbContractId(suffix: string) { return db<{ id: string }>( `SELECT id FROM freight.contracts WHERE reference LIKE 'CTR-IMP-%-' || $1 ORDER BY created_at DESC LIMIT 1`, [suffix], ).then(({ rows }) => { expect(rows, `seeded contract *-${suffix}`).to.have.length(1); return cy.wrap(rows[0].id, { log: false }); }); } // --------------------------------------------------------------------------- // bookings // --------------------------------------------------------------------------- export interface BookingRow { id: string; reference: string; status: string; scheduling_status: string; train_schedule_id: string | null; payment_deadline: string | null; priority_score: number; is_split: boolean; contract_id: string; } export function dbBooking(suffix: string) { return db( `SELECT b.id, b.reference, b.status, b.scheduling_status, b.train_schedule_id, b.payment_deadline, b.priority_score, b.is_split, b.contract_id FROM freight.bookings b JOIN freight.contracts ct ON ct.id = b.contract_id WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 ORDER BY b.created_at DESC LIMIT 1`, [suffix], ); } export function withBooking(suffix: string, fn: (b: BookingRow) => void) { dbBooking(suffix).then(({ rows }) => { expect(rows, `booking under *-${suffix}`).to.have.length(1); fn(rows[0]); }); } export function expectBookingStatus(suffix: string, status: string | string[]) { const want = Array.isArray(status) ? status : [status]; withBooking(suffix, (b) => expect(b.status, `${suffix} booking status`).to.be.oneOf(want), ); } export function pollBookingStatus(suffix: string, status: string | string[], attempts = 40) { const want = Array.isArray(status) ? status : [status]; pollDb( `${suffix} → ${want.join("|")}`, `SELECT b.status FROM freight.bookings b JOIN freight.contracts ct ON ct.id = b.contract_id WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 ORDER BY b.created_at DESC LIMIT 1`, [suffix], (row) => !!row && want.includes(row.status as string), attempts, ); } /** ISO 6346-shaped container number, unique per run+seed (checksum unchecked). */ export function isoNumber(runStamp: string, seed: number): string { return `MSCU${String((Number(runStamp.slice(-6)) * 100 + seed) % 10_000_000).padStart(7, "0")}`; } /** * Customer books containers under a seeded contract via the API (the portal * booking form is exercised by export_one_time/intercity specs; a 22-wagon * booking means 44 ISO inputs — not a UI journey). */ export function bookContainers(opts: { suffix: string; runStamp: string; isoSeed: number; twenty?: number; forty?: number; scheduledDate?: string; // omit for DOMESTIC (intercity) vgmTons?: number; expectFailure?: string | RegExp; // substring/regex of the expected 4xx error }) { const vgm = opts.vgmTons ?? 10; const lines: Array> = []; let unit = 0; if (opts.twenty) { lines.push({ containerSize: "20ft", quantity: opts.twenty, units: Array.from({ length: opts.twenty }, () => ({ containerNumber: isoNumber(opts.runStamp, opts.isoSeed + unit++), vgmTons: vgm, })), }); } if (opts.forty) { lines.push({ containerSize: "40ft", quantity: opts.forty, units: Array.from({ length: opts.forty }, () => ({ containerNumber: isoNumber(opts.runStamp, opts.isoSeed + unit++), vgmTons: vgm, })), }); } db<{ id: string; customs_clearing_enabled: boolean }>( `SELECT id, customs_clearing_enabled FROM freight.contracts WHERE reference LIKE 'CTR-IMP-%-' || $1 ORDER BY created_at DESC LIMIT 1`, [opts.suffix], ).then(({ rows }) => { expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1); // Path B: customs-clearance contracts are booked by Global Logistics on // behalf of the customer — the portal user is rejected with a 403. const actor = rows[0].customs_clearing_enabled ? superAdmin : customer; apiPost( actor, `/api/contracts/${rows[0].id}/bookings`, { ...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}), containers: lines, }, !opts.expectFailure, ).then((res) => { if (opts.expectFailure) { expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422); if (opts.expectFailure instanceof RegExp) { expect(JSON.stringify(res.body)).to.match(opts.expectFailure); } else { expect(JSON.stringify(res.body)).to.include(opts.expectFailure); } } else { expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]); } }); }); } /** * Book bulk tons under a seeded BULK contract via the API. Wagon demand = * ceil(tons / 70) on CW4 covered gondolas. */ export function bookBulk(opts: { suffix: string; tons: number; scheduledDate?: string; // omit for DOMESTIC (intercity) expectFailure?: string | RegExp; }) { db<{ id: string; customs_clearing_enabled: boolean; cargo_type_id: string }>( `SELECT ct.id, ct.customs_clearing_enabled, (SELECT t.id FROM freight.cargo_types t WHERE t.code = 'E2E_IMP_WHEAT') AS cargo_type_id FROM freight.contracts ct WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 ORDER BY ct.created_at DESC LIMIT 1`, [opts.suffix], ).then(({ rows }) => { expect(rows, `seeded contract *-${opts.suffix}`).to.have.length(1); const actor = rows[0].customs_clearing_enabled ? superAdmin : customer; apiPost( actor, `/api/contracts/${rows[0].id}/bookings`, { ...(opts.scheduledDate ? { scheduledDate: opts.scheduledDate } : {}), bulkLines: [{ cargoTypeId: rows[0].cargo_type_id, cargoWeightTons: opts.tons }], cargoFreeText: "E2E import wheat", }, !opts.expectFailure, ).then((res) => { if (opts.expectFailure) { expect(res.status, `${opts.suffix} booking rejected`).to.be.within(400, 422); if (opts.expectFailure instanceof RegExp) { expect(JSON.stringify(res.body)).to.match(opts.expectFailure); } else { expect(JSON.stringify(res.body)).to.include(opts.expectFailure); } } else { expect(res.status, `${opts.suffix} booking created`).to.be.oneOf([200, 201]); } }); }); } /** * Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A): * AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize → * CLEARANCE_READY → customer proceeds with the shipment day → * OPERATION_REQUEST_PENDING → ops accept → FULLY_EXECUTED (pool). The e2e * seed configures no required documents, so one ad-hoc doc satisfies the * 100%-approved gate. */ export function clearGeneralBooking(suffix: string, scheduledDate: string) { withBooking(suffix, (b) => { expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS"); glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e"); apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, { fileKey: "custom_e2e", status: "APPROVED", }) .its("status") .should("be.oneOf", [200, 201]); apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`) .its("status") .should("be.oneOf", [200, 201]); apiPost(customer, `/api/bookings/${b.id}/clearance/proceed`, { scheduledDate }) .its("status") .should("be.oneOf", [200, 201]); }); acceptOperation(suffix); } /** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */ export function acceptOperation(suffix: string) { withBooking(suffix, (b) => { apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" }) .its("status") .should("be.oneOf", [200, 201]); }); pollBookingStatus(suffix, "FULLY_EXECUTED", 10); } /** Batch fill reserves by priority DESC — order 1 = first pick. */ export function setPriority(suffix: string, order: number) { withBooking(suffix, (b) => db(`UPDATE freight.bookings SET priority_score = $2 WHERE id = $1`, [ b.id, 1000 - order, ]), ); } /** Staff force-pay; polls PAID + SCHEDULED. */ export function markPaid(suffix: string) { withBooking(suffix, (b) => { apiPost(opsStaff, `/api/train-scheduling/bookings/${b.id}/mark-paid`) .its("status") .should("be.oneOf", [200, 201]); }); pollDb( `${suffix} PAID+SCHEDULED`, `SELECT b.status, b.scheduling_status FROM freight.bookings b JOIN freight.contracts ct ON ct.id = b.contract_id WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 ORDER BY b.created_at DESC LIMIT 1`, [suffix], (row) => row?.status === "PAID" && row?.scheduling_status === "SCHEDULED", 20, ); } /** * Settle a reservation through the REAL payment pipeline: seed the gateway * intent projection (the payment microservice is absent in e2e), link it to * the open invoice, then deliver the `payment.succeeded` event to the public * internal endpoint. This drives billing settle → `booking.invoice.paid` → * `advanceBookingOnPayment` → `ensurePaidBookingAllocated`, which is the ONLY * path that applies a pending split offer (staff mark-paid skips it). */ export function settleViaGateway(suffix: string) { withBooking(suffix, (b) => { db<{ intent_id: string; currency: string; total: string }>( `WITH inv AS ( SELECT id, currency, total_amount FROM freight.invoices WHERE source_id = $1 AND deleted_at IS NULL AND paid_at IS NULL ORDER BY created_at DESC LIMIT 1 ), intent AS ( INSERT INTO freight.payments (id, ref_id, type, reference_type, method, currency, amount, reason, raw_initiation, merchant_order_id, status) SELECT gen_random_uuid(), $1, 'FREIGHT', 'SHIPMENT', 'telebirr'::freight.payments_method_enum, inv.currency::freight.payments_currency_enum, 1, 'e2e gateway settle', '{}'::jsonb, 'E2E_' || $2, 'processing'::freight.payments_status_enum FROM inv RETURNING id ), link AS ( UPDATE freight.invoices SET payment_id = intent.id FROM intent WHERE freight.invoices.id = (SELECT id FROM inv) RETURNING payment_id ) SELECT intent.id AS intent_id, inv.currency, inv.total_amount AS total FROM intent, inv`, [b.id, `${suffix}-${b.id.slice(0, 8)}`], ).then(({ rows }) => { expect(rows, `${suffix} gateway intent`).to.have.length(1); cy.request({ method: "POST", url: `${apiUrl()}/api/internal/payments/mark-paid`, body: { version: 1, eventId: crypto.randomUUID(), eventType: "payment.succeeded", occurredAt: new Date().toISOString(), service: "FREIGHT", intentId: rows[0].intent_id, referenceType: "SHIPMENT", referenceId: b.id, merchantOrderId: `E2E_${suffix}_${b.id.slice(0, 8)}`, provider: "TELEBIRR", amountMinor: 1, currency: rows[0].currency, }, }).then((res) => { expect(res.status, `${suffix} payment event accepted`).to.eq(200); // The global response interceptor wraps payloads in { success, data }. const raw = res.body as { processed?: boolean; data?: { processed?: boolean } }; expect(raw.processed ?? raw.data?.processed, "event processed").to.eq(true); }); }); }); pollDb( `${suffix} PAID via gateway`, `SELECT b.status FROM freight.bookings b JOIN freight.contracts ct ON ct.id = b.contract_id WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 ORDER BY b.created_at DESC LIMIT 1`, [suffix], (row) => row?.status === "PAID", 20, ); } /** Push a reservation's pay deadline into the past — the 10s tick expires it. */ export function forceReservationExpiry(suffix: string) { withBooking(suffix, (b) => db( `UPDATE freight.bookings SET payment_deadline = now() - interval '1 second' WHERE id = $1`, [b.id], ), ); pollBookingStatus(suffix, "EXPIRED"); } /** * Type-integrity assert for mixed trains: every wagon slot allocated to the * booking is of ONE expected wagon type (containers → NW5, bulk → CW4), and * the slot count matches. No wheat on a flat wagon, no box in a gondola. */ export function expectWagonType(suffix: string, code: string, wagons: number) { withBooking(suffix, (b) => pollDb<{ code: string; n: string }>( `${suffix} rides ${wagons}× ${code}`, `SELECT wt.code, count(*) AS n FROM freight.wagon_booking_allocations wba JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL GROUP BY wt.code`, [b.id], (row) => row?.code === code && Number(row?.n) === wagons, 20, ), ); // A grouped second row would mean mixed wagon types under one booking. withBooking(suffix, (b) => db<{ k: string }>( `SELECT count(DISTINCT tsw.wagon_type_id) AS k FROM freight.wagon_booking_allocations wba JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id WHERE wba.booking_id = $1 AND wba.deleted_at IS NULL`, [b.id], ).then(({ rows }) => expect(Number(rows[0].k), `${suffix} single wagon type`).to.eq(1), ), ); } export function pollAllocations(suffix: string, minWagons = 1) { withBooking(suffix, (b) => pollDb<{ n: string }>( `${suffix} wagon allocations`, `SELECT count(*) AS n FROM freight.wagon_booking_allocations WHERE booking_id = $1 AND deleted_at IS NULL`, [b.id], (row) => Number(row?.n ?? 0) >= minWagons, 30, ), ); } // --------------------------------------------------------------------------- // route + schedule // --------------------------------------------------------------------------- export function dbRouteId(originCode = ORIGIN, destCode = DEST) { return db<{ id: string }>( `SELECT r.id 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`, [originCode, destCode], ); } /** Create the 6-stop corridor route through the API if it doesn't exist yet. */ export function ensureCorridorRoute() { dbRouteId().then(({ rows }) => { if (rows.length > 0) return; db<{ id: string; code: string }>( `SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`, [[...CORRIDOR]], ).then(({ rows: yards }) => { expect(yards, "corridor yards").to.have.length(CORRIDOR.length); const byCode = new Map(yards.map((y) => [y.code, y.id])); apiPost(opsStaff, "/api/routes", { milestones: CORRIDOR.map((code) => ({ yardId: byCode.get(code) })), }) .its("status") .should("be.oneOf", [200, 201]); }); // Direction is frozen from the endpoint countries: DJ → ET = IMPORT. 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`, [ORIGIN, DEST], ).then(({ rows: created }) => { expect(created[0]?.direction, "corridor direction").to.eq("IMPORT"); }); }); } /** * Make a corridor departure-day re-runnable: soft-delete any schedule a prior * run left on that day and expire its leftover fixture bookings (reference * scope CTR-IMP-% only — never touches other suites' data). A wiped schedule * must never leave bookings pointing at it (ghost refs break assign). */ export function resetCorridorDay(departure: Date, destCode = DEST, originCode = ORIGIN) { db( `WITH stale AS ( SELECT ts.id 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 abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 43200 ), unlink AS ( UPDATE freight.bookings b SET train_schedule_id = NULL, status = CASE WHEN b.status IN ('FULLY_EXECUTED','SELECTED_FOR_BATCH','AWAITING_PAYMENT') THEN 'EXPIRED' ELSE b.status END, scheduling_status = 'NOT_SCHEDULED' FROM freight.contracts ct WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%' AND b.train_schedule_id IN (SELECT id FROM stale) ), drop_links AS ( UPDATE freight.train_schedule_bookings SET deleted_at = now() WHERE train_schedule_id IN (SELECT id FROM stale) AND deleted_at IS NULL ) UPDATE freight.train_schedules SET deleted_at = now() WHERE id IN (SELECT id FROM stale)`, [originCode, destCode, departure.toISOString()], ); // Prior-run pool leftovers (never reserved, so no schedule ref) would // contaminate this run's batch — a stale high-priority booking steals the // top-up slot from this run's waiting list. Reset runs before this run // books anything, so every unpinned fixture booking is debris: expire all. db( `UPDATE freight.bookings b SET status = 'EXPIRED' FROM freight.contracts ct WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%' AND b.status = 'FULLY_EXECUTED' AND b.train_schedule_id IS NULL`, [], ); } /** Create the reversed 6-stop corridor (ET → DJ = EXPORT) if missing. */ export function ensureExportRoute() { dbRouteId(EXP_ORIGIN, EXP_DEST).then(({ rows }) => { if (rows.length > 0) return; const stops = [...CORRIDOR].reverse(); db<{ id: string; code: string }>( `SELECT id, code FROM freight.yards WHERE code = ANY($1::text[])`, [stops], ).then(({ rows: yards }) => { expect(yards, "corridor yards").to.have.length(stops.length); const byCode = new Map(yards.map((y) => [y.code, y.id])); apiPost(opsStaff, "/api/routes", { milestones: stops.map((code) => ({ yardId: byCode.get(code) })), }) .its("status") .should("be.oneOf", [200, 201]); }); db<{ direction: string }>( `SELECT r.direction FROM freight.routes r JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = $1 JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = $2 WHERE r.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 1`, [EXP_ORIGIN, EXP_DEST], ).then(({ rows: created }) => { expect(created[0]?.direction, "export corridor direction").to.eq("EXPORT"); }); }); } /** * Ops accepts an EXPORT operation request — FCFS: the accept itself reserves * the train slot and opens a pay window clamped to the window close. */ export function acceptExport(suffix: string) { withBooking(suffix, (b) => { apiPost(opsStaff, `/api/bookings/${b.id}/operation/review`, { decision: "ACCEPT" }) .its("status") .should("be.oneOf", [200, 201]); }); pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 10); } export interface ScheduleRow { id: string; status: string; window_phase: string; booking_window_status: string; booking_cycle_no: number; max_wagons: number; window_opens_at: string | null; window_closes_at: string | null; payment_phase_ends_at: string | null; scheduled_departure_date: string; } const SCHEDULE_COLS = `ts.id, ts.status, ts.window_phase, ts.booking_window_status, ts.booking_cycle_no, ts.max_wagons, ts.window_opens_at, ts.window_closes_at, ts.payment_phase_ends_at, ts.scheduled_departure_date`; /** The corridor schedule departing within ±1h of `departure` (12:00 EAT pin). */ export function dbSchedule(departure: Date, destCode = DEST, originCode = ORIGIN) { return db( `SELECT ${SCHEDULE_COLS} 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 abs(extract(epoch FROM (ts.scheduled_departure_date - $3::timestamptz))) < 3600 ORDER BY ts.created_at DESC LIMIT 1`, [originCode, destCode, departure.toISOString()], ); } export function withSchedule(departure: Date, fn: (s: ScheduleRow) => void) { dbSchedule(departure).then(({ rows }) => { expect(rows, `schedule departing ${departure.toISOString()}`).to.have.length(1); fn(rows[0]); }); } /** Export-corridor (KALITY → DJIB_PORT) variant of withSchedule. */ export function withExportSchedule(departure: Date, fn: (s: ScheduleRow) => void) { dbSchedule(departure, EXP_DEST, EXP_ORIGIN).then(({ rows }) => { expect(rows, `export schedule departing ${departure.toISOString()}`).to.have.length(1); fn(rows[0]); }); } /** * Ops creates an import schedule on the corridor via the API — loco-pair mode * (no built train): capacity comes from maxWagonsPerTrain (54, the corridor * standard) and wagon stock is drawn from the origin yard at allocation time. */ export function createImportSchedule(opts: { departure: Date; locoPair: [string, string]; maxWagons?: number; kind?: "container" | "bulk"; originCode?: string; destCode?: string; }) { const originCode = opts.originCode ?? ORIGIN; const destCode = opts.destCode ?? DEST; dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => { if (rows.length > 0) return; dbRouteId(originCode, destCode).then(({ rows: routes }) => { expect(routes, "corridor route").to.have.length(1); db<{ id: string }>( `SELECT id FROM freight.locomotives WHERE code = ANY($1::text[]) ORDER BY code`, [opts.locoPair], ).then(({ rows: locos }) => { expect(locos, `locomotives ${opts.locoPair.join(",")}`).to.have.length(2); apiPost(opsStaff, `/api/train-scheduling/${opts.kind ?? "container"}/schedules`, { routeId: routes[0].id, scheduleDate: opts.departure.toISOString(), locomotiveIds: locos.map((l) => l.id), maxWagonsPerTrain: opts.maxWagons ?? 54, }) .its("status") .should("be.oneOf", [200, 201]); }); }); }); dbSchedule(opts.departure, destCode, originCode).then(({ rows }) => { expect(rows, "created schedule").to.have.length(1); expect(rows[0].max_wagons, "54-wagon consist").to.eq(opts.maxWagons ?? 54); }); } // --------------------------------------------------------------------------- // window choreography — arrange timestamps, let the engine do the transition // --------------------------------------------------------------------------- function pollSchedulePhase( scheduleId: string, want: string[], label: string, attempts = 40, ) { pollDb( label, `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`, [scheduleId], (row) => !!row && want.includes(row.window_phase as unknown as string), attempts, ); } /** Pull the window-open moment into the past; the tick flips PRE_WINDOW→OPEN. */ export function forceWindowOpen(scheduleId: string, closesInMinutes = 45) { db( `UPDATE freight.train_schedules SET window_opens_at = now() - interval '1 minute', window_closes_at = now() + ($2 || ' minutes')::interval WHERE id = $1`, [scheduleId, String(closesInMinutes)], ); pollSchedulePhase(scheduleId, ["OPEN"], `schedule ${scheduleId} window OPEN`); pollDb( `schedule ${scheduleId} bookable`, `SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`, [scheduleId], (row) => row?.booking_window_status === "OPEN", ); } /** Pull the close moment into the past; the tick flips OPEN→DOC_REVIEW. */ export function closeBookingWindow(scheduleId: string) { db( `UPDATE freight.train_schedules SET window_closes_at = now() - interval '1 second' WHERE id = $1 AND window_phase = 'OPEN'`, [scheduleId], ); pollSchedulePhase(scheduleId, ["DOC_REVIEW"], `schedule ${scheduleId} DOC_REVIEW`); } /** * Staff end document review early → PAYMENT: expires never-accepted bookings, * runs the priority batch over the route-day pool, reserves + issues invoices. * (Lands on DONE instead when the batch reserved nobody.) */ export function completeDocReview(scheduleId: string) { apiPost(opsStaff, `/api/train-scheduling/schedules/${scheduleId}/doc-review-complete`) .its("status") .should("be.oneOf", [200, 201]); pollSchedulePhase( scheduleId, ["PAYMENT", "DONE", "PRE_WINDOW"], `schedule ${scheduleId} payment phase`, ); } /** End the payment phase now — the tick settles (allocate paid / expire unpaid). */ export function endPaymentPhase(scheduleId: string) { db( `UPDATE freight.train_schedules SET payment_phase_ends_at = now() - interval '1 second' WHERE id = $1 AND window_phase = 'PAYMENT'`, [scheduleId], ); } // --------------------------------------------------------------------------- // train journey + clearance // --------------------------------------------------------------------------- export function recordCheckpoint(scheduleId: string, sequenceNo: number, kind: string) { apiPost(opsStaff, `/api/train-scheduling/schedules/${scheduleId}/checkpoints`, { sequenceNo, kind, }) .its("status") .should("be.oneOf", [200, 201]); } /** GL multipart upload via the Node-side task (cy.request can't send files). */ export function glUpload( path: string, fields: Record = {}, fileField = "files", ) { return tokenFor(superAdmin).then((token) => cy .task<{ status: number; body: unknown }>("api:upload", { url: `${apiUrl()}${path}`, token, fields, files: [{ field: fileField, fixture: "docs/license.pdf", filename: "e2e-doc.pdf" }], }) .then((res) => { expect(res.status, `upload ${path}`).to.be.within(200, 201); return cy.wrap(res.body, { log: false }); }), ); } export function completeBookingMilestone(suffix: string, code: string) { withBooking(suffix, (b) => { apiPost(superAdmin, `/api/contracts/bookings/${b.id}/milestones/${code}/complete`, { note: "e2e", }) .its("status") .should("be.oneOf", [200, 201]); }); } export function expectMilestoneDone(suffix: string, code: string) { withBooking(suffix, (b) => pollDb<{ n: string }>( `${suffix} milestone ${code}`, `SELECT count(*) AS n FROM freight.clearance_milestones WHERE booking_id = $1 AND milestone_code = $2 AND status = 'COMPLETED' AND deleted_at IS NULL`, [b.id, code], (row) => Number(row?.n ?? 0) > 0, 10, ), ); }