package assignment import ( "context" "encoding/json" "fmt" "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 CreateExpressBooking. 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, ) // Terminal failure — same handoff as the B2C path. Both entry points must // publish, or failures arriving via the CRM console stay invisible to the // DispatchAgent. publishAssignmentFailed(bookingID, reasonNoMilerAvailable) } // 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, agentDecisionID, found := selectMilerWithAI(&booking, nearby) if !found { return false, nil } if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil { 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, agentDecisionID, found := selectMilerWithAI(&booking, nearby) if !found { return AutoAssignResult{ Escalated: true, Reasoning: "no eligible miler after evaluation", SearchedRadiusKm: geoRadiusKm, CandidatesFound: len(nearby), }, nil } if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil { return AutoAssignResult{}, fmt.Errorf("commit: %w", err) } 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() 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 } // 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, agentDecisionID *uint64) error { milerUserID := candidate.profile.Userid tx := db.DB.Begin() assignment := models.BookingAssignment{ Bookingid: booking.Bookingid, Mileruserid: milerUserID, Assignmentstatus: constants.AssignmentAssigned, Assignedat: time.Now(), AgentDecisionID: agentDecisionID, } 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, "agent_decision_id", agentDecisionID, ) 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) } }