Bulk scenario catalog — BS1–BS40
Real-world bulk (PER_TON) and break-bulk (PER_ITEM) scenarios on the e2e
corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY and its
export inverse. Companion to SCENARIO_ENGINE_NOTES.md (containers, S1–S40).
Fleet facts (dev + e2e DB, verified 2026-08-01):
| Wagon |
Capacity |
Length |
Tare |
| CW4 (wheat, autos, machinery) |
70 T |
13.976 m |
24.8 T |
| PW2 (grains) |
70 T |
17.066 m |
25.2 T |
Standard bulk train: 54 CW4 wagons / 3 780 T cargo. Per-item math
(train-capacity.util.ts): items per wagon = min(floor(capacity ÷ per-item
tons), configured floor from cargo_types.items_per_wagon_map); items never
split across wagons; bulk partial offers are whole wagons at full rated
payload only (sizePartialOfferWagons fullWagonsOnly).
E2E cargo codes: E2E_IMP_WHEAT (PER_TON, CW4), E2E_IMP_GRAINS (PER_TON,
PW2), E2E_IMP_AUTO (PER_ITEM, CW4, floor 4/wagon — seed-bulk-items.sql),
E2E_IMP_MACHINE (PER_ITEM, CW4, no floor → tonnage-only).
Coverage column: spec that runs it, or doc-only (same engine path already
proven by the named spec — a bulk twin adds no new engine coverage), or
gap (engine contradicts the expectation — ticket, not a test).
⚠ bulk_b1_*, bulk_b2_*, bulk_b3_* are authored but not yet run —
first execution may need assertion tuning.
A. Fill · payment · waitlist (import wheat, PER_TON)
| # |
Scenario |
Expected |
Coverage |
| BS1 |
Six wheat bookings (560+420+420+420+1540+420 T = 54 wagons) fill the CW4 train in the first window; mixed USD/ETB, customs/self |
All selected, all pay, 54/54 allocated, window FULL, schedule finalized |
bulk_import_full_train |
| BS2 |
Staff priority ordering: 1 960 T + 1 400 T + 700 T (58 w > 54); staff put the 700 T relief cargo first |
Priority order wins: 700 T + 1 960 T reserved whole, 1 400 T gets a whole-wagon offer for the leftover 16 w |
bulk_b1_priority_expiry_refill |
| BS3 |
Reserved giant misses the 1 h pay window |
EXPIRED; its 28 wagons return; refill round promotes the offered booking WHOLE (offer superseded) |
bulk_b1_priority_expiry_refill |
| BS4 |
Waitlisted wheat promoted on expiry |
Waiting-list booking selected in the freed space, pays, rides |
bulk_import_waiting_expiry |
| BS5 |
Partial offer accepted → split |
is_split = true, offered wagons allocated, remainder must rebook |
bulk_import_split_promote |
| BS6 |
Window reopens after under-fill |
Second cycle opens; late bookings enter cycle 2 |
bulk_import_window_reopen |
| BS7 |
Booking on a day with no open window |
Rejected at creation ("booking window") |
bulk_critical_matrix |
| BS8 |
Currency per booking: USD and ETB invoices on one train |
Each invoice carries its booking's currency |
bulk_import_full_train |
B. Capacity axes · wagon types · giants (PER_TON)
| # |
Scenario |
Expected |
Coverage |
| BS9 |
4 000 T giant alone on a 54-wagon train |
Full-consist offer 3 780 T, gateway settle applies split, train FULL from one booking |
bulk_critical_matrix |
| BS10 |
Giant's 220 T remainder rebooks |
100 T attempt rejected ("must take the whole"); exactly 220 T accepted |
bulk_critical_matrix |
| BS11 |
Wheat rides CW4 only, grains ride PW2 only |
expectWagonType CW4 for wheat / PW2 for grains on their trains |
export_ledger_day + bulk_b1 (CW4 assert) |
| BS12 |
PW2 length tax: 17.066 m wagons on a 760 m board |
44 slots by length vs CW4's 54 — same tonnage needs a longer consist |
doc-only (export_ledger_day runs the 37-wagon PW2 board) |
| BS13 |
Gross weight = tare + cargo (PW2: 37 × 95.2 = 3 522 T ≈ the 3 500 T pull limit) |
Weight axis binds before slots; overbooking by tare fraction impossible |
unit-tested in train-capacity.util + export_ledger_day |
| BS14 |
Tolerance spent only on a whole booking, never sizing a split |
Split offers budget against base caps |
doc-only (booking-batch.service.ts:4109; container twin g2) |
| BS15 |
Two bulk bookings, one over-weight last wagon |
Batch trims to whole wagons at full payload — no part-loaded squeeze into leftover pull weight |
doc-only (sizePartialOfferWagons fullWagonsOnly) |
| BS16 |
Sub-corridor 700 T (NAGAD→MOJO) shares the through-train with a 1 400 T DJIB_PORT→KALITY booking |
Both allocated to the same schedule |
bulk_critical_matrix |
C. Break-bulk PER_ITEM (autos, machinery — NEW)
| # |
Scenario |
Expected |
Coverage |
| BS17 |
16 automobiles @ 2.5 T (40 T). Tonnage alone says 1 wagon; floor says 4/wagon |
4 wagons allocated — physical floor binds, rated capacity rides empty |
bulk_b2_per_item_floor |
| BS18 |
12 machines @ 20 T (240 T), no configured floor |
floor(70/20) = 3 per wagon → 4 wagons — tonnage fallback binds |
bulk_b2_per_item_floor |
| BS19 |
216 automobiles (540 T) — exactly 54 wagons |
Whole booking fits, no split; train FULL from one break-bulk booking |
bulk_b2_per_item_floor |
| BS20 |
Giant: 240 automobiles (60 wagons) on a 54-wagon train |
Whole-wagon offer of 216 autos; is_split = true; 54/54; FULL |
bulk_b3_per_item_giant_quantities |
| BS21 |
Giant's remainder: 24 autos outstanding |
10-auto attempt rejected ("must take the whole"); exactly 24 accepted |
bulk_b3_per_item_giant_quantities |
| BS22 |
hazardousQuantity 12 on a 10-item line |
Engine CLAMPS to 10, returns 201 — no rejection (same gap as container S40). Spec asserts the clamp so the gap is visible |
bulk_b3_per_item_giant_quantities |
| BS23 |
reeferQuantity 3 on an 8-item line |
Stored on the booking (bulk_reefer_quantity = 3), reefer surcharge applies |
bulk_b3_per_item_giant_quantities |
| BS24 |
One item heavier than a whole wagon (80 T machine on a 70 T CW4) |
Engine still charges 1 wagon per item (ponytail: note in bulkItemWagonsRequired) — creation-time rejection does NOT exist |
gap — ticket, not a test |
D. Export bulk (KALITY → DJIB_PORT)
| # |
Scenario |
Expected |
Coverage |
| BS25 |
Sesame/grain export fills the PW2 board |
Whole-booking placement, board FULL |
bulk_export_full_train |
| BS26 |
Export FCFS: space taken by earlier holds |
Later booking sees reduced space |
bulk_export_fcfs_space |
| BS27 |
Export pay-or-lose: hold lapses at deadline |
Space returns, next customer takes it |
bulk_export_pay_or_lose |
| BS28 |
Export whole-or-nothing (no split) |
Oversized booking 409s with a sized message |
bulk_export_matrix |
| BS29 |
Export matrix: currencies × customs |
Per-combination invoice + clearance behavior |
bulk_export_matrix |
| BS30 |
"Export never splits" is flag-dependent |
Assert FREIGHT_EXPORT_SPLIT !== "true" or the suite is vacuous |
noted in SCENARIO_ENGINE_NOTES.md |
| BS31 |
Export day ledger: who booked/rode/expired/refused |
Ledger report written per day |
export_ledger_day |
E. Corridor ops · intercity · disruptions
| # |
Scenario |
Expected |
Coverage |
| BS32 |
Bulk intercity ride-along (MOJO→KALITY, DOMESTIC, dateless) accepted onto the import train's free leg |
Pay window opens on accept; paid + linked |
bulk_critical_matrix |
| BS33 |
Checkpoint-by-checkpoint corridor run; bookings ARRIVED at terminal |
Statuses walk IN_TRANSIT → ARRIVED; ≥54 wagon-movement ledger rows |
bulk_import_full_train |
| BS34 |
Mid-corridor auto-unload: booking destined MOJO on a KALITY train |
Checkpoint at MOJO auto-unloads it (booking-journey.service.ts:261) |
container twin g6_corridor — same engine path |
| BS35 |
Schedule cancelled after bulk allocation |
Frozen wagon_allocation_snapshot, bookings re-pool still PAID |
container twin g7_disruptions — same engine path |
| BS36 |
Paid bulk booking, yard short of CW4 → transfer request |
WAITING_FOR_WAGON → PARTIALLY_FULFILLED → FULFILLED → placed |
fleet_wagon_transfer |
F. Customs tail · validation
| # |
Scenario |
Expected |
Coverage |
| BS37 |
Customs bookings: gatepass → T1 → dispatch → T1 close → risk → second duty → release → final invoice |
Full milestone tail COMPLETED in order |
bulk_import_full_train |
| BS38 |
Self-clearance bookings arrive with NO customs tail |
Zero T1_CLOSED milestones |
bulk_import_full_train |
| BS39 |
importReleaseGranted not gated on second duty |
Release grantable with SECOND_DUTY_PAID pending — known gap |
SCENARIO_ENGINE_NOTES.md §S36 |
| BS40 |
Out-of-order checkpoint relocates rolling stock silently |
No sequence guard in recordCheckpoint — known bug |
SCENARIO_ENGINE_NOTES.md §S29 |