updates on the reverse logistics

This commit is contained in:
2026-10-06 11:05:03 +05:30
parent cb3108ffdb
commit 220e934045
10 changed files with 1541 additions and 8 deletions

View File

@@ -3166,6 +3166,13 @@ func AdminUpdateConsignmentStatus(c *fiber.Ctx) error {
return utils.NotFound(c, "consignment not found")
}
// Guard the move (reverse logistics plan, B1): this used to write any
// string onto any parcel, including Delivered onto a cancelled one.
if msg := checkGenericStatusChange(consignment.Status, req.Status); msg != "" {
tx.Rollback()
return utils.BadRequest(c, msg)
}
consignment.Status = req.Status
consignment.Updatedat = time.Now()
if err := tx.Save(&consignment).Error; err != nil {

View File

@@ -0,0 +1,698 @@
package controllers
import (
"errors"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"doormile/constants"
"doormile/db"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Reverse logistics, phase 1–3: RTO (return to origin).
// Plan: krow_talent_app/docs/reverse-logistics-plan.md.
//
// Collected_By_Miler / Out_for_Delivery / Created / Inwarded_at_Hub
// │ ops "Initiate RTO", or automatically after N failed attempts
// ▼
// RTO_Initiated ──── ops "Re-attempt" ────▶ back to the status it came from
// │
// │ rider returns it (flagged), or ops "Mark returned"
// ▼
// Returned_to_Sender (terminal)
//
// The statuses and the consignment's return columns (returnreason,
// returninitiatedat, returndeliveredat) already existed and were never written;
// this file is the first thing that writes them. Every transition writes a
// consignmenthistory row. Returns go back to the SENDER (the consignment's
// pickup point) — a return-to-hub option is phase 4 of the plan.
// rtoReasons are the reasons ops may pick; the label is what is stored in
// consignments.returnreason (with the free-text note appended).
var rtoReasons = map[string]string{
"receiver_refused": "Receiver refused",
"address_not_found": "Address not found",
"customer_unavailable": "Customer unavailable",
"attempts_exhausted": "Delivery attempts exhausted",
"damaged": "Damaged in transit",
"other": "Other",
}
// rtoStartable is every status a parcel can be returned from: in a rider's
// hands, or waiting at a base. Not from Delivered, Cancelled, Missing,
// Damaged, or anything already in a return.
var rtoStartable = map[string]bool{
constants.ConsignmentCreated: true,
constants.ConsignmentInwardedAtHub: true,
constants.ConsignmentCollectedByMiler: true,
constants.ConsignmentOutForDelivery: true,
}
// consignmentTerminal statuses never change again through the generic status
// endpoint.
var consignmentTerminal = map[string]bool{
constants.ConsignmentDelivered: true,
constants.ConsignmentReturnedToSender: true,
"Cancelled": true,
}
// knownConsignmentStatuses mirrors the consignments_status_check constraint
// (migrations/migrate.go), so a typo is a 400 rather than a 500 from Postgres.
var knownConsignmentStatuses = map[string]bool{
constants.ConsignmentCreated: true, constants.ConsignmentInwardedAtHub: true,
constants.ConsignmentCollectedByMiler: true, constants.ConsignmentTripsheetLoaded: true,
constants.ConsignmentInTransit: true, constants.ConsignmentOutForDelivery: true,
constants.ConsignmentDelivered: true, constants.ConsignmentRTOInitiated: true,
constants.ConsignmentReturnedToSender: true, constants.ConsignmentMissing: true,
constants.ConsignmentDamaged: true, "Cancelled": true,
}
// checkGenericStatusChange is the guard on PUT /admin/consignments/:id/status.
// That endpoint used to write any string onto any parcel — a cancelled parcel
// could be marked Delivered. It now refuses unknown statuses, leaving a
// terminal status, and the two RTO statuses (which must go through the RTO
// endpoints so the return columns and history are written consistently).
func checkGenericStatusChange(from, to string) string {
switch {
case !knownConsignmentStatuses[to]:
return "unknown consignment status"
case to == constants.ConsignmentRTOInitiated || to == constants.ConsignmentReturnedToSender:
return "use the return (RTO) actions to start or complete a return"
case from == constants.ConsignmentRTOInitiated:
return "this parcel is being returned: re-attempt delivery or mark it returned instead"
case consignmentTerminal[from] && from != to:
return "this parcel is already " + strings.ReplaceAll(strings.ToLower(from), "_", " ") + " and cannot change"
}
return ""
}
// rtoAutoAfterAttempts is how many failed delivery attempts start a return
// automatically (env RTO_AUTO_AFTER_ATTEMPTS, default 3; 0 turns it off).
// Read per call, like the other operational knobs.
func rtoAutoAfterAttempts() int {
if v := strings.TrimSpace(os.Getenv("RTO_AUTO_AFTER_ATTEMPTS")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
}
return 3
}
// rtoRiderFlowEnabled gates the rider-app side (next action return_to_sender
// and POST /miler/consignments/:id/return-complete). Off by default: the
// deployed rider app does not know the new action — same rollout pattern as
// MILER_HUB_HANDOVER_ENABLED. With it off, ops close returns from the console.
func rtoRiderFlowEnabled() bool {
return strings.EqualFold(os.Getenv("MILER_RTO_FLOW_ENABLED"), "true")
}
var rtoFromPrefix = regexp.MustCompile(`^\[from:([A-Za-z_]+)\]`)
// rtoHistoryRemark records where the parcel was when the return started, so a
// "re-attempt" can put it back exactly there.
func rtoHistoryRemark(from, reason string) string {
return fmt.Sprintf("[from:%s] %s", from, reason)
}
// 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]] {
return m[1]
}
return constants.ConsignmentOutForDelivery
}
// errRTO carries an operator-readable refusal out of a transaction.
type errRTO struct{ msg string }
func (e errRTO) Error() string { return e.msg }
// moveConsignment writes a status change only if the parcel is still in the
// status it was read in (compare-and-set). Two writers racing on one parcel —
// ops and the rider, or a double-clicked button — would otherwise both pass
// their status check and the later full-row save would overwrite the earlier
// one. It returns the status the row has now when the move did not happen.
func moveConsignment(tx *gorm.DB, id int, from string, fields map[string]interface{}) (moved bool, current string, err error) {
res := tx.Model(&models.Consignment{}).Where("consignmentid = ? AND status = ?", id, from).Updates(fields)
if res.Error != nil {
return false, "", res.Error
}
if res.RowsAffected == 1 {
return true, from, nil
}
var now models.Consignment
if err := tx.Select("status").First(&now, id).Error; err != nil {
return false, "", err
}
return false, now.Status, nil
}
var errRTORaced = errRTO{"this parcel changed while you were working on it; refresh and try again"}
// startRTO moves one consignment into RTO_Initiated inside tx. It writes the
// return columns and history, and resolves the parcel's open Undeliverable /
// Receiver_Refused exceptions — the RTO is their resolution. Idempotent: a
// parcel already in a return is left as it is.
func startRTO(tx *gorm.DB, cn *models.Consignment, reasonText string, actorID *int) (started bool, err error) {
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), "_", " "))}
}
from := cn.Status
now := time.Now()
moved, current, err := moveConsignment(tx, cn.Consignmentid, from, map[string]interface{}{
"status": constants.ConsignmentRTOInitiated,
"returnreason": reasonText,
"returninitiatedat": now,
"returndeliveredat": nil,
"updatedat": now,
})
if err != nil {
return false, err
}
if !moved {
if current == constants.ConsignmentRTOInitiated {
return false, nil // someone else started it first: same outcome
}
return false, errRTORaced
}
cn.Status = constants.ConsignmentRTOInitiated
cn.Returnreason = reasonText
cn.Returninitiatedat = &now
cn.Returndeliveredat = nil
cn.Updatedat = now
if err := tx.Create(&models.ConsignmentHistory{
Consignmentid: cn.Consignmentid,
Hubid: cn.Currenthubid,
Userid: actorID,
Eventstatus: constants.ConsignmentRTOInitiated,
Remarks: rtoHistoryRemark(from, reasonText),
}).Error; err != nil {
return false, err
}
if err := tx.Model(&models.ConsignmentException{}).
Where("consignmentid = ? AND exceptiontype IN ? AND status IN ?", cn.Consignmentid,
[]string{constants.ExceptionUndeliverable, constants.ExceptionReceiverRefused},
[]string{constants.ExceptionOpen, constants.ExceptionUnderInvestigation}).
Updates(map[string]interface{}{
"status": constants.ExceptionResolved,
"resolution": "Return to sender (RTO) initiated: " + reasonText,
"updatedat": now,
}).Error; err != nil {
return false, err
}
return true, nil
}
// completeRTO moves an RTO_Initiated consignment to Returned_to_Sender and
// closes the rider's open assignment on its booking (exactly as a delivery
// does), so the rider is not left holding a stop and can go off duty.
func completeRTO(tx *gorm.DB, cn *models.Consignment, remark string, actorID *int) error {
if cn.Status == constants.ConsignmentReturnedToSender {
return nil
}
if cn.Status != constants.ConsignmentRTOInitiated {
return errRTO{"only a parcel that is being returned can be marked returned"}
}
now := time.Now()
moved, current, err := moveConsignment(tx, cn.Consignmentid, constants.ConsignmentRTOInitiated, map[string]interface{}{
"status": constants.ConsignmentReturnedToSender,
"returndeliveredat": now,
"updatedat": now,
})
if err != nil {
return err
}
if !moved {
if current == constants.ConsignmentReturnedToSender {
cn.Status = current
return nil // closed by someone else first (ops and rider together)
}
return errRTORaced
}
cn.Status = constants.ConsignmentReturnedToSender
cn.Returndeliveredat = &now
cn.Updatedat = now
if err := tx.Create(&models.ConsignmentHistory{
Consignmentid: cn.Consignmentid,
Hubid: cn.Currenthubid,
Userid: actorID,
Eventstatus: constants.ConsignmentReturnedToSender,
Remarks: remark,
}).Error; err != nil {
return err
}
if _, booking, ok := cxDestinationForConsignment(cn.Consignmentid); ok && booking != nil && booking.Assignedmileruserid != nil {
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?", booking.Bookingid,
*booking.Assignedmileruserid, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCompleted,
"completedat": now,
"remarks": "Returned to sender",
}).Error; err != nil {
return err
}
}
return nil
}
// riderHoldsParcel: the statuses in which the booking's rider has the parcel.
// Created counts: with MILER_HUB_HANDOVER_ENABLED on, a hub-routed parcel is
// Created while the rider carries it to the base.
func riderHoldsParcel(status string) bool {
switch status {
case constants.ConsignmentCreated, constants.ConsignmentCollectedByMiler, constants.ConsignmentOutForDelivery:
return true
}
return false
}
// notifyRiderOfReturn tells the rider holding the parcel to bring it back.
// Best effort, after commit: a push failure never undoes the RTO.
func notifyRiderOfReturn(cn *models.Consignment) {
_, booking, ok := cxDestinationForConsignment(cn.Consignmentid)
if !ok || booking == nil || booking.Assignedmileruserid == nil {
return
}
var profile models.MilerProfile
if db.DB.Where("userid = ?", *booking.Assignedmileruserid).First(&profile).Error != nil || profile.Devicetoken == "" {
return
}
if err := notify.SendToDevice(profile.Devicetoken, "Return parcel to sender",
fmt.Sprintf("Parcel %s is being returned to the sender. Do not attempt delivery.", cn.Trackingno),
map[string]string{"type": "rto", "consignmentid": strconv.Itoa(cn.Consignmentid)}); err != nil {
utils.Warn("RTO: rider push failed", "consignment_id", cn.Consignmentid, "error", err)
}
}
func rtoActor(c *fiber.Ctx) *int {
if id, ok := c.Locals("userid").(int); ok {
return &id
}
return nil
}
// loadConsignmentForAdmin applies the caller's tenant scope.
func loadConsignmentForAdmin(c *fiber.Ctx, tx *gorm.DB) (*models.Consignment, error) {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return nil, errRTO{"invalid consignment id"}
}
var cn models.Consignment
if err := scopeToOwnTenant(c, tx, "tenantid").First(&cn, id).Error; err != nil {
return nil, gorm.ErrRecordNotFound
}
return &cn, nil
}
func rtoResult(c *fiber.Ctx, err error, what string) error {
var refusal errRTO
switch {
case errors.As(err, &refusal):
return utils.BadRequest(c, refusal.msg)
case errors.Is(err, gorm.ErrRecordNotFound):
return utils.NotFound(c, "consignment not found")
default:
utils.Error("RTO: "+what, "error", err.Error())
return utils.Internal(c, "failed to "+what+"; nothing was changed")
}
}
// rtoReasonText validates a start-return request and builds the text stored in
// returnreason ("Label: note"). A non-empty refusal is the 400 message.
func rtoReasonText(reason, note string) (text, refusal string) {
reason = strings.ToLower(strings.TrimSpace(reason))
label, ok := rtoReasons[reason]
if !ok {
return "", "choose a return reason"
}
note = strings.TrimSpace(note)
if reason == "other" && note == "" {
return "", "describe the reason when choosing Other"
}
// Characters, not bytes: the console allows 500 characters, and a note
// in Tamil or Hindi is two to three bytes per character.
if utf8.RuneCountInString(note) > 500 {
return "", "note is too long (at most 500 characters)"
}
if note == "" {
return label, ""
}
return label + ": " + note, ""
}
// InitiateConsignmentRTO — POST /admin/consignments/:id/rto {reason, note}
func InitiateConsignmentRTO(c *fiber.Ctx) error {
var req struct {
Reason string `json:"reason"`
Note string `json:"note"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
reasonText, refusal := rtoReasonText(req.Reason, req.Note)
if refusal != "" {
return utils.BadRequest(c, refusal)
}
var cn *models.Consignment
started, from := false, ""
err := db.DB.Transaction(func(tx *gorm.DB) error {
var err error
if cn, err = loadConsignmentForAdmin(c, tx); err != nil {
return err
}
from = cn.Status
started, err = startRTO(tx, cn, reasonText, rtoActor(c))
return err
})
if err != nil {
return rtoResult(c, err, "start the return")
}
if started {
// Only the rider carrying it. A parcel already handed over at a base
// (Inwarded_at_Hub) is no longer with its pickup rider, who must not be
// told "do not attempt delivery" about it.
if riderHoldsParcel(from) {
notifyRiderOfReturn(cn)
}
utils.Info("RTO initiated", "consignment_id", cn.Consignmentid, "by", c.Locals("email"), "reason", reasonText)
}
return utils.OK(c, fiber.Map{"consignment": cn, "started": started})
}
// CancelConsignmentRTO — POST /admin/consignments/:id/rto/cancel {note}
// Ops decide to try delivering again: the parcel goes back to the status it
// had when the return started.
func CancelConsignmentRTO(c *fiber.Ctx) error {
var req struct {
Note string `json:"note"`
}
_ = c.BodyParser(&req)
var cn *models.Consignment
err := db.DB.Transaction(func(tx *gorm.DB) error {
var err error
if cn, err = loadConsignmentForAdmin(c, tx); err != nil {
return err
}
if cn.Status != constants.ConsignmentRTOInitiated {
return errRTO{"this parcel is not being returned"}
}
var last models.ConsignmentHistory
tx.Where("consignmentid = ? AND eventstatus = ?", cn.Consignmentid, constants.ConsignmentRTOInitiated).
Order("historyid DESC").First(&last)
back := statusBeforeRTO(last.Remarks)
// The return is off: clear its reason and start time too, so a parcel
// that is then delivered does not carry a stale return reason. The
// history keeps both.
now := time.Now()
moved, _, err := moveConsignment(tx, cn.Consignmentid, constants.ConsignmentRTOInitiated, map[string]interface{}{
"status": back,
"returnreason": "",
"returninitiatedat": nil,
"updatedat": now,
})
if err != nil {
return err
}
if !moved {
return errRTORaced
}
cn.Status = back
cn.Returnreason = ""
cn.Returninitiatedat = nil
cn.Updatedat = now
remark := "Return cancelled — re-attempting delivery"
if n := strings.TrimSpace(req.Note); n != "" {
remark += ": " + n
}
return tx.Create(&models.ConsignmentHistory{
Consignmentid: cn.Consignmentid, Hubid: cn.Currenthubid, Userid: rtoActor(c),
Eventstatus: back, Remarks: remark,
}).Error
})
if err != nil {
return rtoResult(c, err, "cancel the return")
}
return utils.OK(c, fiber.Map{"consignment": cn})
}
// CompleteConsignmentRTO — POST /admin/consignments/:id/rto/complete {note}
// Ops confirm the parcel is back with the sender (until the rider-app flow is
// on, this is how every return is closed).
func CompleteConsignmentRTO(c *fiber.Ctx) error {
var req struct {
Note string `json:"note"`
}
_ = c.BodyParser(&req)
remark := "Returned to sender (confirmed by ops)"
if n := strings.TrimSpace(req.Note); n != "" {
remark += ": " + n
}
var cn *models.Consignment
err := db.DB.Transaction(func(tx *gorm.DB) error {
var err error
if cn, err = loadConsignmentForAdmin(c, tx); err != nil {
return err
}
return completeRTO(tx, cn, remark, rtoActor(c))
})
if err != nil {
return rtoResult(c, err, "mark the parcel returned")
}
return utils.OK(c, fiber.Map{"consignment": cn})
}
// MilerCompleteReturn — POST /miler/consignments/:id/return-complete
// {lat, lon, receivedby, photourl}. The rider hands the parcel back to the
// sender. Behind MILER_RTO_FLOW_ENABLED.
func MilerCompleteReturn(c *fiber.Ctx) error {
if !rtoRiderFlowEnabled() {
return utils.Fail(c, fiber.StatusForbidden, "RTO_FLOW_DISABLED", "returns are closed by ops for now")
}
milerUserID := c.Locals("userid").(int)
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid consignment ID")
}
var req struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Receivedby string `json:"receivedby"`
Photourl string `json:"photourl"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
cn, code, err := milerConsignmentForRider(milerUserID, id)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.NotFound(c, "consignment not found")
}
return utils.Fail(c, fiber.StatusNotFound, constants.ErrConsignmentNotAssigned, "assigned consignment not found")
}
remark := fmt.Sprintf("Returned to sender by rider at (%.5f, %.5f)", req.Lat, req.Lon)
if r := strings.TrimSpace(req.Receivedby); r != "" {
remark += ", received by " + r
}
if p := strings.TrimSpace(req.Photourl); p != "" {
remark += ", photo " + p
}
err = db.DB.Transaction(func(tx *gorm.DB) error {
return completeRTO(tx, cn, remark, &milerUserID)
})
var refusal errRTO
if errors.As(err, &refusal) {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, refusal.msg)
}
if err != nil {
utils.Error("RTO: rider return-complete", "error", err.Error())
return utils.Internal(c, "failed to record the return")
}
return utils.OK(c, fiber.Map{
"consignmentid": cn.Consignmentid,
"status": cn.Status,
"next_action": nextActionForConsignment(cn.Status),
})
}
// returnRow is one line of GET /admin/returns.
type returnRow struct {
Consignmentid int `json:"consignmentid"`
Trackingno string `json:"trackingno"`
Tenantid int `json:"tenantid"`
Tenantname string `json:"tenantname"`
Status string `json:"status"`
Returnreason string `json:"returnreason"`
Attemptcount int `json:"attemptcount"`
Returninitiatedat *time.Time `json:"returninitiatedat"`
Returndeliveredat *time.Time `json:"returndeliveredat"`
Pickuppincode string `json:"pickuppincode"`
Deliverypincode string `json:"deliverypincode"`
Codamount float64 `json:"codamount"`
Bookingid *int `json:"bookingid"`
Mileruserid *int `json:"mileruserid"`
Milername string `json:"milername"`
}
// returnsDateRange parses ?from=&to= (YYYY-MM-DD, inclusive) as India dates.
func returnsDateRange(from, to string) (*time.Time, *time.Time, error) {
var start, end *time.Time
if from != "" {
t, err := time.ParseInLocation("2006-01-02", from, utils.ISTLocation())
if err != nil {
return nil, nil, errRTO{"from must be YYYY-MM-DD"}
}
start = &t
}
if to != "" {
t, err := time.ParseInLocation("2006-01-02", to, utils.ISTLocation())
if err != nil {
return nil, nil, errRTO{"to must be YYYY-MM-DD"}
}
t = t.AddDate(0, 0, 1)
end = &t
}
return start, end, nil
}
// GetReturns — GET /admin/returns?status=initiated|returned|all&from&to&tenantid&pageno&pagesize
// Every parcel in or through a return, newest first. A client login sees only
// its own (same tenant scoping as the other admin lists).
func GetReturns(c *fiber.Ctx) error {
tenantID, allowed := effectiveTenantID(c)
if !allowed {
return utils.Forbidden(c, "you can only view your own tenant")
}
page := utils.ParsePage(c)
var statuses []string
switch strings.ToLower(c.Query("status", "all")) {
case "initiated":
statuses = []string{constants.ConsignmentRTOInitiated}
case "returned":
statuses = []string{constants.ConsignmentReturnedToSender}
case "all", "":
statuses = []string{constants.ConsignmentRTOInitiated, constants.ConsignmentReturnedToSender}
default:
return utils.BadRequest(c, "status must be initiated, returned or all")
}
start, end, err := returnsDateRange(c.Query("from"), c.Query("to"))
if err != nil {
return utils.BadRequest(c, err.Error())
}
q := scopeToTenant(db.DB.Table("consignments AS cn"), "cn.tenantid", tenantID).
Where("cn.status IN ?", statuses)
if start != nil {
q = q.Where("cn.returninitiatedat >= ?", *start)
}
if end != nil {
q = q.Where("cn.returninitiatedat < ?", *end)
}
var total int64
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
utils.Error("returns: count", "error", err.Error())
return utils.Internal(c, "failed to count returns")
}
rows := []returnRow{}
if err := page.Apply(q.Session(&gorm.Session{}).
Select(`cn.consignmentid, cn.trackingno, cn.tenantid, COALESCE(t.tenantname, '') AS tenantname,
cn.status, cn.returnreason, cn.attemptcount, cn.returninitiatedat, cn.returndeliveredat,
cn.pickuppincode, cn.deliverypincode, cn.codamount`).
Joins("LEFT JOIN tenants t ON t.tenantid = cn.tenantid").
Order("cn.returninitiatedat DESC NULLS LAST, cn.consignmentid DESC")).
Scan(&rows).Error; err != nil {
utils.Error("returns: list", "error", err.Error())
return utils.Internal(c, "failed to list returns")
}
// The rider and booking behind each parcel — one lookup per row through
// the helper that understands multi-destination pickups (pages are capped).
riderNames := map[int]string{}
for i := range rows {
if _, booking, ok := cxDestinationForConsignment(rows[i].Consignmentid); ok && booking != nil {
bid := booking.Bookingid
rows[i].Bookingid = &bid
rows[i].Mileruserid = booking.Assignedmileruserid
if booking.Assignedmileruserid != nil {
uid := *booking.Assignedmileruserid
if _, seen := riderNames[uid]; !seen {
var p models.MilerProfile
if db.DB.Select("displayname").Where("userid = ?", uid).First(&p).Error == nil {
riderNames[uid] = p.Displayname
} else {
riderNames[uid] = ""
}
}
rows[i].Milername = riderNames[uid]
}
}
}
return utils.Paginated(c, rows, total, page)
}
// autoRTOAfterSkip runs after a failed delivery attempt is recorded. At the
// configured attempt count the parcel is returned automatically instead of
// being retried forever.
func autoRTOAfterSkip(cn *models.Consignment, milerUserID int, lastReason string) {
n := rtoAutoAfterAttempts()
if n == 0 || cn.Attemptcount < n {
return
}
reason := fmt.Sprintf("%s: %d delivery attempts failed (last: %s)", rtoReasons["attempts_exhausted"], cn.Attemptcount, lastReason)
started := false
err := db.DB.Transaction(func(tx *gorm.DB) error {
var fresh models.Consignment
if err := tx.First(&fresh, cn.Consignmentid).Error; err != nil {
return err
}
var err error
started, err = startRTO(tx, &fresh, reason, &milerUserID)
if err == nil {
*cn = fresh
}
return err
})
if err != nil {
utils.Warn("RTO: automatic return not started", "consignment_id", cn.Consignmentid, "error", err.Error())
return
}
if started {
notifyRiderOfReturn(cn)
utils.Info("RTO initiated automatically", "consignment_id", cn.Consignmentid, "attempts", cn.Attemptcount)
}
}
// returnDestination is where a returned parcel goes: the sender's pickup
// point (phase 1–3 of the plan). nil unless the parcel is being returned.
func returnDestination(cn *models.Consignment) fiber.Map {
if cn == nil || cn.Status != constants.ConsignmentRTOInitiated {
return nil
}
return fiber.Map{
"type": "sender",
"latitude": cn.Pickuplatitude,
"longitude": cn.Pickuplongitude,
"pincode": cn.Pickuppincode,
}
}

View File

@@ -0,0 +1,249 @@
package controllers
import (
"fmt"
"os"
"strings"
"sync"
"testing"
"doormile/constants"
"doormile/db"
"doormile/internal/testpg"
"doormile/models"
"gorm.io/gorm"
)
// The return (RTO) state machine against a real Postgres. Skipped unless
// REGISTRY_TEST_DSN is set; the DSN must be a THROWAWAY database — the tables
// below are dropped and recreated in their own schema. See
// internal/ai/registry/store_integration_test.go for how to start one.
const rtoRider = 9003
func rtoTestDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres RTO test")
}
gdb := testpg.Open(t, dsn, "rto_controllers_test")
all := []any{&models.Consignment{}, &models.ConsignmentHistory{}, &models.ConsignmentException{},
&models.PickupBooking{}, &models.BookingAssignment{}, &models.BookingDestination{}}
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 })
return gdb
}
// seedParcel creates one parcel out with the rider: consignment, its booking,
// the rider's accepted assignment and an open Undeliverable exception.
func seedParcel(t *testing.T, gdb *gorm.DB, id int, status string) *models.Consignment {
t.Helper()
cn := &models.Consignment{Consignmentid: id, Trackingno: fmt.Sprintf("DMXT%04d", id),
Tenantid: 901, Status: status, Pickuppincode: "641001", Pickuplatitude: 11.0168, Pickuplongitude: 76.9558}
rider := rtoRider
cid := id
must(t, gdb.Create(cn).Error)
must(t, gdb.Create(&models.PickupBooking{Bookingid: id, Bookingno: "DM-T" + cn.Trackingno, Status: "Converted_To_Consignment",
Assignedmileruserid: &rider, Consignmentid: &cid}).Error)
must(t, gdb.Create(&models.BookingAssignment{Bookingid: id, Mileruserid: rtoRider, Assignmentstatus: constants.AssignmentAccepted}).Error)
must(t, gdb.Create(&models.ConsignmentException{Consignmentid: id, Exceptiontype: constants.ExceptionUndeliverable,
Status: constants.ExceptionOpen, Description: "gate locked"}).Error)
return cn
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func reload(t *testing.T, gdb *gorm.DB, id int) models.Consignment {
t.Helper()
var cn models.Consignment
must(t, gdb.First(&cn, id).Error)
return cn
}
func historyOf(t *testing.T, gdb *gorm.DB, id int) []string {
t.Helper()
var rows []models.ConsignmentHistory
must(t, gdb.Where("consignmentid = ?", id).Order("historyid").Find(&rows).Error)
out := make([]string, len(rows))
for i, r := range rows {
out[i] = r.Eventstatus
}
return out
}
func TestRTOLifecycleOnPostgres(t *testing.T) {
gdb := rtoTestDB(t)
cn := seedParcel(t, gdb, 11, constants.ConsignmentOutForDelivery)
actor := 1
// Start: status, return columns, history, exception resolved.
must(t, gdb.Transaction(func(tx *gorm.DB) error {
started, err := startRTO(tx, cn, "Receiver refused: gate locked", &actor)
if !started {
t.Error("first start must report started")
}
return err
}))
got := reload(t, gdb, 11)
if got.Status != constants.ConsignmentRTOInitiated || got.Returnreason != "Receiver refused: gate locked" || got.Returninitiatedat == nil {
t.Fatalf("after start: %+v", got)
}
var exc models.ConsignmentException
must(t, gdb.Where("consignmentid = ?", 11).First(&exc).Error)
if exc.Status != constants.ExceptionResolved || !strings.Contains(exc.Resolution, "Return to sender") {
t.Fatalf("exception not resolved: %+v", exc)
}
// Starting again is a no-op, not a second history row.
stale := *cn
must(t, gdb.Transaction(func(tx *gorm.DB) error {
started, err := startRTO(tx, &stale, "again", &actor)
if started {
t.Error("second start must not report started")
}
return err
}))
// Complete: terminal status, return time, rider's assignment closed.
fresh := reload(t, gdb, 11)
must(t, gdb.Transaction(func(tx *gorm.DB) error { return completeRTO(tx, &fresh, "Returned to sender", &actor) }))
got = reload(t, gdb, 11)
if got.Status != constants.ConsignmentReturnedToSender || got.Returndeliveredat == nil {
t.Fatalf("after complete: %+v", got)
}
var asg models.BookingAssignment
must(t, gdb.Where("bookingid = ?", 11).First(&asg).Error)
if asg.Assignmentstatus != constants.AssignmentCompleted || asg.Completedat == nil {
t.Fatalf("assignment not closed: %+v", asg)
}
// Completing again is a no-op too (rider and ops both confirm).
again := reload(t, gdb, 11)
must(t, gdb.Transaction(func(tx *gorm.DB) error { return completeRTO(tx, &again, "dup", &actor) }))
if h := historyOf(t, gdb, 11); strings.Join(h, ",") != "RTO_Initiated,Returned_to_Sender" {
t.Fatalf("history = %v", h)
}
}
func TestRTORefusalsOnPostgres(t *testing.T) {
gdb := rtoTestDB(t)
delivered := seedParcel(t, gdb, 21, constants.ConsignmentDelivered)
out := seedParcel(t, gdb, 22, constants.ConsignmentOutForDelivery)
err := gdb.Transaction(func(tx *gorm.DB) error { _, err := startRTO(tx, delivered, "x", nil); return err })
if _, ok := err.(errRTO); !ok || !strings.Contains(err.Error(), "delivered cannot be returned") {
t.Fatalf("delivered parcel: %v", err)
}
err = gdb.Transaction(func(tx *gorm.DB) error { return completeRTO(tx, out, "x", nil) })
if _, ok := err.(errRTO); !ok {
t.Fatalf("completing a parcel not in return must be refused: %v", err)
}
if reload(t, gdb, 21).Status != constants.ConsignmentDelivered || reload(t, gdb, 22).Status != constants.ConsignmentOutForDelivery {
t.Fatal("a refusal must change nothing")
}
if len(historyOf(t, gdb, 21))+len(historyOf(t, gdb, 22)) != 0 {
t.Fatal("a refusal must write no history")
}
}
// The race the compare-and-set closes: the parcel was read as Out_for_Delivery,
// then the rider delivered it before ops pressed "Return to sender". The stale
// read must not overwrite Delivered.
func TestRTODoesNotOverwriteAConcurrentDelivery(t *testing.T) {
gdb := rtoTestDB(t)
cn := seedParcel(t, gdb, 31, constants.ConsignmentOutForDelivery)
must(t, gdb.Model(&models.Consignment{}).Where("consignmentid = ?", 31).Update("status", constants.ConsignmentDelivered).Error)
err := gdb.Transaction(func(tx *gorm.DB) error { _, err := startRTO(tx, cn, "Receiver refused", nil); return err })
if err != errRTORaced {
t.Fatalf("want the 'changed, refresh' refusal, got %v", err)
}
got := reload(t, gdb, 31)
if got.Status != constants.ConsignmentDelivered || got.Returnreason != "" {
t.Fatalf("delivery was overwritten: %+v", got)
}
}
// Ten simultaneous "Return to sender" clicks: exactly one return, one history
// row, and every caller gets a non-error answer.
func TestRTOConcurrentStartsWriteOnce(t *testing.T) {
gdb := rtoTestDB(t)
seedParcel(t, gdb, 41, constants.ConsignmentOutForDelivery)
var wg sync.WaitGroup
var mu sync.Mutex
startedCount, errs := 0, 0
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
var started bool
err := gdb.Transaction(func(tx *gorm.DB) error {
var cn models.Consignment
if err := tx.First(&cn, 41).Error; err != nil {
return err
}
var err error
started, err = startRTO(tx, &cn, "Receiver refused", nil)
return err
})
mu.Lock()
defer mu.Unlock()
if err != nil {
errs++
}
if started {
startedCount++
}
}()
}
wg.Wait()
if startedCount != 1 || errs != 0 {
t.Fatalf("started=%d errors=%d, want 1 and 0", startedCount, errs)
}
if h := historyOf(t, gdb, 41); len(h) != 1 {
t.Fatalf("history rows = %v, want exactly one RTO_Initiated", h)
}
}
// Re-attempt puts the parcel back where it was and clears the return fields.
func TestRTOCancelRestoresAndClears(t *testing.T) {
gdb := rtoTestDB(t)
cn := seedParcel(t, gdb, 51, constants.ConsignmentCollectedByMiler)
must(t, gdb.Transaction(func(tx *gorm.DB) error { _, err := startRTO(tx, cn, "Address not found", nil); return err }))
// The cancel handler's core, run directly: read the [from:] remark, move back.
var last models.ConsignmentHistory
must(t, gdb.Where("consignmentid = ? AND eventstatus = ?", 51, constants.ConsignmentRTOInitiated).First(&last).Error)
back := statusBeforeRTO(last.Remarks)
if back != constants.ConsignmentCollectedByMiler {
t.Fatalf("back = %s", back)
}
moved, _, err := moveConsignment(gdb, 51, constants.ConsignmentRTOInitiated, map[string]interface{}{
"status": back, "returnreason": "", "returninitiatedat": nil,
})
if err != nil || !moved {
t.Fatalf("moved=%v err=%v", moved, err)
}
got := reload(t, gdb, 51)
if got.Status != constants.ConsignmentCollectedByMiler || got.Returnreason != "" || got.Returninitiatedat != nil {
t.Fatalf("after cancel: %+v", got)
}
// A second cancel finds nothing to move.
if moved, cur, _ := moveConsignment(gdb, 51, constants.ConsignmentRTOInitiated, map[string]interface{}{"status": back}); moved || cur != back {
t.Fatalf("second cancel: moved=%v current=%s", moved, cur)
}
}

View File

@@ -0,0 +1,166 @@
package controllers
import (
"strings"
"testing"
"time"
"doormile/constants"
"doormile/models"
)
func TestGenericStatusChangeGuard(t *testing.T) {
cases := []struct {
from, to string
allowed bool
}{
{constants.ConsignmentOutForDelivery, constants.ConsignmentDelivered, true},
{constants.ConsignmentCollectedByMiler, constants.ConsignmentOutForDelivery, true},
{constants.ConsignmentOutForDelivery, "Cancelled", true},
{constants.ConsignmentDelivered, constants.ConsignmentDelivered, true}, // no-op re-save
{"Cancelled", constants.ConsignmentDelivered, false}, // the old bug
{constants.ConsignmentDelivered, constants.ConsignmentOutForDelivery, false},
{constants.ConsignmentReturnedToSender, constants.ConsignmentOutForDelivery, false},
{constants.ConsignmentOutForDelivery, constants.ConsignmentRTOInitiated, false}, // must use the RTO action
{constants.ConsignmentRTOInitiated, constants.ConsignmentReturnedToSender, false}, // must use the RTO action
{constants.ConsignmentRTOInitiated, constants.ConsignmentDelivered, false}, // re-attempt first
{constants.ConsignmentOutForDelivery, "Out_For_Delivery_typo", false},
}
for _, c := range cases {
msg := checkGenericStatusChange(c.from, c.to)
if (msg == "") != c.allowed {
t.Errorf("%s -> %s: allowed=%v, got %q", c.from, c.to, c.allowed, msg)
}
}
}
func TestRTOHistoryRemarkRoundTrip(t *testing.T) {
for _, from := range []string{constants.ConsignmentOutForDelivery, constants.ConsignmentCollectedByMiler,
constants.ConsignmentInwardedAtHub, constants.ConsignmentCreated} {
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"} {
if got := statusBeforeRTO(remark); got != constants.ConsignmentOutForDelivery {
t.Errorf("%q -> %s, want Out_for_Delivery", remark, got)
}
}
}
func TestRTOAutoAfterAttempts(t *testing.T) {
t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "")
if rtoAutoAfterAttempts() != 3 {
t.Fatal("default must be 3")
}
t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "0")
if rtoAutoAfterAttempts() != 0 {
t.Fatal("0 must turn it off")
}
t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "5")
if rtoAutoAfterAttempts() != 5 {
t.Fatal("5 must be read")
}
t.Setenv("RTO_AUTO_AFTER_ATTEMPTS", "-2")
if rtoAutoAfterAttempts() != 3 {
t.Fatal("a negative value must fall back to the default, not disable it")
}
}
// The deployed rider app does not know return_to_sender: with the flag off a
// returning parcel must read as "nothing for you", exactly as before.
func TestNextActionForReturnRespectsFlag(t *testing.T) {
t.Setenv("MILER_RTO_FLOW_ENABLED", "")
if got := nextActionForConsignment(constants.ConsignmentRTOInitiated); got != constants.NextActionNone {
t.Fatalf("flag off: %s", got)
}
t.Setenv("MILER_RTO_FLOW_ENABLED", "true")
if got := nextActionForConsignment(constants.ConsignmentRTOInitiated); got != constants.NextActionReturnToSender {
t.Fatalf("flag on: %s", got)
}
if got := nextActionForConsignment(constants.ConsignmentReturnedToSender); got != constants.NextActionNone {
t.Fatalf("returned is terminal: %s", got)
}
// Existing actions are unchanged.
if got := nextActionForConsignment(constants.ConsignmentOutForDelivery); got != constants.NextActionDeliver {
t.Fatalf("out for delivery: %s", got)
}
}
func TestReturnDestination(t *testing.T) {
cn := &models.Consignment{Status: constants.ConsignmentRTOInitiated, Pickuplatitude: 11.01, Pickuplongitude: 76.95, Pickuppincode: "641001"}
d := returnDestination(cn)
if d == nil || d["type"] != "sender" || d["pincode"] != "641001" || d["latitude"] != 11.01 {
t.Fatalf("destination = %v", d)
}
cn.Status = constants.ConsignmentOutForDelivery
if returnDestination(cn) != nil {
t.Fatal("not returning must give nil")
}
}
func TestReturnsDateRangeIsIndiaDays(t *testing.T) {
from, to, err := returnsDateRange("2026-10-01", "2026-10-01")
if err != nil {
t.Fatal(err)
}
// 1 Oct in India runs 30 Sep 18:30 UTC → 1 Oct 18:30 UTC.
if !from.Equal(time.Date(2026, 9, 30, 18, 30, 0, 0, time.UTC)) || !to.Equal(time.Date(2026, 10, 1, 18, 30, 0, 0, time.UTC)) {
t.Fatalf("range = %v .. %v", from, to)
}
if _, _, err := returnsDateRange("01-10-2026", ""); err == nil {
t.Fatal("a non-ISO date must be refused")
}
if f, tt, err := returnsDateRange("", ""); err != nil || f != nil || tt != nil {
t.Fatal("no dates must mean no bounds")
}
}
func TestRTOReasonText(t *testing.T) {
cases := []struct {
reason, note, text string
refused bool
}{
{"receiver_refused", "", "Receiver refused", false},
{"receiver_refused", " gate locked ", "Receiver refused: gate locked", false},
{" Address_Not_Found ", "", "Address not found", false}, // case and spaces forgiven
{"other", "Shop closed", "Other: Shop closed", false},
{"other", " ", "", true},
{"OTHER", "", "", true}, // used to slip past the note check
{"", "", "", true},
{"lost_it", "", "", true},
{"other", strings.Repeat("அ", 500), "Other: " + strings.Repeat("அ", 500), false}, // 500 Tamil chars = 1500 bytes, allowed
{"other", strings.Repeat("a", 501), "", true},
}
for _, c := range cases {
text, refusal := rtoReasonText(c.reason, c.note)
if (refusal != "") != c.refused || text != c.text {
t.Errorf("(%q, %d chars): text=%q refusal=%q", c.reason, len([]rune(c.note)), text, refusal)
}
}
}
func TestRTOReasonsCoverThePlan(t *testing.T) {
for _, k := range []string{"receiver_refused", "address_not_found", "customer_unavailable", "attempts_exhausted", "other"} {
if rtoReasons[k] == "" {
t.Errorf("missing reason %q", k)
}
}
}
// A parcel already handed over at a base is not with its pickup rider any
// more: starting its return must not push "do not attempt delivery" to them.
func TestRiderHoldsParcel(t *testing.T) {
for status, want := range map[string]bool{
constants.ConsignmentCreated: true, // hub handover flag on: carrying it to the base
constants.ConsignmentCollectedByMiler: true,
constants.ConsignmentOutForDelivery: true,
constants.ConsignmentInwardedAtHub: false,
constants.ConsignmentDelivered: false,
} {
if got := riderHoldsParcel(status); got != want {
t.Errorf("%s: %v, want %v", status, got, want)
}
}
}

View File

@@ -243,6 +243,14 @@ func nextActionForConsignment(status string) string {
return constants.NextActionDeliver
case constants.ConsignmentInwardedAtHub:
return constants.NextActionHandedToHub
case constants.ConsignmentRTOInitiated:
// Being returned: the rider carries it back to the sender — once the
// rider app knows this action. Until then it reads as "nothing left
// for you" and ops close the return from the console.
if rtoRiderFlowEnabled() {
return constants.NextActionReturnToSender
}
return constants.NextActionNone
default:
// Tripsheet_Loaded, In_Transit, Delivered, RTO, Returned, Missing,
// Damaged — all past this rider's leg.

View File

@@ -668,6 +668,11 @@ func MilerGetConsignment(c *fiber.Ctx) error {
"next_hub": nextHubForConsignment(consignment),
"can_inward_at_hub": consignment.Status == constants.ConsignmentCreated,
"inwardedat": consignment.Inwardedat,
// Reverse logistics (additive fields; older app builds ignore them).
"returning": consignment.Status == constants.ConsignmentRTOInitiated,
"can_return": consignment.Status == constants.ConsignmentRTOInitiated && rtoRiderFlowEnabled(),
"return_reason": consignment.Returnreason,
"return_to": returnDestination(consignment),
})
}
@@ -1006,16 +1011,18 @@ func MilerSkipDelivery(c *fiber.Ctx) error {
return utils.BadRequest(c, "reason is required")
}
var consignment models.Consignment
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
var booking models.PickupBooking
if err := db.DB.Where("consignmentid = ? AND assignedmileruserid = ?", consignment.Consignmentid, milerUserID).
First(&booking).Error; err != nil {
// Ownership through the multi-destination-aware helper. The direct
// pickupbookings.consignmentid lookup named only the FIRST order of a
// pickup, so a rider could not report a failed attempt on orders 2..N.
// Same responses as before.
consignmentPtr, code, err := milerConsignmentForRider(milerUserID, consignmentID)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.NotFound(c, "consignment not found")
}
return utils.Fail(c, fiber.StatusNotFound, constants.ErrConsignmentNotAssigned, "assigned consignment not found")
}
consignment := *consignmentPtr
// A failed attempt can be reported once the rider is carrying the parcel —
// whether they had already tapped start-delivery (Out_for_Delivery) or not
@@ -1063,6 +1070,11 @@ func MilerSkipDelivery(c *fiber.Ctx) error {
db.DB.Create(&exception)
}
// Reverse logistics: at RTO_AUTO_AFTER_ATTEMPTS failed attempts (default
// 3) the parcel goes back to the sender instead of retrying forever. The
// RTO resolves the exception raised just above.
autoRTOAfterSkip(&consignment, milerUserID, req.Reason)
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"attemptcount": consignment.Attemptcount,