diff --git a/controllers/adminController.go b/controllers/adminController.go index 70022d9..609ba1f 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -2062,6 +2062,41 @@ func AssignMilerVehicle(c *fiber.Ctx) error { // BOOKINGS MANAGEMENT // -------------------- +// bookingDestinationCounts is one row of the grouped bookingdestinations +// aggregate: how many destinations a booking carries and how many packages they +// add up to across all of them. +type bookingDestinationCounts struct { + Bookingid int `gorm:"column:bookingid"` + Destinationcount int `gorm:"column:destinationcount"` + Totalpackagecount int `gorm:"column:totalpackagecount"` +} + +// applyDestinationCounts writes the grouped counts onto the bookings they +// belong to. A booking with no bookingdestinations rows — every +// console-created booking, and every booking predating the customer app — +// keeps the 0/0 zero value, which is the correct answer and the one the +// console distinguishes from a single-destination booking. +// +// Split out from the handler because it is the whole of the mapping logic and +// the only part that can be tested without a database. +func applyDestinationCounts(bookings []models.PickupBooking, counts []bookingDestinationCounts) { + if len(bookings) == 0 || len(counts) == 0 { + return + } + byBooking := make(map[int]bookingDestinationCounts, len(counts)) + for _, row := range counts { + byBooking[row.Bookingid] = row + } + for i := range bookings { + row, ok := byBooking[bookings[i].Bookingid] + if !ok { + continue + } + bookings[i].Destinationcount = row.Destinationcount + bookings[i].Totalpackagecount = row.Totalpackagecount + } +} + func GetAdminBookings(c *fiber.Ctx) error { pageno := max(1, c.QueryInt("pageno", 1)) pagesize := min(100, max(1, c.QueryInt("pagesize", 20))) @@ -2113,6 +2148,27 @@ func GetAdminBookings(c *fiber.Ctx) error { } } + // How many destinations each booking carries, and the packages summed across + // them. A customer-app pickup is ONE booking with N destinations, and the + // console needs to render "3 destinations · 4 packages" on the collapsed row. + // The full array is deliberately NOT preloaded here: the console drains up to + // 12 pages of 100 bookings and only opens one row at a time, so the array is + // payload the list never reads. Batched exactly like the consignment status + // above — one grouped query for the whole page, never one per row. + bookingIDs := make([]int, 0, len(bookings)) + for _, b := range bookings { + bookingIDs = append(bookingIDs, b.Bookingid) + } + if len(bookingIDs) > 0 { + var counts []bookingDestinationCounts + db.DB.Model(&models.BookingDestination{}). + Select("bookingid, COUNT(*) AS destinationcount, COALESCE(SUM(packagecount), 0) AS totalpackagecount"). + Where("bookingid IN ?", bookingIDs). + Group("bookingid"). + Scan(&counts) + applyDestinationCounts(bookings, counts) + } + pages := int(math.Ceil(float64(total) / float64(pagesize))) return c.JSON(fiber.Map{ @@ -2595,7 +2651,23 @@ func AdminBulkCreateBookings(c *fiber.Ctx) error { func GetAdminBookingDetails(c *fiber.Ctx) error { id, _ := strconv.Atoi(c.Params("id")) var booking models.PickupBooking - q := scopeToOwnTenant(c, db.DB.Preload("Parcels").Preload("ServiceOptions").Preload("Payments"), "tenantid") + // Destinations is the customer-app half of the booking: one pickup carries N + // of them, each with its own consignment, tracking number and stage once the + // miler completes pickup (cxPickupFanout.go). The relation has been declared + // on the model since the customer app shipped and nothing preloaded it, which + // is the entire reason the console could only ever show one drop. + // + // Ordered by seq ascending and never by anything else: seq is the + // customer-facing position and the {index} in + // PATCH /customer/bookings/{ref}/destinations/{index}, so the order the + // console renders has to be the order the customer addresses. + q := scopeToOwnTenant(c, db.DB. + Preload("Parcels"). + Preload("ServiceOptions"). + Preload("Payments"). + Preload("Destinations", func(d *gorm.DB) *gorm.DB { + return d.Order("seq ASC") + }), "tenantid") if err := q.First(&booking, id).Error; err != nil { return utils.NotFound(c, "booking not found") } diff --git a/controllers/adminDestinations_test.go b/controllers/adminDestinations_test.go new file mode 100644 index 0000000..44f86c0 --- /dev/null +++ b/controllers/adminDestinations_test.go @@ -0,0 +1,102 @@ +package controllers + +import ( + "testing" + + "doormile/models" +) + +// One customer pickup is ONE booking carrying N destinations. The admin list +// says how many without shipping the array, and these cover the mapping that +// does it — the only half of the change that can be tested without Postgres. +// The queries themselves need the integration pass (docs/customer-app-api.md). + +func TestDestinationCountsLandOnTheRightBooking(t *testing.T) { + bookings := []models.PickupBooking{ + {Bookingid: 11}, + {Bookingid: 22}, + {Bookingid: 33}, + } + // Deliberately out of order and not covering every booking: the grouped + // query returns rows for whichever bookings have destinations, in whatever + // order the database chose. + counts := []bookingDestinationCounts{ + {Bookingid: 33, Destinationcount: 1, Totalpackagecount: 1}, + {Bookingid: 11, Destinationcount: 3, Totalpackagecount: 4}, + } + + applyDestinationCounts(bookings, counts) + + if bookings[0].Destinationcount != 3 || bookings[0].Totalpackagecount != 4 { + t.Errorf("booking 11: got %d destinations / %d packages, want 3/4", + bookings[0].Destinationcount, bookings[0].Totalpackagecount) + } + if bookings[2].Destinationcount != 1 || bookings[2].Totalpackagecount != 1 { + t.Errorf("booking 33: got %d destinations / %d packages, want 1/1", + bookings[2].Destinationcount, bookings[2].Totalpackagecount) + } +} + +// A console-created booking has no bookingdestinations rows at all, so the +// grouped query returns nothing for it. It must report 0/0 rather than +// inheriting a neighbour's counts — the console reads 0 as "no destinations +// recorded" and 1 as "a single drop", and they render differently. +func TestBookingWithNoDestinationsStaysZero(t *testing.T) { + bookings := []models.PickupBooking{ + {Bookingid: 11}, + {Bookingid: 99}, // console-created: no destination rows + } + counts := []bookingDestinationCounts{ + {Bookingid: 11, Destinationcount: 3, Totalpackagecount: 4}, + } + + applyDestinationCounts(bookings, counts) + + if bookings[1].Destinationcount != 0 || bookings[1].Totalpackagecount != 0 { + t.Errorf("console booking: got %d/%d, want 0/0", + bookings[1].Destinationcount, bookings[1].Totalpackagecount) + } +} + +// The aggregate is keyed by booking id, so a count can never be written onto a +// booking that was not on this page. Guards the map lookup against being +// replaced by anything positional. +func TestCountsForBookingsOutsideThePageAreIgnored(t *testing.T) { + bookings := []models.PickupBooking{{Bookingid: 11}} + counts := []bookingDestinationCounts{ + {Bookingid: 77, Destinationcount: 9, Totalpackagecount: 9}, + } + + applyDestinationCounts(bookings, counts) + + if bookings[0].Destinationcount != 0 || bookings[0].Totalpackagecount != 0 { + t.Errorf("booking 11 picked up booking 77's counts: got %d/%d, want 0/0", + bookings[0].Destinationcount, bookings[0].Totalpackagecount) + } +} + +// Empty inputs are the ordinary case on an empty page, not an error. +func TestApplyDestinationCountsHandlesEmptyInputs(t *testing.T) { + applyDestinationCounts(nil, nil) + applyDestinationCounts([]models.PickupBooking{}, []bookingDestinationCounts{{Bookingid: 1}}) + + bookings := []models.PickupBooking{{Bookingid: 11, Destinationcount: 0}} + applyDestinationCounts(bookings, nil) + if bookings[0].Destinationcount != 0 { + t.Errorf("no counts should leave the booking at 0, got %d", bookings[0].Destinationcount) + } +} + +// A single-destination customer booking must report 1, not 0. The console +// branches on destinationcount > 1 to decide whether to show the summary line, +// and 1 is what keeps a B2C row reading as it does today. +func TestSingleDestinationBookingReportsOne(t *testing.T) { + bookings := []models.PickupBooking{{Bookingid: 11}} + counts := []bookingDestinationCounts{{Bookingid: 11, Destinationcount: 1, Totalpackagecount: 2}} + + applyDestinationCounts(bookings, counts) + + if bookings[0].Destinationcount != 1 || bookings[0].Totalpackagecount != 2 { + t.Errorf("got %d/%d, want 1/2", bookings[0].Destinationcount, bookings[0].Totalpackagecount) + } +} diff --git a/controllers/cxBookingController.go b/controllers/cxBookingController.go index dae8706..10571fc 100644 --- a/controllers/cxBookingController.go +++ b/controllers/cxBookingController.go @@ -82,6 +82,13 @@ type cxCreateBookingRequest 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. @@ -270,6 +277,8 @@ func CreateCxBooking(c *fiber.Ctx) error { Estimatemaxrupees: estimateMax, Routekm: quote.RouteKM, + Notes: req.Remarks, + Createdat: now, Updatedat: now, } diff --git a/models/booking.go b/models/booking.go index f506811..b49edc0 100644 --- a/models/booking.go +++ b/models/booking.go @@ -80,6 +80,18 @@ type PickupBooking struct { // GET /customer/track/:trackingno at all: a booking is addressed by id, a // shipment by tracking number, and nothing joined the two. Trackingno string `json:"trackingno,omitempty" gorm:"-"` + // Destinationcount and Totalpackagecount are not columns either. A + // customer-app pickup is ONE booking carrying N destinations, and the admin + // list has to be able to say "3 destinations · 4 packages" without shipping + // the whole destination array on every row — the console drains up to 12 + // pages of 100 bookings and does not use the array in the list. Filled in by + // the admin bookings list from one grouped query per page (see + // applyDestinationCounts). Deliberately NOT omitempty: a console-created + // booking has no bookingdestinations rows at all and must report 0, which + // the console reads as "no destinations recorded" — omitting the field would + // make it indistinguishable from a single-destination booking. + Destinationcount int `json:"destinationcount" gorm:"-"` + Totalpackagecount int `json:"totalpackagecount" gorm:"-"` Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"` Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`