Files
edr-platform/e2e/freight/SCENARIO_ENGINE_NOTES.md

11 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.

Two engine changes the committed specs predate

Both were found by running the suite, and both broke EVERY scenario until fixed. They are recorded here because neither is visible from the scenario document — only from the API source.

1. Every contract booking is born in the clearance gate. contract-booking.service.ts:211"EVERY contract booking clears per booking now — both contract kinds, both paths, intercity included." A booking is created in AWAITING_DOCUMENTS regardless of whether customs clearance is enabled, so bookContainers followed by acceptOperation always 409s with Cannot perform this action on status "AWAITING_DOCUMENTS". Allowed: OPERATION_REQUEST_PENDING.

The gate is upload → GL approve → finalize → customer proceeds with the day. clearToOperationRequestPending (import-utils) runs it; bookAndClear (g1-utils) wraps book + clear + accept and is what the g-specs use.

NOTE: the pre-existing corridor specs (e.g. import_full_train.cy.ts) still call acceptOperation directly and fail for this reason — 3 passing / 10 failing when last run. They predate the gate and need the same treatment.

2. An unpaid hold cannot expire without a reachable payment gateway. Before expiring a reservation the engine asks the gateway whether a late payment landed (booking-batch.service.ts:3484-3506), and treats ANY error as unverifiable: true — deferring the expiry rather than risk expiring a customer who paid:

[BATCH] expire deferred for BK-… — settlement unverifiable at the
gateway; retrying next settle tick

PAYMENT_API_URL defaults to the real https://paymentcallback.triaplc.com (payment-client.service.ts:25), unreachable from e2e, so every expiry deferred forever. Six scenarios turn on an expiry: G1·S1, G1·S5, G1·S6, G3·S16, G5·S22, G5·S24.

Fixed with a stand-in service — e2e/freight/payment-mock/server.js, wired as payment-mock-e2e in docker-compose.e2e.yaml with PAYMENT_API_URL: http://payment-mock-e2e:4500. It answers POST /payments/reconcile with {paid:false, unverifiable:false} so the engine gets a definite "no payment exists" and expires the hold as designed. A stack that does NOT point PAYMENT_API_URL at a reachable service will hang on every expiry assertion.