backend requirements onthe xustomer app
This commit is contained in:
895
controllers/cxBookingController.go
Normal file
895
controllers/cxBookingController.go
Normal file
@@ -0,0 +1,895 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
"doormile/db"
|
||||
"doormile/internal/assignment"
|
||||
"doormile/internal/cxstage"
|
||||
"doormile/middlewares"
|
||||
"doormile/models"
|
||||
"doormile/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Bookings — §9 of the contract.
|
||||
//
|
||||
// A customer books a PICKUP: one visit, 1..N destinations, and no tracking
|
||||
// number anywhere in this file. Tracking numbers are minted per destination
|
||||
// when the miler completes the pickup, because until the parcels are in
|
||||
// someone's hands there is no shipment to track — only an intention to collect
|
||||
// one.
|
||||
|
||||
const (
|
||||
cxDefaultPageSize = 20
|
||||
cxMaxPageSize = 50
|
||||
|
||||
// cxAbsoluteMaxDestinations is a hard ceiling checked BEFORE any database
|
||||
// work, independent of the configured per-city cap.
|
||||
//
|
||||
// The configured cap (customerbookinglimits.maxdestinations) is the real
|
||||
// policy and stays authoritative — but reading it costs two queries, and
|
||||
// the district lookup above it builds a `WHERE districtcode IN (...)` from
|
||||
// however many entries the caller sent. Without a ceiling, a request
|
||||
// carrying ten thousand destinations does all of that work before anything
|
||||
// says no.
|
||||
//
|
||||
// Set well above any plausible policy so it never masks the real cap: this
|
||||
// is a sanity guard on unbounded input, not a product limit.
|
||||
cxAbsoluteMaxDestinations = 25
|
||||
)
|
||||
|
||||
type cxDetailsInput struct {
|
||||
Street *string `json:"street"`
|
||||
Building *string `json:"building"`
|
||||
Landmark *string `json:"landmark"`
|
||||
RecipientName *string `json:"recipientName"`
|
||||
RecipientPhone *string `json:"recipientPhone"`
|
||||
Instructions *string `json:"instructions"`
|
||||
Pin *struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
} `json:"pin"`
|
||||
// CodAmount is money the customer wants collected at this door on their
|
||||
// behalf. Doormile is the carrier, not the seller.
|
||||
CodAmount *float64 `json:"codAmount"`
|
||||
}
|
||||
|
||||
type cxCreateBookingRequest struct {
|
||||
Pickup struct {
|
||||
Title string `json:"title"`
|
||||
Sub string `json:"sub"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
} `json:"pickup"`
|
||||
SlotID string `json:"slotId"`
|
||||
Destinations []struct {
|
||||
StateCode string `json:"stateCode"`
|
||||
DistrictCode string `json:"districtCode"`
|
||||
PackageCount int `json:"packageCount"`
|
||||
Details *cxDetailsInput `json:"details"`
|
||||
} `json:"destinations"`
|
||||
// Estimate is what the customer was shown on Review. Recorded for dispute
|
||||
// audit — when the settled price is questioned months later, the number on
|
||||
// the screen is the fact that matters, not a re-run of today's pricing.
|
||||
Estimate *struct {
|
||||
Min int `json:"min"`
|
||||
Max int `json:"max"`
|
||||
} `json:"estimate"`
|
||||
}
|
||||
|
||||
// CreateCxBooking creates the pickup.
|
||||
func CreateCxBooking(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
var req cxCreateBookingRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
// "No destinations at all" and "a destination is missing its state or
|
||||
// district" are different problems with different fixes, and the customer
|
||||
// is shown this message verbatim. Telling someone who added nothing that
|
||||
// "every destination needs a serviceable state and district" describes a
|
||||
// problem they do not have and hides the one they do — they need to add a
|
||||
// destination, not correct one. The estimate endpoint already says this
|
||||
// correctly; these two now agree.
|
||||
if len(req.Destinations) == 0 {
|
||||
return utils.CxBadRequest(c, "Add at least one destination")
|
||||
}
|
||||
if strings.TrimSpace(req.SlotID) == "" {
|
||||
return utils.CxBadRequest(c, "Pick a pickup slot")
|
||||
}
|
||||
// Cheapest validation first, before anything reaches the database. A slot id
|
||||
// carries its own date, so a stale one is provably stale without a query —
|
||||
// and this is the case an app left open across midnight actually hits.
|
||||
if CxSlotDateIsPast(req.SlotID) {
|
||||
return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot")
|
||||
}
|
||||
// Unbounded input, bounded before it costs anything. The configured cap
|
||||
// below is the real policy; this only stops a caller making the server do
|
||||
// three queries and build an arbitrarily long IN clause to be told no.
|
||||
if len(req.Destinations) > cxAbsoluteMaxDestinations {
|
||||
return utils.CxBadRequest(c, "That is more destinations than one pickup can carry")
|
||||
}
|
||||
|
||||
// The pickup point has to be somewhere Doormile actually collects from.
|
||||
// CityGateMiddleware sniffs the body for a `pickuppincode`, which this
|
||||
// request shape does not have — its pickup is a title/sub/lat/lng from the
|
||||
// place search — so it waves every customer booking through. The check is
|
||||
// done here, against the pincode resolved from the coordinates.
|
||||
pickupPincode := pincodeForPoint(req.Pickup.Lat, req.Pickup.Lng)
|
||||
if _, served := middlewares.PincodeInOperatingCity(pickupPincode); !served {
|
||||
return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable,
|
||||
"We are not collecting from that area yet")
|
||||
}
|
||||
|
||||
appLocationID := appLocationForPoint(req.Pickup.Lat, req.Pickup.Lng)
|
||||
maxPackages, maxDestinations := CxBookingLimits(appLocationID)
|
||||
if len(req.Destinations) > maxDestinations {
|
||||
return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxDestinations)+" destinations per pickup")
|
||||
}
|
||||
|
||||
// Resolve every district in one query, then validate. Names are copied onto
|
||||
// the destination rather than joined at read time — the client renders
|
||||
// "Chennai, Tamil Nadu" straight from the booking.
|
||||
codes := make([]string, 0, len(req.Destinations))
|
||||
for _, d := range req.Destinations {
|
||||
codes = append(codes, strings.ToUpper(strings.TrimSpace(d.DistrictCode)))
|
||||
}
|
||||
var districtRows []models.ServiceableDistrict
|
||||
if err := db.DB.Where("districtcode IN ?", codes).Find(&districtRows).Error; err != nil {
|
||||
utils.Error("CreateCxBooking: district lookup failed", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
districts := make(map[string]models.ServiceableDistrict, len(districtRows))
|
||||
for _, d := range districtRows {
|
||||
districts[d.Districtcode] = d
|
||||
}
|
||||
|
||||
stateNames, err := cxStateNames(districtRows)
|
||||
if err != nil {
|
||||
utils.Error("CreateCxBooking: state lookup failed", "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
totalPackages := 0
|
||||
for _, d := range req.Destinations {
|
||||
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
|
||||
district, ok := districts[code]
|
||||
if !ok || strings.TrimSpace(d.StateCode) == "" {
|
||||
return utils.CxBadRequest(c, "Every destination needs a serviceable state and district")
|
||||
}
|
||||
if !district.Available {
|
||||
// The district was open when the customer picked it and closed
|
||||
// before they confirmed. A distinct code, because the app has a
|
||||
// specific recovery for it: send them back to change that one
|
||||
// destination rather than to a generic retry.
|
||||
return utils.CxFail(c, fiber.StatusUnprocessableEntity, utils.CxErrUnserviceable,
|
||||
"That district is no longer available")
|
||||
}
|
||||
packages := d.PackageCount
|
||||
if packages < 1 {
|
||||
packages = 1
|
||||
}
|
||||
totalPackages += packages
|
||||
}
|
||||
if totalPackages > maxPackages {
|
||||
return utils.CxBadRequest(c, "Up to "+strconv.Itoa(maxPackages)+" packages per pickup")
|
||||
}
|
||||
|
||||
slotFrom, slotTo, slotOK := ResolveCxSlot(req.SlotID)
|
||||
if !slotOK {
|
||||
return utils.CxBadRequest(c, "Pick a pickup slot")
|
||||
}
|
||||
|
||||
// An EXPIRED slot and a FULL slot are different failures and must not share
|
||||
// a message. A slot id encodes its own date, so an app left open across
|
||||
// midnight — or one that cached the slot list for a session — sends
|
||||
// yesterday's window in good faith. Telling that customer the window "just
|
||||
// filled up" is untrue and points them at the wrong recovery: they need to
|
||||
// re-fetch the slot list, not try again for a place in a queue.
|
||||
//
|
||||
// 400 rather than 409 for the same reason. The contract maps 400/invalid to
|
||||
// "Pick a pickup slot", which is exactly the action required, while 409 is
|
||||
// the capacity race below.
|
||||
if !slotFrom.After(utils.ISTNow()) {
|
||||
return utils.CxBadRequest(c, "That pickup time has passed — pick a new slot")
|
||||
}
|
||||
|
||||
if !CxSlotHasCapacity(req.SlotID, req.Pickup.Lat, req.Pickup.Lng) {
|
||||
return utils.CxConflict(c, "That pickup window just filled up")
|
||||
}
|
||||
|
||||
// Price the pickup as one visit. A failed estimate must never block a
|
||||
// booking, so a zero range is stored rather than an error returned — the
|
||||
// receipt settles on what the miler weighs regardless.
|
||||
estimateDestinations := make([]cxEstimateDestination, 0, len(req.Destinations))
|
||||
for _, d := range req.Destinations {
|
||||
estimateDestinations = append(estimateDestinations, cxEstimateDestination{
|
||||
StateCode: d.StateCode,
|
||||
DistrictCode: d.DistrictCode,
|
||||
PackageCount: d.PackageCount,
|
||||
})
|
||||
}
|
||||
quote := quoteCxPickup(req.Pickup.Lat, req.Pickup.Lng, estimateDestinations)
|
||||
estimateMin, estimateMax := quote.Min, quote.Max
|
||||
if req.Estimate != nil && req.Estimate.Max > 0 {
|
||||
// The customer's number wins. They agreed to what was on their screen,
|
||||
// and re-pricing at confirm time would quietly change the deal.
|
||||
estimateMin, estimateMax = req.Estimate.Min, req.Estimate.Max
|
||||
}
|
||||
|
||||
now := utils.DBNow()
|
||||
pickupFromDB := cxToDBTime(slotFrom)
|
||||
pickupToDB := cxToDBTime(slotTo)
|
||||
|
||||
first := req.Destinations[0]
|
||||
firstDistrict := districts[strings.ToUpper(strings.TrimSpace(first.DistrictCode))]
|
||||
|
||||
booking := models.PickupBooking{
|
||||
Bookingno: generateBookingNo(),
|
||||
Appcustomerid: customerID,
|
||||
Pickupaddress: joinNonEmpty(", ", req.Pickup.Title, req.Pickup.Sub),
|
||||
Pickuptitle: req.Pickup.Title,
|
||||
Pickupsub: req.Pickup.Sub,
|
||||
Pickuppincode: pickupPincode,
|
||||
|
||||
Pickuplatitude: req.Pickup.Lat,
|
||||
Pickuplongitude: req.Pickup.Lng,
|
||||
|
||||
// The flat delivery columns mirror destination 0. They are NOT the
|
||||
// destination list — that lives in bookingdestinations — but the miler
|
||||
// app, the hub console, the routing code and the hyperlocal check all
|
||||
// read them, and leaving them empty would make a customer-app booking
|
||||
// invisible to every one of those. Destination 0 is the one the rider
|
||||
// is told about first, so it is the one that mirrors.
|
||||
Deliveryaddress: cxDestinationAddress(first.Details, firstDistrict),
|
||||
Deliverypincode: firstDistrict.Pincodeprefix,
|
||||
Deliverylatitude: firstDistrict.Centrelatitude,
|
||||
Deliverylongitude: firstDistrict.Centrelongitude,
|
||||
Deliverycity: firstDistrict.Districtname,
|
||||
|
||||
Bookingsource: constants.BookingSourceCustomerApp,
|
||||
Pickupsourcetype: constants.PickupSourceCustomer,
|
||||
Status: constants.BookingPendingPickup,
|
||||
|
||||
Preferredpickupfrom: &pickupFromDB,
|
||||
Preferredpickupto: &pickupToDB,
|
||||
|
||||
Slotid: req.SlotID,
|
||||
Customerstage: constants.CxStageBooked,
|
||||
Customerstatus: constants.CxStatusActive,
|
||||
Estimateminrupees: estimateMin,
|
||||
Estimatemaxrupees: estimateMax,
|
||||
Routekm: quote.RouteKM,
|
||||
|
||||
Createdat: now,
|
||||
Updatedat: now,
|
||||
}
|
||||
|
||||
if pin := cxFirstPin(first.Details); pin != nil {
|
||||
booking.Deliverylatitude, booking.Deliverylongitude = pin[0], pin[1]
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
if err := tx.Create(&booking).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CreateCxBooking: could not create booking", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
for i, d := range req.Destinations {
|
||||
code := strings.ToUpper(strings.TrimSpace(d.DistrictCode))
|
||||
district := districts[code]
|
||||
packages := d.PackageCount
|
||||
if packages < 1 {
|
||||
packages = 1
|
||||
}
|
||||
|
||||
dest := models.BookingDestination{
|
||||
Bookingid: booking.Bookingid,
|
||||
Seq: i,
|
||||
Statecode: district.Statecode,
|
||||
Statename: stateNames[district.Statecode],
|
||||
Districtcode: district.Districtcode,
|
||||
Districtname: district.Districtname,
|
||||
Packagecount: packages,
|
||||
Pincode: district.Pincodeprefix,
|
||||
Createdat: now,
|
||||
Updatedat: now,
|
||||
}
|
||||
applyCxDetails(&dest, d.Details)
|
||||
|
||||
if err := tx.Create(&dest).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CreateCxBooking: could not create destination", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
// One parcel row per package, linked to its destination. The miler
|
||||
// weighs and photographs each package at the door, so each needs a row
|
||||
// to be weighed into — and each has to know which order it belongs to.
|
||||
// Weight is deliberately left at zero: it is never collected from the
|
||||
// customer, and a guess here would look like a measurement on the
|
||||
// receipt.
|
||||
for p := 0; p < packages; p++ {
|
||||
parcel := models.BookingParcel{
|
||||
Bookingid: booking.Bookingid,
|
||||
Bookingdestinationid: &dest.Bookingdestinationid,
|
||||
Itemcategory: "General",
|
||||
Createdat: now,
|
||||
Updatedat: now,
|
||||
}
|
||||
if err := tx.Create(&parcel).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CreateCxBooking: could not create parcel", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The service option is what the REST of the platform reads a booking's
|
||||
// value from, and it is not optional just because the customer surface
|
||||
// keeps its own estimate columns:
|
||||
//
|
||||
// * MilerDeliverConsignment and MilerInwardConsignmentAtHub copy
|
||||
// Estimatedprice onto BookingAssignment.ridercharges when a leg closes,
|
||||
// and GET /miler/earnings sums that column — so with no row here every
|
||||
// customer-app job a rider completes would show ₹0 on their Earnings
|
||||
// screen.
|
||||
// * The admin console's Orders list renders serviceoptions[0].estimatedprice
|
||||
// as the Price column, which would read "N/A" for every customer booking.
|
||||
//
|
||||
// The midpoint of the band the customer was shown is the honest figure
|
||||
// before the miler weighs anything, and it is what lookupDoormilePrice
|
||||
// returns everywhere else in this codebase.
|
||||
slaDue := cxToDBTime(slotTo.Add(48 * time.Hour))
|
||||
estimatedDelivery := cxToDBTime(slotTo.Add(24 * time.Hour))
|
||||
srvOption := models.BookingServiceOption{
|
||||
Bookingid: booking.Bookingid,
|
||||
Servicetype: "Normal",
|
||||
Estimatedprice: float64(estimateMin+estimateMax) / 2,
|
||||
Estimateddeliveryat: &estimatedDelivery,
|
||||
Sladueat: &slaDue,
|
||||
Createdat: now,
|
||||
}
|
||||
if err := tx.Create(&srvOption).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CreateCxBooking: could not create service option", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
if err := cxstage.Record(tx, cxstage.Event{
|
||||
BookingID: booking.Bookingid,
|
||||
Stage: constants.CxStageBooked,
|
||||
ActorType: constants.CxActorCustomer,
|
||||
ActorID: &customerID,
|
||||
Source: "POST /customer/bookings",
|
||||
At: now,
|
||||
}); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CreateCxBooking: could not record booked stage", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
utils.Error("CreateCxBooking: commit failed", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
// Fire-and-forget, after commit, exactly as the express path does.
|
||||
go assignment.AssignCustomerMiler(booking.Bookingid)
|
||||
publishCxBookingCreated(&booking, len(req.Destinations))
|
||||
|
||||
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusCreated)
|
||||
}
|
||||
|
||||
// GetCxBookings backs the Orders tabs, Home's recent list and pull-to-refresh.
|
||||
func GetCxBookings(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
|
||||
limit := cxDefaultPageSize
|
||||
if v, err := strconv.Atoi(c.Query("limit")); err == nil && v > 0 {
|
||||
limit = v
|
||||
}
|
||||
if limit > cxMaxPageSize {
|
||||
limit = cxMaxPageSize
|
||||
}
|
||||
|
||||
// One filter, applied to both the page and the count, so `total` is the size
|
||||
// of the tab the customer is actually looking at. Counting every booking
|
||||
// they have ever made would put "48" above a Cancelled tab holding two rows.
|
||||
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||||
scoped := func() *gorm.DB {
|
||||
q := db.DB.Model(&models.PickupBooking{}).Where("appcustomerid = ?", customerID)
|
||||
switch status {
|
||||
case constants.CxStatusActive:
|
||||
// Rows written before this surface existed carry no customerstatus.
|
||||
// Treating a null as active keeps a live booking visible rather
|
||||
// than hiding it from the customer who is waiting on it.
|
||||
// Parenthesised explicitly rather than relying on AND binding
|
||||
// tighter than OR — the two readings differ by "shows every
|
||||
// cancelled booking in the active tab", which is not a thing to
|
||||
// leave to operator precedence.
|
||||
q = q.Where("(customerstatus = ?) OR ((customerstatus IS NULL OR customerstatus = '') AND status <> ?)",
|
||||
constants.CxStatusActive, constants.BookingCancelled)
|
||||
case constants.CxStatusCompleted:
|
||||
q = q.Where("customerstatus = ?", constants.CxStatusCompleted)
|
||||
case constants.CxStatusCancelled:
|
||||
q = q.Where("customerstatus = ? OR status = ?", constants.CxStatusCancelled, constants.BookingCancelled)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
q := scoped()
|
||||
|
||||
// Keyset pagination on the primary key. Offsets drift when a new booking
|
||||
// lands mid-scroll, which shows the customer the same row twice.
|
||||
if cursor := strings.TrimSpace(c.Query("cursor")); cursor != "" {
|
||||
if after, err := strconv.Atoi(cursor); err == nil {
|
||||
q = q.Where("bookingid < ?", after)
|
||||
}
|
||||
}
|
||||
|
||||
var bookings []models.PickupBooking
|
||||
if err := q.Order("bookingid DESC").Limit(limit + 1).Find(&bookings).Error; err != nil {
|
||||
utils.Error("GetCxBookings: query failed", "customer_id", customerID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
var nextCursor *string
|
||||
if len(bookings) > limit {
|
||||
bookings = bookings[:limit]
|
||||
next := strconv.Itoa(bookings[len(bookings)-1].Bookingid)
|
||||
nextCursor = &next
|
||||
}
|
||||
|
||||
bundle := loadCxBundle(bookings)
|
||||
out := make([]fiber.Map, 0, len(bookings))
|
||||
for i := range bookings {
|
||||
out = append(out, renderCxBooking(&bookings[i], bundle))
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := scoped().Count(&total).Error; err != nil {
|
||||
utils.Warn("GetCxBookings: count failed, reporting the page size", "customer_id", customerID, "error", err)
|
||||
total = int64(len(out))
|
||||
}
|
||||
|
||||
return utils.CxList(c, out, int(total), nextCursor)
|
||||
}
|
||||
|
||||
// GetCxBookingDetail is the canonical read — the tracking screen and the
|
||||
// receipt are both rendered from it, and it is polled while tracking is open.
|
||||
func GetCxBookingDetail(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
reference := strings.TrimSpace(c.Params("reference"))
|
||||
|
||||
booking, err := cxLoadBooking(customerID, reference)
|
||||
if err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that pickup")
|
||||
}
|
||||
|
||||
bundle := loadCxBundle([]models.PickupBooking{*booking})
|
||||
payload := renderCxBooking(booking, bundle)
|
||||
|
||||
// Polled every few seconds while the tracking screen is open. A 304 turns
|
||||
// most of those polls into a header exchange instead of a full render.
|
||||
if served := serveIfNotModified(c, payload); served {
|
||||
return nil
|
||||
}
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
return utils.CxOK(c, payload)
|
||||
}
|
||||
|
||||
// GetCxOrder returns one order by tracking number, for push deep links.
|
||||
//
|
||||
// It answers with the whole booking object rather than a slimmer order shape —
|
||||
// the client already parses this one, and a second shape for the same data is
|
||||
// a second parser to keep in step.
|
||||
func GetCxOrder(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
trackingID := strings.TrimSpace(c.Params("trackingId"))
|
||||
if trackingID == "" {
|
||||
return utils.CxNotFound(c, "We could not find that order")
|
||||
}
|
||||
|
||||
var dest models.BookingDestination
|
||||
if err := db.DB.Where("trackingno = ?", trackingID).First(&dest).Error; err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that order")
|
||||
}
|
||||
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Where("bookingid = ? AND appcustomerid = ?", dest.Bookingid, customerID).
|
||||
First(&booking).Error; err != nil {
|
||||
// The tracking number exists but belongs to someone else. Answered as
|
||||
// not-found rather than forbidden: confirming that a tracking number is
|
||||
// real tells an enumerating caller something they should not learn.
|
||||
return utils.CxNotFound(c, "We could not find that order")
|
||||
}
|
||||
|
||||
bundle := loadCxBundle([]models.PickupBooking{booking})
|
||||
return utils.CxOK(c, renderCxBooking(&booking, bundle))
|
||||
}
|
||||
|
||||
// CancelCxBooking cancels the whole pickup.
|
||||
//
|
||||
// Allowed through arrived and refused from picked_up onward. `cancellable` on
|
||||
// the booking mirrors the same policy so the UI can hide the button, but this
|
||||
// re-checks — the button state is a hint the client renders from a response
|
||||
// that may be seconds old, never the authority.
|
||||
func CancelCxBooking(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
reference := strings.TrimSpace(c.Params("reference"))
|
||||
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = c.BodyParser(&req)
|
||||
|
||||
booking, err := cxLoadBooking(customerID, reference)
|
||||
if err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that pickup")
|
||||
}
|
||||
|
||||
if booking.Customerstatus == constants.CxStatusCancelled ||
|
||||
booking.Status == constants.BookingCancelled {
|
||||
// Already cancelled. Answered as success rather than as a conflict: the
|
||||
// customer asked for a state the booking is already in, and a retry
|
||||
// over a flaky network must not read as a failure.
|
||||
return utils.CxOK(c, fiber.Map{
|
||||
"reference": booking.Bookingno,
|
||||
"status": constants.CxStatusCancelled,
|
||||
"cancelReason": booking.Cancelreason,
|
||||
})
|
||||
}
|
||||
|
||||
stage := booking.Customerstage
|
||||
if stage == "" {
|
||||
stage = deriveStageFromStatus(booking)
|
||||
}
|
||||
if !constants.CxCancellable(stage) {
|
||||
return utils.CxConflict(c, "This pickup can no longer be cancelled")
|
||||
}
|
||||
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
|
||||
tx := db.DB.Begin()
|
||||
if err := cxstage.Cancel(tx, booking.Bookingid, reason,
|
||||
constants.CxActorCustomer, &customerID, "POST /customer/bookings/{reference}/cancel"); err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CancelCxBooking: cancel failed", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
// Release the rider. Without this the assignment stays open, the rider
|
||||
// keeps a stop they must not attempt, and MilerEndDuty refuses to let them
|
||||
// go off duty while any assignment is still Assigned or Accepted.
|
||||
if err := tx.Model(&models.BookingAssignment{}).
|
||||
Where("bookingid = ? AND assignmentstatus IN ?", booking.Bookingid,
|
||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||
Updates(map[string]interface{}{
|
||||
"assignmentstatus": constants.AssignmentCancelled,
|
||||
"remarks": "cancelled by customer",
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CancelCxBooking: could not release assignment", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
if booking.Assignedmileruserid != nil {
|
||||
if err := tx.Model(&models.MilerProfile{}).
|
||||
Where("userid = ? AND availabilitystatus IN ?", *booking.Assignedmileruserid,
|
||||
[]string{constants.MilerAssigned, constants.MilerOnPickup, constants.MilerAtCustomer}).
|
||||
Update("availabilitystatus", constants.MilerAvailable).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("CancelCxBooking: could not free the rider", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
utils.Error("CancelCxBooking: commit failed", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
publishCxBookingCancelled(booking, reason)
|
||||
|
||||
return utils.CxOK(c, fiber.Map{
|
||||
"reference": booking.Bookingno,
|
||||
"status": constants.CxStatusCancelled,
|
||||
"cancelReason": reason,
|
||||
})
|
||||
}
|
||||
|
||||
// PatchCxDestination fills in the parts of an address the customer left out.
|
||||
//
|
||||
// Accepted until the parcels are collected. After that the shipment's addresses
|
||||
// are frozen on the consignment and an edit here would change what the customer
|
||||
// sees without changing where the parcel is going — which is worse than
|
||||
// refusing.
|
||||
//
|
||||
// Writes land on the same booking row the miler app reads addresses from, so a
|
||||
// correction made while the rider is on their way reaches them.
|
||||
func PatchCxDestination(c *fiber.Ctx) error {
|
||||
customerID := c.Locals("userid").(int)
|
||||
reference := strings.TrimSpace(c.Params("reference"))
|
||||
|
||||
index, err := strconv.Atoi(c.Params("index"))
|
||||
if err != nil || index < 0 {
|
||||
return utils.CxNotFound(c, "We could not find that destination")
|
||||
}
|
||||
|
||||
booking, err := cxLoadBooking(customerID, reference)
|
||||
if err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that pickup")
|
||||
}
|
||||
|
||||
stage := booking.Customerstage
|
||||
if stage == "" {
|
||||
stage = deriveStageFromStatus(booking)
|
||||
}
|
||||
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStagePickedUp] {
|
||||
return utils.CxConflict(c, "Your packages have been collected — these details can no longer be changed")
|
||||
}
|
||||
if booking.Customerstatus == constants.CxStatusCancelled || booking.Status == constants.BookingCancelled {
|
||||
return utils.CxConflict(c, "This pickup was cancelled")
|
||||
}
|
||||
|
||||
var dest models.BookingDestination
|
||||
if err := db.DB.Where("bookingid = ? AND seq = ?", booking.Bookingid, index).
|
||||
First(&dest).Error; err != nil {
|
||||
return utils.CxNotFound(c, "We could not find that destination")
|
||||
}
|
||||
|
||||
var req cxDetailsInput
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return utils.CxBadRequest(c, "We could not read that request")
|
||||
}
|
||||
|
||||
applyCxDetails(&dest, &req)
|
||||
dest.Updatedat = utils.DBNow()
|
||||
|
||||
tx := db.DB.Begin()
|
||||
if err := tx.Save(&dest).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("PatchCxDestination: save failed", "destination_id", dest.Bookingdestinationid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
// Destination 0 mirrors onto the booking's flat delivery columns, which is
|
||||
// where the miler app and the routing code look. An edit that only landed
|
||||
// on bookingdestinations would be invisible to the rider standing at the
|
||||
// door, which is precisely who it was made for.
|
||||
if dest.Seq == 0 {
|
||||
updates := map[string]interface{}{
|
||||
"deliveryaddress": cxDestinationAddressFromRow(&dest),
|
||||
"updatedat": utils.DBNow(),
|
||||
}
|
||||
if dest.Pinlatitude != nil && dest.Pinlongitude != nil {
|
||||
updates["deliverylatitude"] = *dest.Pinlatitude
|
||||
updates["deliverylongitude"] = *dest.Pinlongitude
|
||||
}
|
||||
if err := tx.Model(&models.PickupBooking{}).
|
||||
Where("bookingid = ?", booking.Bookingid).Updates(updates).Error; err != nil {
|
||||
tx.Rollback()
|
||||
utils.Error("PatchCxDestination: could not mirror onto booking", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
utils.Error("PatchCxDestination: commit failed", "booking_id", booking.Bookingid, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
|
||||
return cxRespondWithBooking(c, booking.Bookingid, fiber.StatusOK)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// cxLoadBooking finds a booking by its customer-facing reference, scoped to the
|
||||
// caller. Ownership is part of the lookup, not a check afterwards — a query
|
||||
// that can return someone else's row is one forgotten `if` away from leaking it.
|
||||
func cxLoadBooking(customerID int, reference string) (*models.PickupBooking, error) {
|
||||
if reference == "" {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.Where("bookingno = ? AND appcustomerid = ?", reference, customerID).
|
||||
First(&booking).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &booking, nil
|
||||
}
|
||||
|
||||
// cxRespondWithBooking re-reads and renders, so a create or a patch answers in
|
||||
// exactly the shape a later GET will.
|
||||
func cxRespondWithBooking(c *fiber.Ctx, bookingID, status int) error {
|
||||
var booking models.PickupBooking
|
||||
if err := db.DB.First(&booking, bookingID).Error; err != nil {
|
||||
utils.Error("cxRespondWithBooking: reload failed", "booking_id", bookingID, "error", err)
|
||||
return utils.CxInternal(c)
|
||||
}
|
||||
bundle := loadCxBundle([]models.PickupBooking{booking})
|
||||
payload := renderCxBooking(&booking, bundle)
|
||||
if status == fiber.StatusCreated {
|
||||
return utils.CxCreated(c, payload)
|
||||
}
|
||||
return utils.CxOK(c, payload)
|
||||
}
|
||||
|
||||
// applyCxDetails writes only the fields actually present in the request. An
|
||||
// omitted key leaves the stored value alone; an explicit null clears it, which
|
||||
// is how the customer removes a landmark they no longer want the rider to use.
|
||||
func applyCxDetails(dest *models.BookingDestination, in *cxDetailsInput) {
|
||||
if in == nil {
|
||||
return
|
||||
}
|
||||
if in.Street != nil {
|
||||
dest.Street = strings.TrimSpace(*in.Street)
|
||||
}
|
||||
if in.Building != nil {
|
||||
dest.Building = strings.TrimSpace(*in.Building)
|
||||
}
|
||||
if in.Landmark != nil {
|
||||
dest.Landmark = strings.TrimSpace(*in.Landmark)
|
||||
}
|
||||
if in.RecipientName != nil {
|
||||
dest.Recipientname = strings.TrimSpace(*in.RecipientName)
|
||||
}
|
||||
if in.RecipientPhone != nil {
|
||||
if phone, _, ok := normalizePhone(*in.RecipientPhone); ok {
|
||||
dest.Recipientphone = phone
|
||||
} else {
|
||||
dest.Recipientphone = strings.TrimSpace(*in.RecipientPhone)
|
||||
}
|
||||
}
|
||||
if in.Instructions != nil {
|
||||
dest.Instructions = strings.TrimSpace(*in.Instructions)
|
||||
}
|
||||
if in.Pin != nil {
|
||||
lat, lng := in.Pin.Lat, in.Pin.Lng
|
||||
if lat == 0 && lng == 0 {
|
||||
dest.Pinlatitude, dest.Pinlongitude = nil, nil
|
||||
} else {
|
||||
dest.Pinlatitude, dest.Pinlongitude = &lat, &lng
|
||||
}
|
||||
}
|
||||
if in.CodAmount != nil {
|
||||
dest.Codamount = *in.CodAmount
|
||||
}
|
||||
}
|
||||
|
||||
func cxFirstPin(in *cxDetailsInput) *[2]float64 {
|
||||
if in == nil || in.Pin == nil {
|
||||
return nil
|
||||
}
|
||||
if in.Pin.Lat == 0 && in.Pin.Lng == 0 {
|
||||
return nil
|
||||
}
|
||||
return &[2]float64{in.Pin.Lat, in.Pin.Lng}
|
||||
}
|
||||
|
||||
// cxDestinationAddress builds the flat address string the rest of the system
|
||||
// stores, from whatever the customer supplied. It always resolves to something
|
||||
// non-empty — pickupbookings.deliveryaddress is NOT NULL, and a booking with
|
||||
// only a state and a district is a legitimate booking.
|
||||
func cxDestinationAddress(in *cxDetailsInput, district models.ServiceableDistrict) string {
|
||||
parts := []string{}
|
||||
if in != nil {
|
||||
if in.Building != nil {
|
||||
parts = append(parts, *in.Building)
|
||||
}
|
||||
if in.Street != nil {
|
||||
parts = append(parts, *in.Street)
|
||||
}
|
||||
if in.Landmark != nil {
|
||||
parts = append(parts, *in.Landmark)
|
||||
}
|
||||
}
|
||||
parts = append(parts, district.Districtname)
|
||||
address := joinNonEmpty(", ", parts...)
|
||||
if address == "" {
|
||||
return district.Districtcode
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func cxDestinationAddressFromRow(d *models.BookingDestination) string {
|
||||
address := joinNonEmpty(", ", d.Building, d.Street, d.Landmark, d.Districtname)
|
||||
if address == "" {
|
||||
return d.Districtcode
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
// cxStateNames resolves display names for the states a set of districts belong
|
||||
// to, in one query.
|
||||
func cxStateNames(districts []models.ServiceableDistrict) (map[string]string, error) {
|
||||
codes := make([]string, 0, len(districts))
|
||||
seen := map[string]bool{}
|
||||
for _, d := range districts {
|
||||
if d.Statecode != "" && !seen[d.Statecode] {
|
||||
seen[d.Statecode] = true
|
||||
codes = append(codes, d.Statecode)
|
||||
}
|
||||
}
|
||||
names := make(map[string]string, len(codes))
|
||||
if len(codes) == 0 {
|
||||
return names, nil
|
||||
}
|
||||
var states []models.ServiceableState
|
||||
if err := db.DB.Where("statecode IN ?", codes).Find(&states).Error; err != nil {
|
||||
return names, err
|
||||
}
|
||||
for _, s := range states {
|
||||
names[s.Statecode] = s.Statename
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// cxToDBTime converts an IST wall clock into the shape this database stores —
|
||||
// the same digits, tagged UTC so the driver writes them verbatim. See
|
||||
// utils.DBNow: comparing a container's UTC clock against IST-stamped rows is
|
||||
// what made date-range reports undercount.
|
||||
func cxToDBTime(t time.Time) time.Time {
|
||||
ist := t.In(utils.ISTLocation())
|
||||
return time.Date(ist.Year(), ist.Month(), ist.Day(), ist.Hour(), ist.Minute(), ist.Second(), 0, time.UTC)
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// publishCxBookingCreated and publishCxBookingCancelled mirror the existing
|
||||
// booking events onto NATS. Best-effort and nil-checked, like every other
|
||||
// publish in this codebase: the event bus is never allowed to fail a booking.
|
||||
func publishCxBookingCreated(b *models.PickupBooking, destinationCount int) {
|
||||
if db.Js == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"booking_id": b.Bookingid,
|
||||
"booking_no": b.Bookingno,
|
||||
"customer_id": b.Appcustomerid,
|
||||
"pickup_address": b.Pickupaddress,
|
||||
"pickup_pincode": b.Pickuppincode,
|
||||
"destination_count": destinationCount,
|
||||
"slot_id": b.Slotid,
|
||||
"status": constants.BookingPendingPickup,
|
||||
"created_at": utils.EpochMillis(b.Createdat),
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := db.Js.Publish("api.v1.bookings.create", data); err != nil {
|
||||
utils.Warn("Failed to publish booking.create to NATS", "booking_id", b.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func publishCxBookingCancelled(b *models.PickupBooking, reason string) {
|
||||
if db.Js == nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"booking_id": b.Bookingid,
|
||||
"booking_no": b.Bookingno,
|
||||
"customer_id": b.Appcustomerid,
|
||||
"status": "Cancelled",
|
||||
"reason": reason,
|
||||
"cancelled_at": time.Now().UnixMilli(),
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := db.Js.Publish("api.v1.bookings.cancel", data); err != nil {
|
||||
utils.Warn("Failed to publish booking.cancel to NATS", "booking_id", b.Bookingid, "error", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user