905 lines
33 KiB
Go
905 lines
33 KiB
Go
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"`
|
|
// Remarks is the free-text note the customer adds on Review ("Handle with
|
|
// care"). It is top-level in the documented payload
|
|
// (docs/customer-app-api-crisp.md) and lands in PickupBooking.Notes, which
|
|
// the admin Orders table displays and searches. Without the field here
|
|
// BodyParser drops it silently and every customer-app booking reaches the
|
|
// console with an empty note.
|
|
Remarks string `json:"remarks"`
|
|
}
|
|
|
|
// 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,
|
|
|
|
Notes: req.Remarks,
|
|
|
|
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)
|
|
}
|
|
}
|