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

@@ -2,7 +2,6 @@ package controllers
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"math"
@@ -16,6 +15,7 @@ import (
"doormile/db"
"doormile/dto"
"doormile/internal/assignment"
"doormile/internal/cxstage"
"doormile/internal/legs"
"doormile/internal/notify"
"doormile/internal/routing"
@@ -26,12 +26,6 @@ import (
"github.com/redis/go-redis/v9"
)
func generateTrackingNo() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("DM-TRK-%X-%d", b, time.Now().Unix()%100000)
}
func LoginMiler(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.MilerLoginRequest)
@@ -529,10 +523,40 @@ func AcceptMilerAssignment(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
// Accepting is the customer's "on the way": the rider has seen the job and
// is heading over. `assigned` was already recorded when the assignment was
// created (assignMilerTx / commitAssignment) and is re-asserted here only
// as a safety net for a booking assigned before this surface existed —
// Record dedupes, so it appends nothing when it is already on the timeline.
//
// Deriving on_the_way from the GPS stream instead would mean re-deriving it
// on every ping: thousands of writes to learn something the accept already
// said.
stageAt := utils.DBNow()
for _, stage := range []string{constants.CxStageAssigned, constants.CxStageOnTheWay} {
if err := cxstage.Record(tx, cxstage.Event{
BookingID: assignment.Bookingid,
Stage: stage,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/assignments/{id}/accept",
At: stageAt,
}); err != nil {
tx.Rollback()
utils.Error("AcceptMilerAssignment: could not record customer stage",
"booking_id", assignment.Bookingid, "stage", stage, "error", err)
return utils.Internal(c, "failed to accept assignment")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to commit assignment acceptance")
}
// No push here: `assigned` was already announced when the assignment was
// created, and on_the_way deliberately rolls up on the timeline. The
// existing "Miler Accepted" notification below is the one the customer gets.
// Accepting a stop moves it into the active set the optimizer orders over, so
// re-sequence the rider off the request path. No-op below two active stops.
routing.SequenceMilerStopsAsync(milerUserID)
@@ -687,6 +711,17 @@ func MilerCancelAssignment(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
// The pickup is NOT cancelled — it goes back into the pool. The customer's
// stage has to walk back with it, or they keep seeing "Miler assigned" and
// a rider card for someone who is no longer coming.
if err := cxstage.Release(tx, booking.Bookingid, req.Reason,
constants.CxActorMiler, &milerUserID,
"POST /miler/bookings/{id}/cancel"); err != nil {
tx.Rollback()
utils.Error("MilerCancelAssignment: could not release the customer stage", "booking_id", booking.Bookingid, "error", err)
return utils.Internal(c, "failed to commit cancellation")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to commit cancellation")
}
@@ -751,9 +786,29 @@ func BookingReachedCustomer(c *fiber.Ctx) error {
return utils.Internal(c, "failed to update miler availability")
}
// Arrival is the LAST cancellable stage on the customer side, so it has to
// be recorded transactionally with the arrival fact itself. A gap between
// the two is a window in which the customer can still cancel a pickup the
// rider is already standing at.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageArrived,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/reached",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
utils.Error("BookingReachedCustomer: could not record arrived stage", "booking_id", booking.Bookingid, "error", err)
return utils.Internal(c, "failed to record arrival")
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to confirm arrival")
}
go cxstage.Notify(booking.Bookingid, nil, constants.CxStageArrived)
return utils.OK(c, fiber.Map{
"bookingid": booking.Bookingid,
"status": booking.Status,
@@ -859,12 +914,19 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
return utils.NotFound(c, "assigned booking not found")
}
// Photos are the evidence half of the receipt. Weight without a photograph
// is a number the customer has no way to check, and this is the only point
// in the flow where anyone is standing next to the parcel. Sent as storage
// keys from the presigned upload (POST /miler/uploads/sign), not as raw
// URLs: the customer is served a short-lived signed link derived from the
// key, never a permanent one.
type ParcelUpdate struct {
ParcelID int `json:"parcel_id"`
Weight float64 `json:"weight"`
Length float64 `json:"length"`
Width float64 `json:"width"`
Height float64 `json:"height"`
ParcelID int `json:"parcel_id"`
Weight float64 `json:"weight"`
Length float64 `json:"length"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Photos []string `json:"photos"`
}
var req struct {
Parcels []ParcelUpdate `json:"parcels"`
@@ -887,8 +949,13 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
parcelMap[parcels[i].Bookingparcelid] = &parcels[i]
}
now := time.Now()
now := utils.DBNow()
var totalChargeable float64
// Chargeable weight per destination, so each order settles on the weight of
// its own parcels rather than on the whole visit's total.
perDestination := map[int]float64{}
tx := db.DB.Begin()
for _, upd := range req.Parcels {
p, ok := parcelMap[upd.ParcelID]
@@ -900,10 +967,51 @@ func BookingParcelConfirm(c *fiber.Ctx) error {
p.Width = upd.Width
p.Height = upd.Height
p.Updatedat = now
db.DB.Save(p)
if err := tx.Save(p).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save parcel measurements")
}
volumetric := calculateVolumetricWeight(upd.Length, upd.Width, upd.Height)
totalChargeable += math.Max(upd.Weight, volumetric)
chargeable := math.Max(upd.Weight, volumetric)
totalChargeable += chargeable
if p.Bookingdestinationid != nil {
perDestination[*p.Bookingdestinationid] += chargeable
}
for _, key := range upd.Photos {
key = strings.TrimSpace(key)
if key == "" {
continue
}
photo := models.BookingParcelPhoto{
Bookingid: bookingID,
Bookingdestinationid: p.Bookingdestinationid,
Objectkey: key,
Capturedbyuserid: &milerUserID,
Capturedat: now,
}
if err := tx.Create(&photo).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to save parcel photo")
}
}
}
// The verification block the customer's receipt reads. Written here, at the
// door, where the measurement was actually taken — pickup-complete restates
// it from the same parcel rows when the price settles, so the two cannot
// disagree.
for destinationID, weight := range perDestination {
if err := cxRecordVerification(tx, destinationID, weight, milerUserID, now); err != nil {
tx.Rollback()
utils.Error("BookingParcelConfirm: could not record verification", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the parcel weight")
}
}
if err := tx.Commit().Error; err != nil {
return utils.Internal(c, "failed to confirm parcels")
}
return utils.OK(c, fiber.Map{
@@ -1030,30 +1138,17 @@ func BookingPickupComplete(c *fiber.Ctx) error {
}
}
var parcels []models.BookingParcel
tx.Where("bookingid = ?", bookingID).Find(&parcels)
var totalDead, totalChargeable, maxL, maxW, maxH float64
for _, p := range parcels {
vol := calculateVolumetricWeight(p.Length, p.Width, p.Height)
totalDead += p.Weight
totalChargeable += math.Max(p.Weight, vol)
if p.Length > maxL {
maxL = p.Length
}
if p.Width > maxW {
maxW = p.Width
}
if p.Height > maxH {
maxH = p.Height
}
// One visit, N orders. A customer-app booking fans out into one consignment
// per destination — each with its own tracking number and its own journey —
// while a console-created booking, which has no destination rows, produces
// the single consignment it always did. cxPickupLegs is what decides which
// of those this is; nothing below needs to know.
legs, err := cxPickupLegs(tx, &booking)
if err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not resolve pickup legs", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to read the parcels on this booking")
}
if len(parcels) == 0 {
totalDead = 0.5
totalChargeable = 0.5
}
trackingNo := generateTrackingNo()
// The base this parcel belongs to. Backend decides — the app is told where to
// go and never picks a base itself. resolveHandoverHub prefers the base the
@@ -1069,36 +1164,6 @@ func BookingPickupComplete(c *fiber.Ctx) error {
utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID)
}
// A hub-routed parcel: with the hub-handover flow ON it stops at Created —
// collected, in the rider's hands, on its way to a base — and only reaches
// Inwarded_at_Hub when the handover is actually recorded. With it OFF
// (default, and what the current app expects) it is marked Inwarded_at_Hub
// here, which is not where the parcel physically is but is what the current
// app and the console's inbound views read.
consignmentStatus := constants.ConsignmentInwardedAtHub
if hubHandoverEnabled() {
consignmentStatus = constants.ConsignmentCreated
}
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
// hub-to-hub tripsheet leg is needed, so the same miler carries it to the
// final mile instead of parking it at the hub.
//
// With the collected-state flow ON it lands in Collected_By_Miler — collected
// but not yet out for delivery — and the rider taps start-delivery to move it
// to Out_for_Delivery, which lets the console tell "collected" from "actively
// delivering". With it OFF (default, and what the current app expects) it goes
// straight to Out_for_Delivery exactly as before.
if isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
booking.Pickuplatitude, booking.Pickuplongitude,
booking.Deliverylatitude, booking.Deliverylongitude) {
if collectedStateEnabled() {
consignmentStatus = constants.ConsignmentCollectedByMiler
} else {
consignmentStatus = constants.ConsignmentOutForDelivery
}
}
// The consignment's tenant is the booking's own tenant (set explicitly at
// CreateExpressBooking time), not the completing miler's tenantid claim — a
// miler can carry parcels for tenants other than their own, and using
@@ -1110,96 +1175,257 @@ func BookingPickupComplete(c *fiber.Ctx) error {
consignmentTenantID = *booking.Tenantid
}
// Carried over so the consignment stays traceable to the client site it was
// collected from — for a food client that's the kitchen, and "how many
// parcels went out of which kitchen" is unanswerable without it.
consignment := models.Consignment{
Trackingno: trackingNo,
Tenantid: consignmentTenantID,
Pickuplocationid: booking.Pickuplocationid,
Tenantlocationid: booking.Tenantlocationid,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
Deliverylatitude: booking.Deliverylatitude,
Deliverylongitude: booking.Deliverylongitude,
Pickuppincode: booking.Pickuppincode,
Deliverypincode: booking.Deliverypincode,
Length: maxL,
Width: maxW,
Height: maxH,
Deadweight: totalDead,
Volumetricweight: totalChargeable - totalDead,
Chargeableweight: totalChargeable,
Paymentmode: "Prepaid",
Status: consignmentStatus,
Estimateddeliveryat: nil,
Createdby: milerUserID,
Originhubid: defaultHubID,
Currenthubid: defaultHubID,
}
// Under the compatibility flow the parcel is treated as received at the base
// the moment it is collected, so the received-at fact is stamped here too —
// otherwise every parcel inwarded this way would have a null handover time
// and base reconciliation would have nothing to compare against.
if consignmentStatus == constants.ConsignmentInwardedAtHub {
consignment.Inwardedat = &now
var tenant models.Tenant
tenantNeedsOTP := false
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil {
tenantNeedsOTP = tenant.Requiredeliveryotp
}
// Money collected at the door. Split across the legs below rather than
// stamped whole onto each one: a single payment covering a three-stop
// pickup must not appear three times in the books.
var payment models.BookingPayment
if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil {
if payment.Paymentstatus == constants.PaymentStatusPaid {
consignment.Codcollected = payment.Amount
} else {
consignment.Codamount = payment.Amount
hasPayment := tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil
created := make([]models.Consignment, 0, len(legs))
trackingNos := make([]string, 0, len(legs))
riderMarkedBusy := false
assignmentStillOpen := false
for i, leg := range legs {
totalDead, totalChargeable, maxL, maxW, maxH := cxLegWeights(leg)
trackingNo := generateTrackingNo()
// A hub-routed parcel: with the hub-handover flow ON it stops at Created —
// collected, in the rider's hands, on its way to a base — and only reaches
// Inwarded_at_Hub when the handover is actually recorded. With it OFF
// (default, and what the current app expects) it is marked Inwarded_at_Hub
// here, which is not where the parcel physically is but is what the current
// app and the console's inbound views read.
consignmentStatus := constants.ConsignmentInwardedAtHub
if hubHandoverEnabled() {
consignmentStatus = constants.ConsignmentCreated
}
// Hyperlocal shortcut: pickup and delivery in the same postal area mean no
// hub-to-hub tripsheet leg is needed, so the same miler carries it to the
// final mile instead of parking it at the hub. Decided per leg, because on
// a multi-destination pickup one parcel can be going round the corner while
// another is going to another state.
//
// With the collected-state flow ON it lands in Collected_By_Miler — collected
// but not yet out for delivery — and the rider taps start-delivery to move it
// to Out_for_Delivery, which lets the console tell "collected" from "actively
// delivering". With it OFF (default, and what the current app expects) it goes
// straight to Out_for_Delivery exactly as before.
if isHyperlocalBooking(booking.Pickuppincode, leg.DeliveryPincode,
booking.Pickuplatitude, booking.Pickuplongitude,
leg.DeliveryLatitude, leg.DeliveryLongitude) {
if collectedStateEnabled() {
consignmentStatus = constants.ConsignmentCollectedByMiler
} else {
consignmentStatus = constants.ConsignmentOutForDelivery
}
}
// Carried over so the consignment stays traceable to the client site it was
// collected from — for a food client that's the kitchen, and "how many
// parcels went out of which kitchen" is unanswerable without it.
consignment := models.Consignment{
Trackingno: trackingNo,
Tenantid: consignmentTenantID,
Pickuplocationid: booking.Pickuplocationid,
Tenantlocationid: booking.Tenantlocationid,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
Deliverylatitude: leg.DeliveryLatitude,
Deliverylongitude: leg.DeliveryLongitude,
Pickuppincode: booking.Pickuppincode,
Deliverypincode: leg.DeliveryPincode,
Length: maxL,
Width: maxW,
Height: maxH,
Deadweight: totalDead,
Volumetricweight: totalChargeable - totalDead,
Chargeableweight: totalChargeable,
Paymentmode: "Prepaid",
Status: consignmentStatus,
Estimateddeliveryat: cxEstimatedDelivery(leg, now),
Createdby: milerUserID,
Originhubid: defaultHubID,
Currenthubid: defaultHubID,
}
// COD the customer asked to have collected at THIS door, on their behalf.
// Doormile is the carrier, not the seller — this money is never Doormile's.
if leg.CodAmount > 0 {
consignment.Codamount = leg.CodAmount
consignment.Paymentmode = "COD"
}
}
// A parcel that goes straight out for delivery here (collected-state flow off)
// needs its receiver OTP before commit — same as before. When the flow is on,
// a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later
// at start-delivery instead, so this block simply doesn't fire. Only clients
// that ask for one get an OTP (Tenant.Requiredeliveryotp).
if consignmentStatus == constants.ConsignmentOutForDelivery {
var tenant models.Tenant
if tx.Where("tenantid = ?", consignmentTenantID).First(&tenant).Error == nil && tenant.Requiredeliveryotp {
// Under the compatibility flow the parcel is treated as received at the base
// the moment it is collected, so the received-at fact is stamped here too —
// otherwise every parcel inwarded this way would have a null handover time
// and base reconciliation would have nothing to compare against.
if consignmentStatus == constants.ConsignmentInwardedAtHub {
consignment.Inwardedat = &now
}
// The pickup fee the miler collected covers the whole visit, so it is
// recorded once — against the first order — rather than repeated on
// every leg.
if hasPayment && i == 0 {
if payment.Paymentstatus == constants.PaymentStatusPaid {
consignment.Codcollected = payment.Amount
} else {
consignment.Codamount += payment.Amount
consignment.Paymentmode = "COD"
}
}
// A parcel that goes straight out for delivery here (collected-state flow off)
// needs its receiver OTP before commit — same as before. When the flow is on,
// a hyperlocal parcel stops at Collected_By_Miler and its OTP is issued later
// at start-delivery instead, so this block simply doesn't fire. Only clients
// that ask for one get an OTP (Tenant.Requiredeliveryotp).
if consignmentStatus == constants.ConsignmentOutForDelivery && tenantNeedsOTP {
consignment.Deliveryotp = utils.GenerateNumericOTP(6)
}
if err := tx.Create(&consignment).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to convert booking to consignment")
}
if err := cxLinkLegToOrder(tx, leg, consignment.Consignmentid, trackingNo,
consignment.Estimateddeliveryat, now); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not link destination to order", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to link the destination to its order")
}
// What the miler weighed at this door, kept where the customer's receipt
// reads it. Without this the verification block stays empty and the
// receipt loses the evidence behind the settled price.
if leg.Destination != nil {
if err := cxRecordVerification(tx, leg.Destination.Bookingdestinationid,
totalChargeable, milerUserID, now); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not record verification", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the parcel weight")
}
}
history := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: defaultHubID,
Userid: &milerUserID,
Eventstatus: consignmentStatus,
Remarks: "Package collected by miler and converted to consignment",
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record consignment history")
}
// Two separate questions, deliberately not merged.
//
// "Is the rider marked busy?" keeps the EXACT rule this endpoint has
// always applied — Created or Collected_By_Miler only. A hyperlocal
// parcel that goes straight to Out_for_Delivery has always left the
// rider Available here, even though the comment below says otherwise.
// That mismatch is pre-existing and is the default path today; changing
// it would alter live rider availability, which is not this work's
// business. Flagged in docs/customer-app-api.md, not silently fixed.
//
// "Is the assignment still open?" is the one that has to understand the
// fan-out: it closes only when every leg has been handed over at a base,
// which for a single-leg booking is identical to the previous behaviour.
if consignmentStatus == constants.ConsignmentCollectedByMiler ||
consignmentStatus == constants.ConsignmentCreated {
riderMarkedBusy = true
}
if consignmentStatus != constants.ConsignmentInwardedAtHub {
assignmentStillOpen = true
}
// The customer's per-order stage. in_transit is recorded here only on the
// compatibility flow, where the parcel really is treated as received at
// the base the instant it is collected; on the handover flow it waits for
// the rider to actually hand it over.
destinationID := cxDestinationIDFor(leg.Destination)
if err := cxstage.Record(tx, cxstage.Event{
BookingID: bookingID,
DestinationID: destinationID,
Stage: constants.CxStageOrderCreated,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/pickup-complete",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not record order_created", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the pickup")
}
// On the compatibility flow a parcel is already past order_created the
// instant it is collected — hub-routed ones are stamped Inwarded_at_Hub
// here, and hyperlocal ones go straight to Out_for_Delivery. Recording
// what the status actually says keeps the timeline honest: those
// transitions really did happen at this moment, and omitting them would
// leave a parcel showing "Package collected" while the rider is already
// carrying it to the door.
if implied, ok := cxStageForConsignmentStatus(consignmentStatus); ok {
if err := cxstage.Record(tx, cxstage.Event{
BookingID: bookingID,
DestinationID: destinationID,
Stage: implied,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/pickup-complete",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
utils.Error("BookingPickupComplete: could not record per-order stage", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the pickup")
}
}
created = append(created, consignment)
trackingNos = append(trackingNos, trackingNo)
}
if err := tx.Create(&consignment).Error; err != nil {
// Recorded before order_created in wall-clock terms — the parcels were in the
// rider's hands before the orders existed — but written after, because the
// weights the price settles on are only known once the legs are built.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: bookingID,
Stage: constants.CxStagePickedUp,
ActorType: constants.CxActorMiler,
ActorID: &milerUserID,
Source: "POST /miler/bookings/{id}/pickup-complete",
At: utils.DBNow(),
}); err != nil {
tx.Rollback()
return utils.Internal(c, "failed to convert booking to consignment")
utils.Error("BookingPickupComplete: could not record picked_up", "booking_id", bookingID, "error", err)
return utils.Internal(c, "failed to record the pickup")
}
booking.Consignmentid = &consignment.Consignmentid
// pickupbookings.consignmentid names the FIRST order only. It is kept for
// the console and the legacy reads that still join on it; anything that
// needs the whole set goes through bookingdestinations.
first := created[0]
booking.Consignmentid = &first.Consignmentid
booking.Status = constants.BookingConvertedConsignment
if err := tx.Save(&booking).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to link booking to consignment")
}
history := models.ConsignmentHistory{
Consignmentid: consignment.Consignmentid,
Hubid: defaultHubID,
Userid: &milerUserID,
Eventstatus: consignmentStatus,
Remarks: "Package collected by miler and converted to consignment",
}
if err := tx.Create(&history).Error; err != nil {
tx.Rollback()
return utils.Internal(c, "failed to record consignment history")
}
// A hyperlocal parcel is still in the rider's hands (they will deliver it), so
// they stay Picked_Up and out of the assignment pool until they finish. A
// hub-routed parcel was dropped at the hub, so the rider frees up.
// Unchanged from before the fan-out: a parcel still on its way to a base
// keeps the rider marked busy; anything else frees them up.
postPickupAvailability := constants.MilerAvailable
if consignmentStatus == constants.ConsignmentCollectedByMiler ||
consignmentStatus == constants.ConsignmentCreated {
// Created here means hub-routed and still in the rider's hands: they are
// carrying it to a base, so they are not free yet.
if riderMarkedBusy {
postPickupAvailability = constants.MilerPickedUp
}
@@ -1208,7 +1434,7 @@ func BookingPickupComplete(c *fiber.Ctx) error {
// on the compatibility flow and the rider could not go off duty — MilerEndDuty
// refuses while any assignment is still Assigned/Accepted. On the handover
// flow the assignment stays open on purpose and closes at inward-at-hub.
if consignmentStatus == constants.ConsignmentInwardedAtHub {
if !assignmentStillOpen {
if err := tx.Model(&models.BookingAssignment{}).
Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?",
bookingID, milerUserID,
@@ -1231,25 +1457,16 @@ func BookingPickupComplete(c *fiber.Ctx) error {
return utils.Internal(c, "failed to complete pickup")
}
// One notification for the milestone, not one per order: three tracking
// numbers arriving as three buzzes for a single visit is noise, and
// order_created deliberately rolls up on the timeline.
go cxstage.Notify(bookingID, nil, constants.CxStagePickedUp)
// If an OTP was issued here (parcel went straight out for delivery), it goes to
// the receiver in this notification — the rider is told it at the door. When
// the collected-state flow is on, no OTP exists yet and the notification is
// just "collected"; the OTP rides the start-delivery notification instead.
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err == nil && customer.Devicetoken != "" {
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNo)
payload := map[string]string{
"booking_id": strconv.Itoa(bookingID),
"tracking_no": trackingNo,
}
if consignment.Deliveryotp != "" {
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignment.Deliveryotp)
payload["delivery_otp"] = consignment.Deliveryotp
}
if notifyErr := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); notifyErr != nil {
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", bookingID, "error", notifyErr)
}
}
notifyCustomerOnPickup(&booking, created, trackingNos)
// next_action says what the rider does next; next_hub says where. An
// inward_at_hub with no base named leaves a rider holding a parcel with
@@ -1259,22 +1476,74 @@ func BookingPickupComplete(c *fiber.Ctx) error {
//
// consignment_id is always present: the delivery leg is keyed on it, and
// without it the app cannot name the parcel it is about to act on.
//
// The single-consignment fields still describe the FIRST order, unchanged,
// so the deployed rider app keeps working exactly as before. `consignments`
// is additive and carries the full set for a build that can show them.
resp := fiber.Map{
"tracking_no": trackingNo,
"consignment_id": consignment.Consignmentid,
"consignmentstatus": consignment.Status,
"status": consignment.Status,
"tracking_no": trackingNos[0],
"consignment_id": first.Consignmentid,
"consignmentstatus": first.Status,
"status": first.Status,
"booking_no": booking.Bookingno,
"booking_status": booking.Status,
"next_action": nextActionForConsignment(consignment.Status),
"next_action": nextActionForConsignment(first.Status),
"consignments": renderPickupOrders(created, trackingNos),
}
if consignment.Status == constants.ConsignmentCreated ||
consignment.Status == constants.ConsignmentInwardedAtHub {
if first.Status == constants.ConsignmentCreated ||
first.Status == constants.ConsignmentInwardedAtHub {
resp["next_hub"] = renderBase(handoverHub)
}
return utils.OK(c, resp)
}
// renderPickupOrders lists every order a pickup produced, so a rider carrying
// three parcels from one visit can be shown three stops rather than one.
func renderPickupOrders(consignments []models.Consignment, trackingNos []string) []fiber.Map {
out := make([]fiber.Map, 0, len(consignments))
for i := range consignments {
cn := consignments[i]
out = append(out, fiber.Map{
"consignment_id": cn.Consignmentid,
"tracking_no": trackingNos[i],
"consignmentstatus": cn.Status,
"next_action": nextActionForConsignment(cn.Status),
"delivery_pincode": cn.Deliverypincode,
})
}
return out
}
// notifyCustomerOnPickup tells the customer their parcels were collected, and
// hands the receiver any delivery OTP that was issued. Best-effort: a push that
// fails must never fail a pickup that already committed.
func notifyCustomerOnPickup(booking *models.PickupBooking, consignments []models.Consignment, trackingNos []string) {
var customer models.AppCustomer
if err := db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer).Error; err != nil {
return
}
if customer.Devicetoken == "" {
return
}
body := fmt.Sprintf("Parcel picked up — Tracking No: %s", trackingNos[0])
if len(trackingNos) > 1 {
body = fmt.Sprintf("%d parcels picked up — first tracking no: %s", len(trackingNos), trackingNos[0])
}
payload := map[string]string{
"booking_id": strconv.Itoa(booking.Bookingid),
"tracking_no": trackingNos[0],
"reference": booking.Bookingno,
}
if len(consignments) > 0 && consignments[0].Deliveryotp != "" {
body = fmt.Sprintf("%s. Share OTP %s with the rider on delivery.", body, consignments[0].Deliveryotp)
payload["delivery_otp"] = consignments[0].Deliveryotp
}
if err := notify.SendToDevice(customer.Devicetoken, "Parcel Picked Up", body, payload); err != nil {
utils.Warn("FCM: failed to notify customer on pickup", "booking_id", booking.Bookingid, "error", err)
}
}
func BookingVehicleRequiredEscalate(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int)
bookingID, err := strconv.Atoi(c.Params("bookingid"))