updates on the reverse logistics
This commit is contained in:
279
docs/reverse-logistics-plan.md
Normal file
279
docs/reverse-logistics-plan.md
Normal file
@@ -0,0 +1,279 @@
|
||||
# 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:<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 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).
|
||||
@@ -25,6 +25,7 @@ const CreateOrder = lazy(() => import('@/pages/doormile/orders/CreateOrder'));
|
||||
const MultipleOrders = lazy(() => import('@/pages/doormile/orders/MultipleOrders'));
|
||||
|
||||
const Deliveries = lazy(() => import('@/pages/doormile/deliveries/Deliveries'));
|
||||
const Returns = lazy(() => import('@/pages/doormile/returns/Returns'));
|
||||
|
||||
const Tenants = lazy(() => import('@/pages/doormile/clients/Tenants'));
|
||||
const CreateClient = lazy(() => import('@/pages/doormile/clients/CreateClient'));
|
||||
@@ -122,6 +123,8 @@ const AuthenticatedApp = () => {
|
||||
<Route path="bookings" element={<Bookings />} />
|
||||
|
||||
<Route path="deliveries" element={<Deliveries />} />
|
||||
{/* Reverse logistics: parcels going back to the sender (RTO). */}
|
||||
<Route path="returns" element={<Returns />} />
|
||||
|
||||
<Route path="tenants" element={<Tenants />} />
|
||||
<Route path="clients/create" element={<CreateClient />} />
|
||||
|
||||
@@ -859,3 +859,46 @@ export const getAiDecisions = async ({ type, before, limit } = {}) => {
|
||||
const response = await doormileAxios.get(`/admin/ai/decisions${buildQuery({ type, before, limit })}`);
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
/* ── Reverse logistics (RTO) ─────────────────────────────────────────────────
|
||||
Start, cancel (re-attempt delivery) and close a return to sender; list
|
||||
returns. Start/cancel/close are Doormile-staff actions (a client login gets
|
||||
403); a client login may read its own returns. Plan:
|
||||
docs/reverse-logistics-plan.md. */
|
||||
|
||||
/** Reasons the backend accepts for starting a return. */
|
||||
export const RTO_REASONS = [
|
||||
{ value: 'receiver_refused', label: 'Receiver refused' },
|
||||
{ value: 'address_not_found', label: 'Address not found' },
|
||||
{ value: 'customer_unavailable', label: 'Customer unavailable' },
|
||||
{ value: 'attempts_exhausted', label: 'Delivery attempts exhausted' },
|
||||
{ value: 'damaged', label: 'Damaged in transit' },
|
||||
{ value: 'other', label: 'Other (describe)' },
|
||||
];
|
||||
|
||||
/** @param {number|string} consignmentId @param {{reason: string, note?: string}} body */
|
||||
export const initiateRto = async (consignmentId, body) => {
|
||||
const response = await doormileAxios.post(`/admin/consignments/${encodeURIComponent(consignmentId)}/rto`, body);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** Cancel a return and put the parcel back to delivery. */
|
||||
export const cancelRto = async (consignmentId, note = '') => {
|
||||
const response = await doormileAxios.post(`/admin/consignments/${encodeURIComponent(consignmentId)}/rto/cancel`, { note });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/** Ops confirm the parcel is back with the sender. */
|
||||
export const completeRto = async (consignmentId, note = '') => {
|
||||
const response = await doormileAxios.post(`/admin/consignments/${encodeURIComponent(consignmentId)}/rto/complete`, { note });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parcels in or through a return, newest first.
|
||||
* @param {{status?: 'initiated'|'returned'|'all', from?: string, to?: string, tenantid?: number, pageno?: number, pagesize?: number}} params
|
||||
*/
|
||||
export const getReturns = async (params = {}) => {
|
||||
const response = await doormileAxios.get(`/admin/returns${buildQuery(params)}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -108,6 +108,10 @@ const BOOKING_STATUS_TO_DELIVERY_STATUS = {
|
||||
canceled: 'cancelled',
|
||||
skipped: 'skipped',
|
||||
rto: 'skipped',
|
||||
// Reverse logistics: the backend's real RTO statuses get their own tabs.
|
||||
// (The generic legacy keys rto/returned above keep their old mapping.)
|
||||
rto_initiated: 'rto',
|
||||
returned_to_sender: 'returned',
|
||||
returned: 'cancelled',
|
||||
failed: 'cancelled'
|
||||
};
|
||||
@@ -1182,7 +1186,9 @@ export const fetchCountAPI = async () => {
|
||||
activeLength: data.active || 0,
|
||||
coveredLength: data.delivered || 0,
|
||||
cancelLength: data.cancelled || 0,
|
||||
skippedLength: data.skipped || 0
|
||||
skippedLength: data.skipped || 0,
|
||||
rtoLength: data.rto || 0,
|
||||
returnedLength: data.returned || 0
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
196
src/components/doormile/RtoDialogs.jsx
Normal file
196
src/components/doormile/RtoDialogs.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PackageCheck, RotateCcw, Undo2 } from 'lucide-react';
|
||||
import { Alert, Button, Field, Modal, Textarea } from '@/components/ds';
|
||||
import { inputVariants } from '@/components/ui/input';
|
||||
import { RTO_REASONS } from '@/api/doormile/endpoints';
|
||||
import { useCancelRto, useCompleteRto, useInitiateRto } from '@/lib/doormileHooks';
|
||||
|
||||
/**
|
||||
* Reverse logistics (RTO) dialogs, shared by Deliveries, Returns and
|
||||
* Exceptions. Plan: docs/reverse-logistics-plan.md.
|
||||
*
|
||||
* A return can start while the parcel is in a rider's hands or at a base; the
|
||||
* backend refuses anything else (delivered, cancelled, already returning) and
|
||||
* its message is shown in the dialog. Every action needs the CONSIGNMENT id —
|
||||
* a booking that has not been picked up has no parcel to return yet.
|
||||
*/
|
||||
|
||||
/** Delivery-list statuses (queries.js) a return can be started from. */
|
||||
export const RTO_STARTABLE = ['picked', 'active', 'skipped'];
|
||||
|
||||
/** What a row may do, from its delivery-list status. */
|
||||
export function rtoActionsFor(row) {
|
||||
const status = String(row?.orderstatus || '').toLowerCase();
|
||||
const hasParcel = row?.consignmentid != null && row?.consignmentid !== '';
|
||||
return {
|
||||
canStart: hasParcel && RTO_STARTABLE.includes(status),
|
||||
canResolve: hasParcel && status === 'rto',
|
||||
};
|
||||
}
|
||||
|
||||
const errorText = (err, fallback) => err?.response?.data?.message || err?.message || fallback;
|
||||
|
||||
/** The dialog subtitle: a caller's own `label`, else "Order <no>". */
|
||||
const subtitleFor = (row) =>
|
||||
row ? row.label || `Order ${row.orderid ?? row.trackingno ?? row.consignmentid}` : undefined;
|
||||
|
||||
/** Start a return to sender. `row` needs `consignmentid` and an order label. */
|
||||
export function StartRtoModal({ row, onClose }) {
|
||||
const start = useInitiateRto();
|
||||
const [reason, setReason] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// `row.defaultReason` pre-selects a reason (e.g. from an exception's type).
|
||||
useEffect(() => {
|
||||
setReason(row?.defaultReason || '');
|
||||
setNote('');
|
||||
setError('');
|
||||
}, [row]);
|
||||
|
||||
const noteRequired = reason === 'other';
|
||||
const canSubmit = Boolean(reason) && (!noteRequired || note.trim().length > 0);
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit) return;
|
||||
setError('');
|
||||
start.mutate(
|
||||
{ consignmentId: row.consignmentid, reason, note: note.trim() },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
if (res?.success !== false) onClose();
|
||||
else setError(res?.message || 'The return was not started.');
|
||||
},
|
||||
onError: (err) => setError(errorText(err, 'The return was not started.')),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={Boolean(row)}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title="Return to sender"
|
||||
description={subtitleFor(row)}
|
||||
icon={Undo2}
|
||||
busy={start.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={start.isPending}>
|
||||
Keep delivering
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={submit} loading={start.isPending} disabled={!canSubmit}>
|
||||
Start return
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<Alert tone="destructive" className="mb-3" role="alert">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<p className="mb-3 text-body-sm text-ink-2">
|
||||
The parcel goes back to the sender's pickup point instead of being delivered. The rider is notified.
|
||||
</p>
|
||||
<Field label="Reason" required>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
aria-label="Return reason"
|
||||
className={inputVariants()}
|
||||
>
|
||||
<option value="">Choose a reason</option>
|
||||
{RTO_REASONS.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={noteRequired ? 'Describe the reason' : 'Note (optional)'} required={noteRequired} className="mt-3">
|
||||
<Textarea rows={3} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} aria-label="Return note" />
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const RESOLVE_COPY = {
|
||||
cancel: {
|
||||
title: 'Re-attempt delivery',
|
||||
icon: RotateCcw,
|
||||
body: 'The return is cancelled and the parcel goes back to delivery, where it was before the return started.',
|
||||
confirm: 'Re-attempt delivery',
|
||||
variant: 'default',
|
||||
fail: 'The return was not cancelled.',
|
||||
},
|
||||
complete: {
|
||||
title: 'Mark returned to sender',
|
||||
icon: PackageCheck,
|
||||
body: 'Confirms the parcel is back with the sender. This closes the return and the rider’s stop, and cannot be undone.',
|
||||
confirm: 'Mark returned',
|
||||
variant: 'destructive',
|
||||
fail: 'The parcel was not marked returned.',
|
||||
},
|
||||
};
|
||||
|
||||
/** Close a return: `mode` is 'cancel' (re-attempt) or 'complete' (returned). */
|
||||
export function ResolveRtoModal({ row, mode, onClose }) {
|
||||
const cancel = useCancelRto();
|
||||
const complete = useCompleteRto();
|
||||
const mutation = mode === 'complete' ? complete : cancel;
|
||||
const copy = RESOLVE_COPY[mode] || RESOLVE_COPY.cancel;
|
||||
const [note, setNote] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setNote('');
|
||||
setError('');
|
||||
}, [row, mode]);
|
||||
|
||||
const submit = () => {
|
||||
setError('');
|
||||
mutation.mutate(
|
||||
{ consignmentId: row.consignmentid, note: note.trim() },
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
if (res?.success !== false) onClose();
|
||||
else setError(res?.message || copy.fail);
|
||||
},
|
||||
onError: (err) => setError(errorText(err, copy.fail)),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={Boolean(row)}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
title={copy.title}
|
||||
description={subtitleFor(row)}
|
||||
icon={copy.icon}
|
||||
busy={mutation.isPending}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose} disabled={mutation.isPending}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant={copy.variant} onClick={submit} loading={mutation.isPending}>
|
||||
{copy.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<Alert tone="destructive" className="mb-3" role="alert">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
<p className="mb-3 text-body-sm text-ink-2">{copy.body}</p>
|
||||
{row?.returnreason && <p className="mb-3 text-caption text-ink-3">Return reason: {row.returnreason}</p>}
|
||||
<Field label="Note (optional)">
|
||||
<Textarea rows={2} maxLength={500} value={note} onChange={(e) => setNote(e.target.value)} aria-label="Resolution note" />
|
||||
</Field>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { motion } from 'framer-motion';
|
||||
import {
|
||||
Activity, Bell, Bike, Bot, Car, ChevronDown, Coins, FileSpreadsheet,
|
||||
FileText, ListTodo, LogOut, Menu, Search, Settings, Shield,
|
||||
ShieldAlert, User, UserCheck, UserPlus, Warehouse,
|
||||
ShieldAlert, Undo2, User, UserCheck, UserPlus, Warehouse,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DOORMILE_MARK_URL } from '@/assets/brand';
|
||||
@@ -66,6 +66,7 @@ const NAV_GROUPS = [
|
||||
{ label: 'Vehicles', path: '/doormile/vehicles', icon: Car },
|
||||
{ label: 'Tripsheets', path: '/doormile/tripsheets', icon: ListTodo },
|
||||
{ label: 'Exceptions', path: '/doormile/exceptions', icon: ShieldAlert },
|
||||
{ label: 'Returns', path: '/doormile/returns', icon: Undo2 },
|
||||
{ label: 'Competitive Intel', path: '/doormile/competitive-intel', icon: Activity },
|
||||
{ label: 'App Users', path: '/doormile/app-users', icon: UserCheck },
|
||||
{ label: 'Agents', path: '/doormile/agents', icon: Bot },
|
||||
|
||||
@@ -541,6 +541,43 @@ export const useCancelDelivery = () =>
|
||||
successMessage: 'Delivery cancelled',
|
||||
});
|
||||
|
||||
/* ── Reverse logistics (RTO) ──────────────────────────────────────────────── */
|
||||
|
||||
// Exceptions too: starting a return resolves the parcel's Undeliverable /
|
||||
// Receiver_Refused exception on the server, and the list must show that at
|
||||
// once rather than on its next poll.
|
||||
const RETURN_KEYS = [...BOOKING_KEYS, ['doormile', 'returns'], KEYS.exceptions];
|
||||
|
||||
export const useReturns = (params = {}, options) =>
|
||||
useQuery({
|
||||
queryKey: ['doormile', 'returns', params],
|
||||
queryFn: () => api.getReturns(params),
|
||||
placeholderData: (previous) => previous,
|
||||
staleTime: 15_000,
|
||||
...options,
|
||||
});
|
||||
|
||||
export const useInitiateRto = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ consignmentId, reason, note }) => api.initiateRto(consignmentId, { reason, note }),
|
||||
invalidates: RETURN_KEYS,
|
||||
successMessage: 'Return to sender started',
|
||||
});
|
||||
|
||||
export const useCancelRto = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ consignmentId, note }) => api.cancelRto(consignmentId, note),
|
||||
invalidates: RETURN_KEYS,
|
||||
successMessage: 'Return cancelled — delivery will be re-attempted',
|
||||
});
|
||||
|
||||
export const useCompleteRto = () =>
|
||||
useDoormileMutation({
|
||||
mutationFn: ({ consignmentId, note }) => api.completeRto(consignmentId, note),
|
||||
invalidates: RETURN_KEYS,
|
||||
successMessage: 'Marked returned to sender',
|
||||
});
|
||||
|
||||
/* ── Consignments ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export const useConsignments = (options) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { Ban, Bell, Bike, Clock, FileSpreadsheet, PackageSearch, Truck } from 'lucide-react';
|
||||
import { Ban, Bell, Bike, Clock, FileSpreadsheet, PackageCheck, PackageSearch, RotateCcw, Truck, Undo2 } from 'lucide-react';
|
||||
import {
|
||||
Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
|
||||
Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
|
||||
@@ -17,6 +17,8 @@ import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebounce
|
||||
import { toDateRange } from '@/lib/dateRange';
|
||||
import { pickupSourceTypeLabel } from '@/lib/orderFlow';
|
||||
import { summariseRouting } from '@/lib/routingSummary';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import { ResolveRtoModal, StartRtoModal, rtoActionsFor } from '@/components/doormile/RtoDialogs';
|
||||
|
||||
/**
|
||||
* Deliveries — orders that have moved past merely being created.
|
||||
@@ -45,6 +47,9 @@ const STATUS_TABS = [
|
||||
{ value: 'skipped', label: 'Skipped', countKey: 'skippedLength' },
|
||||
{ value: 'delivered', label: 'Delivered', countKey: 'coveredLength' },
|
||||
{ value: 'cancelled', label: 'Cancelled', countKey: 'cancelLength' },
|
||||
// Reverse logistics: parcels going back to the sender, and those back.
|
||||
{ value: 'rto', label: 'In return', countKey: 'rtoLength' },
|
||||
{ value: 'returned', label: 'Returned', countKey: 'returnedLength' },
|
||||
];
|
||||
|
||||
const KNOWN_STATUSES = STATUS_TABS.map((tab) => tab.value).concat('canceled');
|
||||
@@ -89,6 +94,11 @@ export default function Deliveries() {
|
||||
const [nextStatus, setNextStatus] = useState('delivered');
|
||||
const [cancelRow, setCancelRow] = useState(null);
|
||||
const [cancelReason, setCancelReason] = useState('');
|
||||
// Reverse logistics. Starting/closing a return is a Doormile-staff action;
|
||||
// a client login sees the statuses but no buttons (the server refuses too).
|
||||
const { isClient } = useAuth();
|
||||
const [rtoRow, setRtoRow] = useState(null);
|
||||
const [resolveRto, setResolveRto] = useState({ row: null, mode: 'cancel' });
|
||||
|
||||
const dateParams = useMemo(() => toDateRange(selectedDate), [selectedDate]);
|
||||
|
||||
@@ -309,7 +319,7 @@ export default function Deliveries() {
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
width: '130px',
|
||||
width: '170px',
|
||||
cell: (row) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton
|
||||
@@ -320,7 +330,10 @@ export default function Deliveries() {
|
||||
onClick={() => setDetailRow(row)}
|
||||
/>
|
||||
|
||||
{row.orderstatus !== 'delivered' && row.milerprofileid ? (
|
||||
{/* Not on parcels in or through a return: the generic "process
|
||||
deliveries" text is wrong for them, and starting a return
|
||||
already pushes the rider a return-specific message. */}
|
||||
{!['delivered', 'rto', 'returned'].includes(String(row.orderstatus || '').toLowerCase()) && row.milerprofileid ? (
|
||||
<IconButton
|
||||
label="Notify rider"
|
||||
icon={Bell}
|
||||
@@ -349,18 +362,51 @@ export default function Deliveries() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<IconButton
|
||||
label="Update status"
|
||||
icon={Truck}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusRow(row);
|
||||
setNextStatus('delivered');
|
||||
}}
|
||||
/>
|
||||
{!isClient && rtoActionsFor(row).canStart ? (
|
||||
<IconButton
|
||||
label="Return to sender"
|
||||
icon={Undo2}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRtoRow(row)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!['delivered', 'cancelled', 'canceled'].includes(String(row.orderstatus || '').toLowerCase()) ? (
|
||||
{!isClient && rtoActionsFor(row).canResolve ? (
|
||||
<>
|
||||
<IconButton
|
||||
label="Re-attempt delivery"
|
||||
icon={RotateCcw}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResolveRto({ row, mode: 'cancel' })}
|
||||
/>
|
||||
<IconButton
|
||||
label="Mark returned"
|
||||
icon={PackageCheck}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResolveRto({ row, mode: 'complete' })}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* A parcel in or through a return is moved only by the return
|
||||
actions above — the server refuses a plain status change. */}
|
||||
{!['rto', 'returned'].includes(String(row.orderstatus || '').toLowerCase()) ? (
|
||||
<IconButton
|
||||
label="Update status"
|
||||
icon={Truck}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setStatusRow(row);
|
||||
setNextStatus('delivered');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!['delivered', 'cancelled', 'canceled', 'rto', 'returned'].includes(String(row.orderstatus || '').toLowerCase()) ? (
|
||||
<IconButton
|
||||
label="Cancel delivery"
|
||||
icon={Ban}
|
||||
@@ -376,7 +422,7 @@ export default function Deliveries() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[notifyMiler]
|
||||
[notifyMiler, isClient]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
@@ -697,6 +743,13 @@ export default function Deliveries() {
|
||||
</Field>
|
||||
</Modal>
|
||||
|
||||
<StartRtoModal row={rtoRow} onClose={() => setRtoRow(null)} />
|
||||
<ResolveRtoModal
|
||||
row={resolveRto.row}
|
||||
mode={resolveRto.mode}
|
||||
onClose={() => setResolveRto({ row: null, mode: 'cancel' })}
|
||||
/>
|
||||
|
||||
<OrderDetailDrawer row={detailRow} onClose={() => setDetailRow(null)} />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import AgentOperationsBanner from '@/components/doormile/AgentOperationsBanner';
|
||||
import { StartRtoModal } from '@/components/doormile/RtoDialogs';
|
||||
import { useCreateException, useExceptions, useHubs, useUpdateExceptionStatus } from '@/lib/doormileHooks';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { matchesQuery, orDash, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
@@ -40,6 +41,10 @@ const EMPTY_FORM = {
|
||||
|
||||
const NO_HUB = 'none';
|
||||
|
||||
// Exceptions whose natural resolution is sending the parcel back (RTO), with
|
||||
// the return reason each one implies.
|
||||
const RTO_FROM_EXCEPTION = { Undeliverable: 'attempts_exhausted', Receiver_Refused: 'receiver_refused' };
|
||||
|
||||
export default function Exceptions() {
|
||||
const { data: exceptions = [], isLoading, isFetching } = useExceptions();
|
||||
const { data: hubs = [] } = useHubs();
|
||||
@@ -53,6 +58,7 @@ export default function Exceptions() {
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const [resolveRow, setResolveRow] = useState(null);
|
||||
const [rtoRow, setRtoRow] = useState(null);
|
||||
const [resolveStatus, setResolveStatus] = useState('Resolved');
|
||||
const [resolution, setResolution] = useState('');
|
||||
|
||||
@@ -199,9 +205,26 @@ export default function Exceptions() {
|
||||
isClosed(row) ? (
|
||||
<span className="text-caption text-ink-4">Closed</span>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => openResolve(row)}>
|
||||
Resolve
|
||||
</Button>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
{RTO_FROM_EXCEPTION[row.exceptiontype] && row.consignmentid ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setRtoRow({
|
||||
consignmentid: row.consignmentid,
|
||||
label: `Consignment #${row.consignmentid}`,
|
||||
defaultReason: RTO_FROM_EXCEPTION[row.exceptiontype],
|
||||
})
|
||||
}
|
||||
>
|
||||
Start return
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outline" size="sm" onClick={() => openResolve(row)}>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -427,6 +450,10 @@ export default function Exceptions() {
|
||||
</Field>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Starting a return resolves the parcel's open Undeliverable /
|
||||
Receiver_Refused exception on the server. */}
|
||||
<StartRtoModal row={rtoRow} onClose={() => setRtoRow(null)} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
209
src/pages/doormile/returns/Returns.jsx
Normal file
209
src/pages/doormile/returns/Returns.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { FileSpreadsheet, PackageCheck, RotateCcw, Undo2 } from 'lucide-react';
|
||||
import {
|
||||
Alert, Button, DataTable, EmptyState, IconButton, PageHeader, Pagination, SearchInput, Stack,
|
||||
StatusBadge, Surface, Tabs,
|
||||
} from '@/components/ds';
|
||||
import { DateRangeFields } from '@/components/ds/DateRangeFields';
|
||||
import { useAuth } from '@/lib/AuthContext';
|
||||
import { useReturns } from '@/lib/doormileHooks';
|
||||
import { exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
import { ResolveRtoModal } from '@/components/doormile/RtoDialogs';
|
||||
|
||||
/**
|
||||
* Returns — parcels going back to the sender (RTO) and those already back.
|
||||
*
|
||||
* Reads GET /admin/returns (newest first, server-paged). A return is started
|
||||
* from Deliveries or Exceptions; here ops follow it up: re-attempt delivery or
|
||||
* confirm the parcel is back. A client login sees its own returns, read-only.
|
||||
* Plan: docs/reverse-logistics-plan.md.
|
||||
*/
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: 'initiated', label: 'In return' },
|
||||
{ value: 'returned', label: 'Returned' },
|
||||
{ value: 'all', label: 'All' },
|
||||
];
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const STATUS_KEY = { RTO_Initiated: 'rto', Returned_to_Sender: 'returned' };
|
||||
|
||||
const fmt = (iso) => (iso ? dayjs(iso).format('DD MMM YYYY, h:mm A') : '—');
|
||||
|
||||
/** Whole days a return has been open (or took, once returned). */
|
||||
export function returnAgeDays(row, now = dayjs()) {
|
||||
if (!row?.returninitiatedat) return null;
|
||||
const end = row.returndeliveredat ? dayjs(row.returndeliveredat) : now;
|
||||
return Math.max(0, end.diff(dayjs(row.returninitiatedat), 'day'));
|
||||
}
|
||||
|
||||
export default function Returns() {
|
||||
const { isClient } = useAuth();
|
||||
const [status, setStatus] = useState('initiated');
|
||||
const [range, setRange] = useState({ from: dayjs().subtract(29, 'day').format('YYYY-MM-DD'), to: dayjs().format('YYYY-MM-DD') });
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
const [resolve, setResolve] = useState({ row: null, mode: 'cancel' });
|
||||
|
||||
const params = useMemo(
|
||||
() => ({ status, from: range.from || undefined, to: range.to || undefined, pageno: page, pagesize: PAGE_SIZE }),
|
||||
[status, range, page]
|
||||
);
|
||||
const { data, isLoading, isFetching, isError, error } = useReturns(params);
|
||||
const rows = useMemo(() => data?.data || [], [data]);
|
||||
const total = Number(data?.total) || 0;
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
rows.filter((r) =>
|
||||
matchesQuery(r, ['trackingno', 'tenantname', 'returnreason', 'milername', 'deliverypincode', 'pickuppincode'], debouncedSearch)
|
||||
),
|
||||
[rows, debouncedSearch]
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{ key: 'trackingno', header: 'Tracking no', accessor: (r) => orDash(r.trackingno) },
|
||||
{ key: 'tenantname', header: 'Client', accessor: (r) => orDash(r.tenantname) },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
cell: (r) => <StatusBadge status={STATUS_KEY[r.status] || r.status} dot size="sm" />,
|
||||
},
|
||||
{ key: 'returnreason', header: 'Reason', accessor: (r) => orDash(r.returnreason) },
|
||||
{ key: 'attemptcount', header: 'Attempts', accessor: (r) => r.attemptcount ?? 0, align: 'right' },
|
||||
{ key: 'returninitiatedat', header: 'Return started', accessor: (r) => fmt(r.returninitiatedat) },
|
||||
{ key: 'returndeliveredat', header: 'Returned', accessor: (r) => fmt(r.returndeliveredat) },
|
||||
{
|
||||
key: 'age',
|
||||
header: 'Days',
|
||||
align: 'right',
|
||||
accessor: (r) => {
|
||||
const d = returnAgeDays(r);
|
||||
return d == null ? '—' : d;
|
||||
},
|
||||
},
|
||||
{ key: 'milername', header: 'Miler', accessor: (r) => orDash(r.milername) },
|
||||
{ key: 'pincodes', header: 'Pickup → Drop', accessor: (r) => `${orDash(r.pickuppincode)} → ${orDash(r.deliverypincode)}` },
|
||||
...(isClient
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'right',
|
||||
cell: (r) =>
|
||||
r.status === 'RTO_Initiated' ? (
|
||||
<div className="flex justify-end gap-1">
|
||||
<IconButton
|
||||
label="Re-attempt delivery"
|
||||
icon={RotateCcw}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResolve({ row: r, mode: 'cancel' })}
|
||||
/>
|
||||
<IconButton
|
||||
label="Mark returned"
|
||||
icon={PackageCheck}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResolve({ row: r, mode: 'complete' })}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const exportColumns = [
|
||||
{ key: 'trackingno', header: 'Tracking no' },
|
||||
{ key: 'tenantname', header: 'Client' },
|
||||
{ key: 'status', header: 'Status' },
|
||||
{ key: 'returnreason', header: 'Reason' },
|
||||
{ key: 'attemptcount', header: 'Attempts' },
|
||||
{ key: 'returninitiatedat', header: 'Return started', value: (r) => fmt(r.returninitiatedat) },
|
||||
{ key: 'returndeliveredat', header: 'Returned', value: (r) => fmt(r.returndeliveredat) },
|
||||
{ key: 'milername', header: 'Miler' },
|
||||
{ key: 'pickuppincode', header: 'Pickup pincode' },
|
||||
{ key: 'deliverypincode', header: 'Drop pincode' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
title="Returns"
|
||||
subtitle="Parcels going back to the sender, and those already returned."
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!visible.length}
|
||||
onClick={() => exportRows(visible, exportColumns, `returns-${status}-${range.from || 'start'}-${range.to || 'now'}`)}
|
||||
>
|
||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Tabs
|
||||
tabs={STATUS_TABS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DateRangeFields
|
||||
value={range}
|
||||
onChange={(next) => {
|
||||
setRange(next);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Tracking no, client, reason, miler" className="w-full sm:w-64" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<Alert tone="destructive" role="alert" title="Returns could not be loaded">
|
||||
{error?.response?.status === 404
|
||||
? 'This API server does not have returns yet — deploy the current doormile_backend build.'
|
||||
: error?.response?.data?.message || error?.message || 'Try again shortly.'}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={visible}
|
||||
getRowId={(r) => r.consignmentid}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
isFiltered={Boolean(debouncedSearch)}
|
||||
onClearFilters={() => setSearch('')}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Undo2}
|
||||
title={status === 'returned' ? 'No returned parcels in this period' : 'No parcels in return'}
|
||||
description="Start a return from Deliveries (Return to sender) or from an Undeliverable exception."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<Surface variant="subtle" padding="sm" radius="lg">
|
||||
<Pagination
|
||||
page={page}
|
||||
pageCount={Math.max(1, Math.ceil(total / PAGE_SIZE))}
|
||||
pageSize={PAGE_SIZE}
|
||||
totalItems={total}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
<ResolveRtoModal row={resolve.row} mode={resolve.mode} onClose={() => setResolve({ row: null, mode: 'cancel' })} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -444,7 +444,10 @@ describe('deliveries data layer', () => {
|
||||
activeLength: 1,
|
||||
coveredLength: 1,
|
||||
cancelLength: 1,
|
||||
skippedLength: 0
|
||||
skippedLength: 0,
|
||||
// Reverse logistics tabs (In return / Returned).
|
||||
rtoLength: 0,
|
||||
returnedLength: 0
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
191
tests/integration/returns.test.jsx
Normal file
191
tests/integration/returns.test.jsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Reverse logistics (RTO) in the console: the shared dialogs, the Returns page
|
||||
* and the delivery-status mapping. HTTP is mocked with the backend's shapes
|
||||
* (POST /admin/consignments/:id/rto[/cancel|/complete], GET /admin/returns).
|
||||
*/
|
||||
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = global.ResizeObserver || class { observe() {} unobserve() {} disconnect() {} };
|
||||
});
|
||||
|
||||
jest.mock('lucide-react', () =>
|
||||
new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => <span data-testid={`icon-${String(prop)}`} {...props} />) })
|
||||
);
|
||||
|
||||
jest.mock('@/api/doormile', () => ({
|
||||
initiateRto: jest.fn(),
|
||||
cancelRto: jest.fn(),
|
||||
completeRto: jest.fn(),
|
||||
getReturns: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/api/doormile/notify', () => ({
|
||||
OpenToast: jest.fn(),
|
||||
messageOf: (err, fallback = 'Something went wrong') => err?.response?.data?.message || err?.message || fallback,
|
||||
}));
|
||||
|
||||
let mockAuth = { user: { email: 'ops@doormile.com', role: 'admin' }, isClient: false };
|
||||
jest.mock('@/lib/AuthContext', () => ({ useAuth: () => mockAuth }));
|
||||
|
||||
import * as api from '@/api/doormile';
|
||||
import { ResolveRtoModal, StartRtoModal, rtoActionsFor } from '@/components/doormile/RtoDialogs';
|
||||
import Returns, { returnAgeDays } from '@/pages/doormile/returns/Returns';
|
||||
import { deriveDeliveryStatus } from '@/api/doormile/queries';
|
||||
|
||||
const wrap = (ui) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const ROW = { consignmentid: 61, orderid: 'DM-000123', orderstatus: 'active' };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAuth = { user: { email: 'ops@doormile.com', role: 'admin' }, isClient: false };
|
||||
api.initiateRto.mockResolvedValue({ success: true, data: { started: true } });
|
||||
api.cancelRto.mockResolvedValue({ success: true, data: {} });
|
||||
api.completeRto.mockResolvedValue({ success: true, data: {} });
|
||||
});
|
||||
|
||||
describe('which rows may start or close a return', () => {
|
||||
it('starts only from a parcel in hand; closes only one in return', () => {
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'active' })).toEqual({ canStart: true, canResolve: false });
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'picked' }).canStart).toBe(true);
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'skipped' }).canStart).toBe(true);
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'delivered' }).canStart).toBe(false);
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'pending' }).canStart).toBe(false);
|
||||
// No parcel yet (not picked up) — nothing to return.
|
||||
expect(rtoActionsFor({ consignmentid: null, orderstatus: 'active' }).canStart).toBe(false);
|
||||
expect(rtoActionsFor({ consignmentid: 1, orderstatus: 'rto' })).toEqual({ canStart: false, canResolve: true });
|
||||
});
|
||||
|
||||
it('maps the backend RTO statuses to their own tabs, leaving legacy keys alone', () => {
|
||||
expect(deriveDeliveryStatus('RTO_Initiated')).toBe('rto');
|
||||
expect(deriveDeliveryStatus('Returned_to_Sender')).toBe('returned');
|
||||
expect(deriveDeliveryStatus('rto')).toBe('skipped');
|
||||
expect(deriveDeliveryStatus('returned')).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Start return dialog', () => {
|
||||
it('needs a reason, and a note for Other, then starts the return', async () => {
|
||||
wrap(<StartRtoModal row={ROW} onClose={jest.fn()} />);
|
||||
const start = screen.getByRole('button', { name: /Start return/ });
|
||||
expect(start).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Return reason'), { target: { value: 'other' } });
|
||||
expect(start).toBeDisabled(); // Other needs a description
|
||||
fireEvent.change(screen.getByLabelText('Return note'), { target: { value: 'Shop closed for good' } });
|
||||
expect(start).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(start);
|
||||
await waitFor(() =>
|
||||
expect(api.initiateRto).toHaveBeenCalledWith(61, { reason: 'other', note: 'Shop closed for good' })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a caller-given label as the subtitle (Exceptions passes "Consignment #id")', () => {
|
||||
wrap(<StartRtoModal row={{ consignmentid: 9106, label: 'Consignment #9106' }} onClose={jest.fn()} />);
|
||||
expect(screen.getByText('Consignment #9106')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Order consignment/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-selects a reason when given one', () => {
|
||||
wrap(<StartRtoModal row={{ ...ROW, defaultReason: 'receiver_refused' }} onClose={jest.fn()} />);
|
||||
expect(screen.getByLabelText('Return reason')).toHaveValue('receiver_refused');
|
||||
expect(screen.getByRole('button', { name: /Start return/ })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the server refusal and stays open', async () => {
|
||||
api.initiateRto.mockRejectedValue({ response: { status: 400, data: { message: 'a parcel that is delivered cannot be returned' } } });
|
||||
const onClose = jest.fn();
|
||||
wrap(<StartRtoModal row={ROW} onClose={onClose} />);
|
||||
fireEvent.change(screen.getByLabelText('Return reason'), { target: { value: 'receiver_refused' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Start return/ }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('cannot be returned');
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resolve return dialog', () => {
|
||||
it.each([
|
||||
['cancel', 'Re-attempt delivery', 'cancelRto'],
|
||||
['complete', 'Mark returned', 'completeRto'],
|
||||
])('%s calls the matching endpoint', async (mode, button, fn) => {
|
||||
const onClose = jest.fn();
|
||||
wrap(<ResolveRtoModal row={{ ...ROW, orderstatus: 'rto' }} mode={mode} onClose={onClose} />);
|
||||
fireEvent.change(screen.getByLabelText('Resolution note'), { target: { value: 'checked with client' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: button }));
|
||||
await waitFor(() => expect(api[fn]).toHaveBeenCalledWith(61, 'checked with client'));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Returns page', () => {
|
||||
const RETURNS = {
|
||||
success: true,
|
||||
total: 2,
|
||||
data: [
|
||||
{
|
||||
consignmentid: 61, trackingno: 'DMX00000061', tenantname: 'Acme Foods', status: 'RTO_Initiated',
|
||||
returnreason: 'Receiver refused', attemptcount: 1, returninitiatedat: '2026-10-01T10:00:00+05:30',
|
||||
returndeliveredat: null, milername: 'Ravi K', pickuppincode: '641001', deliverypincode: '641002',
|
||||
},
|
||||
{
|
||||
consignmentid: 62, trackingno: 'DMX00000062', tenantname: 'Beta Co', status: 'Returned_to_Sender',
|
||||
returnreason: 'Delivery attempts exhausted', attemptcount: 3, returninitiatedat: '2026-09-28T10:00:00+05:30',
|
||||
returndeliveredat: '2026-09-30T10:00:00+05:30', milername: '', pickuppincode: '641005', deliverypincode: '641009',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('lists returns, starting on "In return", with actions only for open ones', async () => {
|
||||
api.getReturns.mockResolvedValue(RETURNS);
|
||||
wrap(<Returns />);
|
||||
expect(await screen.findByText('DMX00000061')).toBeInTheDocument();
|
||||
expect(api.getReturns.mock.calls[0][0]).toMatchObject({ status: 'initiated', pageno: 1, pagesize: 50 });
|
||||
expect(screen.getAllByRole('button', { name: 'Mark returned' })).toHaveLength(1);
|
||||
expect(screen.getAllByRole('button', { name: 'Re-attempt delivery' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('asks the server for the chosen tab', async () => {
|
||||
api.getReturns.mockResolvedValue(RETURNS);
|
||||
wrap(<Returns />);
|
||||
await screen.findByText('DMX00000061');
|
||||
// The tab row renders before the table, whose header also says Returned.
|
||||
fireEvent.click(screen.getAllByText('Returned')[0]);
|
||||
await waitFor(() => expect(api.getReturns.mock.calls.at(-1)[0]).toMatchObject({ status: 'returned', pageno: 1 }));
|
||||
});
|
||||
|
||||
it('is read-only for a client login', async () => {
|
||||
mockAuth = { user: { email: 'ops@acme.test', role: 'manager', tenantid: 7 }, isClient: true };
|
||||
api.getReturns.mockResolvedValue(RETURNS);
|
||||
wrap(<Returns />);
|
||||
await screen.findByText('DMX00000061');
|
||||
expect(screen.queryByRole('button', { name: 'Mark returned' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says plainly when the backend has no returns yet', async () => {
|
||||
api.getReturns.mockRejectedValue({ response: { status: 404 } });
|
||||
wrap(<Returns />);
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('deploy the current doormile_backend build');
|
||||
});
|
||||
|
||||
it('counts days a return has been open, or took', () => {
|
||||
const now = new Date('2026-10-05T10:00:00+05:30');
|
||||
const dayjs = jest.requireActual('dayjs');
|
||||
expect(returnAgeDays(RETURNS.data[0], dayjs(now))).toBe(4);
|
||||
expect(returnAgeDays(RETURNS.data[1], dayjs(now))).toBe(2);
|
||||
expect(returnAgeDays({}, dayjs(now))).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user