From ff1b7952921531227a37a5397412d9f617c8a960 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 7 Oct 2026 13:00:43 +0530 Subject: [PATCH] updates on the reverse logistics --- docs/reverse-logistics-plan.md | 21 +++ src/api/doormile/endpoints.js | 12 ++ .../doormile/ConsignmentTimeline.jsx | 89 ++++++++++ src/lib/doormileHooks.js | 22 +++ src/pages/doormile/deliveries/Deliveries.jsx | 7 + src/pages/doormile/returns/Returns.jsx | 163 ++++++++++++++---- tests/integration/returns.test.jsx | 72 +++++++- 7 files changed, 354 insertions(+), 32 deletions(-) create mode 100644 src/components/doormile/ConsignmentTimeline.jsx diff --git a/docs/reverse-logistics-plan.md b/docs/reverse-logistics-plan.md index 3f6d3a3..8f0daa0 100644 --- a/docs/reverse-logistics-plan.md +++ b/docs/reverse-logistics-plan.md @@ -277,3 +277,24 @@ New env vars: `RTO_AUTO_AFTER_ATTEMPTS` (default 3), `MILER_RTO_FLOW_ENABLED` "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. + diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js index 6950a42..325752b 100644 --- a/src/api/doormile/endpoints.js +++ b/src/api/doormile/endpoints.js @@ -902,3 +902,15 @@ export const getReturns = async (params = {}) => { const response = await doormileAxios.get(`/admin/returns${buildQuery(params)}`); return response.data; }; + +/** Return rates per client and per reason for a date range (GET /admin/returns/summary). */ +export const getReturnsSummary = async (params = {}) => { + const response = await doormileAxios.get(`/admin/returns/summary${buildQuery(params)}`); + return response.data; +}; + +/** Every recorded event of one parcel, oldest first (GET /admin/consignments/:id/history). */ +export const getConsignmentHistory = async (consignmentId) => { + const response = await doormileAxios.get(`/admin/consignments/${consignmentId}/history`); + return response.data; +}; diff --git a/src/components/doormile/ConsignmentTimeline.jsx b/src/components/doormile/ConsignmentTimeline.jsx new file mode 100644 index 0000000..1154d31 --- /dev/null +++ b/src/components/doormile/ConsignmentTimeline.jsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { useConsignmentHistory } from '@/lib/doormileHooks'; +import { formatDoormileTimestamp } from '@/lib/doormileTimestamp'; + +/** + * ConsignmentTimeline — everything recorded about one parcel, oldest first: + * pickup, base hand-overs, failed attempts, a return starting, being + * re-attempted, or completed (reverse logistics plan, C6). + * + * Reads GET /admin/consignments/:id/history, which a client login may call for + * its own parcels only. The return actions invalidate this query, so the + * timeline updates as soon as ops act. + */ + +/** Plain words for each status a history row can carry. */ +export const EVENT_LABELS = { + Created: 'Parcel created', + Collected_By_Miler: 'Collected by the rider', + Inwarded_at_Hub: 'Received at the base', + Tripsheet_Loaded: 'Loaded for transfer', + In_Transit: 'In transit between bases', + Out_for_Delivery: 'Out for delivery', + Delivered: 'Delivered', + Delivery_Skipped: 'Delivery attempt failed', + RTO_Initiated: 'Return to sender started', + Returned_to_Sender: 'Returned to sender', + Missing: 'Marked missing', + Damaged: 'Marked damaged', + Cancelled: 'Cancelled', +}; + +const TONE = { + Delivered: 'bg-success', + Delivery_Skipped: 'bg-warning', + RTO_Initiated: 'bg-warning', + Returned_to_Sender: 'bg-brand', + Missing: 'bg-destructive', + Damaged: 'bg-destructive', + Cancelled: 'bg-ink-4', +}; + +export const eventLabel = (status) => + EVENT_LABELS[status] || String(status || 'Update').replace(/_/g, ' '); + +/** The secondary line: who or where, then any note. */ +export function eventDetail(event) { + const parts = []; + if (event?.actorname) parts.push(`by ${event.actorname}`); + if (event?.hubname) parts.push(`at ${event.hubname}`); + // A re-attempt is recorded as the status the parcel went back to; say so. + if (event?.fromstatus && event.eventstatus === 'RTO_Initiated') { + parts.push(`was ${eventLabel(event.fromstatus).toLowerCase()}`); + } + return parts.join(' · '); +} + +export default function ConsignmentTimeline({ consignmentId }) { + const { data, isLoading, isError } = useConsignmentHistory(consignmentId); + const events = Array.isArray(data?.data) ? data.data : []; + + if (!consignmentId) { + return

No parcel yet — the timeline starts once the order is picked up.

; + } + if (isLoading) return

Loading timeline…

; + if (isError) return

The timeline could not be loaded.

; + if (!events.length) return

Nothing has been recorded for this parcel yet.

; + + return ( +
    + {events.map((event) => { + const detail = eventDetail(event); + return ( +
  1. +
  2. + ); + })} +
+ ); +} diff --git a/src/lib/doormileHooks.js b/src/lib/doormileHooks.js index c59692a..1c85349 100644 --- a/src/lib/doormileHooks.js +++ b/src/lib/doormileHooks.js @@ -108,6 +108,9 @@ const BOOKING_KEYS = [ ['doormile', 'bookings'], ['doormile', 'deliveries'], ['doormile', 'delivery-counts'], + // A parcel's timeline (ConsignmentTimeline), so a status change made from + // Deliveries shows in an open order drawer at once. + ['doormile', 'returns', 'history'], ]; /* ── Dashboard, reports, profile ──────────────────────────────────────────── */ @@ -557,6 +560,25 @@ export const useReturns = (params = {}, options) => ...options, }); +/* Both live under the returns key, so every return action above refreshes them. */ +export const useReturnsSummary = (params = {}, options) => + useQuery({ + queryKey: ['doormile', 'returns', 'summary', params], + queryFn: () => api.getReturnsSummary(params), + placeholderData: (previous) => previous, + staleTime: 60_000, + ...options, + }); + +export const useConsignmentHistory = (consignmentId, options) => + useQuery({ + queryKey: ['doormile', 'returns', 'history', String(consignmentId)], + queryFn: () => api.getConsignmentHistory(consignmentId), + enabled: !!consignmentId, + staleTime: 15_000, + ...options, + }); + export const useInitiateRto = () => useDoormileMutation({ mutationFn: ({ consignmentId, reason, note }) => api.initiateRto(consignmentId, { reason, note }), diff --git a/src/pages/doormile/deliveries/Deliveries.jsx b/src/pages/doormile/deliveries/Deliveries.jsx index a9aaffd..900ef09 100644 --- a/src/pages/doormile/deliveries/Deliveries.jsx +++ b/src/pages/doormile/deliveries/Deliveries.jsx @@ -19,6 +19,7 @@ import { pickupSourceTypeLabel } from '@/lib/orderFlow'; import { summariseRouting } from '@/lib/routingSummary'; import { useAuth } from '@/lib/AuthContext'; import { ResolveRtoModal, StartRtoModal, rtoActionsFor } from '@/components/doormile/RtoDialogs'; +import ConsignmentTimeline from '@/components/doormile/ConsignmentTimeline'; /** * Deliveries — orders that have moved past merely being created. @@ -910,6 +911,12 @@ function OrderDetailDrawer({ row, onClose }) { )} + {/* Attempts, base hand-overs and any return, in order. */} +
+

Timeline

+ +
+ {row?.notes ? (

Notes

diff --git a/src/pages/doormile/returns/Returns.jsx b/src/pages/doormile/returns/Returns.jsx index 9a0b360..115af65 100644 --- a/src/pages/doormile/returns/Returns.jsx +++ b/src/pages/doormile/returns/Returns.jsx @@ -1,15 +1,16 @@ import React, { useMemo, useState } from 'react'; import dayjs from 'dayjs'; -import { FileSpreadsheet, PackageCheck, RotateCcw, Undo2 } from 'lucide-react'; +import { Clock, FileSpreadsheet, History, PackageCheck, Percent, RotateCcw, Truck, Undo2 } from 'lucide-react'; import { - Alert, Button, DataTable, EmptyState, IconButton, PageHeader, Pagination, SearchInput, Stack, + Alert, Button, DataTable, Drawer, EmptyState, Grid, IconButton, KpiCard, 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 { useReturns, useReturnsSummary } from '@/lib/doormileHooks'; import { exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat'; import { ResolveRtoModal } from '@/components/doormile/RtoDialogs'; +import ConsignmentTimeline from '@/components/doormile/ConsignmentTimeline'; /** * Returns — parcels going back to the sender (RTO) and those already back. @@ -38,6 +39,93 @@ export function returnAgeDays(row, now = dayjs()) { return Math.max(0, end.diff(dayjs(row.returninitiatedat), 'day')); } +/** "12.5%" — a rate the server already rounded to one decimal. */ +const pct = (n) => `${Number(n) || 0}%`; + +/** + * How often parcels come back, and why (reverse logistics plan, C7). Of the + * parcels created in the selected period, the share that went into a return, + * per client and per reason. A client login gets its own figures only, so the + * per-client table is left out for it. + */ +export function ReturnsSummary({ range, isClient }) { + const { data, isLoading, isError } = useReturnsSummary({ from: range.from || undefined, to: range.to || undefined }); + const s = data?.data; + if (isError) return null; // the list below already reports a server problem + const clients = s?.by_client || []; + const reasons = s?.by_reason || []; + + return ( + + + + + + + + + {!isLoading && s?.total > 0 && ( +
+ {!isClient && ( + +

Return rate by client

+ + + + + + + + + + + {clients.map((c) => ( + + + + + + + ))} + +
ClientParcelsReturnsRate
{orDash(c.tenantname)}{c.total}{c.in_return + c.returned}{pct(c.return_rate)}
+
+ )} + + +

Why parcels came back

+ {reasons.length === 0 ? ( +

No returns in this period.

+ ) : ( +
    + {reasons.map((r) => ( +
  • + {r.reason} + {r.count} +
  • + ))} +
+ )} +
+
+ )} + + {!isLoading && s && ( +

+ Based on {s.total} parcel{s.total === 1 ? '' : 's'} created in this period (cancelled ones excluded). +

+ )} +
+ ); +} + export default function Returns() { const { isClient } = useAuth(); const [status, setStatus] = useState('initiated'); @@ -46,6 +134,7 @@ export default function Returns() { const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search); const [resolve, setResolve] = useState({ row: null, mode: 'cancel' }); + const [historyRow, setHistoryRow] = useState(null); const params = useMemo( () => ({ status, from: range.from || undefined, to: range.to || undefined, pageno: page, pagesize: PAGE_SIZE }), @@ -86,34 +175,34 @@ export default function Returns() { }, { 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' ? ( -
- setResolve({ row: r, mode: 'cancel' })} - /> - setResolve({ row: r, mode: 'complete' })} - /> -
- ) : null, - }, - ]), + { + key: 'actions', + header: 'Actions', + align: 'right', + cell: (r) => ( +
+ setHistoryRow(r)} /> + {!isClient && r.status === 'RTO_Initiated' ? ( + <> + setResolve({ row: r, mode: 'cancel' })} + /> + setResolve({ row: r, mode: 'complete' })} + /> + + ) : null} +
+ ), + }, ]; const exportColumns = [ @@ -166,6 +255,8 @@ export default function Returns() {
+ + {isError && ( {error?.response?.status === 404 @@ -204,6 +295,16 @@ export default function Returns() { )} setResolve({ row: null, mode: 'cancel' })} /> + + !next && setHistoryRow(null)} + title={`Parcel ${historyRow?.trackingno || ''}`} + description={[historyRow?.tenantname, historyRow?.returnreason].filter(Boolean).join(' · ') || undefined} + icon={History} + > + + ); } diff --git a/tests/integration/returns.test.jsx b/tests/integration/returns.test.jsx index c9f85a7..2a85397 100644 --- a/tests/integration/returns.test.jsx +++ b/tests/integration/returns.test.jsx @@ -22,6 +22,8 @@ jest.mock('@/api/doormile', () => ({ cancelRto: jest.fn(), completeRto: jest.fn(), getReturns: jest.fn(), + getReturnsSummary: jest.fn(), + getConsignmentHistory: jest.fn(), })); jest.mock('@/api/doormile/notify', () => ({ @@ -54,6 +56,8 @@ beforeEach(() => { api.initiateRto.mockResolvedValue({ success: true, data: { started: true } }); api.cancelRto.mockResolvedValue({ success: true, data: {} }); api.completeRto.mockResolvedValue({ success: true, data: {} }); + api.getReturnsSummary.mockResolvedValue({ success: true, data: { total: 0, by_client: [], by_reason: [] } }); + api.getConsignmentHistory.mockResolvedValue({ success: true, data: [] }); }); describe('which rows may start or close a return', () => { @@ -172,7 +176,73 @@ describe('Returns page', () => { wrap(); await screen.findByText('DMX00000061'); expect(screen.queryByRole('button', { name: 'Mark returned' })).not.toBeInTheDocument(); - expect(screen.queryByText('Actions')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Re-attempt delivery' })).not.toBeInTheDocument(); + // A client may still read each parcel's history. + expect(screen.getAllByRole('button', { name: 'History' })).toHaveLength(2); + }); + + const SUMMARY = { + success: true, + data: { + total: 8, delivered: 4, in_return: 1, in_return_now: 2, returned: 3, return_rate: 50, avg_return_days: 1.5, + by_client: [ + { tenantid: 1, tenantname: 'Acme Foods', total: 6, in_return: 1, returned: 3, return_rate: 66.7 }, + { tenantid: 2, tenantname: 'Beta Co', total: 2, in_return: 0, returned: 0, return_rate: 0 }, + ], + by_reason: [ + { reason: 'Receiver refused', count: 3 }, + { reason: 'Address not found', count: 1 }, + ], + }, + }; + + it('summarises the return rate per client and per reason for the chosen period', async () => { + api.getReturns.mockResolvedValue(RETURNS); + api.getReturnsSummary.mockResolvedValue(SUMMARY); + wrap(); + expect(await screen.findByText('Return rate by client')).toBeInTheDocument(); + expect(screen.getByText('50%')).toBeInTheDocument(); + expect(screen.getByText('66.7%')).toBeInTheDocument(); + expect(screen.getByText('1.5 d')).toBeInTheDocument(); + // "In return now" counts every open return, not only parcels created in the window. + expect(screen.getByText('In return now').parentElement).toHaveTextContent('2'); + expect(screen.getByText('Address not found')).toBeInTheDocument(); + expect(screen.getByText(/Based on 8 parcels/)).toBeInTheDocument(); + // Same window as the list. + const listParams = api.getReturns.mock.calls[0][0]; + expect(api.getReturnsSummary.mock.calls[0][0]).toEqual({ from: listParams.from, to: listParams.to }); + }); + + it('does not show a client the per-client table, only its own reasons', async () => { + mockAuth = { user: { email: 'ops@acme.test', role: 'manager', tenantid: 1 }, isClient: true }; + api.getReturns.mockResolvedValue(RETURNS); + api.getReturnsSummary.mockResolvedValue(SUMMARY); + wrap(); + expect(await screen.findByText('Why parcels came back')).toBeInTheDocument(); + expect(screen.queryByText('Return rate by client')).not.toBeInTheDocument(); + }); + + it("opens a parcel's timeline from History", async () => { + api.getReturns.mockResolvedValue(RETURNS); + api.getConsignmentHistory.mockResolvedValue({ + success: true, + data: [ + { historyid: 1, eventstatus: 'Out_for_Delivery', remarks: '', createdat: '2026-10-01T09:00:00+05:30' }, + { historyid: 2, eventstatus: 'Delivery_Skipped', remarks: 'Customer unavailable', createdat: '2026-10-01T09:30:00+05:30' }, + { + historyid: 3, eventstatus: 'RTO_Initiated', remarks: 'Receiver refused: gate closed', + fromstatus: 'Out_for_Delivery', actorname: 'Ops Priya', createdat: '2026-10-01T10:00:00+05:30', + }, + ], + }); + wrap(); + await screen.findByText('DMX00000061'); + fireEvent.click(screen.getAllByRole('button', { name: 'History' })[0]); + expect(await screen.findByText('Return to sender started')).toBeInTheDocument(); + expect(api.getConsignmentHistory).toHaveBeenCalledWith(61); + expect(screen.getByText('Delivery attempt failed')).toBeInTheDocument(); + expect(screen.getByText('Receiver refused: gate closed')).toBeInTheDocument(); + expect(screen.getByText(/by Ops Priya · was out for delivery/)).toBeInTheDocument(); }); it('says plainly when the backend has no returns yet', async () => {