515 lines
19 KiB
Go
515 lines
19 KiB
Go
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,
|
|
})
|
|
}
|