From 220e934045fc5568788d88ab3ea36d7ec84937fa Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Tue, 6 Oct 2026 11:05:03 +0530 Subject: [PATCH] updates on the reverse logistics --- constants/constants.go | 4 + controllers/adminController.go | 7 + controllers/consignmentReturn.go | 698 +++++++++++++++++++++ controllers/consignmentReturn_pg_test.go | 249 ++++++++ controllers/consignmentReturn_test.go | 166 +++++ controllers/logisticsHandoverController.go | 8 + controllers/milerAppController.go | 28 +- docs/reverse-logistics-rider-app.md | 293 +++++++++ routes/routes.go | 9 + routes/routes_rto_test.go | 87 +++ 10 files changed, 1541 insertions(+), 8 deletions(-) create mode 100644 controllers/consignmentReturn.go create mode 100644 controllers/consignmentReturn_pg_test.go create mode 100644 controllers/consignmentReturn_test.go create mode 100644 docs/reverse-logistics-rider-app.md create mode 100644 routes/routes_rto_test.go diff --git a/constants/constants.go b/constants/constants.go index bc604a1..84ed739 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -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 diff --git a/controllers/adminController.go b/controllers/adminController.go index ab8f26c..327b5fa 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -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 { diff --git a/controllers/consignmentReturn.go b/controllers/consignmentReturn.go new file mode 100644 index 0000000..4821a8d --- /dev/null +++ b/controllers/consignmentReturn.go @@ -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, + } +} diff --git a/controllers/consignmentReturn_pg_test.go b/controllers/consignmentReturn_pg_test.go new file mode 100644 index 0000000..f60311b --- /dev/null +++ b/controllers/consignmentReturn_pg_test.go @@ -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) + } +} diff --git a/controllers/consignmentReturn_test.go b/controllers/consignmentReturn_test.go new file mode 100644 index 0000000..c9faeec --- /dev/null +++ b/controllers/consignmentReturn_test.go @@ -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) + } + } +} diff --git a/controllers/logisticsHandoverController.go b/controllers/logisticsHandoverController.go index 837ac91..35c3d6b 100644 --- a/controllers/logisticsHandoverController.go +++ b/controllers/logisticsHandoverController.go @@ -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. diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index 3cab450..4e9b7a0 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -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, diff --git a/docs/reverse-logistics-rider-app.md b/docs/reverse-logistics-rider-app.md new file mode 100644 index 0000000..492d88b --- /dev/null +++ b/docs/reverse-logistics-rider-app.md @@ -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 `). + +> **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 `, `Content-Type: application/json`, and +**`Idempotency-Key: `** (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`.* diff --git a/routes/routes.go b/routes/routes.go index c856f3c..ff3dfc5 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -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) diff --git a/routes/routes_rto_test.go b/routes/routes_rto_test.go new file mode 100644 index 0000000..3628107 --- /dev/null +++ b/routes/routes_rto_test.go @@ -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) + } +}