/** * BULK EXPORT — six wheat bookings fill the 54-wagon CW4 train on the reversed * corridor KALITY → MOJO → E2E_AWASH → DIRE_DAWA → NAGAD → DJIB_PORT, inside * the ONE FCFS export window, then the full life of the train to Djibouti Port * and the export customs tail. * * Export is FCFS: the staff accept IS the reservation — there is no batch — and * a pay deadline may never outlive the window close. Both are asserted here. * * 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T: * XBF1 customs USD 560 T = 8 w · XBF2 customs ETB 420 T = 6 w * XBF3 self ETB 420 T = 6 w · XBF4 self ETB 420 T = 6 w * XBF5 customs USD 1 540 T = 22 w · XBF6 self USD 420 T = 6 w */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { closeDb, gateway } from "./client"; import { EXP_DEST, EXP_ORIGIN, allocatedWagons, bookBulkReady, bookingRow, createSchedule, departureAt, dispatchSchedule, eatDayStr, ensureExportRoute, extendPayWindow, expectMilestoneDone, finalizeSchedule, forceWindowOpen, gatePassGranted, invoiceForBooking, milestoneCount, payViaGateway, pollAllocations, pollBookingStatus, pollWindow, releaseUnpaidHolds, resetCorridorDay, runCorridor, scheduleRow, seedTenantContracts, closeT1, completeMilestone, uploadTransportDocument, } from "./flows"; const DEPARTURE = departureAt(30); const BOOKING_DAY = eatDayStr(DEPARTURE); const STAMP = String(Date.now()); const BOOKINGS = [ { suffix: "XBF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 }, { suffix: "XBF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 }, { suffix: "XBF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 }, { suffix: "XBF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 }, { suffix: "XBF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 }, { suffix: "XBF6", 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 export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", () => { const booking = new Map(); let scheduleId: string; beforeAll(async () => { await gateway.reset(); await releaseUnpaidHolds(); await ensureExportRoute(); await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST); const contracts = await seedTenantContracts( STAMP, BOOKINGS.map((b) => ({ suffix: b.suffix, currency: b.currency, customs: b.customs, freight: "BULK" as const, direction: "EXPORT" as const, })), ); scheduleId = ( await createSchedule({ departure: DEPARTURE, kind: "bulk", locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"], originCode: EXP_ORIGIN, destCode: EXP_DEST, }) ).id; await forceWindowOpen(scheduleId, 60); for (const b of BOOKINGS) { booking.set( b.suffix, await bookBulkReady({ contractId: contracts.get(b.suffix)!, tons: b.tons, scheduledDate: BOOKING_DAY, mode: "export", customs: b.customs, }), ); } }, 1_800_000); afterAll(closeDb); it("each accept reserved immediately, with a deadline clamped to the window close", async () => { const schedule = await scheduleRow(scheduleId); const closesAt = new Date(String(schedule.window_closes_at)).getTime(); for (const b of BOOKINGS) { const row = await bookingRow(booking.get(b.suffix)!); expect(row.payment_deadline, `${b.suffix} pay deadline`).toBeTruthy(); expect( new Date(row.payment_deadline!).getTime(), `${b.suffix} deadline never outlives the window close`, ).toBeLessThanOrEqual(closesAt); const invoice = await invoiceForBooking(booking.get(b.suffix)!); expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency); } }); it("all six pay — 54/54 allocated, the export window flips FULL, staff finalize", async () => { await extendPayWindow(scheduleId, [...booking.values()]); for (const b of BOOKINGS) { await payViaGateway(booking.get(b.suffix)!); await pollAllocations(booking.get(b.suffix)!, b.wagons); } await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "export window FULL"); expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54); await finalizeSchedule(scheduleId); await pollWindow(scheduleId, (s) => s.status === "SCHEDULED", "SCHEDULED"); }); it("gate pass + transport documents, then the train runs the corridor to the port", async () => { await gatePassGranted(scheduleId); for (const b of CUSTOMS) { const res = await uploadTransportDocument(booking.get(b.suffix)!); expect(res.status, `${b.suffix} transport document`).toBeLessThanOrEqual(201); } await dispatchSchedule(scheduleId); await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED"); await runCorridor(scheduleId); for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20); }); it("GL Djibouti closes the export tail on every customs booking", async () => { for (const b of CUSTOMS) { const id = booking.get(b.suffix)!; await closeT1(id); await completeMilestone(id, "OFFLOADED"); await expectMilestoneDone(id, "T1_CLOSED"); await expectMilestoneDone(id, "OFFLOADED"); } }); it("the self-clearing bookings arrived clean — no customs tail", async () => { for (const b of SELF_CLEAR) { const id = booking.get(b.suffix)!; expect((await bookingRow(id)).status, `${b.suffix} final status`).toBe("ARRIVED"); expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0); } }); });