updates on the api endpoints on the customer page and more

This commit is contained in:
2026-09-02 16:32:53 +05:30
parent 8e2c484bcb
commit d12629a1e4
31 changed files with 3541 additions and 121 deletions

View File

@@ -2142,7 +2142,23 @@ type AdminBookingRequest struct {
// callers written against the earlier docs. It is never stored as-is: the
// column of that name foreign-keys to appcustomerlocations, not to a
// client's sites.
Pickuplocationid *int `json:"pickuplocationid"`
Pickuplocationid *int `json:"pickuplocationid"`
// PickupSourceType says what kind of place this booking is collected from —
// one of constants.PickupSource*. Optional: left blank it is classified from
// what the payload carries (a base id, a client site id, or neither), so
// existing console callers keep working unchanged. Send it explicitly to
// create a Base/Hub-origin booking.
PickupSourceType string `json:"pickup_source_type"`
// Pickuphubid names the base a Base → Customer booking is collected FROM.
// Required when pickup_source_type is "hub"; supplying it is also enough on
// its own, since a booking that names a base is a base-origin booking. The
// base's own address, pincode and coordinates fill in whatever the caller
// left blank, so the dispatch board never has to retype a gate address.
Pickuphubid *int `json:"pickuphubid"`
// Sourceid is accepted as an alias for whichever id the source type implies —
// the app and the console have both used this spelling. With
// pickup_source_type "hub" it is a base id; otherwise a client-site id.
Sourceid *int `json:"sourceid"`
Pickupaddress string `json:"pickupaddress"`
Pickuppincode string `json:"pickuppincode"`
Pickuplatitude float64 `json:"pickuplatitude"`
@@ -2202,7 +2218,38 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
// against the earlier documentation. It is the wrong column: it foreign-keys
// to appcustomerlocations, so a tenantlocations id in it fails the insert.
// Both names resolve to Tenantlocationid.
// A base-origin pickup (Base/Hub → Customer). The base is the actual place the
// rider collects from, so its address and coordinates become the booking's
// pickup point and the row records both the type and the base id — that pair
// is what the rider app reads to title the stop as a Base rather than as the
// rider's own office, and what the dispatch board reads back on the row.
baseID := req.Pickuphubid
if baseID == nil && strings.EqualFold(req.PickupSourceType, constants.PickupSourceHub) {
baseID = req.Sourceid
}
if baseID != nil {
var hub models.Hub
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *baseID).First(&hub).Error; err != nil {
return nil, &expressBookingValidationError{"pickuphubid does not match a known base"}
}
req.PickupSourceType = constants.PickupSourceHub
req.Pickuphubid = &hub.Hubid
if req.Pickupaddress == "" {
req.Pickupaddress = hub.Address
}
if req.Pickuppincode == "" {
req.Pickuppincode = hub.Pincode
}
if req.Pickuplatitude == 0 && req.Pickuplongitude == 0 {
req.Pickuplatitude, req.Pickuplongitude = hub.Latitude, hub.Longitude
}
}
siteID := req.Tenantlocationid
// sourceid doubles as the client-site id when the source is not a base.
if siteID == nil && baseID == nil {
siteID = req.Sourceid
}
if siteID == nil {
siteID = req.Pickuplocationid
}
@@ -2238,10 +2285,34 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
// pickup actually is. Without this the field stays null — as it did on every
// booking in the system — and per-site reporting has nothing to group by,
// because the console sends a kitchen's address rather than its id.
if req.Tenantlocationid == nil {
if req.Tenantlocationid == nil && req.Pickuphubid == nil {
req.Tenantlocationid = matchTenantLocation(req.Tenantid, req.Pickupaddress, req.Pickuplatitude, req.Pickuplongitude)
}
// Classify the source once, here, rather than leaving every reader to guess.
// A caller-supplied type wins as long as it is one we know; an unknown word is
// dropped rather than stored, so the column never holds something the app has
// no meaning for. A door pickup is recorded as "customer" explicitly — the
// whole point of the column is that a blank cannot be told apart from an
// address nobody filled in.
switch {
case req.Pickuphubid != nil:
req.PickupSourceType = constants.PickupSourceHub
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceStore):
req.PickupSourceType = constants.PickupSourceStore
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceCustomer):
req.PickupSourceType = constants.PickupSourceCustomer
case strings.EqualFold(req.PickupSourceType, constants.PickupSourceMerchant):
// Honoured even with no site id attached. A merchant collection with no
// configured location is still a shop, and telling the rider "customer
// door" would send them looking for a person who is not there.
req.PickupSourceType = constants.PickupSourceMerchant
case req.Tenantlocationid != nil:
req.PickupSourceType = constants.PickupSourceMerchant
default:
req.PickupSourceType = constants.PickupSourceCustomer
}
tx := db.DB.Begin()
customerID := req.Appcustomerid
@@ -2276,6 +2347,8 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
Appcustomerid: customerID,
Pickuplocationid: req.Pickuplocationid,
Tenantlocationid: req.Tenantlocationid,
Pickupsourcetype: req.PickupSourceType,
Pickuphubid: req.Pickuphubid,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
Pickuplatitude: req.Pickuplatitude,
@@ -2525,7 +2598,65 @@ func GetAdminBookingDetails(c *fiber.Ctx) error {
if err := q.First(&booking, id).Error; err != nil {
return utils.NotFound(c, "booking not found")
}
return utils.OK(c, booking)
// The routing decision and the inputs it was made from, so a support call
// about "why does this say handover instead of delivery" is a lookup rather
// than a reconstruction. Everything here is derived from stored state — no
// new columns, and it stays right if the routing rule changes, because it
// reads the same helpers the pivot does.
var customer models.AppCustomer
db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer)
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&booking,
customer.Firstname+" "+customer.Lastname)
routing := fiber.Map{
"pickup_source_type": sourceType,
"pickup_source_id": sourceID,
"pickup_source_name": sourceName,
"from_address": sourceAddress,
"from_pincode": booking.Pickuppincode,
"to_address": booking.Deliveryaddress,
"destination_pincode": booking.Deliverypincode,
// hyperlocal: same postal area, so no base leg — the collecting rider
// carries it to the receiver. Otherwise it goes through a base. This is the
// decision pickup-complete makes, shown with the inputs it makes it from.
"is_hyperlocal": isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
booking.Pickuplatitude, booking.Pickuplongitude,
booking.Deliverylatitude, booking.Deliverylongitude),
}
// Before pickup the decision has not been taken yet, so the routing result is
// a projection; after pickup it is fact, read off the consignment.
if booking.Consignmentid != nil {
var cn models.Consignment
if db.DB.First(&cn, *booking.Consignmentid).Error == nil {
booking.Consignmentstatus = cn.Status
routing["consignment_state"] = cn.Status
routing["next_action"] = nextActionForConsignment(cn.Status)
routing["next_hub"] = renderBase(loadHub(cn.Currenthubid))
routing["inwardedat"] = cn.Inwardedat
routing["decided"] = true
}
} else {
routing["consignment_state"] = ""
routing["next_action"] = constants.NextActionPickup
routing["next_hub"] = nil
routing["decided"] = false
}
// routing rides alongside the booking's own fields rather than nesting them
// under a new key — the console reads this response as a booking object today,
// and moving those fields would break every screen that does.
raw, err := json.Marshal(booking)
if err != nil {
return utils.OK(c, booking)
}
payload := map[string]interface{}{}
if err := json.Unmarshal(raw, &payload); err != nil {
return utils.OK(c, booking)
}
payload["routing"] = routing
return utils.OK(c, payload)
}
func AdminAssignMiler(c *fiber.Ctx) error {

View File

@@ -421,18 +421,23 @@ func CreateCustomerBooking(c *fiber.Ctx) error {
tx := db.DB.Begin()
booking := models.PickupBooking{
Bookingno: generateBookingNo(),
Appcustomerid: customerID,
Pickuplocationid: req.Pickuplocationid,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
Pickuplatitude: req.Pickuplatitude,
Pickuplongitude: req.Pickuplongitude,
Deliveryaddress: req.Deliveryaddress,
Deliverypincode: req.Deliverypincode,
Deliverylatitude: req.Deliverylatitude,
Deliverylongitude: req.Deliverylongitude,
Bookingsource: "Customer_App",
Bookingno: generateBookingNo(),
Appcustomerid: customerID,
Pickuplocationid: req.Pickuplocationid,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
Pickuplatitude: req.Pickuplatitude,
Pickuplongitude: req.Pickuplongitude,
Deliveryaddress: req.Deliveryaddress,
Deliverypincode: req.Deliverypincode,
Deliverylatitude: req.Deliverylatitude,
Deliverylongitude: req.Deliverylongitude,
Bookingsource: "Customer_App",
// A B2C booking is always collected at the sender's own door, so the source
// type is recorded rather than left blank — the rider app titles the stop
// with the sender's name and address instead of grouping it under the
// rider's own base.
Pickupsourcetype: constants.PickupSourceCustomer,
Status: constants.BookingPendingPickup,
Preferredpickupfrom: req.Preferredpickupfrom,
Preferredpickupto: req.Preferredpickupto,

View File

@@ -13,6 +13,7 @@ import (
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/legs"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
@@ -70,14 +71,11 @@ func zoneName(pincode string) string {
}
// haversineKM returns the great-circle distance between two lat/lon points in km.
// haversineKM stays the name the whole controllers package calls, and now
// delegates to internal/legs so the route sequencer measures distance with the
// identical implementation rather than a second copy of it.
func haversineKM(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadiusKM = 6371.0
toRad := func(deg float64) float64 { return deg * math.Pi / 180 }
dLat := toRad(lat2 - lat1)
dLon := toRad(lon2 - lon1)
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2)
return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return legs.HaversineKM(lat1, lon1, lat2, lon2)
}
// humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago".
@@ -315,15 +313,27 @@ func GetHubUnassignedBookings(c *fiber.Ctx) error {
var customer models.AppCustomer
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
// What kind of place this is collected from, and which one. A Base/Hub
// pickup carries the base id, so the dispatch board can show that the
// parcel starts at a base rather than at a customer's door — and the row
// the rider app receives carries the same pair.
customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
response = append(response, fiber.Map{
"bookingid": b.Bookingid,
"bookingno": b.Bookingno,
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"pickup_address": b.Pickupaddress,
"pickup_pincode": b.Pickuppincode,
"delivery_address": b.Deliveryaddress,
"parcels": b.Parcels,
"created_at": b.Createdat,
"bookingid": b.Bookingid,
"bookingno": b.Bookingno,
"customer_name": customerName,
"pickup_source_type": sourceType,
"sourceid": sourceID,
"pickuplocationid": sourceID,
"pickup_source_name": sourceName,
"pickup_address": sourceAddress,
"pickup_pincode": b.Pickuppincode,
"delivery_address": b.Deliveryaddress,
"delivery_pincode": b.Deliverypincode,
"parcels": b.Parcels,
"created_at": b.Createdat,
})
}
@@ -389,17 +399,25 @@ func GetHubBookingsRange(c *fiber.Ctx) error {
}
}
customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname)
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
response = append(response, fiber.Map{
"bookingid": b.Bookingid,
"bookingno": b.Bookingno,
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"pickup_address": b.Pickupaddress,
"pickup_pincode": b.Pickuppincode,
"delivery_address": b.Deliveryaddress,
"parcels": b.Parcels,
"status": hubBookingDisplayStatus(b.Status, hasMiler),
"milername": milerName,
"created_at": b.Createdat,
"bookingid": b.Bookingid,
"bookingno": b.Bookingno,
"customer_name": customerName,
"pickup_source_type": sourceType,
"sourceid": sourceID,
"pickuplocationid": sourceID,
"pickup_source_name": sourceName,
"pickup_address": sourceAddress,
"pickup_pincode": b.Pickuppincode,
"delivery_address": b.Deliveryaddress,
"delivery_pincode": b.Deliverypincode,
"parcels": b.Parcels,
"status": hubBookingDisplayStatus(b.Status, hasMiler),
"milername": milerName,
"created_at": b.Createdat,
})
}
@@ -437,6 +455,10 @@ func renderInboundConsignment(cs models.Consignment) fiber.Map {
"temperature": "N/A",
"status": cs.Status,
"updatedat": cs.Updatedat,
// The physical-receipt fact, distinct from updatedat, which moves on any
// write. Null on rows inwarded before this column existed.
"inwardedat": cs.Inwardedat,
"inbound_status": "received",
}
}
@@ -539,11 +561,18 @@ func CreateInboundScan(c *fiber.Ctx) error {
}
}
now := time.Now()
consignment.Status = constants.ConsignmentInwardedAtHub
consignment.Currenthubid = &hubID
consignment.Condition = req.Condition
consignment.Shelf = recommendedShelf
consignment.Updatedat = time.Now()
consignment.Updatedat = now
// The inbound scan is a physical receipt, so it stamps the same received-at
// fact the rider handover does. Kept first-write-wins: a second scan of the
// same parcel must not move the time it actually arrived.
if consignment.Inwardedat == nil {
consignment.Inwardedat = &now
}
if err := db.DB.Save(&consignment).Error; err != nil {
return utils.Internal(c, "failed to update consignment")

View File

@@ -0,0 +1,286 @@
package controllers
import (
"fmt"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// --------------------
// BASE INBOUND — what is on its way in, and confirming it arrived
//
// The console side of the rider handover. Wire vocabulary stays hub
// (Inwarded_at_Hub, currenthubid); the rider app renders it as Base.
// --------------------
// scopeConsignmentsToOwnTenant is the consignment counterpart of
// scopeBookingsToOwnTenant: partner-tenant hub staff see only their own tenant's
// parcels, Doormile staff see everything. Same rule, different table — without
// it a partner's staff would read every other client's parcels passing through
// the same base.
func scopeConsignmentsToOwnTenant(c *fiber.Ctx, query *gorm.DB) *gorm.DB {
staff, err := getCurrentHubStaff(c)
if err != nil || isDoormileStaff(staff) {
return query
}
return query.Where("tenantid = ?", *staff.Tenantid)
}
// canHubStaffAccessConsignment proves ownership of a consignment addressed by id
// before it is written to, rather than trusting the path parameter.
func canHubStaffAccessConsignment(c *fiber.Ctx, cn *models.Consignment) bool {
staff, err := getCurrentHubStaff(c)
if err != nil {
return false
}
if isDoormileStaff(staff) {
return true
}
return staff.Tenantid != nil && cn.Tenantid == *staff.Tenantid
}
// GetHubInboundExpected lists parcels a rider is currently carrying towards this
// base — collected, routed here, not yet handed over. Between a rider collecting
// an intercity parcel and inwarding it, nobody at the destination base could see
// it was coming; this is that view.
//
// It reads consignments on Created — collected, in a rider's hands, with a base
// as the next leg — whose current base is this one. Under the compatibility flow
// a hub-routed parcel is marked Inwarded_at_Hub at pickup and so never appears
// here; that is expected, and GetHubInboundToday covers those.
func GetHubInboundExpected(c *fiber.Ctx) error {
hubID := c.Locals("hubid").(int)
query := db.DB.Where("currenthubid = ? AND status = ? AND deletedat IS NULL",
hubID, constants.ConsignmentCreated)
query = scopeConsignmentsToOwnTenant(c, query)
var consignments []models.Consignment
if err := query.Order("createdat DESC").Find(&consignments).Error; err != nil {
return utils.Internal(c, "failed to fetch expected inbound consignments")
}
// Batched lookups — this stays a handful of queries however many parcels are
// in flight towards the base.
ids := make([]int, 0, len(consignments))
for _, cs := range consignments {
ids = append(ids, cs.Consignmentid)
}
bookingByConsignment := map[int]models.PickupBooking{}
riderIDs := []int{}
customerIDs := []int{}
if len(ids) > 0 {
var bookings []models.PickupBooking
db.DB.Where("consignmentid IN ?", ids).Find(&bookings)
for _, b := range bookings {
if b.Consignmentid != nil {
bookingByConsignment[*b.Consignmentid] = b
}
if b.Assignedmileruserid != nil {
riderIDs = append(riderIDs, *b.Assignedmileruserid)
}
customerIDs = append(customerIDs, b.Appcustomerid)
}
}
riderByID := map[int]models.AppUser{}
if len(riderIDs) > 0 {
var riders []models.AppUser
db.DB.Where("userid IN ?", riderIDs).Find(&riders)
for _, r := range riders {
riderByID[r.Userid] = r
}
}
customerByID := map[int]models.AppCustomer{}
if len(customerIDs) > 0 {
var customers []models.AppCustomer
db.DB.Where("appcustomerid IN ?", customerIDs).Find(&customers)
for _, cu := range customers {
customerByID[cu.Appcustomerid] = cu
}
}
response := make([]fiber.Map, 0, len(consignments))
for i := range consignments {
cs := consignments[i]
row := fiber.Map{
"consignmentid": cs.Consignmentid,
"trackingno": cs.Trackingno,
// destination_base is the base this parcel moves on to after here, and
// is only known once something routes it onward — null more often than
// not. The delivery pincode is the reliable statement of where it ends
// up, so both are given rather than one standing in for the other.
"destination_base": renderBase(loadHub(cs.Destinationhubid)),
"final_destination": cs.Deliverypincode,
"delivery_pincode": cs.Deliverypincode,
"chargeableweight": cs.Chargeableweight,
"current_state": cs.Status,
"inbound_status": "expected",
"next_action": nextActionForConsignment(cs.Status),
"collected_at": cs.Createdat,
"pickup_pincode": cs.Pickuppincode,
}
if b, ok := bookingByConsignment[cs.Consignmentid]; ok {
customerName := ""
if cu, ok := customerByID[b.Appcustomerid]; ok {
customerName = strings.TrimSpace(cu.Firstname + " " + cu.Lastname)
}
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName)
row["bookingid"] = b.Bookingid
row["bookingno"] = b.Bookingno
row["customer_name"] = customerName
row["pickup_source_type"] = sourceType
row["pickup_source_id"] = sourceID
row["source"] = sourceName
row["pickup_address"] = sourceAddress
row["destination_address"] = b.Deliveryaddress
if b.Assignedmileruserid != nil {
row["rider_userid"] = *b.Assignedmileruserid
if r, ok := riderByID[*b.Assignedmileruserid]; ok {
row["rider"] = r.Authname
row["rider_phone"] = r.Contactno
}
}
}
response = append(response, row)
}
return utils.List(c, response, int64(len(response)))
}
// ReconcileHubInbound is the base's side of the rider handover: staff either
// confirm the parcel is physically here, or record that it never arrived despite
// a rider marking it handed over.
//
// received=true is the ordinary case and is idempotent — confirming a parcel
// that is already inwarded re-affirms it rather than failing, because staff
// working through a pile will hit some rows twice.
//
// received=false is the reconciliation path. It deliberately does not quietly
// move the parcel backwards: it raises an exception naming the discrepancy, so a
// parcel a rider swears was handed over and staff never saw becomes a tracked
// open item rather than a disagreement nobody owns.
func ReconcileHubInbound(c *fiber.Ctx) error {
hubID := c.Locals("hubid").(int)
// HubStaffAuth sets userid to the hubstaffaccountid, which is NOT an
// appusers.userid. consignmentexceptions.reportedbyuserid and
// consignmenthistory.userid both carry a real FK to appusers(userid), so
// writing a hub-staff id into either violates it — the row is rejected and the
// whole action 500s. The staff identity goes into the free-text fields
// instead, and the FK-bearing columns are left null. createdby on
// consignmentexceptions carries no FK, so it can hold the staff id.
staffAccountID, _ := c.Locals("userid").(int)
consignmentID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid consignment ID")
}
var req struct {
// Pointer so an omitted field is never read as "not received".
Received *bool `json:"received"`
Remarks string `json:"remarks"`
}
if err := c.BodyParser(&req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Received == nil {
return utils.BadRequest(c, "received is required (true = the parcel is physically here, false = it never arrived)")
}
var consignment models.Consignment
if err := db.DB.Where("consignmentid = ? AND deletedat IS NULL", consignmentID).
First(&consignment).Error; err != nil {
return utils.NotFound(c, "consignment not found")
}
if !canHubStaffAccessConsignment(c, &consignment) {
return utils.NotFound(c, "consignment not found")
}
now := time.Now()
if !*req.Received {
description := strings.TrimSpace(req.Remarks)
if description == "" {
description = "Rider recorded a handover at this base but the parcel was not physically received."
}
exception := models.ConsignmentException{
Consignmentid: consignment.Consignmentid,
Hubid: &hubID,
// Lost is the closest type the consignmentexceptions CHECK constraint
// already allows, and it is honest: a parcel recorded as handed over
// that nobody can find is lost until it turns up. A dedicated
// Handover_Not_Received type would need that constraint widened first.
Exceptiontype: constants.ExceptionLost,
Severity: "High",
Description: fmt.Sprintf("%s (reported by hub staff account %d)", description, staffAccountID),
Status: constants.ExceptionOpen,
Createdby: staffAccountID,
}
if err := db.DB.Create(&exception).Error; err != nil {
return utils.Internal(c, "failed to raise handover exception")
}
db.DB.Create(&models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: &hubID,
Eventstatus: consignment.Status,
Remarks: fmt.Sprintf("Handover disputed at base by hub staff account %d: %s",
staffAccountID, description),
})
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"consignmentstatus": consignment.Status,
"received": false,
"exceptionid": exception.Exceptionid,
"exceptiontype": exception.Exceptiontype,
})
}
alreadyInwarded := consignment.Status == constants.ConsignmentInwardedAtHub
if !alreadyInwarded {
consignment.Status = constants.ConsignmentInwardedAtHub
consignment.Currenthubid = &hubID
if consignment.Originhubid == nil {
consignment.Originhubid = &hubID
}
consignment.Updatedat = now
consignment.Updatedby = staffAccountID
}
if consignment.Inwardedat == nil {
consignment.Inwardedat = &now
}
if err := db.DB.Save(&consignment).Error; err != nil {
return utils.Internal(c, "failed to record receipt")
}
if !alreadyInwarded {
remarks := strings.TrimSpace(req.Remarks)
if remarks == "" {
remarks = "Physical receipt confirmed at base"
}
db.DB.Create(&models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: &hubID,
Eventstatus: constants.ConsignmentInwardedAtHub,
Remarks: fmt.Sprintf("%s (hub staff account %d)", remarks, staffAccountID),
})
}
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"consignmentstatus": consignment.Status,
"inwardedat": consignment.Inwardedat,
"received": true,
"already_received": alreadyInwarded,
})
}

View File

@@ -0,0 +1,514 @@
package controllers
import (
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"doormile/constants"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// --------------------
// BASE / HUB HANDOVER — the logistics next-leg surface
//
// Vocabulary note, because two words are in play for one thing: the wire says
// hub (inward_at_hub, Inwarded_at_Hub, next_hub, pickup_source_type "hub") and
// the rider app renders that as Base. Nothing here changes a wire value to suit
// the app's wording, and nothing in the app's wording should leak back in here.
// --------------------
// hubHandoverEnabled gates the two-step hub flow: a hub-routed parcel stops at
// Created — collected, in the rider's hands, on its way to a base — and only
// reaches Inwarded_at_Hub when the handover is actually recorded, by the rider
// (POST /miler/consignments/:id/inward-at-hub) or by base staff (the console
// inbound scan).
//
// Default OFF, and it must stay off until a rider-app build that calls the
// handover endpoint is live. With it off, pickup-complete keeps marking a
// hub-routed parcel Inwarded_at_Hub the instant it is collected — which is not
// true of where the parcel physically is, but is what the current app and the
// console's inbound views expect. Flipping it early would leave every intercity
// parcel sitting on Created with no button in the rider's app to advance it and
// no row in the base's inbound list.
//
// Read at request time (env MILER_HUB_HANDOVER_ENABLED=true) so it can be turned
// on without a redeploy, same as MILER_COLLECTED_STATE_ENABLED. Everything else
// in this file — next_hub, the handover endpoint itself, next_action on the
// queue read, base master data, inbound visibility — is ungated and safe for the
// current app.
func hubHandoverEnabled() bool {
return strings.EqualFold(os.Getenv("MILER_HUB_HANDOVER_ENABLED"), "true")
}
// renderBase is the one shape a base is ever returned in, so pickup-complete,
// the booking rows, the handover response and GET /miler/bases cannot drift
// apart. All six fields every time: the id keys the handover, the name is the
// heading the rider reads, address and pincode are what they read at the gate,
// and the coordinates are the only thing that can drive Navigate. Five of six
// still leaves a rider unable to get there.
func renderBase(hub *models.Hub) fiber.Map {
if hub == nil {
return nil
}
return fiber.Map{
"id": hub.Hubid,
"name": hub.Hubname,
"address": hub.Address,
"pincode": hub.Pincode,
"latitude": hub.Latitude,
"longitude": hub.Longitude,
}
}
// loadHub reads one base by id, ignoring soft-deleted rows. Returns nil rather
// than an error for a missing id so callers can treat "no base" and "unknown
// base" the same way where that is the right call.
func loadHub(hubID *int) *models.Hub {
if hubID == nil || *hubID == 0 {
return nil
}
var hub models.Hub
if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *hubID).First(&hub).Error; err != nil {
return nil
}
return &hub
}
// nearestActiveHub finds the closest active base to a point. Used only as a last
// resort when neither the booking nor the rider names one — a parcel with
// nowhere to go is worse than a parcel sent to the nearest gate. Returns nil
// when no active base has usable coordinates.
func nearestActiveHub(lat, lon float64) *models.Hub {
if lat == 0 && lon == 0 {
return nil
}
var hubs []models.Hub
if err := db.DB.Where("deletedat IS NULL AND status = ?", "Active").Find(&hubs).Error; err != nil {
return nil
}
var best *models.Hub
bestKM := 0.0
for i := range hubs {
h := &hubs[i]
if h.Latitude == 0 && h.Longitude == 0 {
continue
}
d := haversineKM(lat, lon, h.Latitude, h.Longitude)
if best == nil || d < bestKM {
best, bestKM = h, d
}
}
return best
}
// resolveHandoverHub decides which base a hub-routed parcel is carried to. The
// decision is the backend's, never the app's — the app is told where to go and
// navigates there.
//
// Order, most authoritative first:
// 1. the base the booking was routed to (nearesthubid), when the console or the
// dispatch layer set one. Nothing populates this column today; it is checked
// first so that the moment something does, it wins without another change here.
// 2. the collecting rider's own base — the operational default: a rider brings
// the parcel back to where they work out of.
// 3. the active base nearest the pickup point, for a rider with no base set.
// 4. any base at all, so a parcel is never left with nowhere to go.
func resolveHandoverHub(booking *models.PickupBooking, riderHubID *int) *models.Hub {
if hub := loadHub(booking.Nearesthubid); hub != nil {
return warnIfUnnavigable(hub)
}
if hub := loadHub(riderHubID); hub != nil {
return warnIfUnnavigable(hub)
}
if hub := nearestActiveHub(booking.Pickuplatitude, booking.Pickuplongitude); hub != nil {
return hub
}
var hub models.Hub
if db.DB.Where("deletedat IS NULL").Order("hubid").First(&hub).Error == nil {
return warnIfUnnavigable(&hub)
}
return nil
}
// warnIfUnnavigable flags a base the rider cannot actually be routed to. The
// correct base is still returned — sending a rider to a different base because
// this one has bad master data would be worse than sending them to the right one
// with a missing pin. It is a data problem, and it needs to be visible as one.
func warnIfUnnavigable(hub *models.Hub) *models.Hub {
if hub.Latitude == 0 && hub.Longitude == 0 {
utils.Warn("base has no coordinates — Navigate will not work for riders sent here",
"hubid", hub.Hubid, "hubname", hub.Hubname)
}
if strings.TrimSpace(hub.Address) == "" {
utils.Warn("base has no address — the rider has nothing to read at the gate",
"hubid", hub.Hubid, "hubname", hub.Hubname)
}
return hub
}
// derivePickupSourceType classifies where a booking is collected from for rows
// written before pickupsourcetype existed, and as a safety net for any writer
// that forgets to set it. A stored value always wins — this only fills a blank.
//
// A base-origin booking names a base; a client-site pickup names a tenant
// location (a kitchen, branch or depot — "merchant"); everything else is a
// person's door. "customer" is the honest answer for the last case and is
// returned as a value, never as an omission.
func derivePickupSourceType(b *models.PickupBooking) string {
if b.Pickupsourcetype != "" {
return b.Pickupsourcetype
}
if b.Pickuphubid != nil {
return constants.PickupSourceHub
}
if b.Tenantlocationid != nil {
return constants.PickupSourceMerchant
}
return constants.PickupSourceCustomer
}
// pickupSource resolves the source-type, id, name and address the rider app puts
// at the top of a pickup stop. customerName is the booking's customer, used for
// the door-pickup case so a collection at a house is titled with the sender's
// name rather than the rider's own base name.
//
// sourceID is nil for a customer pickup — there is no configured location and
// inventing one would be a lie. That is precisely why pickup_source_type is
// carried on the row: the app can then tell "no id because it is a front door"
// from "no id because nobody filled it in".
func pickupSource(b *models.PickupBooking, customerName string) (sourceType string, sourceID *int, name, address string) {
sourceType = derivePickupSourceType(b)
address = b.Pickupaddress
switch sourceType {
case constants.PickupSourceHub:
sourceID = b.Pickuphubid
if hub := loadHub(b.Pickuphubid); hub != nil {
name = hub.Hubname
if address == "" {
address = hub.Address
}
}
case constants.PickupSourceMerchant, constants.PickupSourceStore:
sourceID = b.Tenantlocationid
if b.Tenantlocationid != nil {
var loc models.TenantLocation
if db.DB.Where("tenantlocationid = ?", *b.Tenantlocationid).First(&loc).Error == nil {
name = loc.Locationname
if address == "" {
address = loc.Address
}
}
}
default:
// Customer door: the sender's own name and the address on the booking.
name = strings.TrimSpace(customerName)
}
if name == "" {
name = strings.TrimSpace(b.Providerlocation)
}
return sourceType, sourceID, name, address
}
// nextActionForConsignment maps a consignment's state to what the rider does
// next with it. This is the single definition — pickup-complete and the queue
// read both call it, so a poll can never disagree with the answer the pivot
// gave. Anything terminal returns "none" so a finished parcel retires from the
// rider's screen instead of lingering.
func nextActionForConsignment(status string) string {
switch status {
case constants.ConsignmentCreated:
// Collected and still in the rider's hands, routed to a base: carry it
// there and hand it over. Under the compatibility flow a hub-routed
// parcel never sits here — it is already Inwarded_at_Hub.
return constants.NextActionInwardAtHub
case constants.ConsignmentCollectedByMiler:
return constants.NextActionStartDelivery
case constants.ConsignmentOutForDelivery:
return constants.NextActionDeliver
case constants.ConsignmentInwardedAtHub:
return constants.NextActionHandedToHub
default:
// Tripsheet_Loaded, In_Transit, Delivered, RTO, Returned, Missing,
// Damaged — all past this rider's leg.
return constants.NextActionNone
}
}
// nextHubForConsignment names the base a parcel is on its way to, for a
// consignment still in a rider's hands. A parcel that has already been inwarded
// has no next base — it is at one.
func nextHubForConsignment(cn *models.Consignment) fiber.Map {
if cn == nil || cn.Status != constants.ConsignmentCreated {
return nil
}
return renderBase(loadHub(cn.Currenthubid))
}
// --------------------
// GET /miler/bases — base master data on a rider token
//
// The rider app could previously only see GET /admin/tenants/:id/locations,
// which is a different dataset entirely (a client's own sites) and is closed to
// a miler token anyway: /admin/* requires roles 1/3/4 and a rider is role 5, so
// that route answers 401 for them by design, not by oversight.
// --------------------
func MilerGetBases(c *fiber.Ctx) error {
query := db.DB.Where("deletedat IS NULL")
if status := c.Query("status"); status != "" {
query = query.Where("status = ?", status)
} else {
query = query.Where("status = ?", "Active")
}
if appLocationID := c.Query("applocationid"); appLocationID != "" {
query = query.Where("applocationid = ?", appLocationID)
}
var hubs []models.Hub
if err := query.Find(&hubs).Error; err != nil {
return utils.Internal(c, "failed to fetch bases")
}
// Ordered nearest-first from wherever the rider last reported being, so the
// base they are most likely to want is at the top. Falls back to id order
// when the rider has no position yet.
milerUserID := c.Locals("userid").(int)
var profile models.MilerProfile
hasPos := db.DB.Where("userid = ?", milerUserID).First(&profile).Error == nil &&
(profile.Currentlatitude != 0 || profile.Currentlongitude != 0)
rows := make([]fiber.Map, 0, len(hubs))
for i := range hubs {
row := renderBase(&hubs[i])
if hasPos && (hubs[i].Latitude != 0 || hubs[i].Longitude != 0) {
row["distance_km"] = haversineKM(profile.Currentlatitude, profile.Currentlongitude,
hubs[i].Latitude, hubs[i].Longitude)
}
rows = append(rows, row)
}
if hasPos {
sort.SliceStable(rows, func(i, j int) bool {
di, oki := rows[i]["distance_km"].(float64)
dj, okj := rows[j]["distance_km"].(float64)
switch {
case oki && okj:
return di < dj
case oki:
return true
default:
return false
}
})
}
return utils.List(c, rows, int64(len(rows)))
}
// --------------------
// POST /miler/consignments/:id/inward-at-hub — the rider handover
//
// The authoritative record that a rider physically handed a parcel in at a base.
// Idempotent (retries at a loading bay with bad signal are normal, and the route
// also carries the shared Idempotency-Key middleware), and it answers with the
// resulting state rather than a bare 200 — every lifecycle transition the app
// makes is checked against the state that comes back.
// --------------------
func MilerInwardConsignmentAtHub(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
consignmentID, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID")
}
var req struct {
HubID *int `json:"hub_id"`
// hubid accepted as an alias: the same value has been spelled both ways
// across this API's history and a handover is not worth failing over a
// missing underscore.
HubIDAlt *int `json:"hubid"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
}
// A body is optional — a rider handing a parcel into the base it is already
// routed to needs to send nothing at all.
_ = c.BodyParser(&req)
consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID)
if err != nil {
if code == constants.ErrConsignmentNotFound {
return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found")
}
return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you")
}
hubID := req.HubID
if hubID == nil {
hubID = req.HubIDAlt
}
if hubID == nil {
// Nothing named: hand it into the base it was routed to.
hubID = consignment.Currenthubid
}
if hubID == nil {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrHubRequired,
"hub_id is required — this consignment is not routed to a base")
}
hub := loadHub(hubID)
if hub == nil {
return utils.Fail(c, fiber.StatusNotFound, constants.ErrHubNotFound, "hub_id does not match a known base")
}
// Already inwarded: answer with the state that stands rather than failing, so
// a retry after a dropped response confirms rather than errors. This is also
// what a rider on the compatibility flow hits every time — there,
// pickup-complete already marked the parcel Inwarded_at_Hub.
if consignment.Status == constants.ConsignmentInwardedAtHub {
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"consignmentstatus": consignment.Status,
"inwardedat": consignment.Inwardedat,
"hub": renderBase(loadHub(consignment.Currenthubid)),
"next_action": nextActionForConsignment(consignment.Status),
"already_inwarded": true,
})
}
// Only a parcel actually in this rider's hands can be handed over. A parcel
// already out for delivery has to be delivered or skipped; a delivered or
// returned one is past this leg entirely.
if consignment.Status != constants.ConsignmentCreated &&
consignment.Status != constants.ConsignmentCollectedByMiler {
return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState,
fmt.Sprintf("consignment is %s — it cannot be handed over at a base from this state", consignment.Status))
}
lat, lon := 0.0, 0.0
if req.Latitude != nil {
lat = *req.Latitude
} else if req.Lat != nil {
lat = *req.Lat
}
if req.Longitude != nil {
lon = *req.Longitude
} else if req.Lon != nil {
lon = *req.Lon
}
now := time.Now()
tx := db.DB.Begin()
consignment.Status = constants.ConsignmentInwardedAtHub
consignment.Currenthubid = &hub.Hubid
if consignment.Originhubid == nil {
consignment.Originhubid = &hub.Hubid
}
consignment.Inwardedat = &now
consignment.Updatedat = now
consignment.Updatedby = milerUserID
if err := tx.Save(consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record the handover")
}
history := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: &hub.Hubid,
Userid: &milerUserID,
Eventstatus: constants.ConsignmentInwardedAtHub,
Remarks: fmt.Sprintf("Rider handed parcel in at %s (%.5f, %.5f)",
hub.Hubname, lat, lon),
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record handover history")
}
// The rider's leg ends here, so the assignment closes and they return to the
// pool. riderkms is the distance actually ridden on this leg — pickup point to
// the base gate — and ridercharges the order amount, both written the same way
// MilerDeliverConsignment writes them for a final-mile leg. Without this an
// intercity rider's every job reported zero distance and zero value.
var booking models.PickupBooking
if tx.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil {
dropLat, dropLon := lat, lon
if dropLat == 0 && dropLon == 0 {
dropLat, dropLon = hub.Latitude, hub.Longitude
}
riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon)
orderAmount := 0.0
var serviceOpt models.BookingServiceOption
if tx.Where("bookingid = ?", booking.Bookingid).Order("createdat DESC").
First(&serviceOpt).Error == nil {
orderAmount = serviceOpt.Estimatedprice
}
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
booking.Bookingid, milerUserID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCompleted,
"completedat": now,
"riderkms": riderKms,
"ridercharges": orderAmount,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to close assignment")
}
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to update miler availability")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to record the handover")
}
// Best-effort, on an already-bound subject — a dropped event must never fail
// a handover the rider has physically completed.
if db.Js != nil {
payload := map[string]interface{}{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"status": constants.ConsignmentInwardedAtHub,
"hubid": hub.Hubid,
"mileruserid": milerUserID,
"inwardedat": now.UnixMilli(),
}
if data, err := json.Marshal(payload); err == nil {
if _, err := db.Js.Publish("booking.status.updated", data); err != nil {
utils.Warn("MilerInwardConsignmentAtHub: NATS publish failed",
"consignment_id", consignment.Consignmentid, "error", err)
}
}
}
return utils.OK(c, fiber.Map{
"consignmentid": consignment.Consignmentid,
"trackingno": consignment.Trackingno,
"consignmentstatus": consignment.Status,
"inwardedat": consignment.Inwardedat,
"hub": renderBase(hub),
"next_action": nextActionForConsignment(consignment.Status),
"already_inwarded": false,
})
}

View File

@@ -0,0 +1,149 @@
package controllers
import (
"os"
"testing"
"doormile/constants"
"doormile/models"
)
func intPtr(v int) *int { return &v }
func TestNextActionForConsignment(t *testing.T) {
cases := []struct {
name string
status string
want string
}{
{
// The whole point of request 27: a hub-routed parcel sits on Created
// while it is being carried to a base, and the app must be able to
// rebuild that leg from server state after a restart.
name: "collected and routed to a base means carry it there",
status: constants.ConsignmentCreated,
want: constants.NextActionInwardAtHub,
},
{
name: "collected hyperlocal parcel waits for start-delivery",
status: constants.ConsignmentCollectedByMiler,
want: constants.NextActionStartDelivery,
},
{
name: "out for delivery means deliver",
status: constants.ConsignmentOutForDelivery,
want: constants.NextActionDeliver,
},
{
name: "already handed in at a base leaves the rider nothing to do",
status: constants.ConsignmentInwardedAtHub,
want: constants.NextActionHandedToHub,
},
{
name: "delivered is past this rider's leg",
status: constants.ConsignmentDelivered,
want: constants.NextActionNone,
},
{
name: "in transit between bases is not a rider action",
status: constants.ConsignmentInTransit,
want: constants.NextActionNone,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := nextActionForConsignment(tc.status); got != tc.want {
t.Errorf("nextActionForConsignment(%q) = %q, want %q", tc.status, got, tc.want)
}
})
}
}
func TestDerivePickupSourceType(t *testing.T) {
cases := []struct {
name string
booking models.PickupBooking
want string
}{
{
name: "a stored type always wins",
booking: models.PickupBooking{Pickupsourcetype: constants.PickupSourceStore, Tenantlocationid: intPtr(7)},
want: constants.PickupSourceStore,
},
{
name: "a booking naming a base is a base pickup",
booking: models.PickupBooking{Pickuphubid: intPtr(1)},
want: constants.PickupSourceHub,
},
{
name: "a booking naming a client site is a merchant pickup",
booking: models.PickupBooking{Tenantlocationid: intPtr(7)},
want: constants.PickupSourceMerchant,
},
{
// The case the whole column exists for: a front-door pickup has no
// location id, and "customer" must be a value rather than a blank.
name: "a booking naming no location at all is a customer door",
booking: models.PickupBooking{Pickupaddress: "12 Race Course Road"},
want: constants.PickupSourceCustomer,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := derivePickupSourceType(&tc.booking); got != tc.want {
t.Errorf("derivePickupSourceType() = %q, want %q", got, tc.want)
}
})
}
}
func TestRenderBaseCarriesAllSixFields(t *testing.T) {
// Five of six leaves a rider unable to get there: the id keys the handover,
// the name is the heading, address and pincode are read at the gate, and the
// coordinates are the only thing that can drive Navigate.
hub := models.Hub{
Hubid: 1,
Hubname: "Coimbatore Hub",
Address: "14 Avinashi Road, Peelamedu, Coimbatore",
Pincode: "641004",
Latitude: 11.0272,
Longitude: 76.9905,
}
got := renderBase(&hub)
for _, field := range []string{"id", "name", "address", "pincode", "latitude", "longitude"} {
if _, ok := got[field]; !ok {
t.Errorf("renderBase() is missing %q", field)
}
}
if len(got) != 6 {
t.Errorf("renderBase() returned %d fields, want exactly 6: %v", len(got), got)
}
if renderBase(nil) != nil {
t.Error("renderBase(nil) should be nil, so an unresolved base is absent rather than empty")
}
}
func TestHubHandoverEnabled(t *testing.T) {
// Default OFF matters operationally: turning it on before a rider-app build
// that can hand a parcel over would strand every intercity parcel on Created
// with no button to advance it.
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "")
os.Unsetenv("MILER_HUB_HANDOVER_ENABLED")
if hubHandoverEnabled() {
t.Error("hub handover must default to off when the env var is unset")
}
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "TRUE")
if !hubHandoverEnabled() {
t.Error("hub handover should be on for TRUE, matching the case-insensitive read used elsewhere")
}
t.Setenv("MILER_HUB_HANDOVER_ENABLED", "1")
if hubHandoverEnabled() {
t.Error(`only "true" turns the flow on — "1" must not`)
}
}

View File

@@ -0,0 +1,137 @@
package controllers
import (
"testing"
"doormile/constants"
)
// The bulk-upload test sheet, checked against the rule that actually decides.
//
// krow_talent_app/tests/fixtures/doormile-logistics-test.xlsx carries 16 rows
// and an "Expected Routing" column saying, in words, what each one should do.
// That column is only a claim until something checks it, and the thing that
// decides is here, in Go — so it is checked here rather than restated in a
// JavaScript test, which would only prove that two copies of the rule agree
// with each other.
//
// If a row of the sheet is edited, this table is what says whether the sheet is
// still testing what it says it tests.
//
// Pickup is the sheet's single sender: Jayanthi's kitchen, Edayarpalayam,
// Coimbatore 641025.
const (
sheetPickupPincode = "641025"
sheetPickupLat = 11.0168
sheetPickupLng = 76.9558
)
func TestBulkTestSheetRoutesAsDocumented(t *testing.T) {
cases := []struct {
row int
receiver string
pincode string
lat, lng float64
wantLocal bool
wantAction string
wantConsStat string // with the hub-handover flow ON
why string
}{
// Customer -> Base. A different postal area, so the parcel cannot be
// carried to the receiver by the collecting rider.
{1, "Suresh Kumar", "600001", 13.091, 80.285, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "641 -> 600, Chennai"},
{2, "Priya Raghavan", "600028", 13.018, 80.256, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "641 -> 600, Chennai"},
{3, "Anil Reddy", "500081", 17.44, 78.3489, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate, Hyderabad"},
{4, "Meera Krishnan", "500032", 17.4156, 78.3378, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate with COD"},
{5, "Rahul Menon", "560001", 12.975, 77.606, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate, Bengaluru"},
{6, "Divya Nair", "560066", 12.9698, 77.75, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate with COD"},
{7, "Karthik Subramani", "625001", 9.9195, 78.119, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "same state, different area"},
{8, "Lakshmi Devi", "636001", 11.664, 78.146, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "same state, different area"},
// Customer -> Customer. The control group: if any of these routes to a
// base, the prefix rule has broken.
{9, "Ganesh Iyer", "641004", 11.029, 76.993, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area"},
{10, "Revathi Balaji", "641012", 11.018, 76.966, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area, COD"},
{11, "Vignesh Murugan", "641025", 11.008, 76.928, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "identical pincode"},
{12, "Anitha Selvam", "641038", 11.023, 76.945, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area"},
// No pincode: the decision falls to straight-line distance. Both
// outcomes are present, because a fallback that only ever answers one
// way is not being tested.
{13, "Mohan Das", "", 11.05, 77.01, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "~8km, inside the 30km fallback"},
{14, "Sridhar Venkat", "", 13.0827, 80.2707, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "~430km, far outside the fallback"},
{15, "Bhavani Shankar", "64", 13.06, 80.24, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "2-digit pincode is unusable, distance decides"},
// The row that proves the prefix rule outranks distance.
{16, "Ramesh Palanisamy", "642001", 10.658, 77.008, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "~40km but 642 is a different area"},
}
if len(cases) != 16 {
t.Fatalf("the sheet has 16 rows, this table has %d — they must not drift apart", len(cases))
}
for _, tc := range cases {
t.Run(tc.receiver, func(t *testing.T) {
gotLocal := isHyperlocalBooking(
sheetPickupPincode, tc.pincode,
sheetPickupLat, sheetPickupLng,
tc.lat, tc.lng,
)
if gotLocal != tc.wantLocal {
t.Fatalf("row %d (%s): hyperlocal = %v, want %v — %s",
tc.row, tc.receiver, gotLocal, tc.wantLocal, tc.why)
}
// What the rider is actually told to do with it, from the same
// helper pickup-complete and the queue read both use.
status := constants.ConsignmentCreated
if gotLocal {
status = constants.ConsignmentOutForDelivery
}
if status != tc.wantConsStat {
t.Errorf("row %d: consignment state %s, want %s", tc.row, status, tc.wantConsStat)
}
if got := nextActionForConsignment(status); got != tc.wantAction {
t.Errorf("row %d: next_action %s, want %s", tc.row, got, tc.wantAction)
}
})
}
}
// The sheet is only worth uploading if it actually splits both ways. A file
// that turned out to be all-hyperlocal would pass every assertion above and
// still test nothing — which is exactly the problem with the tenant's own
// export that this sheet was written to replace.
func TestBulkTestSheetExercisesBothLegs(t *testing.T) {
type dest struct {
pincode string
lat, lng float64
}
dests := []dest{
{"600001", 13.091, 80.285}, {"600028", 13.018, 80.256},
{"500081", 17.44, 78.3489}, {"500032", 17.4156, 78.3378},
{"560001", 12.975, 77.606}, {"560066", 12.9698, 77.75},
{"625001", 9.9195, 78.119}, {"636001", 11.664, 78.146},
{"641004", 11.029, 76.993}, {"641012", 11.018, 76.966},
{"641025", 11.008, 76.928}, {"641038", 11.023, 76.945},
{"", 11.05, 77.01}, {"", 13.0827, 80.2707},
{"64", 13.06, 80.24}, {"642001", 10.658, 77.008},
}
var local, hub int
for _, d := range dests {
if isHyperlocalBooking(sheetPickupPincode, d.pincode, sheetPickupLat, sheetPickupLng, d.lat, d.lng) {
local++
} else {
hub++
}
}
if hub < 5 {
t.Errorf("only %d rows route through a base — too few to exercise the handover flow", hub)
}
if local < 3 {
t.Errorf("only %d rows go direct to the customer — no control group", local)
}
t.Logf("sheet splits %d base-handover / %d direct-to-customer", hub, local)
}

View File

@@ -322,34 +322,68 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
}
}
// Where this parcel is collected FROM, and what kind of place that is, so
// Home can title the stop correctly. Without pickup_source_type every
// logistics pickup was grouped under the rider's own base name and a
// collection at a shop looked identical to one at a house.
//
// sourceid is null for a customer door and that is a real answer, not a
// missing one — the type is what makes the difference legible. pickuplocationid
// mirrors sourceid: the app has used both spellings for the same thing, and
// the booking row never carried either before now.
sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b,
customer.Firstname+" "+customer.Lastname)
// next_action / next_hub: the leg this parcel is on, rebuilt from server
// state on every poll. pickup-complete used to be the only place that ever
// said it, so a restart mid-leg left the app with nothing authoritative to
// read — consignment status alone cannot separate a hub-routed parcel from
// a freshly-collected hyperlocal one, since both can sit on Created.
nextAction := constants.NextActionPickup
var nextHub fiber.Map
if b.Status == constants.BookingCancelled {
nextAction = constants.NextActionNone
} else if b.Consignmentid != nil {
if cn, ok := codByConsignment[*b.Consignmentid]; ok {
nextAction = nextActionForConsignment(cn.Status)
nextHub = nextHubForConsignment(&cn)
}
}
row := fiber.Map{
"bookingid": b.Bookingid,
"bookingreference": b.Bookingno,
"status": b.Status,
"stoptype": milerStopType(b.Status),
"consignmentid": b.Consignmentid,
"consignmentstatus": consignmentStatus,
"pickupaddress": b.Pickupaddress,
"pickuplatitude": b.Pickuplatitude,
"pickuplongitude": b.Pickuplongitude,
"deliveryaddress": b.Deliveryaddress,
"deliverylatitude": b.Deliverylatitude,
"deliverylongitude": b.Deliverylongitude,
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"customerphone": customer.Phone,
"bookingid": b.Bookingid,
"bookingreference": b.Bookingno,
"status": b.Status,
"stoptype": milerStopType(b.Status),
"consignmentid": b.Consignmentid,
"consignmentstatus": consignmentStatus,
"next_action": nextAction,
"next_hub": nextHub,
"pickup_source_type": sourceType,
"sourceid": sourceID,
"pickuplocationid": sourceID,
"pickup_source_name": sourceName,
"pickupaddress": sourceAddress,
"pickuplatitude": b.Pickuplatitude,
"pickuplongitude": b.Pickuplongitude,
"deliveryaddress": b.Deliveryaddress,
"deliverylatitude": b.Deliverylatitude,
"deliverylongitude": b.Deliverylongitude,
"customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
"customerphone": customer.Phone,
// Arrival fact: the rider app derives its "Arrived" rung from
// pickup-scheduled + a non-null reachedat, so this survives an app
// restart without a separate booking status. Null until the rider hits
// the reached endpoint. arrivallatitude/longitude are the GPS captured
// at that moment (null if the app sent none).
"reachedat": b.Arrivedat,
"arrivallatitude": b.Arrivallatitude,
"arrivallongitude": b.Arrivallongitude,
"parcels": b.Parcels,
"serviceoptions": b.ServiceOptions,
"codamount": codAmount,
"paymentmode": paymentMode,
"createdat": b.Createdat,
"reachedat": b.Arrivedat,
"arrivallatitude": b.Arrivallatitude,
"arrivallongitude": b.Arrivallongitude,
"parcels": b.Parcels,
"serviceoptions": b.ServiceOptions,
"codamount": codAmount,
"paymentmode": paymentMode,
"createdat": b.Createdat,
// Route sequencing — 0/empty when the stop was never sequenced.
// sequencedat is the authoritative-order signal: non-null means the
// console/optimizer fixed this stop's position and the app must follow
@@ -442,6 +476,12 @@ func MilerGetConsignment(c *fiber.Ctx) error {
"can_start_delivery": consignment.Status == constants.ConsignmentCollectedByMiler,
"can_deliver": consignment.Status == constants.ConsignmentOutForDelivery,
"can_skip": consignment.Status == constants.ConsignmentOutForDelivery || consignment.Status == constants.ConsignmentCollectedByMiler,
// The same leg information the queue read and pickup-complete give, so a
// single-consignment refresh is as authoritative as a full poll.
"next_action": nextActionForConsignment(consignment.Status),
"next_hub": nextHubForConsignment(consignment),
"can_inward_at_hub": consignment.Status == constants.ConsignmentCreated,
"inwardedat": consignment.Inwardedat,
})
}

View File

@@ -16,6 +16,7 @@ import (
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/legs"
"doormile/internal/notify"
"doormile/internal/routing"
"doormile/models"
@@ -957,10 +958,7 @@ func BookingPaymentCollect(c *fiber.Ctx) error {
// final-mile delivery. Pincodes shorter than 3 characters are treated as
// unknown rather than matching, so bad data falls back to the safe hub route.
func isHyperlocal(pickupPincode, deliveryPincode string) bool {
if len(pickupPincode) < 3 || len(deliveryPincode) < 3 {
return false
}
return pickupPincode[:3] == deliveryPincode[:3]
return legs.SamePostalArea(pickupPincode, deliveryPincode)
}
// maxHyperlocalKM bounds the straight-line pickup→delivery distance under which
@@ -969,7 +967,7 @@ func isHyperlocal(pickupPincode, deliveryPincode string) bool {
// created kitchen→customer bookings (e.g. DailyGrubs) frequently carry accurate
// coordinates but no delivery pincode, and must not be wrongly routed through a
// hub. When both pincodes are present the prefix rule still wins.
const maxHyperlocalKM = 30.0
const maxHyperlocalKM = legs.MaxHyperlocalKM
// isHyperlocalBooking decides whether a booking can skip the hub and be carried
// straight to the final mile. It prefers the pincode-prefix rule (isHyperlocal)
@@ -977,18 +975,7 @@ const maxHyperlocalKM = 30.0
// between the pickup and delivery coordinates when a pincode is missing — so a
// same-area booking whose address carried no pincode isn't sent to a hub.
func isHyperlocalBooking(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool {
// Both pincodes present: the prefix rule decides definitively (a matching
// pincode is hyperlocal, a differing one is genuinely inter-area — don't let
// distance override that).
if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 {
return isHyperlocal(pickupPincode, deliveryPincode)
}
// A pincode is missing: fall back to straight-line distance when we have both
// coordinates.
if pLat != 0 && pLng != 0 && dLat != 0 && dLng != 0 {
return haversineKM(pLat, pLng, dLat, dLng) <= maxHyperlocalKM
}
return false
return legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng)
}
// collectedStateEnabled gates the two-step hyperlocal delivery flow
@@ -1068,15 +1055,29 @@ func BookingPickupComplete(c *fiber.Ctx) error {
trackingNo := generateTrackingNo()
// The base this parcel belongs to. Backend decides — the app is told where to
// go and never picks a base itself. resolveHandoverHub prefers the base the
// booking was routed to, then the collecting rider's own base (the previous
// behaviour, and still the operational default), then the nearest active base
// to the pickup point — that last step replaces a fallback that took whichever
// hub row happened to come back first.
handoverHub := resolveHandoverHub(&booking, profile.Hubid)
var defaultHubID *int
if profile.Hubid != nil {
defaultHubID = profile.Hubid
if handoverHub != nil {
defaultHubID = &handoverHub.Hubid
} else {
utils.Warn("BookingPickupComplete: miler has no assigned hub, falling back to first hub row", "miler_user_id", milerUserID, "booking_id", bookingID)
var hub models.Hub
if tx.First(&hub).Error == nil {
defaultHubID = &hub.Hubid
}
utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID)
}
// A hub-routed parcel: with the hub-handover flow ON it stops at Created —
// collected, in the rider's hands, on its way to a base — and only reaches
// Inwarded_at_Hub when the handover is actually recorded. With it OFF
// (default, and what the current app expects) it is marked Inwarded_at_Hub
// here, which is not where the parcel physically is but is what the current
// app and the console's inbound views read.
consignmentStatus := constants.ConsignmentInwardedAtHub
if hubHandoverEnabled() {
consignmentStatus = constants.ConsignmentCreated
}
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
@@ -1088,7 +1089,6 @@ func BookingPickupComplete(c *fiber.Ctx) error {
// to Out_for_Delivery, which lets the console tell "collected" from "actively
// delivering". With it OFF (default, and what the current app expects) it goes
// straight to Out_for_Delivery exactly as before.
consignmentStatus := constants.ConsignmentInwardedAtHub
if isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
booking.Pickuplatitude, booking.Pickuplongitude,
booking.Deliverylatitude, booking.Deliverylongitude) {
@@ -1138,6 +1138,14 @@ func BookingPickupComplete(c *fiber.Ctx) error {
Currenthubid: defaultHubID,
}
// Under the compatibility flow the parcel is treated as received at the base
// the moment it is collected, so the received-at fact is stamped here too —
// otherwise every parcel inwarded this way would have a null handover time
// and base reconciliation would have nothing to compare against.
if consignmentStatus == constants.ConsignmentInwardedAtHub {
consignment.Inwardedat = &now
}
var payment models.BookingPayment
if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil {
if payment.Paymentstatus == constants.PaymentStatusPaid {
@@ -1188,9 +1196,31 @@ func BookingPickupComplete(c *fiber.Ctx) error {
// they stay Picked_Up and out of the assignment pool until they finish. A
// hub-routed parcel was dropped at the hub, so the rider frees up.
postPickupAvailability := constants.MilerAvailable
if consignmentStatus == constants.ConsignmentCollectedByMiler {
if consignmentStatus == constants.ConsignmentCollectedByMiler ||
consignmentStatus == constants.ConsignmentCreated {
// Created here means hub-routed and still in the rider's hands: they are
// carrying it to a base, so they are not free yet.
postPickupAvailability = constants.MilerPickedUp
}
// A parcel that is already inwarded at the base ends this rider's leg, so the
// assignment closes with it. Without this the assignment stayed open forever
// on the compatibility flow and the rider could not go off duty — MilerEndDuty
// refuses while any assignment is still Assigned/Accepted. On the handover
// flow the assignment stays open on purpose and closes at inward-at-hub.
if consignmentStatus == constants.ConsignmentInwardedAtHub {
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
bookingID, milerUserID,
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
Updates(map[string]interface{}{
"assignmentstatus": constants.AssignmentCompleted,
"completedat": now,
}).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to close assignment")
}
}
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
Update("availabilitystatus", postPickupAvailability).Error; err != nil {
tx.Rollback()
@@ -1221,30 +1251,28 @@ func BookingPickupComplete(c *fiber.Ctx) error {
}
}
return utils.OK(c, fiber.Map{
// next_action says what the rider does next; next_hub says where. An
// inward_at_hub with no base named leaves a rider holding a parcel with
// nowhere to take it, so the two travel together — and next_hub carries all
// six fields, because the coordinates are the only thing that can drive
// Navigate and the address and pincode are what the rider reads at the gate.
//
// consignment_id is always present: the delivery leg is keyed on it, and
// without it the app cannot name the parcel it is about to act on.
resp := fiber.Map{
"tracking_no": trackingNo,
"consignment_id": consignment.Consignmentid,
"consignmentstatus": consignment.Status,
"status": consignment.Status,
"booking_no": booking.Bookingno,
"booking_status": booking.Status,
"next_action": pickupNextAction(consignment.Status),
})
}
// pickupNextAction tells the app what the rider does next after a pickup, so it
// doesn't have to encode the hub-vs-hyperlocal branch itself:
// - Collected_By_Miler → tap start-delivery (collected-state flow on)
// - Out_for_Delivery → deliver directly (hyperlocal, collected-state off)
// - Inwarded_at_Hub → handed to the hub, done for this rider
func pickupNextAction(consignmentStatus string) string {
switch consignmentStatus {
case constants.ConsignmentCollectedByMiler:
return "start_delivery"
case constants.ConsignmentOutForDelivery:
return "deliver"
default:
return "handed_to_hub"
"next_action": nextActionForConsignment(consignment.Status),
}
if consignment.Status == constants.ConsignmentCreated ||
consignment.Status == constants.ConsignmentInwardedAtHub {
resp["next_hub"] = renderBase(handoverHub)
}
return utils.OK(c, resp)
}
func BookingVehicleRequiredEscalate(c *fiber.Ctx) error {