updates on the api endpoints on the customer page and more
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
286
controllers/hubInboundController.go
Normal file
286
controllers/hubInboundController.go
Normal 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,
|
||||
})
|
||||
}
|
||||
514
controllers/logisticsHandoverController.go
Normal file
514
controllers/logisticsHandoverController.go
Normal 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,
|
||||
})
|
||||
}
|
||||
149
controllers/logisticsHandover_test.go
Normal file
149
controllers/logisticsHandover_test.go
Normal 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`)
|
||||
}
|
||||
}
|
||||
137
controllers/logisticsRouting_test.go
Normal file
137
controllers/logisticsRouting_test.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user