updates on the reverse logistics final phrase

This commit is contained in:
2026-10-07 13:00:32 +05:30
parent 6048145377
commit c574a79afc
4 changed files with 449 additions and 1 deletions

View File

@@ -600,7 +600,7 @@ func GetReturns(c *fiber.Ctx) error {
} }
q := scopeToTenant(db.DB.Table("consignments AS cn"), "cn.tenantid", tenantID). q := scopeToTenant(db.DB.Table("consignments AS cn"), "cn.tenantid", tenantID).
Where("cn.status IN ?", statuses) Where("cn.status IN ? AND cn.deletedat IS NULL", statuses)
if start != nil { if start != nil {
q = q.Where("cn.returninitiatedat >= ?", *start) q = q.Where("cn.returninitiatedat >= ?", *start)
} }

View File

@@ -0,0 +1,252 @@
package controllers
import (
"math"
"sort"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Reverse logistics, phase 4 (the parts that need no product decision): the
// timeline of one parcel, and return rates per client and per reason.
// Plan: krow_talent_app/docs/reverse-logistics-plan.md (C6, C7).
// historyEvent is one line of GET /admin/consignments/:id/history.
type historyEvent struct {
Historyid int `json:"historyid"`
Eventstatus string `json:"eventstatus"`
Remarks string `json:"remarks"`
Fromstatus string `json:"fromstatus,omitempty"`
Createdat time.Time `json:"createdat"`
Userid *int `json:"userid"`
Actorname string `json:"actorname"`
Hubid *int `json:"hubid"`
Hubname string `json:"hubname"`
}
// historyEventLimit caps one parcel's timeline; a real parcel has a few dozen
// events at most.
const historyEventLimit = 500
// GetConsignmentHistory — GET /admin/consignments/:id/history
// Every recorded event of one parcel, oldest first: pickup, hub hand-overs,
// failed attempts, a return starting, being re-attempted or completed. A client
// login reads only its own parcels (same scoping as the consignment read).
func GetConsignmentHistory(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil || id <= 0 {
return utils.BadRequest(c, "invalid consignment ID")
}
var cn models.Consignment
if err := scopeToOwnTenant(c, db.DB, "tenantid").Select("consignmentid").First(&cn, id).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
events := []historyEvent{}
if err := db.DB.Table("consignmenthistory AS h").
Select(`h.historyid, h.eventstatus, COALESCE(h.remarks, '') AS remarks, h.createdat,
h.userid, COALESCE(u.authname, '') AS actorname, h.hubid, COALESCE(hb.hubname, '') AS hubname`).
Joins("LEFT JOIN appusers u ON u.userid = h.userid").
Joins("LEFT JOIN hubs hb ON hb.hubid = h.hubid").
Where("h.consignmentid = ?", id).
// /miler/consignments/logs writes one row per GPS ping ("GPS Update:
// ..."); those are a trail, not events, and could run to hundreds.
Where("COALESCE(h.remarks, '') NOT LIKE ?", "GPS Update:%").
Order("h.createdat ASC, h.historyid ASC").
Limit(historyEventLimit).
Scan(&events).Error; err != nil {
utils.Error("consignment history", "consignment_id", id, "error", err.Error())
return utils.Internal(c, "failed to load the parcel history")
}
// The RTO_Initiated remark carries "[from:<status>] " so a re-attempt can
// restore the status; that is bookkeeping, not something to show a person.
for i := range events {
if m := rtoFromPrefix.FindStringSubmatch(events[i].Remarks); m != nil {
events[i].Fromstatus = m[1]
events[i].Remarks = strings.TrimSpace(strings.TrimPrefix(events[i].Remarks, m[0]))
}
}
return utils.OK(c, events)
}
// returnRate is returns as a percentage of parcels, to one decimal place.
func returnRate(returns, total int64) float64 {
if total == 0 {
return 0
}
return math.Round(float64(returns)*1000/float64(total)) / 10
}
// returnReasonLabel is the reason a return was filed under: returnreason is
// stored as "Label: free-text note" (rtoReasonText), and the label is what a
// report groups by.
func returnReasonLabel(stored string) string {
label := strings.TrimSpace(strings.SplitN(stored, ":", 2)[0])
if label == "" {
return "Not recorded"
}
return label
}
type returnsClientRow struct {
Tenantid int `json:"tenantid"`
Tenantname string `json:"tenantname"`
Total int64 `json:"total"`
Delivered int64 `json:"delivered"`
InReturn int64 `json:"in_return"`
Returned int64 `json:"returned"`
ReturnRate float64 `json:"return_rate"`
}
type returnsReasonRow struct {
Reason string `json:"reason"`
Count int64 `json:"count"`
}
// GetReturnsSummary — GET /admin/returns/summary?from&to&tenantid
// Of the parcels created in the period (cancelled ones left out), how many went
// into a return, per client and per reason, and how long completed returns
// took. Defaults to the last 30 days. A client login sees only its own figures.
func GetReturnsSummary(c *fiber.Ctx) error {
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
start, end, err := returnsDateRange(c.Query("from"), c.Query("to"))
if err != nil {
return utils.BadRequest(c, err.Error())
}
if start == nil {
// India midnight 29 days ago, the same clock returnsDateRange parses
// ?from= on (not DBToday, whose India digits are labelled UTC).
n := time.Now().In(utils.ISTLocation())
t := time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, utils.ISTLocation()).AddDate(0, 0, -29)
start = &t
}
base := func() *gorm.DB {
q := scopeToTenant(db.DB.Table("consignments AS cn"), "cn.tenantid", tenantID).
Where("cn.deletedat IS NULL AND cn.status <> ?", "Cancelled").
Where("cn.createdat >= ?", *start)
if end != nil {
q = q.Where("cn.createdat < ?", *end)
}
return q
}
clients := []returnsClientRow{}
if err := base().
Select(`cn.tenantid,
CASE WHEN cn.tenantid = 0 THEN 'Direct customers (B2C)' ELSE COALESCE(t.tenantname, '') END AS tenantname,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE cn.status = ?) AS delivered,
COUNT(*) FILTER (WHERE cn.status = ?) AS in_return,
COUNT(*) FILTER (WHERE cn.status = ?) AS returned`,
constants.ConsignmentDelivered, constants.ConsignmentRTOInitiated, constants.ConsignmentReturnedToSender).
Joins("LEFT JOIN tenants t ON t.tenantid = cn.tenantid").
Group("cn.tenantid, t.tenantname").
Scan(&clients).Error; err != nil {
utils.Error("returns summary: clients", "error", err.Error())
return utils.Internal(c, "failed to summarise returns")
}
var stored []string
if err := base().Where("cn.status IN ?", []string{constants.ConsignmentRTOInitiated, constants.ConsignmentReturnedToSender}).
Pluck("COALESCE(cn.returnreason, '')", &stored).Error; err != nil {
utils.Error("returns summary: reasons", "error", err.Error())
return utils.Internal(c, "failed to summarise returns")
}
reasonCount := map[string]int64{}
for _, r := range stored {
reasonCount[returnReasonLabel(r)]++
}
reasons := make([]returnsReasonRow, 0, len(reasonCount))
for label, n := range reasonCount {
reasons = append(reasons, returnsReasonRow{Reason: label, Count: n})
}
sortReasons(reasons)
// Every parcel in return right now, whenever it was created: the same set
// the Returns list's "In return" tab shows (the windowed counts above
// describe parcels created in the period, for the rate).
var inReturnNow int64
if err := scopeToTenant(db.DB.Table("consignments AS cn"), "cn.tenantid", tenantID).
Where("cn.deletedat IS NULL AND cn.status = ?", constants.ConsignmentRTOInitiated).
Count(&inReturnNow).Error; err != nil {
utils.Error("returns summary: in return now", "error", err.Error())
return utils.Internal(c, "failed to summarise returns")
}
var avg struct{ Days *float64 }
if err := base().Where("cn.status = ? AND cn.returninitiatedat IS NOT NULL AND cn.returndeliveredat IS NOT NULL",
constants.ConsignmentReturnedToSender).
Select("AVG(EXTRACT(EPOCH FROM (cn.returndeliveredat - cn.returninitiatedat)) / 86400.0) AS days").
Scan(&avg).Error; err != nil {
utils.Error("returns summary: duration", "error", err.Error())
return utils.Internal(c, "failed to summarise returns")
}
var total, delivered, inReturn, returned int64
for i := range clients {
clients[i].ReturnRate = returnRate(clients[i].InReturn+clients[i].Returned, clients[i].Total)
total += clients[i].Total
delivered += clients[i].Delivered
inReturn += clients[i].InReturn
returned += clients[i].Returned
}
sortClientsByReturns(clients)
var avgDays *float64
if avg.Days != nil {
d := math.Round(*avg.Days*10) / 10
avgDays = &d
}
resp := fiber.Map{
"from": start.Format("2006-01-02"),
"basis": "parcels created in the period, cancelled ones excluded",
"total": total,
"delivered": delivered,
"in_return": inReturn,
"in_return_now": inReturnNow,
"returned": returned,
"return_rate": returnRate(inReturn+returned, total),
"avg_return_days": avgDays,
"by_client": clients,
"by_reason": reasons,
}
if end != nil {
resp["to"] = end.AddDate(0, 0, -1).Format("2006-01-02")
}
return utils.OK(c, resp)
}
// sortReasons orders reasons by count, most common first (ties by name).
func sortReasons(rows []returnsReasonRow) {
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
return rows[i].Reason < rows[j].Reason
})
}
// sortClientsByReturns puts the clients with the most returns first.
func sortClientsByReturns(rows []returnsClientRow) {
sort.Slice(rows, func(i, j int) bool {
a, b := rows[i].InReturn+rows[i].Returned, rows[j].InReturn+rows[j].Returned
if a != b {
return a > b
}
return rows[i].Tenantname < rows[j].Tenantname
})
}

View File

@@ -418,6 +418,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
// Consignments // Consignments
adminAuth.Get("/consignments", controllers.GetAdminConsignments) adminAuth.Get("/consignments", controllers.GetAdminConsignments)
adminAuth.Get("/consignments/:id", controllers.GetAdminConsignmentDetails) adminAuth.Get("/consignments/:id", controllers.GetAdminConsignmentDetails)
adminAuth.Get("/consignments/:id/history", controllers.GetConsignmentHistory)
adminAuth.Get("/consignments/:id/logs", controllers.GetAdminConsignmentLogs) adminAuth.Get("/consignments/:id/logs", controllers.GetAdminConsignmentLogs)
adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking) adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking)
adminAuth.Put("/consignments/:id/status", controllers.AdminUpdateConsignmentStatus) adminAuth.Put("/consignments/:id/status", controllers.AdminUpdateConsignmentStatus)
@@ -427,6 +428,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Post("/consignments/:id/rto/cancel", middlewares.DoormileStaffOnly, controllers.CancelConsignmentRTO) adminAuth.Post("/consignments/:id/rto/cancel", middlewares.DoormileStaffOnly, controllers.CancelConsignmentRTO)
adminAuth.Post("/consignments/:id/rto/complete", middlewares.DoormileStaffOnly, controllers.CompleteConsignmentRTO) adminAuth.Post("/consignments/:id/rto/complete", middlewares.DoormileStaffOnly, controllers.CompleteConsignmentRTO)
adminAuth.Get("/returns", controllers.GetReturns) adminAuth.Get("/returns", controllers.GetReturns)
adminAuth.Get("/returns/summary", controllers.GetReturnsSummary)
// Tripsheets // Tripsheets
adminAuth.Get("/tripsheets", middlewares.DoormileStaffOnly, controllers.GetTripsheets) adminAuth.Get("/tripsheets", middlewares.DoormileStaffOnly, controllers.GetTripsheets)

View File

@@ -0,0 +1,194 @@
package routes_test
import (
"encoding/json"
"fmt"
"net/http"
"os"
"testing"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/testpg"
"doormile/models"
)
// Reverse logistics phase 4: the parcel timeline and the returns summary, end
// to end through the router against a real Postgres. Postgres-gated; the DSN
// must be a THROWAWAY database.
func insightsDB(t *testing.T) {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres return insights test")
}
gdb := testpg.Open(t, dsn, "return_insights_routes_test")
all := []any{&models.Tenant{}, &models.Consignment{}, &models.ConsignmentHistory{}, &models.AppUser{}, &models.Hub{}}
if err := gdb.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
prev := db.DB
db.DB = gdb
t.Cleanup(func() { db.DB = prev })
for _, tn := range []models.Tenant{
{Tenantid: 1, Tenantname: "Sai's Kitchen", Primaryemail: "sai@k.test", Primarycontact: "9000000001", Status: "Active"},
{Tenantid: 2, Tenantname: "Bawa Medicals", Primaryemail: "bawa@m.test", Primarycontact: "9000000002", Status: "Active"},
} {
tn := tn
if err := gdb.Create(&tn).Error; err != nil {
t.Fatal(err)
}
}
now := time.Now()
parcel := func(id, tenant int, status, reason string, created time.Time, started, back *time.Time) {
cn := models.Consignment{Consignmentid: id, Tenantid: tenant, Trackingno: fmt.Sprintf("DMX%08d", id), Status: status,
Returnreason: reason, Returninitiatedat: started, Returndeliveredat: back}
cn.Createdat = created
if err := gdb.Create(&cn).Error; err != nil {
t.Fatal(err)
}
}
ago := func(d time.Duration) *time.Time { v := now.Add(-d); return &v }
// Sai's Kitchen: 4 parcels, 1 delivered, 1 in return, 2 returned (one took 2 days).
parcel(101, 1, constants.ConsignmentDelivered, "", now, nil, nil)
parcel(102, 1, constants.ConsignmentRTOInitiated, "Receiver refused: gate closed", now, ago(time.Hour), nil)
parcel(103, 1, constants.ConsignmentReturnedToSender, "Receiver refused", now, ago(72*time.Hour), ago(24*time.Hour))
parcel(104, 1, constants.ConsignmentReturnedToSender, "Address not found: no such door", now, ago(48*time.Hour), ago(24*time.Hour))
// Bawa: 2 delivered, 1 cancelled (left out), 1 from 90 days ago (outside the default window).
parcel(201, 2, constants.ConsignmentDelivered, "", now, nil, nil)
parcel(202, 2, constants.ConsignmentDelivered, "", now, nil, nil)
parcel(203, 2, "Cancelled", "", now, nil, nil)
parcel(204, 2, constants.ConsignmentReturnedToSender, "Damaged in transit", now.AddDate(0, 0, -90), ago(90*24*time.Hour), ago(89*24*time.Hour))
// Created 60 days ago, still in return: outside the window's rate, but
// "in return now" all the same.
parcel(205, 2, constants.ConsignmentRTOInitiated, "Customer unavailable", now.AddDate(0, 0, -60), ago(5*24*time.Hour), nil)
ops := models.AppUser{Userid: 7, Authname: "Ops Priya", Email: "ops@doormile.com", Contactno: "9000000007", Password: "x", Roleid: 1}
gdb.Create(&ops)
hub := models.Hub{Hubid: 3, Hubname: "Coimbatore Neptune Hub", Hubtype: "delivery_hub", Applocationid: 1}
gdb.Create(&hub)
uid, hid := 7, 3
for i, h := range []models.ConsignmentHistory{
{Consignmentid: 102, Eventstatus: constants.ConsignmentInwardedAtHub, Hubid: &hid},
{Consignmentid: 102, Eventstatus: constants.ConsignmentOutForDelivery},
{Consignmentid: 102, Eventstatus: constants.ConsignmentOutForDelivery, Remarks: "GPS Update: Lat 11.0, Lon 76.9. Speed 20. Remarks: "},
{Consignmentid: 102, Eventstatus: constants.ConsignmentRTOInitiated, Userid: &uid, Remarks: "[from:Out_for_Delivery] Receiver refused: gate closed"},
} {
h.Createdat = now.Add(time.Duration(i-3) * time.Minute)
if err := gdb.Create(&h).Error; err != nil {
t.Fatal(err)
}
}
}
func TestParcelTimelineIsOrderedReadableAndScoped(t *testing.T) {
insightsDB(t)
app := onboardingApp()
code, body := do(t, app, http.MethodGet, "/api/v1/admin/consignments/102/history", consoleToken(t, "ops@doormile.com", 1, 0), "")
if code != 200 {
t.Fatalf("history = %d %s", code, body)
}
var res struct {
Data []struct {
Eventstatus, Remarks, Fromstatus, Actorname, Hubname string
} `json:"data"`
}
if err := json.Unmarshal([]byte(body), &res); err != nil {
t.Fatal(err)
}
if len(res.Data) != 3 || res.Data[0].Eventstatus != constants.ConsignmentInwardedAtHub || res.Data[2].Eventstatus != constants.ConsignmentRTOInitiated {
t.Fatalf("events out of order (or a GPS ping leaked in): %+v", res.Data)
}
last := res.Data[2]
if last.Remarks != "Receiver refused: gate closed" || last.Fromstatus != "Out_for_Delivery" || last.Actorname != "Ops Priya" {
t.Fatalf("RTO event = %+v, want the bookkeeping prefix stripped and the actor named", last)
}
if res.Data[0].Hubname != "Coimbatore Neptune Hub" {
t.Fatalf("hub event = %+v, want the hub named", res.Data[0])
}
// Its own client may read it; another client may not.
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/consignments/102/history", consoleToken(t, "sai@k.test", 3, 1), ""); code != 200 {
t.Fatalf("own client history = %d, want 200", code)
}
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/consignments/102/history", consoleToken(t, "bawa@m.test", 3, 2), ""); code != 404 {
t.Fatalf("other client history = %d, want 404", code)
}
}
type summaryResp struct {
Data struct {
Total, Delivered, Returned int64
InReturn int64 `json:"in_return"`
InReturnNow int64 `json:"in_return_now"`
ReturnRate float64 `json:"return_rate"`
AvgReturnDays *float64 `json:"avg_return_days"`
ByClient []struct {
Tenantid int
Total int64
InReturn int64 `json:"in_return"`
Returned int64
ReturnRate float64 `json:"return_rate"`
} `json:"by_client"`
ByReason []struct {
Reason string
Count int64
} `json:"by_reason"`
} `json:"data"`
}
func TestReturnsSummaryRatesReasonsAndScoping(t *testing.T) {
insightsDB(t)
app := onboardingApp()
get := func(tok, query string) summaryResp {
t.Helper()
code, body := do(t, app, http.MethodGet, "/api/v1/admin/returns/summary"+query, tok, "")
if code != 200 {
t.Fatalf("summary%s = %d %s", query, code, body)
}
var r summaryResp
if err := json.Unmarshal([]byte(body), &r); err != nil {
t.Fatal(err)
}
return r
}
// Staff, default window (last 30 days): 6 parcels (cancelled and the
// 90-day-old one left out), 3 of them in or through a return = 50%.
s := get(consoleToken(t, "ops@doormile.com", 1, 0), "").Data
if s.Total != 6 || s.Delivered != 3 || s.InReturn != 1 || s.Returned != 2 || s.ReturnRate != 50 || s.InReturnNow != 2 {
t.Fatalf("staff summary = %+v", s)
}
if s.AvgReturnDays == nil || *s.AvgReturnDays != 1.5 {
t.Fatalf("avg return days = %v, want 1.5 (2 days and 1 day)", s.AvgReturnDays)
}
if len(s.ByClient) != 2 || s.ByClient[0].Tenantid != 1 || s.ByClient[0].ReturnRate != 75 || s.ByClient[1].ReturnRate != 0 {
t.Fatalf("by client = %+v, want Sai's Kitchen first at 75%%, Bawa 0%%", s.ByClient)
}
if len(s.ByReason) != 2 || s.ByReason[0].Reason != "Receiver refused" || s.ByReason[0].Count != 2 || s.ByReason[1].Reason != "Address not found" {
t.Fatalf("by reason = %+v, want notes stripped and grouped by label", s.ByReason)
}
// A wider window brings the old return back in.
from := time.Now().AddDate(0, 0, -120).Format("2006-01-02")
if wide := get(consoleToken(t, "ops@doormile.com", 1, 0), "?from="+from).Data; wide.Total != 8 || wide.Returned != 3 {
t.Fatalf("120-day summary = %+v, want 8 parcels / 3 returned", wide)
}
// A client sees only its own figures, whatever it asks for.
own := get(consoleToken(t, "bawa@m.test", 3, 2), "").Data
if own.Total != 2 || len(own.ByClient) != 1 || own.ByClient[0].Tenantid != 2 || len(own.ByReason) != 0 || own.InReturnNow != 1 {
t.Fatalf("Bawa's own summary = %+v", own)
}
if code, body := do(t, app, http.MethodGet, "/api/v1/admin/returns/summary?tenantid=1", consoleToken(t, "bawa@m.test", 3, 2), ""); code != 403 {
t.Fatalf("a client asking for another client's summary = %d %s, want 403", code, body)
}
}