package assignment import ( "context" "encoding/json" "errors" "fmt" "os" "strconv" "strings" "time" "doormile/constants" "doormile/db" "doormile/internal/cxstage" "doormile/internal/milergeo" "doormile/internal/routing" "doormile/models" "doormile/utils" "github.com/redis/go-redis/v9" "gorm.io/gorm" ) const ( maxRetries = 5 retryDelay = 2 * time.Minute geoRadiusKm = 10.0 geoMaxCount = 10 // defaultMaxActive is how many open stops one miler may hold at once. // Override with MILER_MAX_ACTIVE_BOOKINGS — how many parcels a rider can // realistically run in one round is an operational call, not a constant, // and it differs between a dense city round and an intercity leg. defaultMaxActive = 3 ) // defaultMaxGPSAgeMinutes: a rider whose app has not reported a position for // longer than this is not offered orders. Override with // ASSIGNMENT_MAX_GPS_AGE_MINUTES; 0 turns the check off. const defaultMaxGPSAgeMinutes = 15 // maxGPSAgeMinutes is read per call, like maxActiveBookings. A typo falls back // to the default rather than switching the check off. func maxGPSAgeMinutes() int { if v := strings.TrimSpace(os.Getenv("ASSIGNMENT_MAX_GPS_AGE_MINUTES")); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { return n } utils.Warn("ASSIGNMENT_MAX_GPS_AGE_MINUTES is not a non-negative integer, using the default", "value", v, "default", defaultMaxGPSAgeMinutes) } return defaultMaxGPSAgeMinutes } // maxActiveBookings reads the per-miler concurrent-stop cap, read per call so // it can be changed without a redeploy. A non-numeric or non-positive value // falls back to the default rather than uncapping the fleet by typo. func maxActiveBookings() int64 { if v := strings.TrimSpace(os.Getenv("MILER_MAX_ACTIVE_BOOKINGS")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { return int64(n) } utils.Warn("MILER_MAX_ACTIVE_BOOKINGS is not a positive integer, using the default", "value", v, "default", defaultMaxActive) } return defaultMaxActive } type milerCandidate struct { profile models.MilerProfile distanceKm float64 // activeBookings is the rider's load "in hand": today's open orders that // are not cancelled (openStopsToday). Balancing gives the next order to // the rider with the fewest; it is also what the ceiling caps. activeBookings int64 // sessionStops counts orders given to the rider since they started duty — // the first tie-break, so a rider who just came online is not passed over // for one who has been busy all day. sessionStops int64 } // AssignCRMMiler finds the best available nearby miler for an express booking // and assigns them. Call it after tx.Commit() in CreateExpressBooking. // // Like the B2C path, the attempt and its retries run on the ASSIGNMENTS stream // rather than in this process — see queue.go. Both entry points still reach // publishAssignmentFailed on terminal failure, or failures arriving via the // console would stay invisible to the DispatchAgent. func AssignCRMMiler(bookingID int) { defer func() { if r := recover(); r != nil { utils.Error("CRMAssignment: panic recovered", "booking_id", bookingID, "error", r) } }() enqueue(bookingID, kindExpress) } // errBookingTaken: the booking got a rider (or was cancelled) between this // attempt reading it and committing. Not a failure — the work is done. var errBookingTaken = errors.New("booking already assigned or cancelled") // claimBooking sets the booking's rider only if it still has none and is not // cancelled, in the caller's transaction. Two attempts can run for one booking // at once — the retry queue, the pending-order sweeper, a hub "auto-assign" // tap — and without this both would commit and the booking would end up with // two riders. func claimBooking(tx *gorm.DB, bookingID int, updates map[string]interface{}) error { res := tx.Model(&models.PickupBooking{}). Where("bookingid = ? AND assignedmileruserid IS NULL AND status <> ?", bookingID, constants.BookingCancelled). Updates(updates) if res.Error != nil { return fmt.Errorf("update PickupBooking: %w", res.Error) } if res.RowsAffected == 0 { return errBookingTaken } return nil } // tryAssign performs a single attempt: queries Redis GEO, scores candidates, commits. // Returns (true, nil) on success or when the booking no longer needs assignment. // Returns (false, nil) when no eligible miler was found (retry warranted). // Returns (false, err) on hard errors (booking missing, DB failure). func tryAssign(bookingID int) (bool, error) { var booking models.PickupBooking if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil { return false, fmt.Errorf("load booking: %w", err) } // If the booking was cancelled or already assigned between retries, stop. if booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil { utils.Info("CRMAssignment: booking no longer needs assignment", "booking_id", bookingID, "status", booking.Status, ) return true, nil } if booking.Pickuplatitude == 0 || booking.Pickuplongitude == 0 { return false, fmt.Errorf("booking %d has no pickup coordinates", bookingID) } nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude) if err != nil { utils.Warn("CRMAssignment: GEO query failed", "booking_id", bookingID, "error", err) return false, nil } if len(nearby) == 0 { return false, nil } candidate, pool, agentDecisionID, found := selectMilerWithAI(&booking, nearby) if !found { return false, nil } if _, err := commitAssignment(&booking, candidate, pool, agentDecisionID); err != nil { switch { case errors.Is(err, errBookingTaken): return true, nil case errors.Is(err, errNoRiderCapacity): return false, nil // everyone filled up meanwhile; retry later } return false, fmt.Errorf("commit: %w", err) } return true, nil } // AutoAssignResult carries enough detail for a synchronous caller (the hub // console's manual "auto-assign" trigger) to report what happened, since that // caller can't rely on the fire-and-forget logging AssignCRMMiler normally uses. type AutoAssignResult struct { Assigned bool Escalated bool MilerUserID int MilerName string DistanceKm float64 Reasoning string SearchedRadiusKm float64 CandidatesFound int } // TryAssignOnce performs a single assignment attempt (GEOSEARCH → AI decision // → commit) with no retry loop, for a booking that wasn't auto-assigned at // creation time. AssignCRMMiler/AssignCustomerMiler retry over ~10 minutes, // which is too slow for a hub staff member waiting on a synchronous response; // this wraps the same single-attempt core (tryAssign) used internally by both. func TryAssignOnce(bookingID int) (AutoAssignResult, error) { var booking models.PickupBooking if err := db.DB.Preload("Parcels").Preload("ServiceOptions").First(&booking, bookingID).Error; err != nil { return AutoAssignResult{}, fmt.Errorf("load booking: %w", err) } if booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil { return AutoAssignResult{Assigned: true}, nil } if booking.Pickuplatitude == 0 || booking.Pickuplongitude == 0 { return AutoAssignResult{}, fmt.Errorf("booking %d has no pickup coordinates", bookingID) } nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude) if err != nil { return AutoAssignResult{Escalated: true, Reasoning: "miler location search unavailable", SearchedRadiusKm: geoRadiusKm}, nil } if len(nearby) == 0 { return AutoAssignResult{Escalated: true, Reasoning: "no milers within search radius", SearchedRadiusKm: geoRadiusKm}, nil } candidate, pool, agentDecisionID, found := selectMilerWithAI(&booking, nearby) if !found { return AutoAssignResult{ Escalated: true, Reasoning: "no eligible miler after evaluation", SearchedRadiusKm: geoRadiusKm, CandidatesFound: len(nearby), }, nil } chosenID := candidate.profile.Userid candidate, err = commitAssignment(&booking, candidate, pool, agentDecisionID) if err != nil { switch { case errors.Is(err, errBookingTaken): return AutoAssignResult{Assigned: true}, nil case errors.Is(err, errNoRiderCapacity): return AutoAssignResult{ Escalated: true, Reasoning: "every nearby rider is at the order limit", SearchedRadiusKm: geoRadiusKm, CandidatesFound: len(nearby), }, nil } return AutoAssignResult{}, fmt.Errorf("commit: %w", err) } if candidate.profile.Userid != chosenID { agentDecisionID = nil // balancing overrode the AI's pick; its reasoning no longer applies } reasoning := "" if agentDecisionID != nil { var ad models.AgentDecision if db.DB.Where("id = ?", *agentDecisionID).First(&ad).Error == nil { reasoning = ad.Reasoning } } return AutoAssignResult{ Assigned: true, MilerUserID: candidate.profile.Userid, MilerName: candidate.profile.Displayname, DistanceKm: candidate.distanceKm, Reasoning: reasoning, CandidatesFound: len(nearby), }, nil } // queryNearbyMilers runs GEOSEARCH on milers:locations and returns up to geoMaxCount // milers within geoRadiusKm km, sorted nearest-first, with distances populated. func queryNearbyMilers(lat, lon float64) ([]redis.GeoLocation, error) { if db.Rdb == nil { return nil, fmt.Errorf("Redis not available") } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // milergeo falls back to GEORADIUS on Redis older than 6.2, where // GEOSEARCH does not exist and every order would find "no riders". return milergeo.Search(ctx, db.Rdb, lat, lon, geoRadiusKm, geoMaxCount) } // commitAssignment writes the BookingAssignment row, updates the booking and the // miler's availability status in a single transaction, then publishes to NATS. func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate, pool []*milerCandidate, agentDecisionID *uint64) (*milerCandidate, error) { tx := db.DB.Begin() // Re-check the choice with fresh loads under the decision lock, so a burst // of orders spreads across riders instead of all landing on the one that // looked least loaded when each attempt started. See finalizeChoice. final, err := finalizeChoice(tx, candidate, pool) if err != nil { tx.Rollback() return nil, err } if final != candidate { agentDecisionID = nil } candidate = final milerUserID := candidate.profile.Userid // Claim the booking first, and only if it is still unassigned and not // cancelled — see claimBooking. if err := claimBooking(tx, booking.Bookingid, map[string]interface{}{ "assignedmileruserid": milerUserID, "status": constants.BookingMilerAssigned, "updatedat": time.Now(), }); err != nil { tx.Rollback() return nil, err } assignment := models.BookingAssignment{ Bookingid: booking.Bookingid, Mileruserid: milerUserID, Assignmentstatus: constants.AssignmentAssigned, Assignedat: time.Now(), AgentDecisionID: agentDecisionID, Remarks: autoAssignedRemark, } if err := tx.Create(&assignment).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("create BookingAssignment: %w", err) } if err := tx.Model(&models.MilerProfile{}). Where("userid = ?", milerUserID). Update("availabilitystatus", constants.MilerAssigned).Error; err != nil { tx.Rollback() return nil, fmt.Errorf("update MilerProfile availability: %w", err) } // The customer's "Miler assigned" milestone, in the same transaction as the // assignment it describes. The auto-assignment path is how most B2C // bookings get a rider, so without this the customer app's timeline would // only ever advance for manually assigned pickups. if err := cxstage.Record(tx, cxstage.Event{ BookingID: booking.Bookingid, Stage: constants.CxStageAssigned, ActorType: constants.CxActorSystem, Source: "internal/assignment.commitAssignment", }); err != nil { tx.Rollback() return nil, fmt.Errorf("record assigned stage: %w", err) } tx.Commit() go cxstage.Notify(booking.Bookingid, nil, constants.CxStageAssigned) utils.Info("CRMAssignment: assigned", "booking_id", booking.Bookingid, "miler_id", milerUserID, "distance_km", candidate.distanceKm, "active_bookings", candidate.activeBookings, "agent_decision_id", agentDecisionID, ) publishAssignment(booking, milerUserID) notifyMilerNewAssignment(candidate.profile, booking.Bookingid) // Customer push goes through cxstage.Notify above (the doormile_cx device // tokens). notifyCustomerMilerAssigned targeted the older AppCustomer.Devicetoken // and firing both sent the customer two near-identical "miler assigned" pushes. // Order the rider's stops now this one is added. No-op below two active stops; // best-effort and off this goroutine's critical path. routing.SequenceMilerStopsAsync(milerUserID) return candidate, nil } // publishAssignment sends the booking.assigned event to NATS JetStream. // Non-fatal: logs a warning and returns if NATS is unavailable or publish fails. func publishAssignment(booking *models.PickupBooking, milerUserID int) { if db.Js == nil { return } payload := map[string]interface{}{ "booking_id": booking.Bookingid, "booking_no": booking.Bookingno, "miler_id": milerUserID, "provider_company": booking.Providercompany, "provider_hub": booking.Providerlocation, "assigned_at": time.Now().UnixMilli(), } data, err := json.Marshal(payload) if err != nil { utils.Warn("CRMAssignment: failed to marshal NATS payload", "booking_id", booking.Bookingid, "error", err) return } if _, err := db.Js.Publish("booking.assigned", data); err != nil { utils.Warn("CRMAssignment: NATS publish failed", "booking_id", booking.Bookingid, "error", err) } }