updates on the backend about the reverse logistics

This commit is contained in:
2026-10-10 12:40:42 +05:30
parent 153be40e5c
commit d1947dcd7a
6 changed files with 233 additions and 82 deletions

View File

@@ -1,14 +1,14 @@
package constants package constants
import "strings"
// What a client delivers, and what that implies operationally. // What a client delivers, and what that implies operationally.
// //
// ─── One vocabulary, not two ─────────────────────────────────────────────── // ─── One vocabulary, not two ───────────────────────────────────────────────
// //
// These eight values already existed as `validCategories` in // Categories driven by pricing, onboarding, and logistics.
// controllers/doormilePricingController.go and as the comment on // Non-perishable goods like Steel, Clothing, Electronics, Automotive have return paths.
// models.DoormilePricing.Category. Tenants now carry one too, so the set is // Perishable goods like Food, Meat, and Fresh Produce have NO return option.
// promoted here rather than copied: a second list would drift, and a tenant
// whose category is not a pricing category cannot be priced.
var DeliveryCategories = map[string]bool{ var DeliveryCategories = map[string]bool{
"General": true, "General": true,
"Documents": true, "Documents": true,
@@ -17,40 +17,60 @@ var DeliveryCategories = map[string]bool{
"Fragile": true, "Fragile": true,
"Medical": true, "Medical": true,
"Automotive": true, "Automotive": true,
"Steel": true,
"Food": true, "Food": true,
"Meat": true,
"FreshProduce": true,
} }
// DeliveryCategoryList is the same set, ordered, for anything that renders a // DeliveryCategoryList is the same set, ordered, for anything that renders a choice.
// choice. General first because it is the safe default; Food last because it
// is the one that turns a capability off.
var DeliveryCategoryList = []string{ var DeliveryCategoryList = []string{
"General", "Documents", "Electronics", "Clothing", "General", "Documents", "Electronics", "Clothing",
"Fragile", "Medical", "Automotive", "Food", "Fragile", "Medical", "Automotive", "Steel", "Food", "Meat", "FreshProduce",
} }
// CategoryDefault is what a client with no category recorded is treated as. // CategoryDefault is what a client with no category recorded is treated as.
// Every tenant onboarded before this field existed has an empty string, and
// they must keep behaving exactly as they did — which means reverse logistics
// stays available to them.
const CategoryDefault = "General" const CategoryDefault = "General"
// ReverseLogisticsAllowed reports whether a return journey makes sense for // NonReturnableCategories defines categories of goods that cannot be returned
// what this client ships. // (Food items, Meat items, Fresh produce/perishables). Returning them causes
// // spoilage and health risks. Non-perishables like Steel, Clothing, Electronics, etc. can be returned.
// Food is the exception: a meal that comes back is waste, not inventory. There var NonReturnableCategories = map[string]bool{
// is nothing to restock, nothing to refund against a returned item, and a "food": true,
// rider carrying it to a hub is carrying rubbish. Offering RTO there is not a "meat": true,
// harmless extra button — it invites an operator to start a return journey "raw meat": true,
// that can only end in disposal, and it puts a return charge on a client's "fresh produce": true,
// invoice for a parcel nobody can resell. "freshproduce": true,
// "fresh": true,
// Everything else can come back: clothing is the canonical case (wrong size), "perishable": true,
// and electronics, documents and automotive parts all have a real return path. "fish": true,
// "seafood": true,
// An UNKNOWN or empty category allows returns. That is deliberate: this field "poultry": true,
// is new, every existing tenant has no value for it, and a default of "off" "dairy": true,
// would silently withdraw a working capability from every client already using "vegetables": true,
// it. New information must not change old behaviour. "fruits": true,
func ReverseLogisticsAllowed(category string) bool { }
return category != "Food"
// IsPerishableCategory checks if a category or item description is perishable (food, meat, fresh produce).
func IsPerishableCategory(category string) bool {
cat := strings.ToLower(strings.TrimSpace(category))
if cat == "" {
return false
}
if NonReturnableCategories[cat] {
return true
}
if strings.Contains(cat, "food") || strings.Contains(cat, "meat") || strings.Contains(cat, "fresh") {
return true
}
return false
}
// ReverseLogisticsAllowed reports whether a return journey makes sense for
// what this client or parcel ships.
//
// Food items, Meat items, and Fresh produce CANNOT be returned.
// Products like Steel, Electronics, Clothing, and Automotive CAN be returned.
func ReverseLogisticsAllowed(category string) bool {
return !IsPerishableCategory(category)
} }

View File

@@ -2,29 +2,26 @@ package constants
import "testing" import "testing"
// The rule the whole feature exists for: a returned meal is waste, not // Food, Meat, and Fresh produce items must NOT have return options.
// inventory, so Food clients get no reverse-logistics path. // Durable goods like Steel, Electronics, Clothing, etc. DO have return options.
func TestFoodIsTheOnlyCategoryWithoutReturns(t *testing.T) { func TestPerishableCategoriesDoNotAllowReturns(t *testing.T) {
if ReverseLogisticsAllowed("Food") { for _, nonRet := range []string{"Food", "Meat", "FreshProduce", "raw meat", "fish", "fresh vegetables"} {
t.Error("Food allows returns; a returned meal can only be disposed of") if ReverseLogisticsAllowed(nonRet) {
t.Errorf("Perishable category %q should NOT allow returns", nonRet)
} }
for _, c := range DeliveryCategoryList {
if c == "Food" {
continue
} }
if !ReverseLogisticsAllowed(c) {
t.Errorf("%s does not allow returns; only Food should be excluded", c) for _, ret := range []string{"Steel", "Clothing", "Electronics", "Automotive", "General", "Documents"} {
if !ReverseLogisticsAllowed(ret) {
t.Errorf("Durable category %q SHOULD allow returns", ret)
} }
} }
} }
// New information must not change old behaviour. Every tenant onboarded before func TestUnknownAndEmptyCategoriesDefaultToAllowed(t *testing.T) {
// this field existed has an empty category, and they were all using returns. for _, c := range []string{"", " ", "SomethingNew"} {
func TestUnknownAndEmptyCategoriesKeepReturns(t *testing.T) {
for _, c := range []string{"", " ", "Groceries", "SomethingNew"} {
if !ReverseLogisticsAllowed(c) { if !ReverseLogisticsAllowed(c) {
t.Errorf("category %q withdrew returns; an unrecognised category must not "+ t.Errorf("category %q withdrew returns; empty/unknown should default open", c)
"silently remove a capability an existing client is already using", c)
} }
} }
} }

View File

@@ -46,6 +46,11 @@ var rtoReasons = map[string]string{
"customer_unavailable": "Customer unavailable", "customer_unavailable": "Customer unavailable",
"attempts_exhausted": "Delivery attempts exhausted", "attempts_exhausted": "Delivery attempts exhausted",
"damaged": "Damaged in transit", "damaged": "Damaged in transit",
"defective": "Defective or damaged item",
"wrong_item": "Wrong item delivered",
"quality_issue": "Quality not as expected",
"size_mismatch": "Size or fit mismatch",
"not_needed": "No longer needed",
"other": "Other", "other": "Other",
} }
@@ -127,12 +132,66 @@ func rtoHistoryRemark(from, reason string) string {
// statusBeforeRTO reads that back from the RTO_Initiated history remark. // statusBeforeRTO reads that back from the RTO_Initiated history remark.
func statusBeforeRTO(remark string) string { func statusBeforeRTO(remark string) string {
if m := rtoFromPrefix.FindStringSubmatch(remark); m != nil && rtoStartable[m[1]] { if m := rtoFromPrefix.FindStringSubmatch(remark); m != nil && (rtoStartable[m[1]] || m[1] == constants.ConsignmentDelivered) {
return m[1] return m[1]
} }
return constants.ConsignmentOutForDelivery return constants.ConsignmentOutForDelivery
} }
const DefaultReturnWindowDays = 7
// consignmentDeliveredAt finds the delivery timestamp of a consignment
func consignmentDeliveredAt(tx *gorm.DB, cn *models.Consignment) time.Time {
if cn == nil {
return time.Time{}
}
if cn.Deliveredat != nil && !cn.Deliveredat.IsZero() {
return *cn.Deliveredat
}
if tx != nil {
var proof models.DeliveryProof
if err := tx.Select("deliveredat").Where("consignmentid = ?", cn.Consignmentid).First(&proof).Error; err == nil && !proof.Deliveredat.IsZero() {
return proof.Deliveredat
}
var dest models.BookingDestination
if err := tx.Select("deliveredat").Where("consignmentid = ?", cn.Consignmentid).First(&dest).Error; err == nil && dest.Deliveredat != nil && !dest.Deliveredat.IsZero() {
return *dest.Deliveredat
}
var hist models.ConsignmentHistory
if err := tx.Select("createdat").Where("consignmentid = ? AND eventstatus = ?", cn.Consignmentid, constants.ConsignmentDelivered).Order("createdat DESC").First(&hist).Error; err == nil && !hist.Createdat.IsZero() {
return hist.Createdat
}
}
return cn.Updatedat
}
// isReturnEligible checks whether a consignment can be returned (in-transit RTO or 7-day post-delivery window).
func isReturnEligible(tx *gorm.DB, cn *models.Consignment) (bool, error) {
if cn == nil {
return false, errRTO{"consignment not found"}
}
if cn.Status == constants.ConsignmentRTOInitiated {
return true, nil
}
if tx != nil {
if allowed, refusal := tenantAllowsReturns(tx, cn); !allowed {
return false, errRTO{refusal}
}
}
if rtoStartable[cn.Status] {
return true, nil
}
if cn.Status == constants.ConsignmentDelivered {
deliveredAt := consignmentDeliveredAt(tx, cn)
if !deliveredAt.IsZero() && time.Since(deliveredAt) > time.Duration(DefaultReturnWindowDays)*24*time.Hour {
return false, errRTO{fmt.Sprintf("return window expired: orders can only be returned within %d days of delivery", DefaultReturnWindowDays)}
}
return true, nil
}
return false, errRTO{fmt.Sprintf("a parcel that is %s cannot be returned",
strings.ReplaceAll(strings.ToLower(cn.Status), "_", " "))}
}
// errRTO carries an operator-readable refusal out of a transaction. // errRTO carries an operator-readable refusal out of a transaction.
type errRTO struct{ msg string } type errRTO struct{ msg string }
@@ -168,9 +227,8 @@ func startRTO(tx *gorm.DB, cn *models.Consignment, reasonText string, actorID *i
if cn.Status == constants.ConsignmentRTOInitiated { if cn.Status == constants.ConsignmentRTOInitiated {
return false, nil return false, nil
} }
if !rtoStartable[cn.Status] { if eligible, err := isReturnEligible(tx, cn); !eligible {
return false, errRTO{fmt.Sprintf("a parcel that is %s cannot be returned", return false, err
strings.ReplaceAll(strings.ToLower(cn.Status), "_", " "))}
} }
from := cn.Status from := cn.Status
now := time.Now() now := time.Now()
@@ -375,29 +433,48 @@ func rtoReasonText(reason, note string) (text, refusal string) {
// return — the cost of wrongly allowing one is an operator reversing it; the // return — the cost of wrongly allowing one is an operator reversing it; the
// cost of wrongly blocking one is a parcel stranded with no path home. // cost of wrongly blocking one is a parcel stranded with no path home.
func tenantAllowsReturns(tx *gorm.DB, cn *models.Consignment) (bool, string) { func tenantAllowsReturns(tx *gorm.DB, cn *models.Consignment) (bool, string) {
if cn == nil || cn.Tenantid == 0 { if cn == nil {
return true, "" return true, ""
} }
// 1. Check tenant delivery category: Food, Meat, and Fresh items have NO return path.
if cn.Tenantid != 0 {
var t models.Tenant var t models.Tenant
if err := tx.Select("tenantname", "deliverycategory", "reverselogisticsenabled"). if err := tx.Select("tenantname", "deliverycategory", "reverselogisticsenabled").
First(&t, cn.Tenantid).Error; err != nil { First(&t, cn.Tenantid).Error; err == nil {
utils.Warn("tenantAllowsReturns: could not read the tenant, allowing the return", if !t.ReturnsEnabled() || constants.IsPerishableCategory(t.Deliverycategory) {
"tenantid", cn.Tenantid, "error", err)
return true, ""
}
if t.ReturnsEnabled() {
return true, ""
}
who := t.Tenantname who := t.Tenantname
if who == "" { if who == "" {
who = "This client" who = "This client"
} }
what := t.Deliverycategory what := t.Deliverycategory
if what == "" { if what == "" {
what = "what they ship" what = "perishable goods"
} }
return false, who + " does not use reverse logistics (" + what + return false, who + " ships perishable products (" + what +
"). Returns are switched off for this client — change it on their profile if that is wrong." "). Food items, meat items, and fresh produce do not have return options."
}
}
}
// 2. Check booking parcels: if parcel is food, meat, or fresh produce, returns are disabled.
if cn.Orderheaderid != nil {
var parcels []models.BookingParcel
if err := tx.Select("itemcategory", "itemdescription").
Where("bookingid = ?", *cn.Orderheaderid).Find(&parcels).Error; err == nil && len(parcels) > 0 {
for _, p := range parcels {
if constants.IsPerishableCategory(p.Itemcategory) || constants.IsPerishableCategory(p.Itemdescription) {
desc := p.Itemcategory
if desc == "" {
desc = p.Itemdescription
}
return false, fmt.Sprintf("Parcels containing perishable goods (%s) cannot be returned. Return options are only available for non-perishable products like steel, electronics, and clothing.", desc)
}
}
}
}
return true, ""
} }
func InitiateConsignmentRTO(c *fiber.Ctx) error { func InitiateConsignmentRTO(c *fiber.Ctx) error {

View File

@@ -36,19 +36,73 @@ func TestGenericStatusChangeGuard(t *testing.T) {
func TestRTOHistoryRemarkRoundTrip(t *testing.T) { func TestRTOHistoryRemarkRoundTrip(t *testing.T) {
for _, from := range []string{constants.ConsignmentOutForDelivery, constants.ConsignmentCollectedByMiler, for _, from := range []string{constants.ConsignmentOutForDelivery, constants.ConsignmentCollectedByMiler,
constants.ConsignmentInwardedAtHub, constants.ConsignmentCreated} { constants.ConsignmentInwardedAtHub, constants.ConsignmentCreated, constants.ConsignmentDelivered} {
if got := statusBeforeRTO(rtoHistoryRemark(from, "Receiver refused: gate locked")); got != from { if got := statusBeforeRTO(rtoHistoryRemark(from, "Receiver refused: gate locked")); got != from {
t.Errorf("round trip %s -> %s", from, got) t.Errorf("round trip %s -> %s", from, got)
} }
} }
// Anything unreadable or not a returnable status falls back to Out_for_Delivery. // Anything unreadable or not a returnable status falls back to Out_for_Delivery.
for _, remark := range []string{"", "no prefix", "[from:Delivered] x", "[from:Cancelled] x"} { for _, remark := range []string{"", "no prefix", "[from:Cancelled] x", "[from:Missing] x"} {
if got := statusBeforeRTO(remark); got != constants.ConsignmentOutForDelivery { if got := statusBeforeRTO(remark); got != constants.ConsignmentOutForDelivery {
t.Errorf("%q -> %s, want Out_for_Delivery", remark, got) t.Errorf("%q -> %s, want Out_for_Delivery", remark, got)
} }
} }
} }
func TestIsReturnEligiblePostDeliveryWindow(t *testing.T) {
now := time.Now()
// Delivered 3 days ago -> eligible
threeDaysAgo := now.Add(-3 * 24 * time.Hour)
cn3 := &models.Consignment{
Status: constants.ConsignmentDelivered,
Deliveredat: &threeDaysAgo,
}
ok, err := isReturnEligible(nil, cn3)
if !ok || err != nil {
t.Fatalf("order delivered 3 days ago should be eligible, got ok=%v, err=%v", ok, err)
}
// Delivered 6 days 23 hours ago -> eligible
almost7DaysAgo := now.Add(-167 * time.Hour)
cn6 := &models.Consignment{
Status: constants.ConsignmentDelivered,
Deliveredat: &almost7DaysAgo,
}
ok, err = isReturnEligible(nil, cn6)
if !ok || err != nil {
t.Fatalf("order delivered within 7 days should be eligible, got ok=%v, err=%v", ok, err)
}
// Delivered 8 days ago -> NOT eligible
eightDaysAgo := now.Add(-8 * 24 * time.Hour)
cn8 := &models.Consignment{
Status: constants.ConsignmentDelivered,
Deliveredat: &eightDaysAgo,
}
ok, err = isReturnEligible(nil, cn8)
if ok || err == nil {
t.Fatalf("order delivered 8 days ago should NOT be eligible, got ok=%v, err=%v", ok, err)
}
if !strings.Contains(err.Error(), "return window expired") {
t.Fatalf("expected 'return window expired' error, got %q", err.Error())
}
// Cancelled order -> NOT eligible
cancelled := &models.Consignment{Status: "Cancelled"}
ok, _ = isReturnEligible(nil, cancelled)
if ok {
t.Fatal("cancelled consignment should not be returnable")
}
// In-transit order -> eligible
ofd := &models.Consignment{Status: constants.ConsignmentOutForDelivery}
ok, err = isReturnEligible(nil, ofd)
if !ok || err != nil {
t.Fatalf("out_for_delivery should be eligible, got ok=%v, err=%v", ok, err)
}
}
func TestRTOAutoAfterAttempts(t *testing.T) { func TestRTOAutoAfterAttempts(t *testing.T) {
t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "") t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "")
if rtoAutoAfterAttempts() != 3 { if rtoAutoAfterAttempts() != 3 {

View File

@@ -942,7 +942,9 @@ func MilerDeliverConsignment(c *fiber.Ctx) error {
consignment.Status = constants.ConsignmentDelivered consignment.Status = constants.ConsignmentDelivered
// Cleared once redeemed so the same code can't close out a second attempt. // Cleared once redeemed so the same code can't close out a second attempt.
consignment.Deliveryotp = "" consignment.Deliveryotp = ""
consignment.Updatedat = time.Now() nowDelivered := time.Now()
consignment.Deliveredat = &nowDelivered
consignment.Updatedat = nowDelivered
if err := tx.Save(&consignment).Error; err != nil { if err := tx.Save(&consignment).Error; err != nil {
tx.Rollback() tx.Rollback()
return utils.Internal(c, "failed to mark consignment delivered") return utils.Internal(c, "failed to mark consignment delivered")

View File

@@ -70,6 +70,7 @@ type Consignment struct {
Returnreason string `json:"returnreason" gorm:"column:returnreason"` Returnreason string `json:"returnreason" gorm:"column:returnreason"`
Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"` Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"`
Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"` Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"`
Deliveredat *time.Time `json:"deliveredat,omitempty" gorm:"column:deliveredat"`
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"` Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
// Inwardedat is when the parcel was physically received at a base — written // Inwardedat is when the parcel was physically received at a base — written
// by the rider handover (POST /miler/consignments/:id/inward-at-hub) and by // by the rider handover (POST /miler/consignments/:id/inward-at-hub) and by