updates on the reverse logistics final phrase
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
252
controllers/returnInsights.go
Normal file
252
controllers/returnInsights.go
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user