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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
194
routes/routes_return_insights_pg_test.go
Normal file
194
routes/routes_return_insights_pg_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user