23 KiB
EDR Freight — Operations QA Test Plan
End-to-end test flows from booking through warehouse, rail, and delivery — import and export, with and without first/last mile, self-haul and EDR haulage. Every status, guard, and endpoint below is taken from the code, not assumed.
| Branch | Truckdetantion |
| Scope | Warehouse · Fees · Allocation · First mile · Last mile |
| Depth | Tester steps + technical refs |
00 · Test data setup
Nothing below passes without this. Set it up once per environment and confirm each line before opening a single flow.
- Warehouse tree. At least one
ACTIVEwarehouse with a yard and a zone. Capacities are in tonnes, not kg. - Company has a linked portal user. Critical — in-app notifications resolve recipients from the company's portal users. With none linked,
notify()logs0 recipients — skippedand stores nothing. SMS/email still fire. - Customer saved signature. Required for Approve delivery; without it the API returns "Please save your signature before approving delivery".
- Allocation rules covering the freight type and trade direction under test (see §08), or accept the capacity-balanced fallback.
- Fee rules — at least one each of
STORAGE_FEE,DEMURRAGE_FEE,DOUBLE_HANDLING_FEE,TRUCK_DETENTION_FEE(see §07). - Drivers and vehicles registered; a train schedule with wagons for the route under test.
- Booking reaches
PAID. Receive-to-warehouse skips any booking that is not PAID. - Container numbers are ISO 6346 — 4 letters + 7 digits, uppercase (
ABCU1234567). Enforced at booking input and at every reference point.
Direction is derived, not declared. Receive-to-warehouse computes trade direction from the origin and destination yard countries, not the booking's stored
trade_direction. A booking whose route says IMPORT will be skipped from an EXPORT receive with "Booking route is IMPORT, not EXPORT". Set up yards accordingly.
01 · Lifecycle reference
The three state machines a tester needs to read a failure. Anything not listed as an allowed
transition is rejected by assertTransition.
Warehouse inventory transitions
| From | Allowed next | Notes |
|---|---|---|
UNLOADED |
STORED, READY_FOR_PICKUP |
Import landing state after train unload |
RECEIVED |
STORED, READY_FOR_PICKUP |
Export landing state after truck receive |
STORED |
RESERVED, READY_FOR_LOADING |
Reserve is retired from the UI; STORED → READY_FOR_LOADING is the live path |
READY_FOR_LOADING |
LOADED |
Onto a wagon |
LOADED |
DISPATCHED |
|
DISPATCHED |
UNLOADED_AT_DJIBOUTI_PORT |
Export only, at Djibouti |
READY_FOR_PICKUP |
DELIVERED, STORED, DISPATCHED |
Import; may be put back into storage |
Container item stages
PENDING → RECEIVED → GRN → ASSIGNED → LOADED → LEFT → DELIVERED
ASSIGNED means the customer picked which containers ride which truck — planning only.
LOADED requires the operator to actually load them, and only after the truck has arrived
(loaded_at is stamped then). Assignment alone must never show LOADED.
First mile & last mile
| Leg | Statuses, in order | Gate it controls |
|---|---|---|
| First mile | PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT |
Export receive is blocked until RECEIVED_TO_PORT |
| Last mile | PAYMENT_PENDING → READY_TO_TRANSIT → IN_TRANSIT → DELIVERED |
Truck-detention window: arrivedAt (reached destination) → deliveredAt (vehicle returned) |
02 · Export — without first mile
Customer brings the cargo to the facility themselves. The happy path from a paid booking to cargo unloaded at Djibouti port with an interchange document.
| Step | Tester action | Expected result | Technical ref |
|---|---|---|---|
| E1.1 | Create an export booking (route origin ET → destination DJ), pay it. | Booking reaches PAID. |
Container numbers must be ISO 6346 |
| E1.2 | Export Operations → Receive for Loading. Select the booking, capture the truck entrance (plate, driver, weights), pick warehouse/yard/zone. | Inventory created; GRN issued as GRN-EXPORT-YYYYMMDD-XXXXXXXX. Customer gets a receive SMS. |
POST /warehouse-inventory/receive-bulk |
| E1.3 | Store the item — auto-allocate, or pick warehouse/yard/zone in the Store modal. | Status STORED; note records "allocation rule", "capacity-balanced", or "operator-selected". |
Capacity decremented in tonnes |
| E1.4 | Inspect: mark selected items as inspected, outcome PASSED. | Export items advance straight to READY_FOR_LOADING. |
Reserve step is retired |
| E1.5 | Ready To Load tab → load onto the allocated wagon. | Status LOADED; a warehouse loading record exists. |
Requires an allocated wagon |
| E1.6 | Download the export marshalling / load list PDF. | PDF lists the train's wagons, bookings, containers. | train-scheduling controller |
| E1.7 | Dispatch Queue → dispatch. | Status DISPATCHED. Customer receives "Shipment dispatched" naming origin → destination. |
Per booking on the schedule |
| E1.8 | Move the schedule to arrived at the Djibouti-side port. | Train appears in the Djibouti unloading queue. Customer receives "Shipment arrived". | status ARRIVED / ARRIVED_AT_DJIBOUTI |
| E1.9 | Grant the gate pass for the train, then Unload at Djibouti. | Items become UNLOADED_AT_DJIBOUTI_PORT and an interchange document is generated. |
Unload checks gatepass_granted_at |
| E1.G | Try to unload at Djibouti before granting the gate pass. | 🚫 Blocked. Items skipped with a gate-pass reason; no interchange document. | See §06 |
03 · Export — with first mile
EDR collects the cargo from the customer's premises. Identical to §02 from the store step onward; the difference is entirely in the gate before receive.
| Step | Tester action | Expected result | Technical ref |
|---|---|---|---|
| E2.1 | Create the export booking with a first-mile pickup address (or a service type that includes first mile). | Booking is flagged hasFirstMile. |
Derived from address or service_types.includes_first_mile |
| E2.2 | Create the first-mile request; assign a vehicle and driver. | Driver receives an SMS naming the vehicle, booking, pickup and destination. | First Mile page |
| E2.3 | Walk the leg: READY_TO_TRANSIT → IN_TRANSIT → RECEIVED_TO_PORT. |
Each transition persists. | |
| E2.4 | Now run Receive for Loading. | Booking is received; truck entrance pre-filled from the first-mile vehicle and driver. | Then continue at E1.3 |
| E2.G1 | Attempt receive with no first-mile request created. | 🚫 Skipped: "First-mile request not created". | |
| E2.G2 | Attempt receive while first-mile status is IN_TRANSIT. |
🚫 Skipped: "First-mile truck has not arrived". | Only RECEIVED_TO_PORT passes |
04 · Import — self-haul (customer collects)
The longest flow, and the one with the most guards. The customer assigns their own trucks, signs a booking-level handover, and collects. Test this one first — it exercises truck arrival, loading, weighing, handover, exit paper, and fees.
train arrives → unload → inspect → ready for pickup → assign truck → truck arrival
→ sign handover → load → truck leaving → exit paper → deliver
| Step | Tester action | Expected result | Technical ref |
|---|---|---|---|
| I1.1 | Import booking (route DJ → ET), paid, no last-mile address and a service type that excludes last mile. | Booking is self-haul. | Drives usesCustomerTruck |
| I1.2 | Import Operations → Arrival Queue. Open the arrived train, assign warehouse/yard/zone per booking, Auto Unload Arrived Bookings. | Inventory created at UNLOADED. Counter shows n/total unloaded. |
Train must be ARRIVED |
| I1.3 | Inspect the item, outcome PASSED. | Item advances to READY_FOR_PICKUP. Customer receives "Assign a truck for pickup" (in-app + SMS + email). |
Fires only when self-haul and no truck assigned |
| I1.4 | Portal: customer assigns truck(s), entering ISO container numbers per truck. | Containers move to stage ASSIGNED and show their planned truck. Booking becomes TRUCK_ASSIGNED. |
20ft → max 2/truck; 40ft → 1/truck; trucks ≤ containers |
| I1.5 | Backoffice row menu → Truck Arrival. Select the assigned truck, record gate-in time and tare weight (tonnes). | Truck stamped arrived. A SELF_HAUL handover is generated (booking-level) and the customer is notified to sign, on all three channels. | Truck Arrival disabled until a truck is assigned |
| I1.6 | Portal: customer opens the booking → Approve delivery. | The handover PDF opens for review; approving applies their saved signature and returns the signed PDF. | Signs all unsigned handovers on the booking |
| I1.7 | Open the container list → select the assigned containers → Load onto truck (pick the arrived truck). | Containers move to LOADED; loaded_at stamped. |
Only arrived, not-departed trucks are listed |
| I1.8 | Row menu → Truck Leaving. Select the containers on the truck, record gate-out time and gross weight. | Net is computed from the selected containers' cargo weight and must equal gross − tare. Release document issued. |
Weight match enforced client- and server-side |
| I1.9 | Generate the exit paper for the truck. | PDF lists the truck, driver, and its containers. Containers move to LEFT. |
Requires signed handover + cleared fees |
| I1.10 | Deliver the inventory, recording the receiver name. | Status DELIVERED; handovers stamped delivered. |
Requires handover signed and truck departed |
🚫 The single most likely bug you will hit. Exit paper returns
400when the handover is not fully signed, or when a warehouse fee invoice isISSUED/PARTIALLY_PAID. In the container list, the Exit Paper button turns grey and clicking it sends the customer a signature request instead of erroring. That is correct behaviour — verify the message, don't file it as a bug.
05 · Import — EDR last mile
EDR delivers to the customer's door. No customer truck, no portal Approve delivery, and the handover is per delivering truck — not per booking. This is where truck detention accrues.
| Step | Tester action | Expected result | Technical ref |
|---|---|---|---|
| I2.1 | Import booking with a last-mile delivery address (or service type including last mile). | hasLastMile is true; no "assign a truck" notification is sent. |
EDR haulage — customer assigns nothing |
| I2.2 | Unload from the arrived train, inspect PASSED. | Item becomes READY_FOR_PICKUP and the last-mile leg is accepted automatically. |
|
| I2.3 | Last Mile page: assign vehicle + driver. | Leg reaches READY_TO_TRANSIT. Row shows Assigned. |
Driver notified by SMS |
| I2.4 | Truck Arrival from the Last Mile row: gate-in, tare weight. | Weighing saved; the assigned last-mile truck is pre-selected. | Re-opening must show the saved details |
| I2.5 | Truck Leaving: gate-out, gross weight. | Release document issued; leg moves to IN_TRANSIT. arrivedAt starts the detention clock. |
Detention window opens |
| I2.6 | Deliver at the customer's door, recording the receiver name. | Item DELIVERED. An EDR_LAST_MILE handover is generated per delivering truck, resolved from the container's allocated vehicle. |
Not booking-level |
| I2.7 | Return the vehicle → mark the leg DELIVERED. |
deliveredAt stamped; detention clock stops. |
|
| I2.8 | Preview, then generate the truck detention invoice. | Charged per truck per day beyond the grace hours, at the matching tier. See §07. | POST /last-mile/:id/generate-truck-detention-invoice |
| I2.G | Open the booking in the portal. | 🚫 Approve delivery must NOT appear. The portal flag counts only SELF_HAUL handovers; EDR handovers are signed by the receiver at the door. |
Regression check |
06 · Marshalling, gate pass, interchange
Documents are generated, not uploaded. Each has a precondition; test the precondition, not just the PDF.
| Document | When | Precondition | Verify |
|---|---|---|---|
| GRN | On receive to warehouse | Booking PAID; route direction matches; export needs a truck entrance |
Number format GRN-<DIR>-<YYYYMMDD>-<8>; PDF opens; customer SMS sent |
| Import load list / marshalling | Import train, before unload | Schedule has bookings assigned | Summary endpoint and printable PDF agree; portrait/landscape both render |
| Export marshalling / load list | Export train, after loading | Items LOADED onto wagons |
Wagon, booking, container rows are complete |
| Gate pass | Djibouti-side operations | Granted per schedule | gatepass_granted_at is set; export Djibouti unload reads this same field |
| Interchange document | Automatically, after a successful Djibouti unload | At least one item unloaded | Document number returned in the unload response; visible in Interchange Documents |
| Handover | Self-haul: on truck arrival. EDR: at delivery. | See §04 / §05 | Self-haul is booking-level, one per booking; EDR is one per delivering truck |
| Exit paper / release doc | Truck leaving | Handover fully signed and no unpaid warehouse fee | Weights on the paper match the gate weighing |
⚠️ Cross-module quirk worth confirming with the team. The export Djibouti unload checks the gate-pass flag stored on the import Djibouti operations record (
import_djibouti_operations.gatepass_granted_at). It works, but it is surprising. If an export unload silently skips every item, check that field first.
07 · Fee rules
Four rule types. Two are day-based with free days and tiers; double handling is a flat rate multiplied by a basis; truck detention is hour-graced and vehicle-scoped.
| Rule type | Charged on | Key fields | Test cases |
|---|---|---|---|
STORAGE_FEE |
Days in storage | free days, tiers |
Within free days → zero. One day past → tier 1. Cross a tier boundary → correct tier rate. |
DEMURRAGE_FEE |
Days beyond free time | free days, tiers |
Same boundary tests. Confirm it blocks exit paper and delivery while ISSUED. |
DOUBLE_HANDLING_FEE |
Flat rate × quantity | basis: PER_CONTAINER | PER_TON | PER_ITEM |
Container booking → PER_CONTAINER uses container count. Bulk → PER_TON uses tonnage. Break-bulk machinery → PER_ITEM uses item count. Import only. Free days and tiers must not apply. |
TRUCK_DETENTION_FEE |
Per truck, per day | free_hours grace, tiers, vehicle type scope |
Return inside the grace window → zero. Just past grace → day 1 at tier 1. Multi-day → tier escalation. A vehicle type outside the rule's scope → no charge. Import only. |
Fee behaviour to verify on every rule
- Preview before invoice. The preview amount must equal the issued invoice total.
- Notification on issue. Issuing a warehouse fee invoice sends the customer an in-app
INVOICE_ISSUEDnotification and an SMS, deep-linked to pay. - Clearance gate. While a warehouse-source invoice is
ISSUEDorPARTIALLY_PAID, exit paper, terminal release, and Approve delivery are all blocked. - Payable-but-uninvoiced. If fees are payable and no invoice exists yet, release is still blocked with "Generate and fully pay…". Confirm the operator can generate it from that state.
- Fully paid → release proceeds; a receipt PDF is available.
- Edit a rule (rate, free days, tiers, grace hours) and confirm the next preview reflects it.
08 · Allocation rules
Where an item is stored is decided by the first matching rule, in priority order. Test the precedence, not just one rule.
| Match criteria (any may be null = wildcard) | Targets |
|---|---|
freight_type, trade_direction, cargo_type_code, container_status, requires_inspection, ordered by priority |
target_facility_code, target_warehouse_code, target_yard_code (required), target_zone_code, storage_type |
Precedence, highest first
| # | Source of the location | How to trigger | Note recorded |
|---|---|---|---|
| 1 | Operator selection | Store modal → pick warehouse + yard + zone | "Stored at operator-selected location" |
| 2 | Allocation rule | Leave the Store modal blank; a matching rule exists | "Stored by allocation rule <name>" |
| 3 | Capacity-balanced fallback | Leave blank; no rule matches | "Stored by capacity-balanced allocation" |
- Two matching rules → the lower priority number wins.
- Yard dropdowns are filtered by freight type — container bookings offer container yards only.
- Only
ACTIVEwarehouses, yards and zones are selectable. - Storing beyond a zone's capacity is rejected; capacities are compared in tonnes.
- Move an item to another warehouse/yard/zone → capacity released at source, taken at destination.
- Edit a rule, an existing warehouse, a yard, and a zone — all four must be editable.
09 · Negative & guard cases
Every row here is intended behaviour. The test passes when the action is refused with the stated message. Anything that succeeds is the bug.
| Area | Attempt | Expected refusal |
|---|---|---|
| Booking | Enter a container number that is not 4 letters + 7 digits (e.g. MSKU10105185, 3456789). |
"Enter a valid ISO container number". Lowercase is auto-uppercased; input capped at 11 characters. |
| Booking | Enter the same container number twice in one shipment. | "Duplicate container number in this shipment." |
| Receive | Receive a booking that is not PAID. |
Skipped: "Booking not PAID". |
| Receive | Receive the same booking twice. | Skipped: "Already received". |
| Receive | Export receive with no truck entrance captured. | Rejected before any inventory is created. |
| Truck assign | Put two 40ft containers on one truck. | "A 40ft container fills the truck — assign only 1 container to this truck". |
| Truck assign | Put three containers on one truck. | "A truck carries at most 2 containers". |
| Truck assign | Assign more trucks than the booking has containers. | "Cannot assign more trucks than containers…". |
| Truck assign | Assign a container from another booking, or one already on another truck. | "…is not one of this booking's containers" / "…already loaded onto another truck". |
| Truck assign | Edit a truck after it has arrived. | Refused — edits are allowed only until arrival. |
| Loading | Load containers onto a truck that has not arrived. | "Record the truck arrival before loading…". The truck picker lists only arrived, not-departed trucks. |
| Loading | Load onto a truck that has already departed. | "This truck has already left — its load is locked". |
| Stages | Customer assigns containers to a truck, then check the container list. | Stage is ASSIGNED, never LOADED. Exit Paper is not offered. |
| Truck leaving | Enter a gross weight where gross − tare ≠ the selected containers' cargo weight. |
"Weight mismatch…". Exit paper and gate clearance blocked, client and server. |
| Truck leaving | Save leaving with no containers selected. | "Select the containers loaded on this truck". |
| Exit paper | Generate before the handover is signed. | "Handover must be signed…". In the container list the button is grey and instead sends the customer a signature request. |
| Exit paper | Generate with an ISSUED demurrage/storage invoice. |
"…must be fully paid before terminal release". |
| Approve delivery | Approve without a saved signature. | "Please save your signature…" and the portal routes to the signature page. |
| Approve delivery | Approve before warehouse inspection has passed. | "Delivery can be approved after warehouse inspection has passed". |
| Approve delivery | Approve when a truck is assigned but has not arrived. | "Customer truck arrival must be recorded before delivery approval". |
| Deliver | Deliver before a release order was issued, or before the self-haul truck has left. | "A release order must be issued…" / "Deliver is available only after the customer truck has left". |
| Djibouti unload | Unload an export train with no gate pass granted. | Items skipped; no interchange document generated. |
| Warehouse | Store into an INACTIVE warehouse/yard/zone, or beyond capacity. |
Not selectable / "No active warehouse yard/zone is available" / capacity error. |
10 · Notifications
All customer notifications land in the same portal inbox. Verify the message, the deep link, and — where noted — the SMS and email.
| Notification | Fires when | Channels | Deep link |
|---|---|---|---|
| Shipment dispatched | Train schedule dispatched, per booking | In-app, SMS, email | Booking |
| Shipment arrived | Train schedule arrived, per booking | In-app, SMS, email | Booking |
| Assign a truck for pickup | Export: on warehouse receive. Import: on inspection pass → ready for pickup. Only if self-haul and no truck assigned. | In-app, SMS, email | Booking → assign truck |
| Handover — signature needed | Self-haul truck arrives (handover generated), and re-sent when an operator requests a signature from the Exit Paper button | In-app, SMS, email | Booking → approve delivery |
| Warehouse fee due | Storage / demurrage invoice issued | In-app, SMS | Booking → pay |
| Wagon allocated / payment window | Scheduling | In-app | Booking |
🚫 Do not chase a missing in-app notification before checking this. Recipients are resolved from the company's linked portal users. If a company has none,
notify()logs "0 recipients — skipped" and stores nothing — the notification simply never appears, with no error. SMS and email still go out, because they address the company's phone and email directly. On a fresh environment this is the usual explanation.
11 · Known open issues
Do not raise duplicates for these. Each is already identified.
| Status | Issue | Impact on testing |
|---|---|---|
| 🔴 Open | Export receive returns 500 on the deployed environment (POST /warehouse-inventory/receive-bulk). |
Blocks flows E1 and E2 at step .2. Awaiting the response body / server log to diagnose. Likely schema drift, not the SQL. |
| 🟠 Fix pending deploy | Export receive was extremely slow. The owner SMS was awaited inside the DB transaction, and the SMS client had no HTTP timeout. | Fixed on branch: SMS now has a timeout, and notifications are sent after commit. Re-test receive latency once deployed. |
| 🟠 Data | Drivers table has no unique constraints on licence number, email, or phone, despite the entity declaring them unique. | Duplicate drivers can be created. Do not rely on uniqueness in test assertions. |
| 🔵 Behaviour | Handover generated before truck arrival. If an operator triggers a signature request from the Exit Paper button while a truck is assigned but not arrived, Approve delivery refuses with "truck arrival must be recorded". | Only reachable off the normal path. Follow flow I1 in order and it will not occur. |