Add assignment engine, FCM, WebSockets, city gate, and internal APIs
- internal/assignment: GEORADIUS miler assignment with retry/escalation, customer-side provider scoring, FCM notifications on assign - internal/notify: Firebase Admin SDK (FCM) client initialisation - internal/ws: WebSocket handlers for live parcel tracking and customer↔miler chat - middlewares: city gate (pincode prefix validation), internal API key auth, WebSocket JWT auth - controllers: InternalNotify + InternalReassign for machine-to-machine calls; pricing helpers wired into CreateCustomerBooking and CreateCRMBooking - routes: /internal/*, /ws/bookings/:id/track, /ws/bookings/:id/chat - models/users, models/doormile_pricing: new fields for device tokens, assignment state, pricing bands - seed_data.sql: initial pricing seed rows .env and Firebase service-account JSON intentionally excluded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
391
internal/assignment/customer_assignment.go
Normal file
391
internal/assignment/customer_assignment.go
Normal file
@@ -0,0 +1,391 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (shared GEO logic from crm_assignment.go).
|
||||
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, found := pickBestMiler(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, proceeding without one",
|
||||
"booking_id", bookingID,
|
||||
"zone", zone,
|
||||
"category", category,
|
||||
)
|
||||
provider = providerResult{}
|
||||
}
|
||||
|
||||
// Step 6 — ETA based on miler-to-pickup distance.
|
||||
etaMinutes := calculateETA(miler.distanceKm)
|
||||
|
||||
// Steps 3–5 — Commit to DB and publish to NATS.
|
||||
if err := commitCustomerAssignment(&booking, miler, provider, etaMinutes); 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,
|
||||
) error {
|
||||
milerUserID := miler.profile.Userid
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
ba := models.BookingAssignment{
|
||||
Bookingid: booking.Bookingid,
|
||||
Mileruserid: milerUserID,
|
||||
Assignmentstatus: constants.AssignmentAssigned,
|
||||
Assignedat: time.Now(),
|
||||
}
|
||||
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,
|
||||
)
|
||||
|
||||
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 "Interstate"
|
||||
}
|
||||
return "OtherState"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user