# Reverse Logistics — Analysis & Implementation Plan Status: **Phases 1–3 built 2026-10-05 (uncommitted, not deployed)** — see §10. Drafted 2026-10-05. Scope: `doormile_backend` (rules + endpoints), `krow_talent_app` (ops console), and the rider app (Flutter, separate team). The customer app is touched only where noted. Defaults below are **proposals**; every one marked ⚑ is an open decision listed in §9. --- ## 1. What exists today (verified in code) | Area | Finding | Where | |---|---|---| | Return statuses | `RTO_Initiated`, `Returned_to_Sender` are defined and **allowed by the DB constraint** — but **nothing in the code ever sets them** | `doormile_backend/constants/constants.go:91-92`, `migrations/migrate.go:124` | | Return fields on the parcel | `Consignment` already has `Returnreason`, `Returninitiatedat`, `Returndeliveredat`, `Parentconsignmentid` — **all unused** | `doormile_backend/models/audit.go:70-73` | | Failed delivery | Rider taps *skip* → `MilerSkipDelivery` adds 1 to `Attemptcount`, logs `Delivery_Skipped` history. After **3** attempts it opens an `Undeliverable` exception — and stops there. The parcel stays `Out_for_Delivery` with the rider; no return leg, no owner, client not told | `doormile_backend/controllers/milerAppController.go:990-1070` | | Exception types | `Receiver_Refused`, `Undeliverable`, `Damaged`, `Lost`, `Misrouted`, `Missing_Contents` exist | `constants.go:186-195` | | Rider "what next" | `nextActionForConsignment` maps status → rider action. `RTO`/`Returned` fall into `none` ("past this rider's leg") — so a rider is never asked to bring a parcel back | `controllers/logisticsHandoverController.go:233-251` | | Admin status change | `PUT /admin/consignments/:id/status` accepts **any** status string with no transition rules (a separate bug: it can set `Delivered` on a cancelled parcel) | `controllers/adminController.go:3133-3190` | | Console | Only traces: an `rto` badge tone (`components/ds/StatusBadge.jsx:47`), `rto → skipped` mapping (`api/doormile/queries.js:110`), a comment in `lib/orderFlow.js:108`. Status update offers only Out_for_Delivery / Delivered / Cancelled | `krow_talent_app/src/...` | | Pricing | `Pricing` has base/per-km/per-kg/handling — **no return charge** | `models` Pricing | | Customer returns (reverse pickup) | Not present anywhere | — | **Conclusion:** the data model is mostly ready; the lifecycle, the rules, the endpoints, the console screens and the rider task are all missing. --- ## 2. Scope | Flow | Description | Phase | |---|---|---| | **A. RTO — Return to Origin** | Delivery fails → parcel goes back to the sender's pickup point (⚑ or a hub) | **Phase 1–3 (this plan)** | | **B. Customer return** | Receiver sends a delivered item back; rider picks up from receiver, returns to sender | Phase 5 (later) | | **C. Exchange** | Deliver new + collect old in one visit | Out of scope for now | --- ## 3. RTO lifecycle (proposed) ``` Collected_By_Miler / Out_for_Delivery │ failed attempt (rider skip) → Attemptcount++ (as today) │ ├─ attempts ≥ N (⚑ default 3) ─┐ ├─ ops "Initiate RTO" (console) ─┼─▶ RTO_Initiated └─ (⚑ later) client request ───┘ returnreason, returninitiatedat set history: RTO_Initiated rider next action: return_to_sender │ ┌──────────────┼───────────────────────────┐ ▼ ▼ ▼ rider returns ops "Re-attempt delivery" ops "Mark returned" to sender (cancels the RTO) (manual close, e.g. hub drop) │ │ │ ▼ ▼ ▼ Returned_to_Sender Out_for_Delivery Returned_to_Sender returndeliveredat (attempts kept) returndeliveredat ``` Rules: - RTO only from `Collected_By_Miler`, `Out_for_Delivery` (or `Inwarded_at_Hub` ⚑). - `Returned_to_Sender` is terminal. - Every transition writes `ConsignmentHistory` with actor + reason. - Opening RTO resolves the linked `Undeliverable` exception (if any) with resolution "RTO initiated". - COD: a returned parcel collects nothing; `Codcollected` stays 0. --- ## 4. Backend — `doormile_backend` | # | Change | Detail | |---|---|---| | B1 | **Transition guard** | One function `canTransition(from, to)` used by every consignment status write; `PUT /admin/consignments/:id/status` refuses illegal moves (also fixes the any-status bug). | | B2 | `POST /admin/consignments/:id/rto` `{reason, note}` | Staff only. Sets `RTO_Initiated`, `returnreason`, `returninitiatedat`; history; resolves the Undeliverable exception; publishes NATS `consignment.rto_initiated`; notifies the rider. Idempotent. | | B3 | `POST /admin/consignments/:id/rto/cancel` `{note}` | Back to `Out_for_Delivery` (re-attempt). | | B4 | `POST /admin/consignments/:id/rto/complete` `{note}` | Ops closes it manually → `Returned_to_Sender`, `returndeliveredat`. | | B5 | `GET /admin/returns?status&from&to&tenantid&hubid&pageno` | List for the Returns page: tracking no, client, sender, reason, attempts, initiated/returned times, rider, age. Tenant-scoped like other admin lists. | | B6 | **Auto-RTO** in `MilerSkipDelivery` | When `Attemptcount ≥ RTO_AUTO_AFTER_ATTEMPTS` (env, ⚑ default 3; `0` = off) → call the same B2 logic instead of only opening an exception. | | B7 | **Rider task** | New next action `return_to_sender`; `nextActionForConsignment(RTO_Initiated)` returns it; rider queue includes the return stop (sender's pickup coords). New `POST /miler/consignments/:id/return-complete` `{lat, lon, photourl?, receivedby}` → `Returned_to_Sender`. **Behind flag `MILER_RTO_FLOW_ENABLED`** (default off) — the deployed rider app doesn't know `return_to_sender`, same pattern as `MILER_HUB_HANDOVER_ENABLED`. | | B8 | Booking/customer stage | For customer-app bookings, record the return in `cxstage`. ⚠ The customer app renders unknown stage keys as `booked`, so a new "returning" stage needs a client release — until then do **not** add a new key. | | B9 | Tests | Transition table, each endpoint's gates (staff/tenant/owner), auto-RTO at N attempts, flag off/on rider queue, idempotency. Postgres-gated tests for the SQL. | Schema: **none for Phase 1–3** (columns exist). ⚑ Return-to-hub needs one additive nullable column (`returnhubid`) — Phase 4. --- ## 5. Console — `krow_talent_app` | # | Change | Where | |---|---|---| | C1 | API + hooks: `initiateRto`, `cancelRto`, `completeRto`, `getReturns` + `useInitiateRto` … with invalidation of deliveries/returns keys | `src/api/doormile/endpoints.js`, `src/lib/doormileHooks.js` | | C2 | **Deliveries page actions**: on failed / out-for-delivery rows — *Initiate RTO* (reason picker: Receiver refused, Address not found, Customer unavailable, Attempts exhausted, Other + note), *Re-attempt*, *Mark returned*. Show attempt count | `src/pages/doormile/deliveries/Deliveries.jsx` | | C3 | **Status tabs**: add *RTO* and *Returned* tabs/counts (today `rto` collapses into `skipped`) | `queries.js` status map, `StatusBadge` | | C4 | **Returns page** `/doormile/returns`: tabs Initiated · In return · Returned; filters (date, client, hub, reason); age/SLA column; XLSX export; nav entry under Fleet Ops (staff) | new `src/pages/doormile/returns/Returns.jsx`, `App.jsx`, `AdminLayout.jsx` | | C5 | **Exceptions page**: an `Undeliverable` / `Receiver_Refused` exception gets a *Start RTO* button | `src/pages/doormile/exceptions/Exceptions.jsx` | | C6 | **Order / consignment timeline** shows attempts, RTO start, return | booking detail drawer | | C7 | **Reports**: return rate per client and per reason on Orders Summary | `src/pages/doormile/reports/` | | C8 | Client (tenant) logins: read-only view of their own returns (no actions) | Returns page + role check | | C9 | Tests: actions call the right endpoints, reason required, tabs/counts, Returns filters, client read-only | `tests/integration/` | --- ## 6. Rider app (Flutter — separate team) - Handle next action `return_to_sender`: show the return stop, navigate to the sender, "Returned" button → `POST /miler/consignments/:id/return-complete` with location (+ optional photo / receiver name). - Release before turning on `MILER_RTO_FLOW_ENABLED`. Until then, ops close returns from the console (B4) and riders are told by push. --- ## 7. Phases & order | Phase | Content | Repos | Depends on | |---|---|---|---| | **1** | B1 guard, B2–B5 endpoints, B9 tests | backend | — | | **2** | C1–C5, C9 | console | Phase 1 deployed | | **3** | B6 auto-RTO, B7 rider task (flag off) + rider app release, then flag on | backend + rider app | Phase 1 | | **4** | Return-to-hub option (`returnhubid`), C6 timeline, C7 reports, C8 client view, return charges | all | decisions ⚑ | | **5** | Flow B — customer returns (reverse pickup booking linked by `Parentconsignmentid`) | all + customer app | product spec | Rough effort: Phase 1 ≈ 1.5–2 days · Phase 2 ≈ 2 days · Phase 3 ≈ 1 day backend (+ rider app team) · Phase 4/5 to estimate after decisions. --- ## 8. Risks - **Rider app compatibility** — a new next action unknown to the deployed app must stay behind a flag (B7). - **Customer app stage keys** — unknown keys render as `booked` (B8). - **Ops discipline** — until the rider task ships, returns depend on ops closing them in the console. - **Existing any-status endpoint** — B1 changes its behaviour: callers that relied on setting arbitrary statuses will now get 400. The console only sends Out_for_Delivery / Delivered / Cancelled, which stay allowed. - **Deploy** — no migration in Phase 1–3; Phase 4 adds one nullable column (needs approval). --- ## 9. Open decisions ⚑ 1. **Flows first:** A (RTO) only, then B? *(proposed: yes)* 2. **Return destination:** sender's pickup point, nearest hub, or per client? *(proposed: sender for Phase 1; hub option in Phase 4)* 3. **Who starts RTO:** auto after N attempts (N = ?), ops, client, or all? *(proposed: ops + auto after 3; client later)* 4. **Can RTO start from a hub** (`Inwarded_at_Hub`)? *(proposed: yes)* 5. **Return charges:** billed to the client? Same rate, fixed fee, or free? *(proposed: decide before Phase 4; Phase 1–3 record only)* 6. **Rider app:** is the Flutter team available for B7, and when? 7. **Client visibility:** may tenants see their own returns? *(proposed: read-only)* --- ## 10. Implementation status (2026-10-05) Built with the proposed defaults: return to the **sender**, started by **ops or automatically after 3 failed attempts**, rider task **behind a flag (off)**. Nothing is committed or pushed. ### Backend — `doormile_backend` | Item | Where | |---|---| | RTO core: `startRTO`, `completeRTO`, cancel → previous status (recorded as `[from:]` in the history remark), exception auto-resolve, rider push | `controllers/consignmentReturn.go` | | `POST /admin/consignments/:id/rto` · `/rto/cancel` · `/rto/complete` (Doormile staff only) | `routes/routes.go` | | `GET /admin/returns?status=initiated\|returned\|all&from&to&pageno&pagesize` (tenant-scoped; client logins read their own) | same | | `POST /miler/consignments/:id/return-complete` — 403 `RTO_FLOW_DISABLED` unless `MILER_RTO_FLOW_ENABLED=true` | same | | Status guard on `PUT /admin/consignments/:id/status` (unknown status, leaving Delivered/Cancelled/Returned, RTO statuses → 400) | `controllers/adminController.go` | | Auto-RTO after `RTO_AUTO_AFTER_ATTEMPTS` (default 3, `0` = off) | `MilerSkipDelivery` in `controllers/milerAppController.go` | | `MilerSkipDelivery` ownership now multi-destination safe (`milerConsignmentForRider`) — fixes skip on orders 2..N | same | | Next action `return_to_sender` (flagged); rider consignment read adds `returning`, `can_return`, `return_reason`, `return_to` | `constants`, `logisticsHandoverController.go`, `milerAppController.go` | | Tests: 7 unit + 5 route-gate tests | `controllers/consignmentReturn_test.go`, `routes/routes_rto_test.go` | New env vars: `RTO_AUTO_AFTER_ATTEMPTS` (default 3), `MILER_RTO_FLOW_ENABLED` (default off). **No schema change.** No customer-app stage key added (B8). ### Console — `krow_talent_app` | Item | Where | |---|---| | API + hooks: `initiateRto`, `cancelRto`, `completeRto`, `getReturns`, `RTO_REASONS`; `useReturns`, `useInitiateRto`, `useCancelRto`, `useCompleteRto` | `src/api/doormile/endpoints.js`, `src/lib/doormileHooks.js` | | Status mapping: `RTO_Initiated → rto`, `Returned_to_Sender → returned` (legacy `rto`/`returned` keys unchanged) + counts | `src/api/doormile/queries.js` | | Shared dialogs: Start return (reason + note, pre-select), Re-attempt / Mark returned | `src/components/doormile/RtoDialogs.jsx` | | Deliveries: **In return** + **Returned** tabs; Return to sender / Re-attempt / Mark returned (staff only); status-update and cancel hidden on returning rows | `src/pages/doormile/deliveries/Deliveries.jsx` | | **Returns page** `/doormile/returns` (tabs, date range, search, export, pagination; read-only for clients); nav under Fleet Ops | `src/pages/doormile/returns/Returns.jsx`, `App.jsx`, `AdminLayout.jsx` | | Exceptions: **Start return** on open Undeliverable / Receiver_Refused | `src/pages/doormile/exceptions/Exceptions.jsx` | | Tests: 13 new; deliveries counts test updated | `tests/integration/returns.test.jsx`, `tests/api/deliveries.test.js` | ### Verified / not verified - ✅ `go build`, `go vet`, all 17 backend packages; console suites pass except the 13 Agent Studio tests already failing since the 2026-09-30 redesign; console production build OK. - ✅ **End-to-end on a real database (2026-10-05).** This ran on a throwaway local stack: PostgreSQL 17.10 on 127.0.0.1, the backend built from this tree with `MILER_RTO_FLOW_ENABLED=true`, and the console on a local port pointed at it. Production was not touched. - Browser, as staff: - Return to sender (reason + note), then Re-attempt (back to Out_for_Delivery), then Start again, then Mark returned. Result: `Returned_to_Sender`, the rider's assignment `Completed`, and the full status history. - Exceptions → Start return pre-selects the reason, and the exception leaves the list. - The Returns page lists rows with correct India times. - API: - 3 skips start RTO automatically and resolve the Undeliverable exception. - The rider read gives `next_action=return_to_sender` with `return_to` set to the sender's coordinates. - Rider return-complete closes the assignment. - Every guard answers 400 / `INVALID_STATE` as designed. - Client login: no return buttons on Deliveries; Returns is read-only; `rto/cancel` and `rto/complete` return 403. - Bugs the browser test found, all fixed: 1. Notify rider showed on returned and delivered rows. It is now hidden. 2. Exceptions didn't refresh after Start return. `RETURN_KEYS` now includes the exceptions key. 3. The dialog subtitle from Exceptions read "Order consignment #…". It now uses a `label` prop. - Backend review (2026-10-05), fixed: 1. **Race conditions.** Start, complete and cancel used to read the parcel and then save the whole row. A rider delivering at the same moment, or a double click, could be overwritten. Each one is now a compare-and-set (`moveConsignment`: update only if the status is still the one read). A lost race either becomes the same no-op as a repeat, or returns "changed, refresh". 2. **Re-attempt clears the return.** `returnreason` and `returninitiatedat` are cleared, so a parcel delivered afterwards doesn't carry a stale reason. The history keeps the reason. 3. **"OTHER" check.** A reason sent as "OTHER" or "Other " skipped the note-required check. The check now uses the normalised reason (`rtoReasonText`). 4. **Note length.** The 500 limit is counted in characters, not bytes. Before, 200 Tamil characters were refused even though the console allows 500. 5. **Wrong rider notified.** Starting a return on a parcel already handed over at a base pushed "do not attempt delivery" to its old pickup rider. Now only a rider holding the parcel is notified (`riderHoldsParcel`: Created, Collected_By_Miler, Out_for_Delivery). - Impact on existing users (checked 2026-10-05). Decide these before deploying: - **Rider app (deployed build):** it shows a parcel from the *booking* status, which a return doesn't change. So a parcel in return still looks deliverable; tapping deliver or skip returns a 400. The rider also can't go off duty until ops press **Mark returned**, because the assignment stays open. Automatic return is **on by default** (3 attempts), so this starts on deploy. Either set `RTO_AUTO_AFTER_ATTEMPTS=0` until the rider app supports returns, or make sure ops close returns daily. - **Console "Update status":** Delivered and Cancelled parcels can no longer be changed (400). Before, ops could undo a wrong "Delivered" or reopen a cancelled parcel. That is now refused by design. - **Customer app:** a returned parcel keeps showing "Out for delivery" (there is no customer stage for returns). - **Rider pay:** closing a return marks the assignment Completed with 0 km and 0 charges. The return trip isn't paid. - **Not affected:** rider delivered counts and reports (Delivered only), the hub console (a parcel in return simply drops out of its status-based lists), the B2C booking flow, the existing status values, and the database schema (no change). - Tests added: `TestRiderHoldsParcel`, `TestRTOReasonText`, plus `controllers/consignmentReturn_pg_test.go` (5 tests on real Postgres: lifecycle, refusals, no overwrite of a concurrent delivery, 10 simultaneous starts → one return, cancel). These skip unless `REGISTRY_TEST_DSN` is set, and passed on PostgreSQL 17. - Timestamps stay `time.Now()`, not `utils.DBNow()`. `AutoMigrate` creates `timestamptz` columns, where `DBNow()` would store a time 5 h 30 m ahead. `time.Now()` is correct for both column types because the Dockerfile sets `TZ=Asia/Kolkata`. - Seen, not fixed (pre-existing, outside RTO): the Exceptions page shows "Raised" times about 6 h ahead of IST. - Not built: Phase 4 (return-to-hub, timeline, reports, charges), Phase 5 (customer returns), the rider-app UI (Flutter team). ## 11. Phase 4, first part (2026-10-07) Built the two Phase 4 items that need no product decision. Uncommitted. | Item | Where | |---|---| | `GET /admin/consignments/:id/history`: every event of one parcel, oldest first, with who did it and at which base. The `[from:]` bookkeeping tag is removed from the remark and returned as `fromstatus`. Client logins read their own parcels only. | `doormile_backend/controllers/returnInsights.go`, `routes/routes.go` | | `GET /admin/returns/summary?from&to`: of the parcels created in the period (cancelled ones excluded), the return rate overall, per client and per reason, plus the average days a completed return took. Defaults to the last 30 days. Client logins get their own figures only. | same | | **C6 timeline:** shared `ConsignmentTimeline`, shown in the Deliveries order drawer and behind a **History** button on every Returns row (clients included). | `src/components/doormile/ConsignmentTimeline.jsx`, `Deliveries.jsx`, `Returns.jsx` | | **C7 report:** a summary on the **Returns page** (KPIs, return rate by client, reasons), driven by the page's date range. Clients don't get the per-client table. Placed on Returns rather than Orders Summary so that clients see it too. | `src/pages/doormile/returns/Returns.jsx` | | Tests: 2 Postgres route tests (timeline order, actor, hub, scoping; summary rates, reasons, window, scoping) and 4 console tests. | `routes/routes_return_insights_pg_test.go`, `tests/integration/returns.test.jsx` | Verified in the browser against a throwaway local database: summary figures, per-client and per-reason tables, and the timeline drawer. Decision 2026-10-07 on return charges (open decision 5): **none for now.** Returns are free for every client. Revisit after 2–3 months using the return rates the Returns summary now shows. If a charge is introduced later, it should be per client, only for receiver- or client-caused returns, and never for damage or Doormile errors. Still open in Phase 4 (needs a decision ⚑): return-to-hub (the `returnhubid` column). Decision 2026-10-07 on Phase 5 (customer returns after delivery): **deferred.** Current clients are mainly food and medicine businesses, where returns after delivery are rare, and refunds are handled by the client. Build it when a client asks for it or when parcel/e-commerce clients are onboarded. Until then, ops handle the rare case by booking a normal order with the customer's address as pickup and the client as drop. The return button appears only on active orders (RTO, before delivery); `Delivered` stays terminal.