Files
doormile_backend/controllers/doormilePricingController.go
Suriya 9d409a0d85 Fix 9 backend bugs: pricing, zones, geocoding, device tokens, assignment retry
- pricingid null: lookupDoormilePrice now returns matched rule ID; wired to BookingServiceOption.Pricingid
- Zone rename: Interstate→Regional, OtherState→National throughout (code + DB migrated)
- Zone from pincodes: CheckPrice now accepts pickup_pincode+delivery_pincode and auto-resolves zone
- Delivery geocoding: pincodeToLatLon() maps 3-digit prefix to city coords when lat/lon are 0
- Device tokens: device_token field added to PinVerify DTOs; saved on both customer and miler login
- Assignment retry: RejectMilerAssignment now re-triggers AssignCustomerMiler/AssignCRMMiler immediately
- Provider empty B2C: defaults to Doormile when no pricing provider row matches
- City gate 422→400: StatusUnprocessableEntity corrected to StatusBadRequest
- Miler GPS 0,0: WS tracking falls back to MilerProfile DB coords when Redis key is expired

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 11:47:41 +05:30

480 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controllers
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// Valid enum values
var validZones = map[string]bool{
"Local": true,
"Regional": true,
"National": true,
}
var validCategories = map[string]bool{
"General": true,
"Documents": true,
"Electronics": true,
"Clothing": true,
"Fragile": true,
"Medical": true,
"Automotive": true,
"Food": true,
}
var validServiceTypes = map[string]bool{
"Normal": true,
"Express": true,
}
var categoryLabels = map[string]string{
"General": "General Goods",
"Documents": "Books & Documents",
"Electronics": "Electronics & Gadgets",
"Clothing": "Clothing & Textiles",
"Fragile": "Fragile Items",
"Medical": "Medical & Pharma",
"Automotive": "Automotive Parts",
"Food": "Food & Perishables",
}
// pricingCacheKey returns the Redis key for a zone+servicetype slab.
// Only 6 possible keys — 3 zones × 2 service types.
func pricingCacheKey(zone, servicetype string) string {
return fmt.Sprintf("doormile:pricing:%s:%s", zone, servicetype)
}
// invalidatePricingCache drops the Redis key for a zone+servicetype so the next
// CheckPrice request re-warms it from Postgres.
func invalidatePricingCache(zone, servicetype string) {
if db.Rdb == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
key := pricingCacheKey(zone, servicetype)
if err := db.Rdb.Del(ctx, key).Err(); err != nil {
utils.Warn("Failed to invalidate pricing cache", "key", key, "error", err)
} else {
utils.Info("Pricing cache invalidated", "key", key)
}
}
// loadFromPostgres fetches all Active rules for a zone+servicetype and writes
// them into Redis. Returns the rules regardless of whether Redis succeeds.
func loadFromPostgres(zone, servicetype string) ([]models.DoormilePricing, error) {
var rules []models.DoormilePricing
err := db.DB.
Where("zone = ? AND servicetype = ? AND status = ? AND deletedat IS NULL", zone, servicetype, "Active").
Order("category ASC").
Find(&rules).Error
if err != nil {
return nil, err
}
// Warm the cache — no TTL because we invalidate explicitly on admin writes
if db.Rdb != nil && len(rules) > 0 {
data, merr := json.Marshal(rules)
if merr == nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if rerr := db.Rdb.Set(ctx, pricingCacheKey(zone, servicetype), data, 0).Err(); rerr != nil {
utils.Warn("Failed to warm pricing cache", "zone", zone, "servicetype", servicetype, "error", rerr)
} else {
utils.Info("Pricing cache warmed", "zone", zone, "servicetype", servicetype, "count", len(rules))
}
}
}
return rules, nil
}
// applyFilters takes the full slab for a zone+servicetype and applies the
// weight and optional category filters in memory.
func applyFilters(rules []models.DoormilePricing, weight float64, category string) []models.DoormilePricing {
out := make([]models.DoormilePricing, 0, len(rules))
for _, r := range rules {
if r.Minweight > weight || r.Maxweight < weight {
continue
}
if category != "" && r.Category != category {
continue
}
out = append(out, r)
}
return out
}
// buildPriceResponse constructs the final JSON response from a filtered rule set.
func buildPriceResponse(zone, servicetype string, weight float64, rules []models.DoormilePricing) fiber.Map {
if len(rules) == 0 {
return fiber.Map{
"found": false,
"zone": zone,
"service_type": servicetype,
"weight": weight,
"message": "no pricing configured for this combination",
"results": []interface{}{},
"total": 0,
}
}
results := make([]fiber.Map, 0, len(rules))
for _, r := range rules {
results = append(results, fiber.Map{
"category": r.Category,
"category_label": categoryLabels[r.Category],
"min_price": r.Minprice,
"max_price": r.Maxprice,
})
}
return fiber.Map{
"found": true,
"zone": zone,
"service_type": servicetype,
"weight": weight,
"currency": rules[0].Currency,
"total": len(results),
"results": results,
}
}
// CheckPrice is the public endpoint Flutter calls after the customer fills the form.
// Reads from Redis first (full zone+servicetype slab), filters by weight in memory.
// Falls back to Postgres on cache miss and re-warms Redis before returning.
//
// POST /api/v1/pricing/check
// Body: { zone, service_type, weight, category? }
func CheckPrice(c *fiber.Ctx) error {
type req struct {
Zone string `json:"zone"`
ServiceType string `json:"service_type"`
Weight float64 `json:"weight"`
Category string `json:"category"`
PickupPincode string `json:"pickup_pincode"`
DeliveryPincode string `json:"delivery_pincode"`
}
body := new(req)
if err := c.BodyParser(body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
// Auto-resolve zone from pincodes when not explicitly provided.
if body.Zone == "" && body.PickupPincode != "" && body.DeliveryPincode != "" {
body.Zone = resolveZone(body.PickupPincode, body.DeliveryPincode)
}
if !validZones[body.Zone] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"message": "invalid zone",
"valid_zones": []string{"Local", "Regional", "National"},
})
}
if !validServiceTypes[body.ServiceType] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"message": "invalid service_type",
"valid_service_types": []string{"Normal", "Express"},
})
}
if body.Weight <= 0 {
return utils.BadRequest(c, "weight must be greater than 0")
}
if body.Category != "" && !validCategories[body.Category] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"success": false,
"message": "invalid category",
"valid_categories": []string{"General", "Documents", "Electronics", "Clothing", "Fragile", "Medical", "Automotive", "Food"},
})
}
var allRules []models.DoormilePricing
if db.Rdb != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
cached, err := db.Rdb.Get(ctx, pricingCacheKey(body.Zone, body.ServiceType)).Result()
cancel()
if err == nil {
if jerr := json.Unmarshal([]byte(cached), &allRules); jerr != nil {
utils.Warn("Corrupt pricing cache entry, evicting", "zone", body.Zone, "servicetype", body.ServiceType)
invalidatePricingCache(body.Zone, body.ServiceType)
allRules = nil
}
}
}
if allRules == nil {
var err error
allRules, err = loadFromPostgres(body.Zone, body.ServiceType)
if err != nil {
return utils.Internal(c, "failed to fetch pricing")
}
}
matched := applyFilters(allRules, body.Weight, body.Category)
return c.JSON(fiber.Map{
"success": true,
"data": buildPriceResponse(body.Zone, body.ServiceType, body.Weight, matched),
})
}
// GetDoormilePricing returns all pricing rules for admin review.
// Always reads from Postgres — admins need the authoritative view.
// GET /api/v1/admin/doormile-pricing
func GetDoormilePricing(c *fiber.Ctx) error {
query := db.DB.Where("deletedat IS NULL")
if z := c.Query("zone"); z != "" {
query = query.Where("zone = ?", z)
}
if cat := c.Query("category"); cat != "" {
query = query.Where("category = ?", cat)
}
if st := c.Query("service_type"); st != "" {
query = query.Where("servicetype = ?", st)
}
var rules []models.DoormilePricing
if err := query.Order("zone, category, servicetype, min_weight").Find(&rules).Error; err != nil {
return utils.Internal(c, "failed to fetch pricing rules")
}
return utils.List(c, rules, int64(len(rules)))
}
// CreateDoormilePricing adds a new pricing band and invalidates the relevant cache.
// POST /api/v1/admin/doormile-pricing
func CreateDoormilePricing(c *fiber.Ctx) error {
type req struct {
Zone string `json:"zone"`
Category string `json:"category"`
ServiceType string `json:"service_type"`
MinWeight float64 `json:"min_weight"`
MaxWeight float64 `json:"max_weight"`
MinPrice float64 `json:"min_price"`
MaxPrice float64 `json:"max_price"`
Currency string `json:"currency"`
}
body := new(req)
if err := c.BodyParser(body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if !validZones[body.Zone] || !validCategories[body.Category] || !validServiceTypes[body.ServiceType] {
return utils.BadRequest(c, "invalid zone, category, or service_type")
}
if body.MinWeight < 0 || body.MaxWeight <= body.MinWeight {
return utils.BadRequest(c, "max_weight must be greater than min_weight")
}
if body.MinPrice <= 0 || body.MaxPrice <= body.MinPrice {
return utils.BadRequest(c, "max_price must be greater than min_price")
}
currency := body.Currency
if currency == "" {
currency = "INR"
}
rule := models.DoormilePricing{
Zone: body.Zone,
Category: body.Category,
Servicetype: body.ServiceType,
Minweight: body.MinWeight,
Maxweight: body.MaxWeight,
Minprice: body.MinPrice,
Maxprice: body.MaxPrice,
Currency: currency,
Status: "Active",
}
if err := db.DB.Create(&rule).Error; err != nil {
return utils.Internal(c, "failed to create pricing rule")
}
invalidatePricingCache(rule.Zone, rule.Servicetype)
return utils.Created(c, rule)
}
// UpdateDoormilePricing updates a pricing band and invalidates affected cache keys.
// If zone or service_type changes, both the old and new cache keys are invalidated.
// PUT /api/v1/admin/doormile-pricing/:id
func UpdateDoormilePricing(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid pricing rule ID")
}
var rule models.DoormilePricing
if err := db.DB.Where("doormile_pricing_id = ? AND deletedat IS NULL", id).First(&rule).Error; err != nil {
return utils.NotFound(c, "pricing rule not found")
}
// Capture old keys before any mutation
oldZone := rule.Zone
oldServicetype := rule.Servicetype
type req struct {
Zone string `json:"zone"`
Category string `json:"category"`
ServiceType string `json:"service_type"`
MinWeight float64 `json:"min_weight"`
MaxWeight float64 `json:"max_weight"`
MinPrice float64 `json:"min_price"`
MaxPrice float64 `json:"max_price"`
Currency string `json:"currency"`
Status string `json:"status"`
}
body := new(req)
if err := c.BodyParser(body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if body.Zone != "" {
if !validZones[body.Zone] {
return utils.BadRequest(c, "invalid zone")
}
rule.Zone = body.Zone
}
if body.Category != "" {
if !validCategories[body.Category] {
return utils.BadRequest(c, "invalid category")
}
rule.Category = body.Category
}
if body.ServiceType != "" {
if !validServiceTypes[body.ServiceType] {
return utils.BadRequest(c, "invalid service_type")
}
rule.Servicetype = body.ServiceType
}
if body.MinWeight >= 0 {
rule.Minweight = body.MinWeight
}
if body.MaxWeight > 0 {
rule.Maxweight = body.MaxWeight
}
if body.MinPrice > 0 {
rule.Minprice = body.MinPrice
}
if body.MaxPrice > 0 {
rule.Maxprice = body.MaxPrice
}
if body.Currency != "" {
rule.Currency = body.Currency
}
if body.Status != "" {
rule.Status = body.Status
}
rule.Updatedat = time.Now()
if err := db.DB.Save(&rule).Error; err != nil {
return utils.Internal(c, "failed to update pricing rule")
}
invalidatePricingCache(oldZone, oldServicetype)
if rule.Zone != oldZone || rule.Servicetype != oldServicetype {
invalidatePricingCache(rule.Zone, rule.Servicetype)
}
return utils.OK(c, rule)
}
// DeleteDoormilePricing soft-deletes a pricing band and invalidates its cache key.
// DELETE /api/v1/admin/doormile-pricing/:id
func DeleteDoormilePricing(c *fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return utils.BadRequest(c, "invalid pricing rule ID")
}
var rule models.DoormilePricing
if err := db.DB.Where("doormile_pricing_id = ? AND deletedat IS NULL", id).First(&rule).Error; err != nil {
return utils.NotFound(c, "pricing rule not found")
}
now := time.Now()
rule.Deletedat = &now
db.DB.Save(&rule)
invalidatePricingCache(rule.Zone, rule.Servicetype)
return utils.Message(c, "pricing rule deleted successfully")
}
// WarmPricingCache loads every Active pricing rule from Postgres into Redis on
// server startup. Call this once from main.go after DB and Redis are ready.
// If Redis is unavailable it logs a warning and returns — the lazy-load fallback
// in CheckPrice will handle individual misses at request time.
func WarmPricingCache() {
if db.Rdb == nil {
utils.Warn("WarmPricingCache: Redis not available, skipping pre-warm")
return
}
utils.Info("WarmPricingCache: warming pricing cache from Postgres...")
// Iterate every zone × servicetype combination
zones := []string{"Local", "Regional", "National"}
serviceTypes := []string{"Normal", "Express"}
warmed := 0
for _, zone := range zones {
for _, st := range serviceTypes {
rules, err := loadFromPostgres(zone, st)
if err != nil {
utils.Error("WarmPricingCache: failed to load", "zone", zone, "servicetype", st, "error", err)
continue
}
if len(rules) > 0 {
warmed++
}
}
}
utils.Info("WarmPricingCache: done", "slabs_warmed", warmed)
}
// GetPricingMeta returns the valid enum values so Flutter can populate its dropdowns.
// GET /api/v1/pricing/meta
func GetPricingMeta(c *fiber.Ctx) error {
return utils.OK(c, fiber.Map{
"zones": []fiber.Map{
{"value": "Local", "label": "Local / Same City"},
{"value": "Regional", "label": "Regional / Same State"},
{"value": "National", "label": "National / Other State"},
},
"categories": []fiber.Map{
{"value": "General", "label": "General Goods"},
{"value": "Documents", "label": "Books & Documents"},
{"value": "Electronics", "label": "Electronics & Gadgets"},
{"value": "Clothing", "label": "Clothing & Textiles"},
{"value": "Fragile", "label": "Fragile Items"},
{"value": "Medical", "label": "Medical & Pharma"},
{"value": "Automotive", "label": "Automotive Parts"},
{"value": "Food", "label": "Food & Perishables"},
},
"service_types": []fiber.Map{
{"value": "Normal", "label": "Normal"},
{"value": "Express", "label": "Express"},
},
})
}