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

@@ -140,6 +140,10 @@ const (
NextActionInwardAtHub = "inward_at_hub" // carry it to a base and hand it over
NextActionHandedToHub = "handed_to_hub" // already inwarded at the base — nothing left for this rider
NextActionNone = "none" // terminal (delivered, cancelled, returned)
// NextActionReturnToSender: the parcel is being returned (RTO) — carry it
// back to the sender's pickup point. Only emitted when
// MILER_RTO_FLOW_ENABLED=true (the deployed rider app does not know it yet).
NextActionReturnToSender = "return_to_sender"
)
// Payment Modes

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,

View File

@@ -0,0 +1,293 @@
# Reverse logistics (Return to sender): rider app integration
This is the backend contract the Miler (rider) app needs in order to support
**returns**, also called RTO ("return to origin"). It lists what the server
sends, what the app should show, the one new endpoint, and the order to roll it
out in.
Base URL `https://api.doormile.com/api/v1`. Every endpoint here uses the normal
rider login (`Authorization: Bearer <miler token>`).
> **Summary for the app team.** A parcel can now be sent back to its sender
> instead of being delivered. While that is happening, the parcel's
> **consignment** status is `RTO_Initiated`, but the **booking** status does not
> change. Today the app decides what to show from the booking status, so a
> parcel being returned still looks deliverable. Tapping Deliver or Skip then
> returns a 400. The app must read the per-parcel fields below and show a
> "Return to sender" stop instead.
---
## 1. What happens to a parcel
```
Collected_By_Miler / Out_for_Delivery
│ ops press "Return to sender" in the console,
│ or automatically after 3 failed delivery attempts (skips)
▼
RTO_Initiated ──── ops "Re-attempt delivery" ───▶ back to Out_for_Delivery
│ (or wherever it was)
│ rider hands it back to the sender ← NEW: POST …/return-complete
│ (or ops press "Mark returned")
▼
Returned_to_Sender (final; the rider's job on it is closed)
```
- **Where it goes:** back to the **sender**, which is the parcel's pickup
point. The server sends the coordinates (`return_to`, see §3). The app never
chooses the destination.
- **Who starts it:** ops, from the console, or the server automatically on the
3rd failed attempt. The rider never starts a return.
- **Who finishes it:** the rider, through the new endpoint once the feature
flag is on (§6), or ops from the console.
### Wire values (spell exactly like this)
| Thing | Value |
|---|---|
| Consignment status while returning | `RTO_Initiated` |
| Consignment status once returned | `Returned_to_Sender` |
| `next_action` while returning | `return_to_sender` (only when the flag is on, §6) |
| `next_action` once returned | `none` |
| `return_to.type` | `sender` |
| Push `data.type` | `rto` |
---
## 2. The feature flag (server side)
`MILER_RTO_FLOW_ENABLED` is an environment variable on the server, **off by
default**. It is read on every request.
| | flag **off** (today) | flag **on** (after your release) |
|---|---|---|
| `next_action` for a parcel being returned | `none` | `return_to_sender` |
| `can_return` | `false` | `true` |
| `POST /miler/consignments/:id/return-complete` | `403 RTO_FLOW_DISABLED` | works |
| Who closes the return | ops, in the console | the rider (or ops) |
`returning`, `return_reason` and `return_to` are sent **in both states**, so the
app can show the return as soon as your build ships. That is before the flag
goes on.
---
## 3. Reading a parcel: `GET /miler/consignments/:consignmentid`
Unchanged fields stay as they were. **Four fields are new** and are additive,
so older builds ignore them.
| Field | Type | Meaning |
|---|---|---|
| `returning` | bool | `true` while the parcel is being returned (`status == "RTO_Initiated"`). |
| `can_return` | bool | `true` when the rider may finish the return in the app (returning **and** flag on). Show the "Returned to sender" button only when this is `true`. |
| `return_reason` | string | Why it is being returned, e.g. `"Receiver refused: gate locked"`. `""` when not returning. |
| `return_to` | object \| null | Where to take it. `null` when not returning. |
`return_to`:
```json
{ "type": "sender", "latitude": 11.0168, "longitude": 76.9558, "pincode": "641001" }
```
Example response for a parcel being returned (flag on):
```json
{
"success": true,
"data": {
"consignmentid": 9103,
"trackingno": "DMX09103",
"status": "RTO_Initiated",
"attemptcount": 0,
"next_action": "return_to_sender",
"returning": true,
"can_return": true,
"return_reason": "Address not found: No such door number",
"return_to": { "type": "sender", "latitude": 11.0168, "longitude": 76.9558, "pincode": "641001" },
"can_deliver": false,
"can_skip": false,
"can_start_delivery": false,
"can_inward_at_hub": false,
"delivered": false,
"out_for_delivery": false,
"collected": false,
"paymentmode": "",
"codamount": 0,
"codcollected": 0,
"next_hub": null,
"inwardedat": null
}
}
```
With the flag **off**, the same parcel has `"next_action": "none"` and
`"can_return": false`. Everything else is the same.
Note that `can_deliver`, `can_skip` and `can_start_delivery` are all `false`
while returning. If the app already uses these flags to enable its buttons, the
buttons disable themselves correctly.
---
## 4. The queue: `GET /miler/bookings`
Each stop already carries `consignmentid`, `consignmentstatus`, `trackingno`,
`next_action` and `next_hub`. Nothing new is added here. A stop being returned
shows:
- `consignmentstatus: "RTO_Initiated"`
- `next_action: "return_to_sender"` (flag on) or `"none"` (flag off)
- the booking `status` **unchanged** (e.g. `Converted_To_Consignment`)
**This is the important app change:** `ApiConfig.legacyStatusFromNew` maps
`Converted_To_Consignment` to `picked`. A parcel being returned therefore
renders as a normal delivery today. Before mapping a stop to a delivery card,
check `consignmentstatus` / `next_action`:
| `consignmentstatus` | `next_action` | Show |
|---|---|---|
| `RTO_Initiated` | `return_to_sender` | **Return to sender** card: navigate to `return_to`, "Returned to sender" button |
| `RTO_Initiated` | `none` | **Being returned**: no Deliver/Skip buttons; text such as "Return to sender. Ops will close this." |
| `Returned_to_Sender` | `none` | Done: move to history like a delivered stop |
To get `return_to` and `return_reason` for a stop, read
`GET /miler/consignments/:consignmentid`. A push (below) is also a good moment
to refresh.
---
## 5. Finishing a return: `POST /miler/consignments/:id/return-complete` (NEW)
The rider has handed the parcel back to the sender.
Headers: `Authorization: Bearer <token>`, `Content-Type: application/json`, and
**`Idempotency-Key: <uuid>`** (recommended; see below).
Request:
```json
{
"lat": 11.0168,
"lon": 76.9558,
"receivedby": "Acme kitchen manager",
"photourl": "https://…/proof.jpg"
}
```
| Field | Required | Notes |
|---|---|---|
| `lat`, `lon` | send them | Rider's position at handover. Stored in the parcel history. |
| `receivedby` | optional | Who at the sender took it back. |
| `photourl` | optional | Proof photo. Upload it the same way as delivery proof (`POST /miler/uploads/sign`, then use the URL). |
Success `200`:
```json
{ "success": true, "data": { "consignmentid": 9103, "status": "Returned_to_Sender", "next_action": "none" } }
```
What the server does on success:
- parcel → `Returned_to_Sender`, with the return time stored;
- a history row: `Returned to sender by rider at (lat, lon), received by …, photo …`;
- the rider's assignment on that booking → `Completed`.
The rider's job on that parcel is then closed, so it no longer blocks
**End duty**.
Errors. Codes follow the existing miler format,
`{"success": false, "code": "...", "message": "..."}`. Plain 400/404/500
responses have no `code` and only carry `message`.
| HTTP | `code` | When | App should |
|---|---|---|---|
| 403 | `RTO_FLOW_DISABLED` | The server flag is off | Hide the button. This should not happen if you check `can_return`. |
| 400 | `INVALID_STATE` | Parcel is not being returned (e.g. ops re-attempted it, or it was delivered) | Refresh the parcel and show its new state |
| 404 | — (`message: "consignment not found"`) | No such parcel | Refresh the queue |
| 404 | `CONSIGNMENT_NOT_ASSIGNED` | Parcel isn't on this rider's bookings | Refresh the queue |
| 400 | — | Bad id or bad JSON body | Bug in the app |
| 500 | — | Server error, nothing was saved | Retry with the **same** `Idempotency-Key` |
**Retries are safe.** With the same `Idempotency-Key`, a successful response is
replayed for 24 h. Even without the key, a second call on a parcel that is
already `Returned_to_Sender` returns `200` and changes nothing.
---
## 6. Skip responses change on the 3rd attempt
`POST /miler/consignments/:id/skip` is unchanged in what it accepts. The
response `status` can now be **`RTO_Initiated`**:
```json
{ "success": true, "data": { "consignmentid": 9102, "attemptcount": 3, "status": "RTO_Initiated" } }
```
That is the server starting the return automatically, by default on the 3rd
failed attempt (server setting `RTO_AUTO_AFTER_ATTEMPTS`; ops may set it to `0`
to turn this off). After a skip, use the returned `status`. If it is
`RTO_Initiated`, switch the stop to the return card (§4) instead of keeping it
as a delivery to retry.
Also new: skip now works for the 2nd, 3rd … orders of a multi-drop pickup. It
used to answer `CONSIGNMENT_NOT_ASSIGNED` for every order after the first.
---
## 7. Push notification
When ops start a return (or it starts automatically), the rider **holding the
parcel** gets:
| | |
|---|---|
| title | `Return parcel to sender` |
| body | `Parcel DMX09103 is being returned to the sender. Do not attempt delivery.` |
| data | `{ "type": "rto", "consignmentid": "9103" }` (both strings) |
On `type == "rto"`: refresh that consignment (§3) and the queue (§4). A rider
who already handed the parcel over at a base is **not** notified.
---
## 8. Rollout order
1. **Backend deployed** with the flag off. Ops start and close returns from the
console; riders get the push. Until step 3, a return can leave a rider
unable to end duty until ops press "Mark returned". To avoid that, ops may
deploy with `RTO_AUTO_AFTER_ATTEMPTS=0`.
2. **App release**: §4 (card per `consignmentstatus` / `next_action`), §3
fields, §5 button behind `can_return`, §6 skip handling, §7 push.
3. **Flag on** (`MILER_RTO_FLOW_ENABLED=true`) once most riders have the new
build. Riders finish their own returns, and automatic returns can be turned
on.
Old builds keep working at every step. The new fields are additive, and with
the flag off nothing new is required from the app.
---
## 9. App test checklist
Use a staging backend with `MILER_RTO_FLOW_ENABLED=true`.
- [ ] Ops start a return on a parcel the rider is carrying → push arrives →
the stop turns into a **Return to sender** card with the reason; Deliver
and Skip are gone.
- [ ] Navigation goes to `return_to` (the sender), not to the receiver.
- [ ] "Returned to sender" (with photo and receiver name) → `200` → the stop
moves to history → **End duty** works.
- [ ] Tap it twice or with no network, then retry → no error, one return.
- [ ] Skip the same parcel 3 times → the 3rd response has
`status: RTO_Initiated` → the card switches to return.
- [ ] Ops press "Re-attempt delivery" while the card is open → button tap gets
`400 INVALID_STATE` → the app refreshes and shows the delivery again.
- [ ] Flag **off**: the card shows "Being returned", with no button and no 403
shown to the rider.
- [ ] Multi-drop pickup: skip and return work on the 2nd and 3rd order.
---
*Server code: `controllers/consignmentReturn.go`. The full plan, decisions and
test record are in `krow_talent_app/docs/reverse-logistics-plan.md`.*

View File

@@ -268,6 +268,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
milerAuth.Post("/consignments/:id/start-delivery", middlewares.Idempotency(), controllers.MilerStartDelivery)
milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment)
milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery)
// Rider hands a returned (RTO) parcel back to the sender. Refused unless
// MILER_RTO_FLOW_ENABLED=true.
milerAuth.Post("/consignments/:id/return-complete", middlewares.Idempotency(), controllers.MilerCompleteReturn)
// Rider handover at a base. The authoritative record that a hub-routed parcel
// physically changed hands; answers with the resulting state rather than a
// bare 200, and carries the shared idempotency middleware because riders retry
@@ -407,6 +410,12 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Get("/consignments/:id/logs", controllers.GetAdminConsignmentLogs)
adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking)
adminAuth.Put("/consignments/:id/status", controllers.AdminUpdateConsignmentStatus)
// Reverse logistics (RTO). Starting, cancelling and closing a return are
// Doormile-staff actions; a client login can read its own returns.
adminAuth.Post("/consignments/:id/rto", middlewares.DoormileStaffOnly, controllers.InitiateConsignmentRTO)
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)
// Tripsheets
adminAuth.Get("/tripsheets", controllers.GetTripsheets)

87
routes/routes_rto_test.go Normal file
View File

@@ -0,0 +1,87 @@
package routes_test
import (
"net/http"
"strings"
"testing"
)
// Reverse logistics (RTO) gates, over real HTTP. Refusals happen in
// middleware (or before any query), so no database is needed.
var rtoWrites = []struct{ method, path, body string }{
{http.MethodPost, "/api/v1/admin/consignments/5/rto", `{"reason":"receiver_refused"}`},
{http.MethodPost, "/api/v1/admin/consignments/5/rto/cancel", `{}`},
{http.MethodPost, "/api/v1/admin/consignments/5/rto/complete", `{}`},
}
func TestRTONeedsALogin(t *testing.T) {
app := newApp()
for _, w := range rtoWrites {
if code, _ := do(t, app, w.method, w.path, "", w.body); code != http.StatusUnauthorized {
t.Errorf("%s %s with no token = %d, want 401", w.method, w.path, code)
}
}
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/returns", "", ""); code != http.StatusUnauthorized {
t.Errorf("GET /admin/returns with no token = %d, want 401", code)
}
}
// A client login may read its returns but never start, cancel or close one.
func TestRTOActionsAreDoormileStaffOnly(t *testing.T) {
app := newApp()
client := tenantToken(t, 50, 1, 7)
for _, w := range rtoWrites {
code, body := do(t, app, w.method, w.path, client, w.body)
if code != http.StatusForbidden || !strings.Contains(body, "Doormile staff only") {
t.Errorf("client %s %s = %d %s, want 403 staff only", w.method, w.path, code, body)
}
}
}
func TestRTORefusesNonConsoleRoles(t *testing.T) {
app := newApp()
for _, role := range []int{5, 6, 9} {
for _, w := range rtoWrites {
if code, _ := do(t, app, w.method, w.path, token(t, 1, role), w.body); code != http.StatusForbidden {
t.Errorf("role %d %s %s = %d, want 403", role, w.method, w.path, code)
}
}
}
}
// Staff pass every gate (no database here, so the handler's first query
// panics and recover answers 500 — proof nothing in front of it refused).
// A missing reason is refused before any query.
func TestRTOStaffPassTheGate(t *testing.T) {
app := newApp()
staff := token(t, 1, 1)
for _, w := range rtoWrites {
if code, _ := do(t, app, w.method, w.path, staff, w.body); code == 401 || code == 403 || code == 404 {
t.Errorf("staff %s %s = %d; a gate refused or the route is missing", w.method, w.path, code)
}
}
if code, body := do(t, app, http.MethodPost, "/api/v1/admin/consignments/5/rto", staff, `{"reason":"teleported"}`); code != http.StatusBadRequest {
t.Errorf("unknown reason = %d %s, want 400", code, body)
}
if code, body := do(t, app, http.MethodPost, "/api/v1/admin/consignments/5/rto", staff, `{"reason":"other"}`); code != http.StatusBadRequest {
t.Errorf("other without a note = %d %s, want 400", code, body)
}
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/returns?status=bogus", staff, ""); code != http.StatusBadRequest {
t.Errorf("bad status filter = %d, want 400", code)
}
}
// The rider endpoint stays shut until MILER_RTO_FLOW_ENABLED=true — the
// deployed rider app does not know returns yet.
func TestRiderReturnIsOffByDefault(t *testing.T) {
t.Setenv("MILER_RTO_FLOW_ENABLED", "")
app := newApp()
code, body := do(t, app, http.MethodPost, "/api/v1/miler/consignments/5/return-complete", token(t, 9, 5), `{}`)
if code != http.StatusForbidden || !strings.Contains(body, "RTO_FLOW_DISABLED") {
t.Fatalf("flag off = %d %s, want 403 RTO_FLOW_DISABLED", code, body)
}
if code, _ := do(t, app, http.MethodPost, "/api/v1/miler/consignments/5/return-complete", token(t, 1, 1), `{}`); code != http.StatusForbidden {
t.Fatalf("a console token on the rider route = %d, want 403", code)
}
}