backend requirements onthe xustomer app
This commit is contained in:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user