package assignment import ( "context" "encoding/json" "fmt" "strconv" "time" "doormile/constants" "doormile/db" "doormile/models" "doormile/utils" "github.com/redis/go-redis/v9" ) const ( maxRetries = 5 retryDelay = 2 * time.Minute geoRadiusKm = 10.0 geoMaxCount = 10 maxActive = 3 ) type milerCandidate struct { profile models.MilerProfile distanceKm float64 activeBookings int64 } // AssignCRMMiler finds the best available nearby miler for a CRM booking and assigns them. // It retries up to maxRetries times (retryDelay apart) before logging NO_MILER_AVAILABLE. // Must be called as a goroutine after tx.Commit() in CreateCRMBooking. func AssignCRMMiler(bookingID int) { defer func() { if r := recover(); r != nil { utils.Error("CRMAssignment: panic recovered", "booking_id", bookingID, "error", r) } }() for attempt := 1; attempt <= maxRetries; attempt++ { if attempt > 1 { time.Sleep(retryDelay) } utils.Info("CRMAssignment: attempting assignment", "booking_id", bookingID, "attempt", attempt) done, err := tryAssign(bookingID) if err != nil { utils.Error("CRMAssignment: attempt error", "booking_id", bookingID, "attempt", attempt, "error", err) continue } if done { return } utils.Warn("CRMAssignment: no eligible miler found on attempt", "booking_id", bookingID, "attempt", attempt, "remaining", maxRetries-attempt, ) } utils.Error("CRMAssignment: NO_MILER_AVAILABLE — all retries exhausted", "booking_id", bookingID, "max_retries", maxRetries, ) } // 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.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, found := pickBestMiler(nearby) if !found { return false, nil } if err := commitAssignment(&booking, candidate); err != nil { return false, fmt.Errorf("commit: %w", err) } return true, 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() locs, err := db.Rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{ GeoSearchQuery: redis.GeoSearchQuery{ Longitude: lon, Latitude: lat, Radius: geoRadiusKm, RadiusUnit: "km", Sort: "ASC", Count: geoMaxCount, }, WithDist: true, }).Result() if err != nil { return nil, err } return locs, nil } // pickBestMiler filters the nearby list for eligibility, then returns the candidate // with the lowest score. score = distance_km*1.0 + active_bookings*2.0 - rating*0.5 func pickBestMiler(nearby []redis.GeoLocation) (*milerCandidate, bool) { var best *milerCandidate bestScore := 1e18 for _, loc := range nearby { milerUserID, err := strconv.Atoi(loc.Name) if err != nil { utils.Warn("CRMAssignment: 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 } if profile.Availabilitystatus != constants.MilerAvailable { continue } var activeCount int64 db.DB.Model(&models.BookingAssignment{}). Where("mileruserid = ? AND assignmentstatus IN ?", milerUserID, []string{ constants.AssignmentAssigned, constants.AssignmentAccepted, }). Count(&activeCount) if activeCount >= maxActive { continue } score := loc.Dist*1.0 + float64(activeCount)*2.0 - profile.Rating*0.5 if score < bestScore { bestScore = score best = &milerCandidate{ profile: profile, distanceKm: loc.Dist, activeBookings: activeCount, } } } return best, best != nil } // 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) error { milerUserID := candidate.profile.Userid tx := db.DB.Begin() assignment := models.BookingAssignment{ Bookingid: booking.Bookingid, Mileruserid: milerUserID, Assignmentstatus: constants.AssignmentAssigned, Assignedat: time.Now(), } if err := tx.Create(&assignment).Error; err != nil { tx.Rollback() return fmt.Errorf("create BookingAssignment: %w", err) } now := time.Now() if err := tx.Model(booking).Updates(map[string]interface{}{ "assignedmileruserid": milerUserID, "status": constants.BookingMilerAssigned, "updatedat": now, }).Error; err != nil { tx.Rollback() return fmt.Errorf("update PickupBooking: %w", err) } if err := tx.Model(&models.MilerProfile{}). Where("userid = ?", milerUserID). Update("availabilitystatus", constants.MilerAssigned).Error; err != nil { tx.Rollback() return fmt.Errorf("update MilerProfile availability: %w", err) } tx.Commit() utils.Info("CRMAssignment: assigned", "booking_id", booking.Bookingid, "miler_id", milerUserID, "distance_km", candidate.distanceKm, "active_bookings", candidate.activeBookings, "score", candidate.distanceKm*1.0+float64(candidate.activeBookings)*2.0-candidate.profile.Rating*0.5, ) publishAssignment(booking, milerUserID) notifyMilerNewAssignment(candidate.profile, booking.Bookingid) notifyCustomerMilerAssigned(booking, candidate.profile.Displayname) return 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) } }