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