Files
edr-platform/e2e/freight/SCENARIO_ENGINE_NOTES.md
Marshal 123f925cc1 add Group 9 delivery scenarios for self-haul and last-mile paths
- Implemented G9·S38 tests for customer self-haul truck assignments, ensuring compliance with container limits and truck assignments.
- Added G9·S39 tests for last-mile delivery, self-haul, and yard pickup, verifying independent paths for multiple bookings on the same train.
- Created seed data for Group 1 and Group 2 scenarios, ensuring proper setup for weight and capacity tests.
- Updated booking interface to deprecate  in favor of  for better clarity in allocations.
2026-08-01 12:14:37 +00:00

9.0 KiB
Raw Blame History

Scenario ↔ engine reconciliation (S1S40)

Verified against the API source while writing the Group 1 specs. Every claim below carries a file:line; re-check them before trusting this file, it is a snapshot of the code as of the freight_feature/usermanagement branch.

The point of this file: several scenarios in the original 40-case document describe behaviour the engine does not implement. Those are not spec bugs to code around — they are either product gaps worth a ticket, or scenarios whose premise needs restating. Writing a green test against a premise the code contradicts is worse than having no test.

Capacity: 53 vs 54

The scenarios are written for a 53-wagon train. A loco-pair schedule cannot hold 53 on this corridor: syncScheduleMaxWagons recomputes max_wagons as floor(locoLength / shortest active wagon length), and seed-import-corridor.sql deliberately pins that at floor(760 / 13.966) = 54.

A built train is exempt — booking-batch.service.ts:4152:

const maxWagons = physicalWagons ?? capacityLimits(loco).base.wagons;

So Group 1 runs on TRN-G1-1, a 53-wagon built consist (seed-g1-train.sql). Note train-capacity.util.ts:107 calls 53 "the marshalling figure" and says the slot count is "never a fixed 53" — the number is real, it just has to come from a consist rather than from locomotive length.

Confirmed — scenario matches the engine

Scenario Engine fact Where
S1/S5 cascading promotion fillFromWaitingList loops up to 10 rounds until a pass reserves nothing booking-batch.service.ts:2788
S4/S6 split offers booking_batch_offers, status defaults OFFERED, offered_wagons booking-batch-offer.entity.ts:27,46,73
S9 heavy VGM NW5 tare 22.4T → 2×28 + 22.4 = 78.4T gross, exactly as the scenario computes train-capacity.util.ts:16,82
S11 split ignores tolerance tolerance "spendable only by admitting a booking whole, never by a split" train-capacity.util.ts:58, booking-batch.service.ts:4109
S13 holds occupy space reserved = SELECTED_FOR_BATCH/AWAITING_PAYMENT, subtracted from capacity until the deadline lapses bookings.repository.ts:1372, booking-batch.service.ts:4487
S18 oversized export rejected 409 at requestOperation with a sized message booking-transition.service.ts:1016, booking-batch.service.ts:900
S25 per-station arrival checkpoint at an intermediate yard auto-unloads bookings destined there booking-journey.service.ts:261
S30 cancel snapshot train_schedules.wagon_allocation_snapshot jsonb, frozen before wagons are released; holds wagon numbers + per-slot weights train-scheduling.service.ts:3984, 4647
S31 wagon transfer statuses PENDING/PARTIALLY_FULFILLED/FULFILLED/CLOSED_SHORT/CANCELLED packages/types/src/freight/index.ts:353
S31 movement ledger wagon_movements, kind EMPTY_REPOSITION, carries transfer_request_id wagon-movement.entity.ts:16,49,55
S37 risk history clearance_milestones.metadatariskLevel + append-only riskHistory (oldest-first) clearance-milestone.entity.ts:38, clearance-milestone.service.ts:250
S38 max 2 per truck MAX_CONTAINERS_PER_TRUCK = 2, error A truck carries at most 2 containers truck-load.util.ts:5,36
S38 duplicate container 409 Container X is already loaded onto another truck truck-load.util.ts:52
S40 re-price parks the booking batch pool is status = 'PAID' exactly, so PRICE_CHANGED_PENDING_CONFIRM holds zero capacity bookings.repository.ts:1136

Corrected — same intent, different mechanism

S7 government. The scenario says government "jumps the queue" via an institution field and a +50,000 priority. Two corrections:

  • the bonus is real (GOVERNMENT_PRIORITY_BONUS = 50_000, rule-engine.service.ts:258) but keys off bookings.is_government, set from contracts.is_government — there is no institution lookup;
  • government does not merely outrank, it preempts: it displaces the lowest-priority already-reserved commercial booking and rides unpaid (preemptForGovernment, and see flows/government_preemption.cy.ts). Created via POST /bookings + POST /bookings/:id/government-expedite against a kind='government' company, not through the contract wizard.

S8 priority tiers. USD_PAYER / RAIL_AND_FORWARDING no longer exist as priority types — migration 1783000000000-ReplacePriorityRulesWithPriorityConfigs replaced priority_rules with priority_configs, whose type is only WAGON | CURRENCY | CUSTOMS, scored by wagon-count range (priority-config.entity.ts, applied at rule-engine.service.ts:247). The scenario's ordering intent survives as: a CURRENCY(USD) config, a CUSTOMS config, and a plain booking that matches neither.

S3 "day stays open". Best asserted through GET /bookings/:id/day-availability?date={ fits, freeWagons, trainsForDay } (booking-transition.service.ts:1078), which is what the portal calendar reads.

Contradicted — the engine does NOT do this

These need a product decision before a test can be written honestly.

S26 — mid-corridor intercity is NOT blocked. The scenario expects Dire Dawa → GMP (both Ethiopian) to be rejected as disabled intercity. It is the opposite: DOMESTIC is a first-class direction derived from yard countries (bookings.service.ts:270), and the guard rejects non-Ethiopian endpoints — 'Intercity bookings only run between Ethiopian yards' (bookings.service.ts:218). The only related rejections are 'Intercity bookings cannot pin a date or schedule' (:771) and 'No route passes through this origin and destination in order'. → Either the scenario is stale, or intercity was meant to be disabled and is not. Ticket, not a test.

S29 — there is no out-of-order checkpoint guard. recordCheckpoint (train-scheduling.service.ts:3681) validates only: schedule exists, status is DISPATCHED, and the station is on the route. Nothing compares sequenceNo against the highest already logged, so "Arrived Adama" before "Passed Meiso" is accepted. currentSequenceNo is a Math.max (:3643) so the timeline does not visibly regress — which masks the real damage: the position fix at :3741 moves the locomotives, every wagon on the schedule, and the built train to that station's yard. A stray backward checkpoint silently relocates rolling stock. → Real bug. Worth a spec that documents current behaviour as .skip plus a ticket, rather than an assertion that pretends the guard exists.

S36 — importReleaseGranted is NOT gated on the second duty. The scenario expects release to stay false until the second duty settles. importReleaseGranted is computed from the IMPORT_RELEASE_GRANTED milestone (booking-clearance.service.ts:368), which completes purely by uploading a file with fieldname import_release. completeByDocTrigger (clearance-milestone.service.ts:430) performs no precondition check, and assertPriorCompleteOnMilestones only walks pre-booking milestones (clearance-workflow.service.ts:119) — so all 17 post-DO_COLLECTED codes, including the whole T1_CLOSED → RISK_ASSIGNED → SECOND_DUTY_* → IMPORT_RELEASE_GRANTED tail, are unordered. → Release can be granted with SECOND_DUTY_PAID still PENDING. Real gap.

S40 — hazardous quantity is NOT rejected, it is silently clamped. The scenario expects hazardousQuantity=12 on a quantity=10 line to be rejected. The DTO has @Min(0) and no @Max (create-booking.dto.ts:60), and the repository clamps to 0..quantity (bookings.repository.ts:217): the booking is created 201 with the value truncated, no warning. Reefer behaves identically. Note returnQuantity — same layer, same shape of data — does throw (contract-booking.service.ts:1740), so the pattern exists and these two just do not use it. → Real gap. A test asserting rejection would fail today.

S40 — reefer is NOT derived from container type. The scenario expects reeferQuantity forced to 0 for DRY types. No such logic exists; a DRY type with reeferQuantity > 0 is an explicitly supported state and applies the surcharge anyway (booking.entity.ts:384, booking-pricing.service.ts:402).

Flag-dependent — assert the flag, or the test is vacuous

S13/S14/S18 "export never splits" holds only while FREIGHT_EXPORT_SPLIT !== "true" (booking-batch.service.ts:394). With the flag on, isSplitEligible admits EXPORT (:2558) and tryExportPartialOffer (:1240) runs. The export specs must assert the flag is off, or they silently stop testing whole-or-nothing the day someone flips it.

Known type hole (not scenario-blocking)

Last-mile and first-mile billing write the literal 'last_mile' / 'first_mile' cast past the type checker (last-mile-invoice.service.ts:41), while Freight.InvoiceSource.LastMile is "lastmile". invoices.source is a plain varchar with no constraint, so both spellings persist. Writes and reads agree within each module so billing works — but S39's "invoice source LASTMILE" assertion must match 'last_mile', not the enum value.