18 KiB
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(orInwarded_at_Hub⚑). Returned_to_Senderis terminal.- Every transition writes
ConsignmentHistorywith actor + reason. - Opening RTO resolves the linked
Undeliverableexception (if any) with resolution "RTO initiated". - COD: a returned parcel collects nothing;
Codcollectedstays 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-completewith 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 ⚑
- Flows first: A (RTO) only, then B? (proposed: yes)
- Return destination: sender's pickup point, nearest hub, or per client? (proposed: sender for Phase 1; hub option in Phase 4)
- Who starts RTO: auto after N attempts (N = ?), ops, client, or all? (proposed: ops + auto after 3; client later)
- Can RTO start from a hub (
Inwarded_at_Hub)? (proposed: yes) - Return charges: billed to the client? Same rate, fixed fee, or free? (proposed: decide before Phase 4; Phase 1–3 record only)
- Rider app: is the Flutter team available for B7, and when?
- 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:<status>] 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 assignmentCompleted, 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.
- Return to sender (reason + note), then Re-attempt (back to
Out_for_Delivery), then Start again, then Mark returned. Result:
- API:
- 3 skips start RTO automatically and resolve the Undeliverable exception.
- The rider read gives
next_action=return_to_senderwithreturn_toset to the sender's coordinates. - Rider return-complete closes the assignment.
- Every guard answers 400 /
INVALID_STATEas designed.
- Client login: no return buttons on Deliveries; Returns is read-only;
rto/cancelandrto/completereturn 403.
- Browser, as staff:
- Bugs the browser test found, all fixed:
- Notify rider showed on returned and delivered rows. It is now hidden.
- Exceptions didn't refresh after Start return.
RETURN_KEYSnow includes the exceptions key. - The dialog subtitle from Exceptions read "Order consignment #…". It now
uses a
labelprop.
- Backend review (2026-10-05), fixed:
- 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". - Re-attempt clears the return.
returnreasonandreturninitiatedatare cleared, so a parcel delivered afterwards doesn't carry a stale reason. The history keeps the reason. - "OTHER" check. A reason sent as "OTHER" or "Other " skipped the
note-required check. The check now uses the normalised reason
(
rtoReasonText). - Note length. The 500 limit is counted in characters, not bytes. Before, 200 Tamil characters were refused even though the console allows 500.
- 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).
- 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
(
- 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=0until 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).
- 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
- Tests added:
TestRiderHoldsParcel,TestRTOReasonText, pluscontrollers/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 unlessREGISTRY_TEST_DSNis set, and passed on PostgreSQL 17. - Timestamps stay
time.Now(), notutils.DBNow().AutoMigratecreatestimestamptzcolumns, whereDBNow()would store a time 5 h 30 m ahead.time.Now()is correct for both column types because the Dockerfile setsTZ=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).