package controllers import ( "encoding/json" "fmt" "os" "sort" "strconv" "strings" "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. // -------------------- // maxHandoverBaseKM bounds how far a rider may be from a base they EXPLICITLY // name at handover. A rider stands at the base they hand into, so a named base // this far from their reported position is a wrong id (a different city), not a // real handover. Generous enough to never reject two bases in one metro. const maxHandoverBaseKM = 50.0 // 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") } 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 } // Guard a fat-fingered base id from silently rerouting the parcel to a base in // the wrong city. Only a base the rider EXPLICITLY names (not the routed // default) is checked, and only when they report their position and the base // has real coordinates: a rider is physically at the base they hand into, so a // named base far from where they stand is a wrong id, not a real handover. riderNamedHub := req.HubID != nil || req.HubIDAlt != nil routedHub := consignment.Currenthubid != nil && hub.Hubid == *consignment.Currenthubid if riderNamedHub && !routedHub && (lat != 0 || lon != 0) && hub.Latitude != 0 && hub.Longitude != 0 { if km := haversineKM(lat, lon, hub.Latitude, hub.Longitude); km > maxHandoverBaseKM { return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, fmt.Sprintf("selected base %s is %.0f km from your location — check the base before handing over", hub.Hubname, km)) } } // 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)) } // IST wall-clock, matching createdat/updatedat and the DBNow() convention, so // inwardedat lines up with the other timestamps base reconciliation and the // earnings "today" window compare it against. now := utils.DBNow() 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. // Close the rider's booking-level assignment and free them ONLY once every // parcel from this pickup has left their hands. A customer-app booking is one // booking → N destinations → N consignments but a single BookingAssignment; // closing on the FIRST handover freed the rider and dropped the remaining // stops from the sequencer while parcels 2..N were still on them, crediting // only the first leg. So finalize only when no consignment of this booking is // still in a rider-carrying state (this one is already Inwarded_at_Hub above). finalizeRiderLeg := true if _, bookingPtr, ok := cxDestinationForConsignment(consignment.Consignmentid); ok && bookingPtr != nil { booking := *bookingPtr var carrying int64 if err := tx.Model(&models.Consignment{}). Joins("JOIN bookingdestinations bd ON bd.consignmentid = consignments.consignmentid"). Where("bd.bookingid = ? AND consignments.status IN ?", booking.Bookingid, []string{constants.ConsignmentCreated, constants.ConsignmentCollectedByMiler, constants.ConsignmentOutForDelivery}). Count(&carrying).Error; err != nil { tx.Rollback() return utils.Internal(c, "failed to check the booking's remaining parcels") } if carrying > 0 { // Rider still carries other parcels from this pickup: leave the // assignment open and the rider on the job. The leg is credited and the // rider freed at the final handover. finalizeRiderLeg = false } else { 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 finalizeRiderLeg { 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, }) }