fix: per-site attribution needs its own column, not pickuplocationid

Caught by testing the previous commit against production: creating a booking
with a resolved site failed with

  pickupbookings_pickuplocationid_fkey
  FOREIGN KEY (pickuplocationid) REFERENCES appcustomerlocations(...)

pickuplocationid is the *customer's* saved address, a B2C concept. It never
referred to the client company's own kitchens or branches. The pre-existing
code that validated an incoming pickuplocationid against TenantLocation was
wrong on the same point and would have 500'd for any caller that used it — it
had simply never been called with a value.

Adds tenantlocationid to pickupbookings and consignments (nullable, indexed,
additive via AutoMigrate), carried across at pickup, and points the reporting
filter, the by_location breakdown and the Unattributed bucket at it.

The booking request accepts tenantlocationid, and still accepts
pickuplocationid as an alias so anything written against the earlier docs
starts working instead of failing.

Also gofmt on the two model files touched; booking.go was already failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-06 13:08:57 +05:30
parent 0c407e5b27
commit 90fa4fbb74
6 changed files with 108 additions and 70 deletions

View File

@@ -356,7 +356,7 @@ func GetAdminReports(c *fiber.Ctx) error {
bookingScope := func() *gorm.DB {
q := scopeToTenant(db.DB.Model(&models.PickupBooking{}), "tenantid", ownTenant)
return scopeToLocation(q, "pickuplocationid", locationID)
return scopeToLocation(q, "tenantlocationid", locationID)
}
var totalBookings int64
@@ -374,7 +374,7 @@ func GetAdminReports(c *fiber.Ctx) error {
consignmentQuery := scopeToLocation(
scopeToTenant(db.DB.Model(&models.Consignment{}), "tenantid", ownTenant),
"pickuplocationid", locationID).
"tenantlocationid", locationID).
Where("createdat BETWEEN ? AND ?", from, to)
if hubID != "" {
consignmentQuery = consignmentQuery.Where("currenthubid = ?", hubID)
@@ -639,11 +639,11 @@ func GetLocationSummary(c *fiber.Ctx) error {
// 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"
// Bookings with no tenantlocationid 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.
// tenantlocationid.
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
@@ -680,7 +680,7 @@ func locationBreakdown(tenantID, locationID int, from, to time.Time) []fiber.Map
COALESCE(SUM(p.paid), 0) AS cod_collected
FROM tenantlocations tl
LEFT JOIN pickupbookings b
ON b.pickuplocationid = tl.tenantlocationid
ON b.tenantlocationid = tl.tenantlocationid
AND b.createdat BETWEEN ? AND ?
LEFT JOIN (
SELECT bookingid, SUM(amount) AS paid
@@ -723,7 +723,7 @@ func locationBreakdown(tenantID, locationID int, from, to time.Time) []fiber.Map
if locationID == 0 {
var unattributed int64
db.DB.Model(&models.PickupBooking{}).
Where("tenantid = ? AND pickuplocationid IS NULL AND createdat BETWEEN ? AND ?",
Where("tenantid = ? AND tenantlocationid IS NULL AND createdat BETWEEN ? AND ?",
tenantID, from, to).Count(&unattributed)
if unattributed > 0 {
out = append(out, fiber.Map{
@@ -1977,10 +1977,16 @@ type AdminBookingRequest struct {
Appcustomerid int `json:"appcustomerid"`
CustomerPhone string `json:"customer_phone"`
CustomerName string `json:"customer_name"`
// Pickuplocationid names the client site the parcel is collected from — a
// Tenantlocationid names the client site the parcel is collected from — a
// DailyGrubs kitchen, for instance. Optional, but supplying it lets the
// address/pincode/coordinates be filled from the stored location instead of
// retyped, and is the only thing that makes per-site reporting possible.
// address/pincode/coordinates be filled from the stored site instead of
// retyped, and is what per-site reporting groups by. When it is omitted the
// site is inferred from the pickup coordinates or address.
Tenantlocationid *int `json:"tenantlocationid"`
// Pickuplocationid is accepted only as an alias for the field above, for
// callers written against the earlier docs. It is never stored as-is: the
// column of that name foreign-keys to appcustomerlocations, not to a
// client's sites.
Pickuplocationid *int `json:"pickuplocationid"`
Pickupaddress string `json:"pickupaddress"`
Pickuppincode string `json:"pickuppincode"`
@@ -2027,18 +2033,30 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error
return nil, &expressBookingValidationError{"tenantid does not match a known tenant"}
}
// A named pickup location fills in whatever the caller left blank, so the
// console can send a kitchen id instead of restating its address every time.
// It must belong to the booking's tenant — otherwise one client could book
// against another client's site.
if req.Pickuplocationid != nil {
// A named pickup site fills in whatever the caller left blank, so the console
// can send a kitchen id instead of restating its address every time. It must
// belong to the booking's tenant — otherwise one client could book against
// another client's site.
//
// `pickuplocationid` is accepted as an alias here purely for callers written
// against the earlier documentation. It is the wrong column: it foreign-keys
// to appcustomerlocations, so a tenantlocations id in it fails the insert.
// Both names resolve to Tenantlocationid.
siteID := req.Tenantlocationid
if siteID == nil {
siteID = req.Pickuplocationid
}
req.Pickuplocationid = nil
if siteID != nil {
var loc models.TenantLocation
if err := db.DB.Where("tenantlocationid = ?", *req.Pickuplocationid).First(&loc).Error; err != nil {
return nil, &expressBookingValidationError{"pickuplocationid does not match a known location"}
if err := db.DB.Where("tenantlocationid = ?", *siteID).First(&loc).Error; err != nil {
return nil, &expressBookingValidationError{"tenantlocationid does not match a known location"}
}
if loc.Tenantid != req.Tenantid {
return nil, &expressBookingValidationError{"pickuplocationid does not belong to this tenant"}
return nil, &expressBookingValidationError{"tenantlocationid does not belong to this tenant"}
}
req.Tenantlocationid = siteID
if req.Pickupaddress == "" {
req.Pickupaddress = loc.Address
}
@@ -2060,10 +2078,8 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error
// 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
}
if req.Tenantlocationid == nil {
req.Tenantlocationid = matchTenantLocation(req.Tenantid, req.Pickupaddress, req.Pickuplatitude, req.Pickuplongitude)
}
tx := db.DB.Begin()
@@ -2099,6 +2115,7 @@ func createExpressBooking(req AdminBookingRequest) (*models.PickupBooking, error
Tenantid: &tenantID,
Appcustomerid: customerID,
Pickuplocationid: req.Pickuplocationid,
Tenantlocationid: req.Tenantlocationid,
Pickupaddress: req.Pickupaddress,
Pickuppincode: req.Pickuppincode,
Pickuplatitude: req.Pickuplatitude,

View File

@@ -804,6 +804,7 @@ func BookingPickupComplete(c *fiber.Ctx) error {
Trackingno: trackingNo,
Tenantid: consignmentTenantID,
Pickuplocationid: booking.Pickuplocationid,
Tenantlocationid: booking.Tenantlocationid,
Pickuplatitude: booking.Pickuplatitude,
Pickuplongitude: booking.Pickuplongitude,
Deliverylatitude: booking.Deliverylatitude,

View File

@@ -128,7 +128,7 @@ tenants at once aren't a meaningful report, so it 400s without one.
The same rows appear as `by_location` inside `GET /admin/reports`.
**Send `pickuplocationid` on bookings.** Attribution depends on it. The server
**Send `tenantlocationid` on bookings.** Attribution depends on it. The server
will try to recognise the site from the pickup coordinates (within 150m) or a
matching address, but an explicit id is exact and always wins.
@@ -266,8 +266,8 @@ delivered, riderkms, ridercharges, dutyminutes }`.
POST /admin/expressbooking
{
"tenantid": 13, // required; forced to your own tenant on client logins
"pickuplocationid": 15, // a stored kitchen/branch — fills address, pincode and
// coords for you, and is what makes per-site reporting work
"tenantlocationid": 13, // a stored kitchen/branch — fills address, pincode and
// coords for you, and is what per-site reporting groups by
"customer_phone": "9876543210", // creates a Guest customer if unknown
"customer_name": "Ramesh",
"deliveryaddress": "12 Cross Cut Road, Gandhipuram",
@@ -286,8 +286,12 @@ POST /admin/expressbooking
Rules worth knowing:
- `parcels` must be non-empty and `tenantid` must exist.
- Pickup address + pincode are required **unless** `pickuplocationid` supplies them.
- A `pickuplocationid` belonging to another tenant is rejected.
- Pickup address + pincode are required **unless** `tenantlocationid` supplies them.
- A `tenantlocationid` belonging to another tenant is rejected.
- `pickuplocationid` is accepted as an alias for `tenantlocationid`, for anything
written against the earlier version of this doc. Prefer the new name: the
database column called `pickuplocationid` means something else entirely (the
B2C customer's saved address) and is not what per-site reporting uses.
- **CityGate**: the pickup pincode prefix must be an open city — `641`
Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil.
Any other prefix is refused at the middleware, before the handler runs.

View File

@@ -98,13 +98,17 @@ Two jupiter bugs that do not carry over, by construction:
`/admin/reports` now carries a `by_location` block alongside `by_hub`,
`by_tenant` and `by_rider`.
**Attribution caveat.** Per-site figures depend on `pickuplocationid` being set
on the booking. Every booking created before 2026-08-06 has it null, and the
console sends a kitchen's *address*, not its id. So `createExpressBooking` now
resolves the site itself — nearest stored location within 150m, falling back to
an address match — and unattributed bookings are reported as their own
`"Unattributed"` row rather than dropped, so the per-site rows still add up to
the summary total. Sending `pickuplocationid` explicitly is still better and
**Attribution caveat.** Per-site figures group by `tenantlocationid` on the
booking — a column added 2026-08-06. The pre-existing `pickuplocationid` column
is *not* it: that one foreign-keys to `appcustomerlocations`, the B2C customer's
saved address, so writing a client-site id into it fails the insert. Every
booking created before 2026-08-06 has no site at all.
Since the console sends a kitchen's *address* rather than its id,
`createExpressBooking` resolves the site itself — nearest stored location within
150m, falling back to an address match. Bookings with no site are reported as
their own `"Unattributed"` row rather than dropped, so per-site rows still add
up to the summary total. Sending `tenantlocationid` explicitly is exact and
always wins.
**`applocationid` (city) is still not a report parameter.** jupiter had it;
@@ -121,7 +125,7 @@ than one city.
| ~ tenant create/edit | `POST /admin/tenants`, `PUT /admin/tenants/:id` | **Done** |
| ~ location create/edit | `POST /admin/tenants/:id/locations`, `PUT /admin/tenantlocations/:id` | **Done** |
| ~ `getbranches` | `GET /admin/hubs` — *jupiter "branches" ≈ Doormile hubs; verify this is the same concept before relying on it* | **Built** |
| ~ `getlocationsummary` | **Gap** — no per-site rollup, same missing piece as `locationid` above | **Gap** |
| ~ `getlocationsummary` | `GET /admin/locations/summary` — see §2.3 | **Done** |
Doormile adds `locationname` on a tenant location. jupiter identified a site by
its address alone, which does not distinguish two branches on one street.

View File

@@ -32,13 +32,17 @@ func (Pricing) TableName() string {
}
type Consignment struct {
Consignmentid int `json:"consignmentid" gorm:"primaryKey;column:consignmentid"`
Trackingno string `json:"trackingno" gorm:"column:trackingno;unique;not null"`
Orderheaderid *int `json:"orderheaderid" gorm:"column:orderheaderid"`
Tenantid int `json:"tenantid" gorm:"column:tenantid"`
Senderid *int `json:"senderid" gorm:"column:senderid"`
Receiverid *int `json:"receiverid" gorm:"column:receiverid"`
Consignmentid int `json:"consignmentid" gorm:"primaryKey;column:consignmentid"`
Trackingno string `json:"trackingno" gorm:"column:trackingno;unique;not null"`
Orderheaderid *int `json:"orderheaderid" gorm:"column:orderheaderid"`
Tenantid int `json:"tenantid" gorm:"column:tenantid"`
Senderid *int `json:"senderid" gorm:"column:senderid"`
Receiverid *int `json:"receiverid" gorm:"column:receiverid"`
// See PickupBooking: pickuplocationid is the customer's saved address,
// tenantlocationid is the client's own site. Carried over at pickup so a
// parcel stays traceable to the kitchen or branch it left.
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
Deliverylocationid *int `json:"deliverylocationid" gorm:"column:deliverylocationid"`
Originhubid *int `json:"originhubid" gorm:"column:originhubid"`
Currenthubid *int `json:"currenthubid" gorm:"column:currenthubid"`

View File

@@ -5,8 +5,8 @@ import (
)
type PickupBooking struct {
Bookingid int `json:"bookingid" gorm:"primaryKey;column:bookingid"`
Bookingno string `json:"bookingno" gorm:"column:bookingno;unique;not null"`
Bookingid int `json:"bookingid" gorm:"primaryKey;column:bookingid"`
Bookingno string `json:"bookingno" gorm:"column:bookingno;unique;not null"`
// Tenantid identifies which client company this booking is for. Nil for
// direct B2C bookings (Bookingsource "Customer_App") that aren't attributed
// to a tenant yet — see CreateCustomerBooking. Required for CRM bookings
@@ -14,31 +14,39 @@ type PickupBooking struct {
// a specific tenant. Propagated onto the resulting Consignment at pickup
// time in BookingPickupComplete, instead of inferring it from whichever
// miler happens to complete the pickup.
Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"`
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid"`
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
Pickuplongitude float64 `json:"pickuplongitude" gorm:"column:pickuplongitude;not null"`
Deliveryaddress string `json:"deliveryaddress" gorm:"column:deliveryaddress;not null"`
Deliverypincode string `json:"deliverypincode" gorm:"column:deliverypincode;not null"`
Deliverylatitude float64 `json:"deliverylatitude" gorm:"column:deliverylatitude;not null"`
Deliverylongitude float64 `json:"deliverylongitude" gorm:"column:deliverylongitude;not null"`
Deliverycity string `json:"deliverycity" gorm:"column:deliverycity"`
Nearesthubid *int `json:"nearesthubid" gorm:"column:nearesthubid"`
Bookingsource string `json:"bookingsource" gorm:"column:bookingsource;default:Customer_App"`
Providercompany string `json:"providercompany" gorm:"column:providercompany"`
Providerlocation string `json:"providerlocation" gorm:"column:providerlocation"`
Notes string `json:"notes" gorm:"column:notes"`
Status string `json:"status" gorm:"column:status;default:Created"` // Created, Miler_Assigned, Pickup_Scheduled, Picked_Up, Converted_To_Consignment, Cancelled
Preferredpickupfrom *time.Time `json:"preferredpickupfrom" gorm:"column:preferredpickupfrom"`
Preferredpickupto *time.Time `json:"preferredpickupto" gorm:"column:preferredpickupto"`
Assignedmileruserid *int `json:"assignedmileruserid" gorm:"column:assignedmileruserid"`
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
Tenantid *int `json:"tenantid" gorm:"column:tenantid;index"`
Appcustomerid int `json:"appcustomerid" gorm:"column:appcustomerid"`
// Pickuplocationid is the *customer's* saved address the parcel was collected
// from — it carries a foreign key to appcustomerlocations. It is a B2C
// concept and has nothing to do with the client company's own sites.
Pickuplocationid *int `json:"pickuplocationid" gorm:"column:pickuplocationid"`
// Tenantlocationid is the *client's* site: the kitchen, branch or depot the
// parcel came out of. Separate column because pickuplocationid points at a
// different table entirely; writing a tenantlocations id into it violates
// that foreign key. This is what per-site reporting groups by.
Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"`
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
Pickuplongitude float64 `json:"pickuplongitude" gorm:"column:pickuplongitude;not null"`
Deliveryaddress string `json:"deliveryaddress" gorm:"column:deliveryaddress;not null"`
Deliverypincode string `json:"deliverypincode" gorm:"column:deliverypincode;not null"`
Deliverylatitude float64 `json:"deliverylatitude" gorm:"column:deliverylatitude;not null"`
Deliverylongitude float64 `json:"deliverylongitude" gorm:"column:deliverylongitude;not null"`
Deliverycity string `json:"deliverycity" gorm:"column:deliverycity"`
Nearesthubid *int `json:"nearesthubid" gorm:"column:nearesthubid"`
Bookingsource string `json:"bookingsource" gorm:"column:bookingsource;default:Customer_App"`
Providercompany string `json:"providercompany" gorm:"column:providercompany"`
Providerlocation string `json:"providerlocation" gorm:"column:providerlocation"`
Notes string `json:"notes" gorm:"column:notes"`
Status string `json:"status" gorm:"column:status;default:Created"` // Created, Miler_Assigned, Pickup_Scheduled, Picked_Up, Converted_To_Consignment, Cancelled
Preferredpickupfrom *time.Time `json:"preferredpickupfrom" gorm:"column:preferredpickupfrom"`
Preferredpickupto *time.Time `json:"preferredpickupto" gorm:"column:preferredpickupto"`
Assignedmileruserid *int `json:"assignedmileruserid" gorm:"column:assignedmileruserid"`
Consignmentid *int `json:"consignmentid" gorm:"column:consignmentid"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
// Relations
Parcels []BookingParcel `json:"parcels" gorm:"foreignKey:Bookingid"`
ServiceOptions []BookingServiceOption `json:"serviceoptions" gorm:"foreignKey:Bookingid"`
@@ -91,7 +99,7 @@ type BookingPayment struct {
Bookingpaymentid int `json:"bookingpaymentid" gorm:"primaryKey;column:bookingpaymentid"`
Bookingid int `json:"bookingid" gorm:"column:bookingid"`
Amount float64 `json:"amount" gorm:"column:amount;not null"`
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode"` // Cash, UPI, Card, Wallet
Paymentmode string `json:"paymentmode" gorm:"column:paymentmode"` // Cash, UPI, Card, Wallet
Paymentstatus string `json:"paymentstatus" gorm:"column:paymentstatus;default:Pending"` // Pending, Paid, Failed, Refunded
Collectedbyuserid *int `json:"collectedbyuserid" gorm:"column:collectedbyuserid"`
Transactionref string `json:"transactionref" gorm:"column:transactionref"`
@@ -129,8 +137,8 @@ type BookingVehicleRequirement struct {
Requiredvehicletype string `json:"requiredvehicletype" gorm:"column:requiredvehicletype;not null"`
Reason string `json:"reason" gorm:"column:reason"`
Nearesthubid *int `json:"nearesthubid" gorm:"column:nearesthubid"`
Scheduledpickupfrom *time.Time `json:"scheduledpickupfrom" gorm:"column:scheduledpickupfrom"`
Scheduledpickupto *time.Time `json:"scheduledpickupto" gorm:"column:scheduledpickupto"`
Scheduledpickupfrom *time.Time `json:"scheduledpickupfrom" gorm:"column:scheduledpickupfrom"`
Scheduledpickupto *time.Time `json:"scheduledpickupto" gorm:"column:scheduledpickupto"`
Assignedvehicleid *int `json:"assignedvehicleid" gorm:"column:assignedvehicleid"`
Assigneddriveruserid *int `json:"assigneddriveruserid" gorm:"column:assigneddriveruserid"`
Status string `json:"status" gorm:"column:status;default:Required"` // Required, Scheduled, Assigned, Arrived, Picked_Up, Cancelled