updates on the reverse logistics
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
698
controllers/consignmentReturn.go
Normal file
698
controllers/consignmentReturn.go
Normal 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,
|
||||
}
|
||||
}
|
||||
249
controllers/consignmentReturn_pg_test.go
Normal file
249
controllers/consignmentReturn_pg_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
166
controllers/consignmentReturn_test.go
Normal file
166
controllers/consignmentReturn_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
293
docs/reverse-logistics-rider-app.md
Normal file
293
docs/reverse-logistics-rider-app.md
Normal 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`.*
|
||||
@@ -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
87
routes/routes_rto_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user