/** * BULK IMPORT — six wheat bookings fill the 54-wagon CW4 train on the long * corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY, all in * the FIRST window, then the whole life of the train: payment through the real * gateway, allocation, gate pass, T1, dispatch, checkpoint-by-checkpoint * movement, arrival, and the post-arrival customs tail. * * 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T: * BF1 customs + USD 560 T = 8 w * BF2 customs + ETB 420 T = 6 w * BF3 self + ETB 420 T = 6 w * BF4 self + ETB 420 T = 6 w * BF5 customs + USD 1 540 T = 22 w (the ≥22-wagon giant) * BF6 self + USD 420 T = 6 w * * Difference from the Cypress twin: every payment goes through the payment * microservice and a signed gateway callback, not the staff mark-paid shortcut. * Steps are sequential and not idempotent — the file runs as one journey. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { closeDb, db, gateway } from "./client"; import { allocatedWagons, bookBulkReady, closeBookingWindow, completeDocReview, createSchedule, departureAt, dispatchSchedule, eatDayStr, endPaymentPhase, extendPayWindow, ensureCorridorRoute, expectMilestoneDone, forceWindowOpen, gatePassGranted, invoiceForBooking, milestoneCount, payViaGateway, pollBookingStatus, pollWindow, releaseUnpaidHolds, resetCorridorDay, runCorridor, runImportCustomsTail, scheduleRow, seedTenantContracts, uploadT1, } from "./flows"; const DEPARTURE = departureAt(20); const BOOKING_DAY = eatDayStr(DEPARTURE); const STAMP = String(Date.now()); const BOOKINGS = [ { suffix: "BF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 }, { suffix: "BF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 }, { suffix: "BF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 }, { suffix: "BF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 }, { suffix: "BF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 }, { suffix: "BF6", customs: false, currency: "USD" as const, tons: 420, wagons: 6 }, ]; const CUSTOMS = BOOKINGS.filter((b) => b.customs); const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs); describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", () => { const booking = new Map(); let scheduleId: string; beforeAll(async () => { await gateway.reset(); await releaseUnpaidHolds(); await ensureCorridorRoute(); await resetCorridorDay(DEPARTURE); const contracts = await seedTenantContracts( STAMP, BOOKINGS.map((b) => ({ suffix: b.suffix, currency: b.currency, customs: b.customs, freight: "BULK" as const, })), ); const schedule = await createSchedule({ departure: DEPARTURE, kind: "bulk", locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"], }); scheduleId = schedule.id; // The cycle counter turns over when the window opens, not at creation. await forceWindowOpen(scheduleId, 45); expect((await scheduleRow(scheduleId)).booking_cycle_no, "FIRST window cycle").toBe(1); for (const b of BOOKINGS) { booking.set( b.suffix, await bookBulkReady({ contractId: contracts.get(b.suffix)!, tons: b.tons, scheduledDate: BOOKING_DAY, customs: b.customs, }), ); } }, 1_800_000); afterAll(closeDb); it("reserves all six with invoices in their contract currency — they fit exactly", async () => { await closeBookingWindow(scheduleId); await completeDocReview(scheduleId); for (const b of BOOKINGS) { const row = await pollBookingStatus(booking.get(b.suffix)!, [ "SELECTED_FOR_BATCH", "AWAITING_PAYMENT", ]); expect(row.payment_deadline ?? "pending", `${b.suffix} pay deadline`).toBeTruthy(); const invoice = await invoiceForBooking(booking.get(b.suffix)!); expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency); } }); it("all six pay through the gateway — 54/54 wagons, window FULL, schedule finalized", async () => { await extendPayWindow(scheduleId, [...booking.values()]); for (const b of BOOKINGS) { await payViaGateway(booking.get(b.suffix)!); } await endPaymentPhase(scheduleId); await pollWindow( scheduleId, (s) => s.booking_window_status === "FULL" && s.window_phase === "DONE" && s.status === "SCHEDULED", "FULL + DONE + finalized", ); expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54); }); it("GL Djibouti grants the gate pass and uploads T1 for the customs bookings", async () => { await gatePassGranted(scheduleId); for (const b of CUSTOMS) { const res = await uploadT1(booking.get(b.suffix)!); expect(res.status, `${b.suffix} T1 upload`).toBeLessThanOrEqual(201); } }); it("the train dispatches and runs the corridor checkpoint by checkpoint", async () => { await dispatchSchedule(scheduleId); await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED"); for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "IN_TRANSIT", 15); await runCorridor(scheduleId); for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20); const [row] = await db<{ n: string }>( `SELECT count(*)::text AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`, [scheduleId], ); expect(Number(row.n), "wagon movement ledger rows").toBeGreaterThanOrEqual(54); }); it("GL runs the customs tail on every customs booking", async () => { for (const b of CUSTOMS) { const id = booking.get(b.suffix)!; await runImportCustomsTail(id); await expectMilestoneDone(id, "T1_CLOSED"); await expectMilestoneDone(id, "RISK_ASSIGNED"); await expectMilestoneDone(id, "IMPORT_RELEASE_GRANTED"); await expectMilestoneDone(id, "IMPORT_PROCESS_COMPLETED"); } }); it("the self-clearing bookings arrived clean — no customs tail", async () => { for (const b of SELF_CLEAR) { const id = booking.get(b.suffix)!; const row = await pollBookingStatus(id, "ARRIVED", 5); expect(row.status, `${b.suffix} final status`).toBe("ARRIVED"); expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0); } }); });