diff --git a/controllers/adminController.go b/controllers/adminController.go index 7e20abc..ed32b1e 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -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, diff --git a/controllers/milerController.go b/controllers/milerController.go index 6481d2b..926cd29 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -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, diff --git a/docs/express-console-api.md b/docs/express-console-api.md index 349d8d3..10e4e69 100644 --- a/docs/express-console-api.md +++ b/docs/express-console-api.md @@ -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. diff --git a/docs/jupiter2doormile.md b/docs/jupiter2doormile.md index 6f84a75..3e82f5c 100644 --- a/docs/jupiter2doormile.md +++ b/docs/jupiter2doormile.md @@ -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. diff --git a/models/audit.go b/models/audit.go index f64369b..df11870 100644 --- a/models/audit.go +++ b/models/audit.go @@ -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"` diff --git a/models/booking.go b/models/booking.go index 25dd316..5285132 100644 --- a/models/booking.go +++ b/models/booking.go @@ -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