AssignCustomerMiler and AssignCRMMiler retried five times, two minutes apart, using time.Sleep inside a bare goroutine — about ten minutes of state held only in one pod's memory. Any restart dropped every retry in flight, and nothing recorded it: the booking just stayed unassigned forever with no failure event, because publishAssignmentFailed only runs at the end of a loop that no longer existed. Deploying during a quiet patch was enough to lose bookings this way, and it happened during today's rollout. Retries now run on the ASSIGNMENTS stream. Each entry point publishes one booking.assignment_requested message; a durable consumer performs a single attempt per delivery and NAKs with retryDelay when no miler is available, so JetStream owns both the waiting and the delivery count. A pod dying mid-wait costs nothing — the message is still on the server and another replica takes it. Behaviour is deliberately unchanged from the caller's side: same five attempts, same two-minute spacing, same publishAssignmentFailed handoff to the DispatchAgent. The failure event is fired explicitly on the last delivery, since JetStream stops redelivering at MaxDeliver and would otherwise let the booking fail silently again. runInline keeps the old loop as a fallback for when JetStream is down. Assignment is how a booking reaches a rider, so it must not become dependent on the event bus: an outage should cost durability, which is what we had before, not stop bookings being assigned at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
377 lines
12 KiB
Go
377 lines
12 KiB
Go
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 entry point. It selects both a miler
|
||
// (first-mile pickup) and a provider (delivery routing), then commits the
|
||
// assignment. Call it after tx.Commit() in CreateCustomerBooking.
|
||
//
|
||
// The attempt and its retries now run on the ASSIGNMENTS stream rather than in
|
||
// this process — see queue.go for why. Publishing is fast enough that the
|
||
// caller's `go` is no longer strictly needed, but it is harmless and the call
|
||
// sites are left as they are.
|
||
//
|
||
// Terminal failure still hands off to the AI layer's DispatchAgent via
|
||
// publishAssignmentFailed, which the worker fires once the last delivery is
|
||
// exhausted.
|
||
func AssignCustomerMiler(bookingID int) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
utils.Error("B2CAssignment: panic recovered", "booking_id", bookingID, "error", r)
|
||
}
|
||
}()
|
||
|
||
enqueue(bookingID, kindCustomer)
|
||
}
|
||
|
||
// 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
|
||
}
|