- 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.
9.0 KiB
Scenario ↔ engine reconciliation (S1–S40)
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.metadata → riskLevel + 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 offbookings.is_government, set fromcontracts.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 seeflows/government_preemption.cy.ts). Created viaPOST /bookings+POST /bookings/:id/government-expediteagainst akind='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.