mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
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.
This commit is contained in:
141
e2e/freight/SCENARIO_ENGINE_NOTES.md
Normal file
141
e2e/freight/SCENARIO_ENGINE_NOTES.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 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 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.
|
||||
Reference in New Issue
Block a user