From c574a79afcb53017b4893d631149e42888bc7422 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 7 Oct 2026 13:00:32 +0530 Subject: [PATCH] updates on the reverse logistics final phrase --- controllers/consignmentReturn.go | 2 +- controllers/returnInsights.go | 252 +++++++++++++++++++++++ routes/routes.go | 2 + routes/routes_return_insights_pg_test.go | 194 +++++++++++++++++ 4 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 controllers/returnInsights.go create mode 100644 routes/routes_return_insights_pg_test.go diff --git a/controllers/consignmentReturn.go b/controllers/consignmentReturn.go index 4821a8d..d07a69a 100644 --- a/controllers/consignmentReturn.go +++ b/controllers/consignmentReturn.go @@ -600,7 +600,7 @@ func GetReturns(c *fiber.Ctx) error { } 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 { q = q.Where("cn.returninitiatedat >= ?", *start) } diff --git a/controllers/returnInsights.go b/controllers/returnInsights.go new file mode 100644 index 0000000..e155783 --- /dev/null +++ b/controllers/returnInsights.go @@ -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:] " 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 + }) +} diff --git a/routes/routes.go b/routes/routes.go index 5d41ac7..14f328c 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -418,6 +418,7 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // Consignments adminAuth.Get("/consignments", controllers.GetAdminConsignments) adminAuth.Get("/consignments/:id", controllers.GetAdminConsignmentDetails) + adminAuth.Get("/consignments/:id/history", controllers.GetConsignmentHistory) adminAuth.Get("/consignments/:id/logs", controllers.GetAdminConsignmentLogs) adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking) 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/complete", middlewares.DoormileStaffOnly, controllers.CompleteConsignmentRTO) adminAuth.Get("/returns", controllers.GetReturns) + adminAuth.Get("/returns/summary", controllers.GetReturnsSummary) // Tripsheets adminAuth.Get("/tripsheets", middlewares.DoormileStaffOnly, controllers.GetTripsheets) diff --git a/routes/routes_return_insights_pg_test.go b/routes/routes_return_insights_pg_test.go new file mode 100644 index 0000000..be7e2f3 --- /dev/null +++ b/routes/routes_return_insights_pg_test.go @@ -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) + } +}