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).
Where("cn.status IN ?", statuses)
Where("cn.status IN ? AND cn.deletedat IS NULL", statuses)
if start != nil {
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
})
}