- 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>
480 lines
14 KiB
Go
480 lines
14 KiB
Go
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"},
|
||
},
|
||
})
|
||
}
|