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
|
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
|
NextActionHandedToHub = "handed_to_hub" // already inwarded at the base — nothing left for this rider
|
||||||
NextActionNone = "none" // terminal (delivered, cancelled, returned)
|
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
|
// Payment Modes
|
||||||
|
|||||||
@@ -3166,6 +3166,13 @@ func AdminUpdateConsignmentStatus(c *fiber.Ctx) error {
|
|||||||
return utils.NotFound(c, "consignment not found")
|
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.Status = req.Status
|
||||||
consignment.Updatedat = time.Now()
|
consignment.Updatedat = time.Now()
|
||||||
if err := tx.Save(&consignment).Error; err != nil {
|
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
|
return constants.NextActionDeliver
|
||||||
case constants.ConsignmentInwardedAtHub:
|
case constants.ConsignmentInwardedAtHub:
|
||||||
return constants.NextActionHandedToHub
|
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:
|
default:
|
||||||
// Tripsheet_Loaded, In_Transit, Delivered, RTO, Returned, Missing,
|
// Tripsheet_Loaded, In_Transit, Delivered, RTO, Returned, Missing,
|
||||||
// Damaged — all past this rider's leg.
|
// Damaged — all past this rider's leg.
|
||||||
|
|||||||
@@ -668,6 +668,11 @@ func MilerGetConsignment(c *fiber.Ctx) error {
|
|||||||
"next_hub": nextHubForConsignment(consignment),
|
"next_hub": nextHubForConsignment(consignment),
|
||||||
"can_inward_at_hub": consignment.Status == constants.ConsignmentCreated,
|
"can_inward_at_hub": consignment.Status == constants.ConsignmentCreated,
|
||||||
"inwardedat": consignment.Inwardedat,
|
"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")
|
return utils.BadRequest(c, "reason is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
var consignment models.Consignment
|
// Ownership through the multi-destination-aware helper. The direct
|
||||||
if err := db.DB.First(&consignment, consignmentID).Error; err != nil {
|
// 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.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 {
|
|
||||||
return utils.Fail(c, fiber.StatusNotFound, constants.ErrConsignmentNotAssigned, "assigned 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 —
|
// 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
|
// 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)
|
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{
|
return utils.OK(c, fiber.Map{
|
||||||
"consignmentid": consignment.Consignmentid,
|
"consignmentid": consignment.Consignmentid,
|
||||||
"attemptcount": consignment.Attemptcount,
|
"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/start-delivery", middlewares.Idempotency(), controllers.MilerStartDelivery)
|
||||||
milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment)
|
milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment)
|
||||||
milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery)
|
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
|
// Rider handover at a base. The authoritative record that a hub-routed parcel
|
||||||
// physically changed hands; answers with the resulting state rather than a
|
// physically changed hands; answers with the resulting state rather than a
|
||||||
// bare 200, and carries the shared idempotency middleware because riders retry
|
// 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/:id/logs", controllers.GetAdminConsignmentLogs)
|
||||||
adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking)
|
adminAuth.Get("/consignments/track/:trackingno", controllers.GetAdminConsignmentTracking)
|
||||||
adminAuth.Put("/consignments/:id/status", controllers.AdminUpdateConsignmentStatus)
|
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
|
// Tripsheets
|
||||||
adminAuth.Get("/tripsheets", controllers.GetTripsheets)
|
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