package assignment import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "os" "strconv" "time" "doormile/constants" "doormile/db" "doormile/models" "doormile/utils" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) // ─── Request / response types ──────────────────────────────────────────────── type aiCandidate struct { MilerID int `json:"miler_id"` DistanceKm float64 `json:"distance_km"` Rating float64 `json:"rating"` ActiveBookings int64 `json:"active_bookings"` OnTimeRate30d float64 `json:"on_time_rate_30d"` CompletedToday int64 `json:"completed_today"` HubID int `json:"hub_id"` HubLoad int64 `json:"hub_load"` HubCapacity int `json:"hub_capacity"` } type aiDecisionRequest struct { Booking aiBookingInfo `json:"booking"` Candidates []aiCandidate `json:"candidates"` Context aiRequestCtx `json:"context"` } type aiBookingInfo struct { PickupLat float64 `json:"pickup_lat"` PickupLon float64 `json:"pickup_lon"` DeliveryLat float64 `json:"delivery_lat"` DeliveryLon float64 `json:"delivery_lon"` ItemCategory string `json:"item_category"` Weight float64 `json:"weight"` ServiceType string `json:"service_type"` } type aiRequestCtx struct { Hour int `json:"hour"` IsPeak bool `json:"is_peak"` Zone string `json:"zone"` } type aiDecisionResponse struct { ChosenMilerID int `json:"chosen_miler_id"` Escalate bool `json:"escalate"` AgentDecisionID uint64 `json:"agent_decision_id"` Reasoning string `json:"reasoning"` } // ─── Main entry point ──────────────────────────────────────────────────────── // selectMilerWithAI collects all eligible milers from the GEORADIUS result, calls // the AI decision engine, and returns the chosen miler plus the decision ID for // audit. Falls back to the original distance/load/rating formula when the AI // layer is unreachable or times out. Returns (nil, nil, false) when there are no // eligible candidates or when the AI layer escalates the booking. func selectMilerWithAI(booking *models.PickupBooking, nearby []redis.GeoLocation) (*milerCandidate, []*milerCandidate, *uint64, bool) { // Never offer an order back to a rider it was released from, who // rejected it or who cancelled it. nearby = withoutRiders(nearby, ridersToSkip(booking.Bookingid)) pool, poolAI := collectEligibleCandidates(nearby) if len(pool) == 0 { return nil, nil, nil, false } // Balance: only the riders holding the fewest orders are considered. The // AI (or the fallback) chooses among them, so the split stays even however // many orders and riders there are. The full pool goes back to the caller // for the final re-check at commit time (finalizeChoice). candidates, aiCandidates := leastLoaded(pool, poolAI) decision, err := callDecisionEngine(booking, aiCandidates) if err != nil { utils.Warn("AI_LAYER_FALLBACK: decide-assignment unreachable, using legacy scoring", "booking_id", booking.Bookingid, "error", err) best := pickBestFromCandidates(candidates) return best, pool, nil, best != nil } utils.Info("Assignment: AI layer responded", "booking_id", booking.Bookingid, "escalate", decision.Escalate, "chosen_miler_id", decision.ChosenMilerID, "agent_decision_id", decision.AgentDecisionID, "reasoning", decision.Reasoning, ) if decision.Escalate { utils.Warn("Assignment: AI layer escalated — skipping assignment this attempt", "booking_id", booking.Bookingid, "reasoning", decision.Reasoning, ) return nil, nil, nil, false } var decisionID *uint64 if decision.AgentDecisionID != 0 { id := decision.AgentDecisionID decisionID = &id } for _, c := range candidates { if c.profile.Userid == decision.ChosenMilerID { return c, pool, decisionID, true } } // AI returned an ID that is not among the least-loaded riders — fall back. utils.Warn("AI_LAYER_FALLBACK: chosen miler not in eligible set, using legacy scoring", "booking_id", booking.Bookingid, "chosen_miler_id", decision.ChosenMilerID, ) best := pickBestFromCandidates(candidates) return best, pool, nil, best != nil } // leastLoaded keeps the candidates holding the fewest orders in hand (and // the matching AI rows, which are parallel to them; ai may be nil). func leastLoaded(cands []*milerCandidate, ai []aiCandidate) ([]*milerCandidate, []aiCandidate) { if len(cands) == 0 { return cands, ai } least := cands[0].activeBookings for _, c := range cands[1:] { if c.activeBookings < least { least = c.activeBookings } } var outC []*milerCandidate var outAI []aiCandidate for i, c := range cands { if c.activeBookings == least { outC = append(outC, c) if i < len(ai) { outAI = append(outAI, ai[i]) } } } return outC, outAI } // ─── Candidate collection ──────────────────────────────────────────────────── // collectEligibleCandidates iterates the GEORADIUS result, applies eligibility // filters (availability, active-booking cap), and fetches per-miler stats needed // by the AI layer. Returns parallel slices so callers can use either. func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, []aiCandidate) { var candidates []*milerCandidate var aiCandidates []aiCandidate for _, loc := range nearby { milerUserID, err := strconv.Atoi(loc.Name) if err != nil { utils.Warn("Assignment: skipping non-numeric GEO member", "name", loc.Name) continue } var profile models.MilerProfile if err := db.DB.Where("userid = ?", milerUserID).First(&profile).Error; err != nil { continue } // Carrying an order is not a reason to be skipped — maxActive below // is what decides how much one miler can hold. Testing for Available // here made that cap unreachable: a rider was eligible only while // idle, so activeCount was always 0 and the multi-stop round the cap // was written for could never be built. if !constants.MilerCanTakeWork(profile.Availabilitystatus) { continue } // Only a rider whose app is actually reporting can take an order. A // status left at "Assigned" by an app that stopped weeks ago used to // make that rider look like the least busy one, so orders went to // nobody. Compared in SQL against the database clock, which is right // whatever type the column has. if !milerHasFreshGPS(milerUserID) { continue } activeCount := openStopsToday(milerUserID) if activeCount >= maxActiveBookings() { continue } onTimeRate, completedToday := fetchMilerStats(milerUserID) hubID, hubLoad, hubCapacity := fetchHubData(profile.Hubid) candidates = append(candidates, &milerCandidate{ profile: profile, distanceKm: loc.Dist, activeBookings: activeCount, sessionStops: sessionStops(db.DB, milerUserID), }) aiCandidates = append(aiCandidates, aiCandidate{ MilerID: milerUserID, DistanceKm: loc.Dist, Rating: profile.Rating, ActiveBookings: activeCount, OnTimeRate30d: onTimeRate, CompletedToday: completedToday, HubID: hubID, HubLoad: hubLoad, HubCapacity: hubCapacity, }) } return candidates, aiCandidates } // openStopsToday is what counts towards the per-rider cap: the rider's open // assignments (Assigned / Accepted) made since midnight India time, on orders // that are not cancelled. Before, every open record of any age counted, and // nothing closed a record when ops cancelled its order — so a rider with a // pile of old or cancelled orders sat at the cap for good and every new order // stayed Pending. func openStopsToday(milerUserID int) int64 { return openStopsTodayIn(db.DB, milerUserID) } // openStopsTodayIn is openStopsToday on a given handle, so the commit can // recount inside its own transaction. func openStopsTodayIn(h *gorm.DB, milerUserID int) int64 { var n int64 h.Table("bookingassignments AS ba"). Joins("JOIN pickupbookings pb ON pb.bookingid = ba.bookingid"). Where("ba.mileruserid = ? AND ba.assignmentstatus IN ? AND ba.assignedat >= ? AND pb.status <> ?", milerUserID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}, startOfISTDay(time.Now()), constants.BookingCancelled). Count(&n) return n } // milerHasFreshGPS reports whether the rider's last position is recent enough // (ASSIGNMENT_MAX_GPS_AGE_MINUTES, default 15; 0 turns the check off). func milerHasFreshGPS(milerUserID int) bool { maxAge := maxGPSAgeMinutes() if maxAge == 0 { return true } var n int64 db.DB.Model(&models.MilerProfile{}). Where("userid = ? AND lastlocationupdatedat >= NOW() - make_interval(mins => ?)", milerUserID, maxAge). Count(&n) return n > 0 } // sessionStops counts the orders given to the rider since their current duty // session started (the latest milerdutylogs row with no logout), excluding // ones that fell through (cancelled, rejected, reassigned). 0 when the rider // has no open session. func sessionStops(h *gorm.DB, milerUserID int) int64 { var n int64 h.Table("bookingassignments"). Where("mileruserid = ? AND assignmentstatus NOT IN ? AND assignedat >= "+ "(SELECT MAX(loginat) FROM milerdutylogs WHERE userid = ? AND logoutat IS NULL)", milerUserID, []string{constants.AssignmentCancelled, constants.AssignmentRejected, constants.AssignmentReassigned}, milerUserID). Count(&n) return n } // errNoRiderCapacity: by the time the commit ran, every candidate had reached // the per-rider ceiling. The attempt is retried later like "no rider found". var errNoRiderCapacity = errors.New("every candidate rider is at the order limit") // decisionLockKey serialises the final rider choice across attempts and // replicas (a transaction-scoped Postgres advisory lock). Held only for a // handful of quick counts and the commit, never across the AI call. const decisionLockKey int64 = 7_270_001 // finalizeChoice re-checks the choice inside the commit transaction. Attempts // for different orders run in parallel — a bulk upload of 50 starts 50 — and // each picked "the least-loaded rider" from counts read before the others // committed, so they would all pile onto the same one. Under the lock the // loads are recounted: the chosen rider is kept if still among the least // loaded and under the ceiling, otherwise the best of the least loaded is // taken instead (same order as betterChoice). func finalizeChoice(tx *gorm.DB, chosen *milerCandidate, pool []*milerCandidate) (*milerCandidate, error) { if len(pool) == 0 { pool = []*milerCandidate{chosen} } if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", decisionLockKey).Error; err != nil { return nil, fmt.Errorf("decision lock: %w", err) } ceiling := maxActiveBookings() var open []*milerCandidate for _, c := range pool { c.activeBookings = openStopsTodayIn(tx, c.profile.Userid) c.sessionStops = sessionStops(tx, c.profile.Userid) if c.activeBookings < ceiling { open = append(open, c) } } if len(open) == 0 { return nil, errNoRiderCapacity } least, _ := leastLoaded(open, nil) for _, c := range least { if c.profile.Userid == chosen.profile.Userid { return c, nil // the original choice still holds } } return pickBestFromCandidates(least), nil } // startOfISTDay is midnight today in India, the boundary for "today's" // open stops. An absolute instant, so it compares correctly with assignedat // whether the column stores IST wall-clock or a real timestamp. func startOfISTDay(now time.Time) time.Time { n := now.In(utils.ISTLocation()) return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, utils.ISTLocation()) } // ─── Per-miler stats ───────────────────────────────────────────────────────── func fetchMilerStats(milerUserID int) (onTimeRate float64, completedToday int64) { type statsRow struct { Total int64 OnTime int64 } var row statsRow db.DB.Raw(` SELECT COUNT(*) AS total, COUNT(CASE WHEN assignmentstatus = 'Completed_OnTime' THEN 1 END) AS on_time FROM bookingassignments WHERE mileruserid = ? AND createdat > NOW() - INTERVAL '30 days' `, milerUserID).Scan(&row) if row.Total == 0 { onTimeRate = 0.85 // neutral assumption for milers with no recent history } else { onTimeRate = float64(row.OnTime) / float64(row.Total) } today := time.Now().Truncate(24 * time.Hour) db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND createdat >= ? AND assignmentstatus = ?", milerUserID, today, constants.AssignmentCompleted). Count(&completedToday) return } // ─── Hub stats ─────────────────────────────────────────────────────────────── // fetchHubData returns the hub_load (active assignments across the hub) and // hub_capacity for the given hub. Defaults to 50 for capacity when the column // doesn't exist or the miler has no assigned hub. func fetchHubData(hubID *int) (resolvedHubID int, hubLoad int64, hubCapacity int) { hubCapacity = 50 // safe default — also used if hubs.capacity column is absent if hubID == nil { return 0, 0, hubCapacity } resolvedHubID = *hubID // hub_load: total active assignments whose miler belongs to this hub db.DB.Raw(` SELECT COUNT(*) FROM bookingassignments ba JOIN milerprofiles mp ON ba.mileruserid = mp.userid WHERE mp.hubid = ? AND ba.assignmentstatus IN ('Assigned', 'Accepted', 'Pickup_Scheduled') `, resolvedHubID).Scan(&hubLoad) // hub_capacity: gracefully handle column not existing yet var cap int if err := db.DB.Raw(`SELECT capacity FROM hubs WHERE hubid = ?`, resolvedHubID).Scan(&cap).Error; err == nil && cap > 0 { hubCapacity = cap } return } // ─── Fallback scorer ───────────────────────────────────────────────────────── // pickBestFromCandidates applies the original formula to an already-collected // eligible slice: score = distance_km*1.0 + active_bookings*2.0 - rating*0.5 func pickBestFromCandidates(candidates []*milerCandidate) *milerCandidate { var best *milerCandidate for _, c := range candidates { if best == nil || betterChoice(c, best) { best = c } } return best } // betterChoice is the balancing order: fewest orders in hand, then fewest // orders this duty session, then nearest to the pickup, then best rated. // It replaced a weighted score in which distance dominated, so riders near // the pickups took most of the orders. func betterChoice(a, b *milerCandidate) bool { if a.activeBookings != b.activeBookings { return a.activeBookings < b.activeBookings } if a.sessionStops != b.sessionStops { return a.sessionStops < b.sessionStops } if a.distanceKm != b.distanceKm { return a.distanceKm < b.distanceKm } return a.profile.Rating > b.profile.Rating } // ─── AI layer HTTP call ────────────────────────────────────────────────────── func callDecisionEngine(booking *models.PickupBooking, candidates []aiCandidate) (aiDecisionResponse, error) { baseURL := os.Getenv("AI_LAYER_BASE_URL") if baseURL == "" { baseURL = "https://routemate.workolik.com" } now := time.Now() hour := now.Hour() isPeak := (hour >= 7 && hour <= 10) || (hour >= 17 && hour <= 21) reqBody := aiDecisionRequest{ Booking: aiBookingInfo{ PickupLat: booking.Pickuplatitude, PickupLon: booking.Pickuplongitude, DeliveryLat: booking.Deliverylatitude, DeliveryLon: booking.Deliverylongitude, ItemCategory: b2cFirstParcelCategory(booking), Weight: b2cChargeableWeight(booking), ServiceType: b2cFirstServiceType(booking), }, Candidates: candidates, Context: aiRequestCtx{ Hour: hour, IsPeak: isPeak, Zone: booking.Deliverycity, }, } body, err := json.Marshal(reqBody) if err != nil { return aiDecisionResponse{}, fmt.Errorf("marshal AI request: %w", err) } utils.Info("Assignment: AI layer outgoing payload", "booking_id", booking.Bookingid, "payload", string(body), ) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api/v1/doormile/decide-assignment", bytes.NewReader(body)) if err != nil { return aiDecisionResponse{}, fmt.Errorf("build AI request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return aiDecisionResponse{}, fmt.Errorf("AI layer call: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return aiDecisionResponse{}, fmt.Errorf("AI layer HTTP %d", resp.StatusCode) } var decision aiDecisionResponse if err := json.NewDecoder(resp.Body).Decode(&decision); err != nil { return aiDecisionResponse{}, fmt.Errorf("decode AI response: %w", err) } return decision, nil }