feat: per-site reporting, and actually populate the site on a booking
jupiter's getreportsummary took a locationid — per kitchen, per branch. That was the one report parameter with no Doormile equivalent, and for a food client with 23 kitchens it is the difference between one number and a usable report. GET /admin/reports?locationid= narrows every figure to one site GET /admin/reports now carries a by_location block GET /admin/locations/summary the standalone per-site table The filter alone would have been useless: pickuplocationid was null on every booking in the system, because the console sends a kitchen's address rather than its id. createExpressBooking now resolves the site itself — nearest stored location within 150m, falling back to an address match, nil when nothing matches confidently, since a wrong attribution silently moves orders between kitchens. An explicit pickuplocationid still wins. Bookings that named no site are reported as their own "Unattributed" row rather than dropped, so per-site rows add up to the summary total. Two fixes found while in here: - the payments join in the per-site query fanned out, counting a booking once per payment row; payments are now pre-aggregated per booking - by_rider was empty for every client login, which reads as "your riders did nothing". Riders are tenant-scoped now, so a client sees its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,41 @@ func scopeToTenant(query *gorm.DB, column string, tenantID int) *gorm.DB {
|
||||
return query.Where(column+" = ?", tenantID)
|
||||
}
|
||||
|
||||
// requestedLocationID reads ?locationid= and proves the site belongs to the
|
||||
// tenant the request is scoped to. Returns 0 for "all sites". The second value
|
||||
// is "ok", not an error, for the reason on effectiveTenantID — utils.NotFound
|
||||
// returns nil, so it cannot be used as a refusal signal.
|
||||
//
|
||||
// The ownership check matters because a location id is just an integer in a
|
||||
// query string: without it a client could read another client's per-site
|
||||
// numbers by guessing ids, which is the whole tenant boundary undone by one
|
||||
// unvalidated param.
|
||||
func requestedLocationID(c *fiber.Ctx, tenantID int) (locationID int, ok bool) {
|
||||
locationID = c.QueryInt("locationid", 0)
|
||||
if locationID == 0 {
|
||||
return 0, true
|
||||
}
|
||||
|
||||
var loc models.TenantLocation
|
||||
if err := db.DB.Select("tenantlocationid", "tenantid").
|
||||
Where("tenantlocationid = ?", locationID).First(&loc).Error; err != nil {
|
||||
return 0, false
|
||||
}
|
||||
if tenantID != 0 && loc.Tenantid != tenantID {
|
||||
return 0, false
|
||||
}
|
||||
return locationID, true
|
||||
}
|
||||
|
||||
// scopeToLocation narrows a query to one client site, no-oping when locationID
|
||||
// is 0.
|
||||
func scopeToLocation(query *gorm.DB, column string, locationID int) *gorm.DB {
|
||||
if locationID == 0 {
|
||||
return query
|
||||
}
|
||||
return query.Where(column+" = ?", locationID)
|
||||
}
|
||||
|
||||
// milerUserIDsForTenant lists the appusers.userid of a tenant's riders. Riders
|
||||
// belong to a client through their appusers row, not the miler profile.
|
||||
func milerUserIDsForTenant(tenantID int) []int {
|
||||
@@ -310,21 +345,36 @@ func GetAdminReports(c *fiber.Ctx) error {
|
||||
return utils.Forbidden(c, "you can only view your own tenant")
|
||||
}
|
||||
|
||||
// ?locationid= narrows every figure to one of the client's sites — a single
|
||||
// kitchen, branch or depot. This was jupiter's getreportsummary locationid
|
||||
// param; without it a food client with 23 kitchens can only see one number
|
||||
// for all of them.
|
||||
locationID, locOK := requestedLocationID(c, ownTenant)
|
||||
if !locOK {
|
||||
return utils.NotFound(c, "location not found")
|
||||
}
|
||||
|
||||
bookingScope := func() *gorm.DB {
|
||||
q := scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", ownTenant)
|
||||
return scopeToLocation(q, "pickuplocationid", locationID)
|
||||
}
|
||||
|
||||
var totalBookings int64
|
||||
scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", ownTenant).
|
||||
Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings)
|
||||
bookingScope().Where("createdat BETWEEN ? AND ?", from, to).Count(&totalBookings)
|
||||
|
||||
var delivered int64
|
||||
scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", ownTenant).
|
||||
bookingScope().
|
||||
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingConvertedConsignment, from, to).
|
||||
Count(&delivered)
|
||||
|
||||
var cancelled int64
|
||||
scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", ownTenant).
|
||||
bookingScope().
|
||||
Where("status = ? AND updatedat BETWEEN ? AND ?", constants.BookingCancelled, from, to).
|
||||
Count(&cancelled)
|
||||
|
||||
consignmentQuery := scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", ownTenant).
|
||||
consignmentQuery := scopeToLocation(
|
||||
scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", ownTenant),
|
||||
"pickuplocationid", locationID).
|
||||
Where("createdat BETWEEN ? AND ?", from, to)
|
||||
if hubID != "" {
|
||||
consignmentQuery = consignmentQuery.Where("currenthubid = ?", hubID)
|
||||
@@ -414,6 +464,11 @@ func GetAdminReports(c *fiber.Ctx) error {
|
||||
byTenant = append(byTenant, fiber.Map{"tenantid": r.Tenantid, "tenantname": r.Tenantname, "bookings": r.Bookings})
|
||||
}
|
||||
|
||||
// ---- by location: what went out of each of the client's own sites ----
|
||||
// For a food client this is "how many orders left which kitchen", which is
|
||||
// the question a single network-wide total cannot answer.
|
||||
byLocation := locationBreakdown(ownTenant, locationID, from, to)
|
||||
|
||||
// ---- by rider: completed stops/kms/earnings per rider in range,
|
||||
// optionally scoped to one hub ----
|
||||
type riderRow struct {
|
||||
@@ -437,14 +492,23 @@ func GetAdminReports(c *fiber.Ctx) error {
|
||||
AND ba.assignmentstatus = ? AND ba.completedat BETWEEN ? AND ?
|
||||
`
|
||||
args := []interface{}{constants.AssignmentCompleted, from, to}
|
||||
where := []string{}
|
||||
if hubID != "" {
|
||||
riderQuery += " WHERE mp.hubid = ? "
|
||||
where = append(where, "mp.hubid = ?")
|
||||
args = append(args, hubID)
|
||||
}
|
||||
riderQuery += " GROUP BY mp.userid, mp.displayname ORDER BY completed_stops DESC LIMIT 50"
|
||||
if isDoormileConsoleStaff(c) {
|
||||
db.DB.Raw(riderQuery, args...).Scan(&riderRows)
|
||||
// A client sees its own riders' numbers, not the network's. Previously this
|
||||
// list was simply empty for every client login, which reads as "your riders
|
||||
// did nothing" rather than "this view isn't for you".
|
||||
if ownTenant != 0 {
|
||||
where = append(where, "mp.userid IN (SELECT userid FROM appusers WHERE tenantid = ? AND roleid = 5)")
|
||||
args = append(args, ownTenant)
|
||||
}
|
||||
if len(where) > 0 {
|
||||
riderQuery += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
riderQuery += " GROUP BY mp.userid, mp.displayname ORDER BY completed_stops DESC LIMIT 50"
|
||||
db.DB.Raw(riderQuery, args...).Scan(&riderRows)
|
||||
|
||||
byRider := make([]fiber.Map, 0, len(riderRows))
|
||||
for _, r := range riderRows {
|
||||
@@ -455,8 +519,10 @@ func GetAdminReports(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
return utils.OK(c, fiber.Map{
|
||||
"from": from.Format("2006-01-02"),
|
||||
"to": to.Format("2006-01-02"),
|
||||
"from": from.Format("2006-01-02"),
|
||||
"to": to.Format("2006-01-02"),
|
||||
"tenantid": ownTenant,
|
||||
"locationid": locationID,
|
||||
"summary": fiber.Map{
|
||||
"total_bookings": totalBookings,
|
||||
"delivered": delivered,
|
||||
@@ -466,12 +532,216 @@ func GetAdminReports(c *fiber.Ctx) error {
|
||||
"open_exceptions": openExceptions,
|
||||
"completion_rate": completionRate,
|
||||
},
|
||||
"by_hub": byHub,
|
||||
"by_tenant": byTenant,
|
||||
"by_rider": byRider,
|
||||
"by_hub": byHub,
|
||||
"by_tenant": byTenant,
|
||||
"by_location": byLocation,
|
||||
"by_rider": byRider,
|
||||
})
|
||||
}
|
||||
|
||||
// matchTenantLocation resolves which of a client's sites a pickup came from,
|
||||
// for callers that send an address instead of a location id. Returns nil when
|
||||
// nothing matches confidently — a wrong attribution is worse than none, since
|
||||
// it silently moves orders between kitchens on the report.
|
||||
//
|
||||
// Coordinates first: they are unambiguous where an address string is not
|
||||
// ("Vidhya kitchen, Ritham Tours & Travels, Peelamedu" versus the same site
|
||||
// stored as "Ritham Tours and Travels, Peelamedu"). Address matching is only a
|
||||
// fallback for bookings that arrive without coordinates.
|
||||
func matchTenantLocation(tenantID int, address string, lat, lon float64) *int {
|
||||
var locations []models.TenantLocation
|
||||
if err := db.DB.Where("tenantid = ?", tenantID).Find(&locations).Error; err != nil || len(locations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 150m: tight enough that two kitchens on the same street stay distinct,
|
||||
// loose enough to absorb the drift between a stored pin and the one the
|
||||
// console sends.
|
||||
const matchRadiusKM = 0.15
|
||||
|
||||
if lat != 0 || lon != 0 {
|
||||
best := -1
|
||||
bestDist := matchRadiusKM
|
||||
for i, loc := range locations {
|
||||
if loc.Latitude == 0 && loc.Longitude == 0 {
|
||||
continue
|
||||
}
|
||||
if d := haversineKM(lat, lon, loc.Latitude, loc.Longitude); d < bestDist {
|
||||
best, bestDist = i, d
|
||||
}
|
||||
}
|
||||
if best >= 0 {
|
||||
id := locations[best].Tenantlocationid
|
||||
return &id
|
||||
}
|
||||
}
|
||||
|
||||
if address == "" {
|
||||
return nil
|
||||
}
|
||||
needle := strings.ToLower(strings.TrimSpace(address))
|
||||
for _, loc := range locations {
|
||||
stored := strings.ToLower(strings.TrimSpace(loc.Address))
|
||||
if stored == "" {
|
||||
continue
|
||||
}
|
||||
// Exact either way round, so "Vidhya kitchen, <address>" still resolves
|
||||
// when the stored row holds just the address. Substring matching is
|
||||
// deliberately not loosened past this — a short stored address would
|
||||
// otherwise swallow unrelated pickups.
|
||||
if stored == needle || strings.Contains(needle, stored) {
|
||||
id := loc.Tenantlocationid
|
||||
return &id
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLocationSummary is the standalone per-site table — jupiter's
|
||||
// getlocationsummary. Same rows as the report's by_location block, without
|
||||
// pulling the whole report, so a "Kitchens" screen can load on its own.
|
||||
//
|
||||
// GET /admin/locations/summary?tenantid=&locationid=&from=&to=
|
||||
func GetLocationSummary(c *fiber.Ctx) error {
|
||||
from, to, err := parseHubDateRange(c)
|
||||
if err != nil {
|
||||
return utils.BadRequest(c, err.Error())
|
||||
}
|
||||
|
||||
tenantID, allowed := effectiveTenantID(c)
|
||||
if !allowed {
|
||||
return utils.Forbidden(c, "you can only view your own tenant")
|
||||
}
|
||||
// Doormile staff must name a client: per-site rows across every tenant at
|
||||
// once are a mix of unrelated sites, not a report.
|
||||
if tenantID == 0 {
|
||||
return utils.BadRequest(c, "tenantid is required — per-site figures are reported within one client")
|
||||
}
|
||||
|
||||
locationID, locOK := requestedLocationID(c, tenantID)
|
||||
if !locOK {
|
||||
return utils.NotFound(c, "location not found")
|
||||
}
|
||||
|
||||
rows := locationBreakdown(tenantID, locationID, from, to)
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"data": rows,
|
||||
"total": len(rows),
|
||||
"tenantid": tenantID,
|
||||
"locationid": locationID,
|
||||
"from": from.Format("2006-01-02"),
|
||||
"to": to.Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
|
||||
// locationBreakdown returns per-site totals for a tenant: how many bookings
|
||||
// each of the client's own locations raised in the range, how many reached a
|
||||
// consignment, and what COD came back.
|
||||
//
|
||||
// Bookings with no pickuplocationid are reported as a single "Unattributed"
|
||||
// row rather than dropped, so the per-site figures still add up to the summary
|
||||
// total. That row is not noise — it is every booking created without naming
|
||||
// its site, and it should shrink to zero as the console starts sending
|
||||
// pickuplocationid.
|
||||
func locationBreakdown(tenantID, locationID int, from, to time.Time) []fiber.Map {
|
||||
// Only meaningful within one client. Across the whole network the rows
|
||||
// would be a mix of every tenant's sites, which is a screen nobody asked
|
||||
// for; Doormile staff pass ?tenantid= to get this.
|
||||
if tenantID == 0 {
|
||||
return []fiber.Map{}
|
||||
}
|
||||
|
||||
type locRow struct {
|
||||
Tenantlocationid int `gorm:"column:tenantlocationid"`
|
||||
Locationname string `gorm:"column:locationname"`
|
||||
Address string `gorm:"column:address"`
|
||||
Pincode string `gorm:"column:pincode"`
|
||||
Bookings int64 `gorm:"column:bookings"`
|
||||
Delivered int64 `gorm:"column:delivered"`
|
||||
Cancelled int64 `gorm:"column:cancelled"`
|
||||
CodCollected float64 `gorm:"column:cod_collected"`
|
||||
}
|
||||
|
||||
// The date range sits in the JOIN, not a WHERE, so a site with no orders in
|
||||
// the window still appears with zeros instead of vanishing from the report.
|
||||
//
|
||||
// Payments are pre-aggregated per booking before joining. Joining
|
||||
// bookingpayments directly would fan out — a booking with two payment rows
|
||||
// would be counted as two bookings.
|
||||
sql := `
|
||||
SELECT tl.tenantlocationid AS tenantlocationid,
|
||||
tl.locationname AS locationname,
|
||||
tl.address AS address,
|
||||
tl.pincode AS pincode,
|
||||
COUNT(b.bookingid) AS bookings,
|
||||
COUNT(b.bookingid) FILTER (WHERE b.status = ?) AS delivered,
|
||||
COUNT(b.bookingid) FILTER (WHERE b.status = ?) AS cancelled,
|
||||
COALESCE(SUM(p.paid), 0) AS cod_collected
|
||||
FROM tenantlocations tl
|
||||
LEFT JOIN pickupbookings b
|
||||
ON b.pickuplocationid = tl.tenantlocationid
|
||||
AND b.createdat BETWEEN ? AND ?
|
||||
LEFT JOIN (
|
||||
SELECT bookingid, SUM(amount) AS paid
|
||||
FROM bookingpayments
|
||||
WHERE paymentstatus = ?
|
||||
GROUP BY bookingid
|
||||
) p ON p.bookingid = b.bookingid
|
||||
WHERE tl.tenantid = ?`
|
||||
args := []interface{}{
|
||||
constants.BookingConvertedConsignment, constants.BookingCancelled,
|
||||
from, to, constants.PaymentStatusPaid, tenantID,
|
||||
}
|
||||
if locationID != 0 {
|
||||
sql += ` AND tl.tenantlocationid = ?`
|
||||
args = append(args, locationID)
|
||||
}
|
||||
sql += `
|
||||
GROUP BY tl.tenantlocationid, tl.locationname, tl.address, tl.pincode
|
||||
ORDER BY bookings DESC, tl.locationname`
|
||||
|
||||
var rows []locRow
|
||||
db.DB.Raw(sql, args...).Scan(&rows)
|
||||
|
||||
out := make([]fiber.Map, 0, len(rows)+1)
|
||||
for _, r := range rows {
|
||||
out = append(out, fiber.Map{
|
||||
"tenantlocationid": r.Tenantlocationid,
|
||||
"locationname": r.Locationname,
|
||||
"address": r.Address,
|
||||
"pincode": r.Pincode,
|
||||
"bookings": r.Bookings,
|
||||
"delivered": r.Delivered,
|
||||
"cancelled": r.Cancelled,
|
||||
"cod_collected": r.CodCollected,
|
||||
})
|
||||
}
|
||||
|
||||
// Anything the client raised without naming a site. Suppressed when the
|
||||
// caller asked for one specific location.
|
||||
if locationID == 0 {
|
||||
var unattributed int64
|
||||
db.DB.Model(&models.PickupBooking{}).
|
||||
Where("tenantid = ? AND pickuplocationid IS NULL AND createdat BETWEEN ? AND ?",
|
||||
tenantID, from, to).Count(&unattributed)
|
||||
if unattributed > 0 {
|
||||
out = append(out, fiber.Map{
|
||||
"tenantlocationid": nil,
|
||||
"locationname": "Unattributed",
|
||||
"address": "",
|
||||
"pincode": "",
|
||||
"bookings": unattributed,
|
||||
"delivered": 0,
|
||||
"cancelled": 0,
|
||||
"cod_collected": 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// APP USERS MANAGEMENT
|
||||
// --------------------
|
||||
@@ -1786,6 +2056,16 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error
|
||||
return nil, &expressBookingValidationError{"pickup address and pincode are required"}
|
||||
}
|
||||
|
||||
// If the caller did not name a site, try to recognise it from where the
|
||||
// pickup actually is. Without this the field stays null — as it did on every
|
||||
// booking in the system — and per-site reporting has nothing to group by,
|
||||
// because the console sends a kitchen's address rather than its id.
|
||||
if req.Pickuplocationid == nil {
|
||||
if matched := matchTenantLocation(req.Tenantid, req.Pickupaddress, req.Pickuplatitude, req.Pickuplongitude); matched != nil {
|
||||
req.Pickuplocationid = matched
|
||||
}
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
customerID := req.Appcustomerid
|
||||
|
||||
Reference in New Issue
Block a user