package assignment import ( "encoding/json" "fmt" "math" "strconv" "time" "doormile/constants" "doormile/db" "doormile/models" "doormile/utils" ) // providerResult holds the chosen logistics provider and their pricing details. type providerResult struct { company string estimatedPrice float64 reliability float64 } // AssignCustomerMiler is the B2C goroutine entry point. // It selects both a miler (first-mile pickup) and a provider (delivery routing), // then commits the assignment. Retries up to maxRetries times with retryDelay in between. // Must be called as a goroutine after tx.Commit() in CreateCustomerBooking. func AssignCustomerMiler(bookingID int) { defer func() { if r := recover(); r != nil { utils.Error("B2CAssignment: panic recovered", "booking_id", bookingID, "error", r) } }() for attempt := 1; attempt <= maxRetries; attempt++ { if attempt > 1 { time.Sleep(retryDelay) } utils.Info("B2CAssignment: attempting assignment", "booking_id", bookingID, "attempt", attempt) done, err := tryCustomerAssign(bookingID) if err != nil { utils.Error("B2CAssignment: attempt error", "booking_id", bookingID, "attempt", attempt, "error", err) continue } if done { return } utils.Warn("B2CAssignment: no eligible miler on attempt", "booking_id", bookingID, "attempt", attempt, "remaining", maxRetries-attempt, ) } utils.Error("B2CAssignment: NO_MILER_AVAILABLE — all retries exhausted", "booking_id", bookingID, "max_retries", maxRetries, ) // Terminal failure — hand off to the AI layer's DispatchAgent, which owns // what happens next (coverage sweep, escalation). Reached only after every // retry is exhausted, so it fires at most once per booking. publishAssignmentFailed(bookingID, reasonNoMilerAvailable) } // tryCustomerAssign performs one full attempt: GEO miler search → provider selection → commit. // 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. func tryCustomerAssign(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 booking.Status == constants.BookingCancelled || booking.Assignedmileruserid != nil { utils.Info("B2CAssignment: 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) } // Step 1 — Find the best nearby miler via AI decision engine (GEORADIUS unchanged). nearby, err := queryNearbyMilers(booking.Pickuplatitude, booking.Pickuplongitude) if err != nil { utils.Warn("B2CAssignment: GEO query failed", "booking_id", bookingID, "error", err) return false, nil } if len(nearby) == 0 { return false, nil } miler, agentDecisionID, found := selectMilerWithAI(&booking, nearby) if !found { return false, nil } // Step 2 — Select the best logistics provider from DoormilePricing. zone := b2cResolveZone(booking.Pickuppincode, booking.Deliverypincode) category := b2cNormalizePricingCategory(b2cFirstParcelCategory(&booking)) serviceType := b2cFirstServiceType(&booking) weight := b2cChargeableWeight(&booking) provider, providerFound := selectBestProvider(zone, category, serviceType, weight) if !providerFound { utils.Warn("B2CAssignment: no provider pricing match, defaulting to Doormile", "booking_id", bookingID, "zone", zone, "category", category, ) provider = providerResult{company: "Doormile"} } // Step 3 — ETA based on miler-to-pickup distance. etaMinutes := calculateETA(miler.distanceKm) // Steps 4–6 — Commit to DB and publish to NATS. if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes, agentDecisionID); err != nil { return false, fmt.Errorf("commit: %w", err) } return true, nil } // ─── Provider selection ─────────────────────────────────────────────────────── // selectBestProvider queries all active DoormilePricing rows that cover the given // zone / serviceType / weight and picks the provider with the lowest composite score. // // Scoring (lower = better): // // score = avg_price - reliability_score * 2.0 // // A reliability point is worth 2 rupees — price dominates for large spreads, but // a highly reliable provider can edge out a marginally cheaper one. func selectBestProvider(zone, category, serviceType string, weight float64) (providerResult, bool) { pricingServiceType := b2cMapServiceTypeToPricing(serviceType) var rules []models.DoormilePricing db.DB.Where( "zone = ? AND servicetype = ? AND status = ? AND deletedat IS NULL"+ " AND min_weight <= ? AND max_weight >= ?", zone, pricingServiceType, "Active", weight, weight, ).Find(&rules) if len(rules) == 0 { return providerResult{}, false } // Prefer exact category match; fall back to General; use all rows as last resort. matched := b2cFilterByCategory(rules, category) if len(matched) == 0 && category != "General" { matched = b2cFilterByCategory(rules, "General") } if len(matched) == 0 { matched = rules } var best *providerResult bestScore := math.MaxFloat64 for _, rule := range matched { avgPrice := (rule.Minprice + rule.Maxprice) / 2.0 score := avgPrice - rule.Reliabilityscore*2.0 if score < bestScore { bestScore = score r := providerResult{ company: rule.Providercompany, estimatedPrice: avgPrice, reliability: rule.Reliabilityscore, } best = &r } } if best == nil { return providerResult{}, false } return *best, true } // ─── ETA ───────────────────────────────────────────────────────────────────── // calculateETA returns the estimated arrival time in minutes. // Formula: (miler_to_pickup_distance_km / 20.0) * 60 + 10-minute pickup buffer. func calculateETA(distanceKm float64) float64 { return math.Round((distanceKm/20.0)*60.0 + 10.0) } // ─── Commit ────────────────────────────────────────────────────────────────── // commitCustomerAssignment writes the full assignment in a single DB transaction: // - BookingAssignment row (status: Assigned) // - PickupBooking: assignedmileruserid, status → Miler_Assigned, providercompany (if found) // - MilerProfile: availabilitystatus → Assigned // // Publishes to NATS after a successful commit. func commitCustomerAssignment( booking *models.PickupBooking, miler *milerCandidate, provider providerResult, etaMinutes float64, agentDecisionID *uint64, ) error { milerUserID := miler.profile.Userid tx := db.DB.Begin() ba := models.BookingAssignment{ Bookingid: booking.Bookingid, Mileruserid: milerUserID, Assignmentstatus: constants.AssignmentAssigned, Assignedat: time.Now(), AgentDecisionID: agentDecisionID, } if err := tx.Create(&ba).Error; err != nil { tx.Rollback() return fmt.Errorf("create BookingAssignment: %w", err) } bookingUpdates := map[string]interface{}{ "assignedmileruserid": milerUserID, "status": constants.BookingMilerAssigned, "updatedat": time.Now(), } if provider.company != "" { bookingUpdates["providercompany"] = provider.company } if err := tx.Model(booking).Updates(bookingUpdates).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("B2CAssignment: assigned", "booking_id", booking.Bookingid, "miler_id", milerUserID, "provider", provider.company, "estimated_price", provider.estimatedPrice, "eta_minutes", etaMinutes, "miler_distance_km", miler.distanceKm, "agent_decision_id", agentDecisionID, ) publishCustomerAssignment(booking, milerUserID, provider, etaMinutes) notifyMilerNewAssignment(miler.profile, booking.Bookingid) notifyCustomerMilerAssigned(booking, miler.profile.Displayname) return nil } // publishCustomerAssignment sends the booking.assigned event to NATS JetStream. // Non-fatal: logs a warning and returns if NATS is unavailable or publish fails. func publishCustomerAssignment( booking *models.PickupBooking, milerUserID int, provider providerResult, etaMinutes float64, ) { if db.Js == nil { return } payload := map[string]interface{}{ "booking_id": booking.Bookingid, "booking_no": booking.Bookingno, "miler_id": milerUserID, "selected_provider": provider.company, "estimated_price": provider.estimatedPrice, "eta_minutes": etaMinutes, "assigned_at": time.Now().UnixMilli(), } data, err := json.Marshal(payload) if err != nil { utils.Warn("B2CAssignment: failed to marshal NATS payload", "booking_id", booking.Bookingid, "error", err) return } if _, err := db.Js.Publish("booking.assigned", data); err != nil { utils.Warn("B2CAssignment: NATS publish failed", "booking_id", booking.Bookingid, "error", err) } } // ─── Booking field extractors ───────────────────────────────────────────────── func b2cFirstParcelCategory(booking *models.PickupBooking) string { if len(booking.Parcels) > 0 { return booking.Parcels[0].Itemcategory } return "" } func b2cFirstServiceType(booking *models.PickupBooking) string { if len(booking.ServiceOptions) > 0 { return booking.ServiceOptions[0].Servicetype } return "Normal" } // b2cChargeableWeight sums max(actual, volumetric) per parcel across the booking. func b2cChargeableWeight(booking *models.PickupBooking) float64 { var total float64 for _, p := range booking.Parcels { volumetric := (p.Length * p.Width * p.Height) / 5000.0 total += math.Max(p.Weight, volumetric) } if total == 0 { return 0.5 // minimum chargeable to avoid zero-weight lookup } return total } // ─── Zone / category helpers (self-contained; no import of controllers pkg) ── func b2cPincodeToState(pincode string) string { if len(pincode) < 3 { return pincode } p, err := strconv.Atoi(pincode[:3]) if err != nil { if len(pincode) >= 2 { return pincode[:2] } return pincode } switch { case p >= 500 && p <= 535: return "AP_TS" case p >= 560 && p <= 591: return "KA" case p >= 600 && p <= 643: return "TN" case p >= 670 && p <= 695: return "KL" case p >= 380 && p <= 396: return "GJ" case p >= 400 && p <= 444: return "MH" default: if len(pincode) >= 2 { return pincode[:2] } return pincode } } func b2cResolveZone(pickupPincode, deliveryPincode string) string { if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 && pickupPincode[:3] == deliveryPincode[:3] { return "Local" } if b2cPincodeToState(pickupPincode) == b2cPincodeToState(deliveryPincode) { return "Regional" } return "National" } func b2cNormalizePricingCategory(category string) string { switch category { case "General", "Documents", "Electronics", "Clothing", "Fragile", "Medical", "Automotive", "Food": return category default: return "General" } } func b2cMapServiceTypeToPricing(serviceType string) string { if serviceType == "Fast" || serviceType == "Superfast" || serviceType == "Express" { return "Express" } return "Normal" } func b2cFilterByCategory(rules []models.DoormilePricing, category string) []models.DoormilePricing { out := make([]models.DoormilePricing, 0, len(rules)) for _, r := range rules { if r.Category == category { out = append(out, r) } } return out }