267 lines
8.7 KiB
Go
267 lines
8.7 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
|
|
"doormile/db"
|
|
"doormile/models"
|
|
"doormile/utils"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
)
|
|
|
|
// Fare estimate — §7 of the contract.
|
|
//
|
|
// The number here is an ESTIMATE RANGE and never a final price. Weight is never
|
|
// collected from the customer: the miler weighs and photographs each package at
|
|
// the door, and that is when the price settles. Everything shown before that is
|
|
// a promise about a band, which is why this returns min/max rather than a
|
|
// figure.
|
|
//
|
|
// Called on every route and package-count change, so it stays cheap: the
|
|
// pricing slab comes out of the same Redis-warmed cache the public price check
|
|
// uses, and the distance is straight-line rather than a routing call.
|
|
|
|
// cxAssumedKgPerPackage is the weight band the estimate is priced against
|
|
// before anything has been weighed. It is the ceiling of the standard box the
|
|
// copy promises ("Standard box (up to 3 kg)"), so the customer is quoted the
|
|
// top of the band they were shown rather than an optimistic guess that the
|
|
// settled price then exceeds.
|
|
const cxAssumedKgPerPackage = 3.0
|
|
|
|
// cxMultiStopUpliftPct is charged per additional destination on one pickup.
|
|
// One visit collecting for three places is one visit, so the uplift is well
|
|
// under three times the price — but the parcels still travel three separate
|
|
// journeys after the hub, and pricing them as one would undercharge the part
|
|
// that actually costs money.
|
|
const cxMultiStopUpliftPct = 0.35
|
|
|
|
// cxFallbackBase / cxFallbackPerKm / cxFallbackPerKg reproduce the estimate the
|
|
// booking path already falls back to when no pricing rule matches, so an
|
|
// unpriced lane quotes the same number it charges.
|
|
const (
|
|
cxFallbackBase = 50.0
|
|
cxFallbackPerKm = 5.0
|
|
cxFallbackPerKg = 10.0
|
|
)
|
|
|
|
type cxEstimateDestination struct {
|
|
StateCode string `json:"stateCode"`
|
|
DistrictCode string `json:"districtCode"`
|
|
PackageCount int `json:"packageCount"`
|
|
}
|
|
|
|
type cxEstimateRequest struct {
|
|
Pickup struct {
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
} `json:"pickup"`
|
|
Destinations []cxEstimateDestination `json:"destinations"`
|
|
}
|
|
|
|
// cxQuote is what both the estimate endpoint and the booking create path work
|
|
// from, so the number quoted on Review is the number stored on the booking.
|
|
type cxQuote struct {
|
|
Min int
|
|
Max int
|
|
PaymentMethod string
|
|
Parcel string
|
|
RouteKM float64
|
|
}
|
|
|
|
// EstimateCxFare prices a whole pickup — one visit, every destination.
|
|
func EstimateCxFare(c *fiber.Ctx) error {
|
|
var req cxEstimateRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return utils.CxBadRequest(c, "We could not read that request")
|
|
}
|
|
if len(req.Destinations) == 0 {
|
|
return utils.CxBadRequest(c, "Add at least one destination")
|
|
}
|
|
|
|
quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, req.Destinations)
|
|
|
|
// Volatile enough to be worth a short cache, stable enough that the same
|
|
// route typed twice should not re-price.
|
|
c.Set("Cache-Control", "max-age=60")
|
|
|
|
return utils.CxOK(c, fiber.Map{
|
|
"min": quote.Min,
|
|
"max": quote.Max,
|
|
"paymentMethod": quote.PaymentMethod,
|
|
"parcel": quote.Parcel,
|
|
"routeKm": quote.RouteKM,
|
|
})
|
|
}
|
|
|
|
// quoteCxPickup prices one pickup visit across all of its destinations.
|
|
//
|
|
// The farthest destination sets the route distance shown on the outline and the
|
|
// receipt; every destination contributes its own leg price, and the additional
|
|
// stops carry an uplift rather than a full second visit.
|
|
func quoteCxPickup(pickupLat, pickupLng float64, destinations []cxEstimateDestination) cxQuote {
|
|
districts := loadDistricts(destinations)
|
|
|
|
var totalMin, totalMax, maxRouteKM float64
|
|
totalPackages := 0
|
|
|
|
for i, d := range destinations {
|
|
packages := d.PackageCount
|
|
if packages < 1 {
|
|
packages = 1
|
|
}
|
|
totalPackages += packages
|
|
|
|
district, known := districts[strings.ToUpper(strings.TrimSpace(d.DistrictCode))]
|
|
|
|
routeKM := 0.0
|
|
if known && (district.Centrelatitude != 0 || district.Centrelongitude != 0) &&
|
|
(pickupLat != 0 || pickupLng != 0) {
|
|
routeKM = calculateDistance(pickupLat, pickupLng, district.Centrelatitude, district.Centrelongitude)
|
|
}
|
|
if routeKM > maxRouteKM {
|
|
maxRouteKM = routeKM
|
|
}
|
|
|
|
legMin, legMax := priceLeg(pickupLat, pickupLng, district, known, packages, routeKM)
|
|
|
|
// The first destination is the visit; every one after it is an extra
|
|
// leg on the same visit.
|
|
if i > 0 {
|
|
legMin *= cxMultiStopUpliftPct + 1
|
|
legMax *= cxMultiStopUpliftPct + 1
|
|
}
|
|
totalMin += legMin
|
|
totalMax += legMax
|
|
}
|
|
|
|
// Whole rupees, not paise — "min: 49" renders as ₹49. Rounded outward so
|
|
// the band the customer is shown always contains the price that settles
|
|
// inside it.
|
|
minR := int(math.Floor(totalMin))
|
|
maxR := int(math.Ceil(totalMax))
|
|
if maxR < minR {
|
|
maxR = minR
|
|
}
|
|
|
|
return cxQuote{
|
|
Min: minR,
|
|
Max: maxR,
|
|
PaymentMethod: "UPI · Cash at doorstep",
|
|
Parcel: describeParcels(totalPackages),
|
|
RouteKM: math.Round(maxRouteKM*10) / 10,
|
|
}
|
|
}
|
|
|
|
// priceLeg prices one destination's journey. Falls back to the same
|
|
// distance-and-weight formula the booking path uses when no pricing rule
|
|
// covers the lane, so an unpriced route still quotes rather than failing —
|
|
// a failed estimate must never block a booking.
|
|
func priceLeg(pickupLat, pickupLng float64, district models.ServiceableDistrict, known bool, packages int, routeKM float64) (min, max float64) {
|
|
weight := float64(packages) * cxAssumedKgPerPackage
|
|
|
|
zone := "National"
|
|
if known && district.Pincodeprefix != "" {
|
|
// Zone is resolved from postal prefixes, the same rule the rest of the
|
|
// pricing engine uses, so an estimate and a settlement agree on which
|
|
// slab applies.
|
|
zone = resolveZone(pincodeForPoint(pickupLat, pickupLng), district.Pincodeprefix)
|
|
}
|
|
|
|
if rules := matchedPricingRules(zone, "Normal", weight); len(rules) > 0 {
|
|
lo, hi := rules[0].Minprice, rules[0].Maxprice
|
|
for _, r := range rules[1:] {
|
|
if r.Minprice < lo {
|
|
lo = r.Minprice
|
|
}
|
|
if r.Maxprice > hi {
|
|
hi = r.Maxprice
|
|
}
|
|
}
|
|
return lo, hi
|
|
}
|
|
|
|
base := cxFallbackBase + routeKM*cxFallbackPerKm + weight*cxFallbackPerKg
|
|
// A ±20% band around the fallback, so the customer still sees a range and
|
|
// not a false precision the miler's scale is about to contradict.
|
|
return base * 0.8, base * 1.2
|
|
}
|
|
|
|
// matchedPricingRules returns the active rules covering a weight in a zone,
|
|
// reusing the Redis-warmed slab the public price check reads.
|
|
func matchedPricingRules(zone, serviceType string, weight float64) []models.DoormilePricing {
|
|
rules, err := loadFromPostgres(zone, serviceType)
|
|
if err != nil || len(rules) == 0 {
|
|
return nil
|
|
}
|
|
matched := applyFilters(rules, weight, "General")
|
|
if len(matched) == 0 {
|
|
matched = applyFilters(rules, weight, "")
|
|
}
|
|
return matched
|
|
}
|
|
|
|
// loadDistricts fetches every district named in one estimate in a single query.
|
|
func loadDistricts(destinations []cxEstimateDestination) map[string]models.ServiceableDistrict {
|
|
codes := make([]string, 0, len(destinations))
|
|
seen := map[string]bool{}
|
|
for _, d := range destinations {
|
|
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
|
|
if code != "" && !seen[code] {
|
|
seen[code] = true
|
|
codes = append(codes, code)
|
|
}
|
|
}
|
|
out := make(map[string]models.ServiceableDistrict, len(codes))
|
|
if len(codes) == 0 {
|
|
return out
|
|
}
|
|
var districts []models.ServiceableDistrict
|
|
if err := db.DB.Where("districtcode IN ?", codes).Find(&districts).Error; err != nil {
|
|
utils.Warn("loadDistricts: query failed, pricing on the fallback formula", "error", err)
|
|
return out
|
|
}
|
|
for _, d := range districts {
|
|
out[d.Districtcode] = d
|
|
}
|
|
return out
|
|
}
|
|
|
|
// pincodeForPoint gives the zone resolver something to work with when the
|
|
// pickup is a map pin rather than a typed address. Empty is a valid answer —
|
|
// resolveZone treats an unknown prefix as a different state, which prices the
|
|
// long way round rather than under-quoting.
|
|
func pincodeForPoint(lat, lng float64) string {
|
|
if lat == 0 && lng == 0 {
|
|
return ""
|
|
}
|
|
var hubs []models.Hub
|
|
if err := db.DB.Select("hubid, pincode, latitude, longitude").
|
|
Where("status = ? AND deletedat IS NULL", "Active").Find(&hubs).Error; err != nil {
|
|
return ""
|
|
}
|
|
best := -1.0
|
|
pincode := ""
|
|
for i := range hubs {
|
|
h := hubs[i]
|
|
if h.Pincode == "" || (h.Latitude == 0 && h.Longitude == 0) {
|
|
continue
|
|
}
|
|
d := calculateDistance(lat, lng, h.Latitude, h.Longitude)
|
|
if best < 0 || d < best {
|
|
best, pincode = d, h.Pincode
|
|
}
|
|
}
|
|
return pincode
|
|
}
|
|
|
|
// describeParcels is the "what is being priced" line on Review and the receipt.
|
|
func describeParcels(packages int) string {
|
|
if packages <= 1 {
|
|
return fmt.Sprintf("Standard box (up to %.0f kg)", cxAssumedKgPerPackage)
|
|
}
|
|
return fmt.Sprintf("%d boxes (up to %.0f kg each)", packages, cxAssumedKgPerPackage)
|
|
}
|