Files
doormile_backend/controllers/logisticsHandoverController.go

535 lines
20 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.
// Resolved through bookingdestinations, not through
// pickupbookings.consignmentid. That column names only the FIRST order of a
// multi-destination pickup, so joining on it found nothing for orders 2..N
// — and an intercity rider handing in the second parcel of a three-stop
// pickup had their assignment left open and their distance recorded as zero.
if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil {
booking := *bookingPtr
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")
}
// The customer's "In transit" milestone. Recorded against THIS order, not
// the booking, because the other parcels from the same visit may still be
// in the rider's hands.
notifyInTransit, err := recordCxConsignmentStage(tx, consignment.Consignmentid,
constants.ConsignmentInwardedAtHub, constants.CxActorMiler, &milerUserID,
"POST /miler/consignments/{id}/inward-at-hub")
if err != nil {
tx.Rollback()
utils.Error("MilerInwardConsignmentAtHub: could not record in_transit",
"consignment_id", consignment.Consignmentid, "error", err)
return utils.Internal(c, "failed to record the handover")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to record the handover")
}
notifyInTransit()
// 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,
})
}