Files
edr-platform/docs/qa/edr-freight-qa-test-plan.md
2026-07-09 15:37:06 +00:00

293 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `ACTIVE` warehouse 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()` logs `0 recipients — skipped` and 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 `400` when the handover is not
> fully signed, or when a warehouse fee invoice is `ISSUED` / `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_ISSUED` notification *and* an SMS, deep-linked to pay.
- [ ] **Clearance gate.** While a warehouse-source invoice is `ISSUED` or `PARTIALLY_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 `ACTIVE` warehouses, 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. |