backend requirements onthe xustomer app

This commit is contained in:
2026-09-07 10:55:11 +05:30
parent 35675d8a9b
commit 1b2690b21a
56 changed files with 13342 additions and 1071 deletions

View File

@@ -0,0 +1,266 @@
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)
}