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

@@ -46,6 +46,11 @@ var rtoReasons = map[string]string{
"customer_unavailable": "Customer unavailable",
"attempts_exhausted": "Delivery attempts exhausted",
"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",
}
@@ -127,12 +132,66 @@ func rtoHistoryRemark(from, reason string) string {
// statusBeforeRTO reads that back from the RTO_Initiated history remark.
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 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.
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 {
return false, nil
}
if !rtoStartable[cn.Status] {
return false, errRTO{fmt.Sprintf("a parcel that is %s cannot be returned",
strings.ReplaceAll(strings.ToLower(cn.Status), "_", " "))}
if eligible, err := isReturnEligible(tx, cn); !eligible {
return false, err
}
from := cn.Status
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
// cost of wrongly blocking one is a parcel stranded with no path home.
func tenantAllowsReturns(tx *gorm.DB, cn *models.Consignment) (bool, string) {
if cn == nil || cn.Tenantid == 0 {
if cn == nil {
return true, ""
}
var t models.Tenant
if err := tx.Select("tenantname", "deliverycategory", "reverselogisticsenabled").
First(&t, cn.Tenantid).Error; err != nil {
utils.Warn("tenantAllowsReturns: could not read the tenant, allowing the return",
"tenantid", cn.Tenantid, "error", err)
return true, ""
// 1. Check tenant delivery category: Food, Meat, and Fresh items have NO return path.
if cn.Tenantid != 0 {
var t models.Tenant
if err := tx.Select("tenantname", "deliverycategory", "reverselogisticsenabled").
First(&t, cn.Tenantid).Error; err == nil {
if !t.ReturnsEnabled() || constants.IsPerishableCategory(t.Deliverycategory) {
who := t.Tenantname
if who == "" {
who = "This client"
}
what := t.Deliverycategory
if what == "" {
what = "perishable goods"
}
return false, who + " ships perishable products (" + what +
"). Food items, meat items, and fresh produce do not have return options."
}
}
}
if t.ReturnsEnabled() {
return true, ""
// 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)
}
}
}
}
who := t.Tenantname
if who == "" {
who = "This client"
}
what := t.Deliverycategory
if what == "" {
what = "what they ship"
}
return false, who + " does not use reverse logistics (" + what +
"). Returns are switched off for this client — change it on their profile if that is wrong."
return true, ""
}
func InitiateConsignmentRTO(c *fiber.Ctx) error {

View File

@@ -36,19 +36,73 @@ func TestGenericStatusChangeGuard(t *testing.T) {
func TestRTOHistoryRemarkRoundTrip(t *testing.T) {
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 {
t.Errorf("round trip %s -> %s", from, got)
}
}
// 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 {
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) {
t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "")
if rtoAutoAfterAttempts() != 3 {

View File

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