updates on the reverse logistics final phrase
This commit is contained in:
@@ -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