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

@@ -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)

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)
}
}