Files
doormile_backend/internal/assignment/crm_assignment.go

355 lines
12 KiB
Go

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 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, agentDecisionID, found := selectMilerWithAI(&booking, nearby)
if !found {
return false, nil
}
if err := commitAssignment(&booking, candidate, agentDecisionID); err != nil {
if errors.Is(err, errBookingTaken) {
return true, 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 {
if errors.Is(err, errBookingTaken) {
return AutoAssignResult{Assigned: true}, 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()
// 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, agentDecisionID *uint64) error {
milerUserID := candidate.profile.Userid
tx := db.DB.Begin()
// 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 err
}
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)
}
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)
}
// 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 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 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)
}
}