Reviewed the 10 merged customer-app/base-handover commits and fixed the
defects found:
- HIGH (money): CreateCxBooking let the request body's `estimate` set the
billed price with no server-side check; it flows into Estimatedprice →
ridercharges (miler pay + tenant bill) with no weight re-price, so
{min:1,max:1} settled a delivery at ₹1. Now the client estimate is honoured
only when it matches the server quote within 15%, else the server quote
stands.
- MED: base handover freed the rider and closed the booking-level assignment
after the FIRST parcel of a multi-destination pickup, dropping the remaining
stops and crediting one leg. Now finalized only when no consignment of the
booking is still in the rider's hands.
- MED: inwardedat/completedat were written with time.Now() (UTC) instead of
DBNow() (IST), skewing them ~5h30 vs createdat and the earnings/reconcile
windows. Fixed in the handover, inbound-scan, reconcile and pickup-complete
paths.
- MED: B2C customers got two "miler assigned" pushes on auto-assign (two token
stores) and none on manual assign. Reconciled to one cxstage.Notify on both
paths.
- LOW: ReconcileHubInbound now runs in a transaction and checks its audit
inserts (was returning 200 with a silently-missing history row); CxLogout no
longer reports signedOut when the token revoke fails; a rider-named handover
base far from their reported position is rejected instead of silently
rerouting the parcel to another city.
go build, go vet and go test ./... all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
266 lines
9.6 KiB
Go
266 lines
9.6 KiB
Go
package controllers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"doormile/constants"
|
|
"doormile/db"
|
|
"doormile/internal/cxstage"
|
|
"doormile/internal/notify"
|
|
"doormile/internal/routing"
|
|
"doormile/models"
|
|
"doormile/utils"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// milerStopSequence carries the road-optimized ordering the Route Optimization
|
|
// API produced for one stop. Passed to assignMilerTx when the caller already
|
|
// knows the sequence (the express-batch path), nil when it does not (a plain
|
|
// manual assignment, which leaves the stop unsequenced at step 0).
|
|
type milerStopSequence struct {
|
|
Step int
|
|
Previouskms float64
|
|
Cumulativekms float64
|
|
Etaminutes int
|
|
Cumulativeeta int
|
|
}
|
|
|
|
// assignMilerTx performs the DB half of a miler assignment inside the given
|
|
// transaction: flip the booking to Miler_Assigned, create the BookingAssignment
|
|
// (optionally already sequenced), and mark the miler Assigned. It does not
|
|
// commit, publish, or notify — those are the caller's job, so the same writes
|
|
// can be reused by the single-booking path (AssignMilerToBooking) and the
|
|
// batch express path (assignExpressStops) without duplicating the SQL or firing
|
|
// one notification per stop.
|
|
func assignMilerTx(tx *gorm.DB, bookingID, milerUserID int, assignedByUserID *int, seq *milerStopSequence) (*models.PickupBooking, error) {
|
|
var booking models.PickupBooking
|
|
if err := tx.First(&booking, bookingID).Error; err != nil {
|
|
return nil, fmt.Errorf("booking not found")
|
|
}
|
|
|
|
booking.Status = constants.BookingMilerAssigned
|
|
booking.Assignedmileruserid = &milerUserID
|
|
booking.Updatedat = time.Now()
|
|
if err := tx.Save(&booking).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to update booking: %w", err)
|
|
}
|
|
|
|
assignment := models.BookingAssignment{
|
|
Bookingid: booking.Bookingid,
|
|
Mileruserid: milerUserID,
|
|
Assignedbyuserid: assignedByUserID,
|
|
Assignmentstatus: constants.AssignmentAssigned,
|
|
}
|
|
if seq != nil {
|
|
now := time.Now()
|
|
assignment.Step = seq.Step
|
|
assignment.Previouskms = seq.Previouskms
|
|
assignment.Cumulativekms = seq.Cumulativekms
|
|
assignment.Etaminutes = seq.Etaminutes
|
|
assignment.Cumulativeeta = seq.Cumulativeeta
|
|
assignment.Sequencedat = &now
|
|
}
|
|
if err := tx.Create(&assignment).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to create assignment: %w", err)
|
|
}
|
|
|
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
|
Update("availabilitystatus", constants.MilerAssigned).Error; err != nil {
|
|
return nil, fmt.Errorf("failed to update miler availability: %w", err)
|
|
}
|
|
|
|
// The customer's "Miler assigned" milestone, recorded where the assignment
|
|
// is actually created rather than where a rider taps Accept. A rider who
|
|
// never opens the app would otherwise leave the customer watching "finding
|
|
// a Miler" while ops has the booking down as assigned — two surfaces
|
|
// disagreeing about the same fact.
|
|
if err := cxstage.Record(tx, cxstage.Event{
|
|
BookingID: booking.Bookingid,
|
|
Stage: constants.CxStageAssigned,
|
|
ActorType: constants.CxActorOps,
|
|
ActorID: assignedByUserID,
|
|
Source: "assignMilerTx",
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("failed to record the assigned stage: %w", err)
|
|
}
|
|
|
|
return &booking, nil
|
|
}
|
|
|
|
// publishAssignmentUpdate emits the best-effort booking.update NATS event and
|
|
// pushes the FCM notification for one assignment. Shared by both assignment
|
|
// paths so the outward side effects stay identical.
|
|
func publishAssignmentUpdate(booking *models.PickupBooking, milerUserID int) {
|
|
if db.Js != nil {
|
|
payload := map[string]interface{}{
|
|
"booking_id": booking.Bookingid,
|
|
"booking_no": booking.Bookingno,
|
|
"status": booking.Status,
|
|
"miler_id": milerUserID,
|
|
"updated_at": time.Now().UnixMilli(),
|
|
}
|
|
if data, err := json.Marshal(payload); err == nil {
|
|
if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil {
|
|
utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var miler models.MilerProfile
|
|
if db.DB.Where("userid = ?", milerUserID).First(&miler).Error == nil && miler.Devicetoken != "" {
|
|
if err := notify.SendToDevice(
|
|
miler.Devicetoken,
|
|
"New Pickup Assigned",
|
|
"New booking assigned — tap to view details",
|
|
map[string]string{"booking_id": fmt.Sprintf("%d", booking.Bookingid)},
|
|
); err != nil {
|
|
utils.Warn("FCM: failed to notify miler on assignment",
|
|
"miler_id", milerUserID, "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
|
|
// Customer push, the same path auto-assign uses (cxstage → doormile_cx device
|
|
// tokens). Manual assignment recorded the CxStageAssigned stage but sent the
|
|
// customer nothing, so a console/hub assignment left the customer with no
|
|
// "miler assigned" notification while auto-assign sent one.
|
|
cxstage.Notify(booking.Bookingid, nil, constants.CxStageAssigned)
|
|
}
|
|
|
|
// AssignMilerToBooking is the single source of truth for manually assigning a
|
|
// miler to a pickup booking — shared by the admin console (AdminAssignMiler)
|
|
// and the hub console (HubAssignMiler) so both go through identical DB
|
|
// updates, NATS publish, and FCM notify instead of duplicating the logic.
|
|
func AssignMilerToBooking(bookingID, milerUserID int, assignedByUserID *int) (*models.PickupBooking, error) {
|
|
tx := db.DB.Begin()
|
|
|
|
booking, err := assignMilerTx(tx, bookingID, milerUserID, assignedByUserID, nil)
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return nil, err
|
|
}
|
|
|
|
if err := tx.Commit().Error; err != nil {
|
|
return nil, fmt.Errorf("failed to commit miler assignment: %w", err)
|
|
}
|
|
|
|
publishAssignmentUpdate(booking, milerUserID)
|
|
|
|
// Re-sequence the rider's stops now they hold one more. No-op below two active
|
|
// stops; runs off the request path so the optimizer's network call never
|
|
// blocks or fails a manual assignment.
|
|
routing.SequenceMilerStopsAsync(milerUserID)
|
|
|
|
return booking, nil
|
|
}
|
|
|
|
// ExpressStop is one already-decided assignment from the ExpressDispatchAgent:
|
|
// which miler carries which booking, in what road-optimized order. The agent
|
|
// chose the miler and called the Route Optimization API for the sequence; this
|
|
// struct is the writeback contract.
|
|
type ExpressStop struct {
|
|
BookingID int `json:"booking_id"`
|
|
MilerUserID int `json:"miler_user_id"`
|
|
Step int `json:"step"`
|
|
Previouskms float64 `json:"previouskms"`
|
|
Cumulativekms float64 `json:"cumulativekms"`
|
|
Etaminutes int `json:"etaminutes"`
|
|
Cumulativeeta int `json:"cumulativeeta"`
|
|
}
|
|
|
|
// ExpressAssignResult is the per-booking outcome of a batch writeback.
|
|
type ExpressAssignResult struct {
|
|
BookingID int `json:"booking_id"`
|
|
MilerUserID int `json:"miler_user_id"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// assignExpressStops writes a batch of agent-decided assignments. Each stop is
|
|
// its own transaction so one bad booking id cannot roll back the whole batch —
|
|
// the same per-row-independence the bulk-create endpoint gives. Each carries its
|
|
// sequence, so the assignment lands already ordered rather than needing a second
|
|
// sequencing pass. A miler is notified once for the whole batch, not once per
|
|
// stop, so a rider handed five stops gets one push, not five.
|
|
func assignExpressStops(stops []ExpressStop) []ExpressAssignResult {
|
|
results := make([]ExpressAssignResult, 0, len(stops))
|
|
// Preserve first-seen miler order so the summary notification is deterministic.
|
|
notifyBooking := map[int]*models.PickupBooking{}
|
|
notifyOrder := []int{}
|
|
|
|
for _, s := range stops {
|
|
tx := db.DB.Begin()
|
|
booking, err := assignMilerTx(tx, s.BookingID, s.MilerUserID, nil, &milerStopSequence{
|
|
Step: s.Step,
|
|
Previouskms: s.Previouskms,
|
|
Cumulativekms: s.Cumulativekms,
|
|
Etaminutes: s.Etaminutes,
|
|
Cumulativeeta: s.Cumulativeeta,
|
|
})
|
|
if err != nil {
|
|
tx.Rollback()
|
|
results = append(results, ExpressAssignResult{
|
|
BookingID: s.BookingID, MilerUserID: s.MilerUserID, Success: false, Error: err.Error()})
|
|
continue
|
|
}
|
|
if err := tx.Commit().Error; err != nil {
|
|
results = append(results, ExpressAssignResult{
|
|
BookingID: s.BookingID, MilerUserID: s.MilerUserID, Success: false, Error: "commit failed"})
|
|
continue
|
|
}
|
|
|
|
// booking.update per stop keeps live trackers accurate; the FCM push is
|
|
// deferred and coalesced per miler below.
|
|
if db.Js != nil {
|
|
payload := map[string]interface{}{
|
|
"booking_id": booking.Bookingid,
|
|
"booking_no": booking.Bookingno,
|
|
"status": booking.Status,
|
|
"miler_id": s.MilerUserID,
|
|
"updated_at": time.Now().UnixMilli(),
|
|
}
|
|
if data, err := json.Marshal(payload); err == nil {
|
|
if _, err := db.Js.Publish("api.v1.bookings.update", data); err != nil {
|
|
utils.Warn("Failed to publish booking.update to NATS", "booking_id", booking.Bookingid, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if _, seen := notifyBooking[s.MilerUserID]; !seen {
|
|
notifyOrder = append(notifyOrder, s.MilerUserID)
|
|
}
|
|
notifyBooking[s.MilerUserID] = booking
|
|
results = append(results, ExpressAssignResult{
|
|
BookingID: s.BookingID, MilerUserID: s.MilerUserID, Success: true})
|
|
}
|
|
|
|
for _, milerUserID := range notifyOrder {
|
|
count := 0
|
|
for _, r := range results {
|
|
if r.MilerUserID == milerUserID && r.Success {
|
|
count++
|
|
}
|
|
}
|
|
var miler models.MilerProfile
|
|
if db.DB.Where("userid = ?", milerUserID).First(&miler).Error == nil && miler.Devicetoken != "" {
|
|
msg := "New pickups assigned — tap to view your route"
|
|
if count == 1 {
|
|
msg = "New booking assigned — tap to view details"
|
|
}
|
|
if err := notify.SendToDevice(
|
|
miler.Devicetoken,
|
|
"New Pickups Assigned",
|
|
msg,
|
|
map[string]string{"count": fmt.Sprintf("%d", count)},
|
|
); err != nil {
|
|
utils.Warn("FCM: failed to notify miler on express batch assignment",
|
|
"miler_id", milerUserID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|