From d12629a1e405377fc1d91978b34300898d537570 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Wed, 2 Sep 2026 16:32:53 +0530 Subject: [PATCH] updates on the api endpoints on the customer page and more --- CLAUDE.md | 64 ++- constants/constants.go | 31 ++ controllers/adminController.go | 137 +++++- controllers/customerController.go | 29 +- controllers/hubController.go | 81 ++-- controllers/hubInboundController.go | 286 ++++++++++++ controllers/logisticsHandoverController.go | 514 +++++++++++++++++++++ controllers/logisticsHandover_test.go | 149 ++++++ controllers/logisticsRouting_test.go | 137 ++++++ controllers/milerAppController.go | 84 +++- controllers/milerController.go | 116 +++-- docs/DEV_ONBOARDING.md | 271 +++++++++++ docs/logistics-base-handover.md | 360 +++++++++++++++ docs/miler-app-api.md | 61 ++- go.mod | 8 +- internal/legs/legs.go | 71 +++ internal/legs/legs_test.go | 117 +++++ internal/routing/dropforleg_test.go | 141 ++++++ internal/routing/optimizer.go | 66 ++- models/audit.go | 10 +- models/booking.go | 15 +- routes/routes.go | 14 + routes/routes_logistics_test.go | 257 +++++++++++ scratch/check_booking_paging.go | 104 +++++ scratch/check_bookingno.go | 71 +++ scratch/check_customer_bookings.go | 115 +++++ scratch/check_handover_schema.go | 136 ++++++ scratch/check_latest.go | 62 +++ scratch/check_miler23.go | 37 ++ scratch/check_phone.go | 38 ++ scratch/check_two_bookings.go | 80 ++++ 31 files changed, 3541 insertions(+), 121 deletions(-) create mode 100644 controllers/hubInboundController.go create mode 100644 controllers/logisticsHandoverController.go create mode 100644 controllers/logisticsHandover_test.go create mode 100644 controllers/logisticsRouting_test.go create mode 100644 docs/DEV_ONBOARDING.md create mode 100644 docs/logistics-base-handover.md create mode 100644 internal/legs/legs.go create mode 100644 internal/legs/legs_test.go create mode 100644 internal/routing/dropforleg_test.go create mode 100644 routes/routes_logistics_test.go create mode 100644 scratch/check_booking_paging.go create mode 100644 scratch/check_bookingno.go create mode 100644 scratch/check_customer_bookings.go create mode 100644 scratch/check_handover_schema.go create mode 100644 scratch/check_latest.go create mode 100644 scratch/check_miler23.go create mode 100644 scratch/check_phone.go create mode 100644 scratch/check_two_bookings.go diff --git a/CLAUDE.md b/CLAUDE.md index 3a6a665..13f7edf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ customers. prior sessions]** - **Backend**: Go + Fiber, deployed on **Kubernetes**, at `api.doormile.com`. - 200 registered routes **[verified this session, exact count]** — see §7. + 220 registered routes **[verified 2026-09-02, exact count]** — see §7. This is the primary booking/assignment API and the primary trigger for miler assignment, calling the AI decision layer with a 5-second timeout fallback so a slow AI response never blocks a booking. @@ -196,7 +196,8 @@ websocket routes. **[verified this session]** `MilerSkipDelivery`, added this session). `ConsignmentHistory` (event log), `ConsignmentException` (Lost/Damaged/Misrouted/Receiver_Refused/ Missing_Contents/Undeliverable). -- `Hub`, `Vehicle`, `Tripsheet`, `TripsheetItem`, `DeliveryProof`. +- `Hub`, `Vehicle`, `Tripsheet`, `TripsheetItem`, `DeliveryProof`. `Hub` is what + the rider app calls a **Base** — same row, different word (see §12). - `AppUser` (`appusers`) — shared login table for staff/miler/admin roles (`Roleid`: 1 admin, 3 manager, 4 rep/exec, 5 miler, 6 hub staff via a separate `HubStaffAccount` table). `MilerProfile` — actual rider profile @@ -402,6 +403,65 @@ code compiled correctly the first time it hit a real toolchain. --- +## 8.4 Logistics pickup-source & base-handover flow (2026-09-02) + +**[verified this session]** — closes requests 25–31 on the Miler logistics line. +Full contract, state-transition tables and wire values: +[`docs/logistics-base-handover.md`](docs/logistics-base-handover.md). + +**Vocabulary.** The wire says *hub*; the rider app renders it as *Base*. Never +change a wire value to match the app's wording: `inward_at_hub`, +`Inwarded_at_Hub`, `next_hub`, `pickup_source_type: "hub"` stay exactly as spelt. + +**Feature flag `MILER_HUB_HANDOVER_ENABLED`** (default **off**, read per request, +same pattern as `MILER_COLLECTED_STATE_ENABLED`). On, a hub-routed parcel stops +at `Created` at pickup-complete and only reaches `Inwarded_at_Hub` when the +handover is recorded. Off (today), pickup-complete marks it `Inwarded_at_Hub` +immediately — which is what the deployed rider app expects. **Do not turn it on +until a rider build that calls `inward-at-hub` is live**, or every intercity +parcel strands on `Created` with no way to advance it. Everything else in this +work is ungated. + +**New endpoints (4):** + +| Method | Path | Handler | +|---|---|---| +| POST | `/miler/consignments/:id/inward-at-hub` | `MilerInwardConsignmentAtHub` | +| GET | `/miler/bases` | `MilerGetBases` | +| GET | `/hub/inbound/expected` | `GetHubInboundExpected` | +| POST | `/hub/inbound/:id/reconcile` | `ReconcileHubInbound` | + +**New columns** (additive, nullable, `AutoMigrate`; no CHECK constraint needed +widening — `Created` was already permitted on `consignments`): +`pickupbookings.pickupsourcetype`, `pickupbookings.pickuphubid`, +`consignments.inwardedat`. + +**Conventions added — reuse these, don't reimplement:** +- `renderBase(hub)` (`controllers/logisticsHandoverController.go`) is the ONE + shape a base is returned in — all six fields, everywhere. A test enforces the + count, because five of six leaves a rider unable to navigate. +- `nextActionForConsignment(status)` is the ONE definition of what a rider does + next. pickup-complete, the queue read and the consignment read all call it, so + a poll can never disagree with the pivot. +- `resolveHandoverHub(booking, riderHubID)` decides which base a parcel goes to. + Backend decides; the app never picks a base. +- `pickupSource(booking, customerName)` resolves type/id/name/address for any + booking row, in the miler queue, the hub dispatch board and the admin detail. +- `scopeConsignmentsToOwnTenant(c, query)` (`hubInboundController.go`) is the + consignment counterpart of `scopeBookingsToOwnTenant` — use it on any new + hub-console consignment query. + +**Two pre-existing bugs fixed in passing:** a hub-routed pickup left its +`BookingAssignment` open forever, so the rider could never go off duty +(`MilerEndDuty` refuses while any assignment is Assigned/Accepted); and the +no-rider-hub fallback took whichever hub row an unordered query returned first, +now nearest-active-base by haversine. + +**Not verified:** no integration test has hit the 4 new endpoints; the migration +has not run against a real DB. `go build`, `go vet` and `go test ./...` all pass. + +--- + ## 9. Current blockers & open work (whole-project level) **[carried forward]** diff --git a/constants/constants.go b/constants/constants.go index 7b59e71..23573bc 100644 --- a/constants/constants.go +++ b/constants/constants.go @@ -75,6 +75,37 @@ const ( ErrOtpInvalid = "OTP_INVALID" ErrIdempotencyInProgress = "IDEMPOTENCY_IN_PROGRESS" // an identical keyed request is still running ErrEmailInUse = "EMAIL_IN_USE" + ErrHubNotFound = "HUB_NOT_FOUND" // hub_id on a handover does not resolve to an active base + ErrHubRequired = "HUB_REQUIRED" // handover attempted with no base to hand over to +) + +// Pickup source types — what kind of place a booking is collected FROM. Sent +// on every miler booking row as pickup_source_type so the rider app can title a +// stop correctly instead of guessing from the source name, the pincode or the +// rider's own base. "customer" is a real value, never an omission: a front-door +// pickup has no configured location id, and "no location because it is a front +// door" must be distinguishable from "no location because nobody filled it in". +// +// The rider app renders "hub" as Base — the wire value stays hub. +const ( + PickupSourceHub = "hub" + PickupSourceCustomer = "customer" + PickupSourceMerchant = "merchant" + PickupSourceStore = "store" +) + +// Next actions — what the rider does next with a parcel. Returned by +// pickup-complete and, so a poll or a cold restart can rebuild the leg without +// a local cache, on every GET /miler/bookings row. Consignment status alone +// cannot carry this: a hub-routed parcel and a freshly-collected hyperlocal one +// can both sit on Created. +const ( + NextActionPickup = "pickup" // not collected yet — the stop is the pickup + NextActionStartDelivery = "start_delivery" // collected, hyperlocal, not yet out for delivery + NextActionDeliver = "deliver" // carry it to the receiver + NextActionInwardAtHub = "inward_at_hub" // carry it to a base and hand it over + NextActionHandedToHub = "handed_to_hub" // already inwarded at the base — nothing left for this rider + NextActionNone = "none" // terminal (delivered, cancelled, returned) ) // Payment Modes diff --git a/controllers/adminController.go b/controllers/adminController.go index 95f4c7d..2f683ac 100644 --- a/controllers/adminController.go +++ b/controllers/adminController.go @@ -2142,7 +2142,23 @@ type AdminBookingRequest struct { // 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"` + Pickuplocationid *int `json:"pickuplocationid"` + // PickupSourceType says what kind of place this booking is collected from — + // one of constants.PickupSource*. Optional: left blank it is classified from + // what the payload carries (a base id, a client site id, or neither), so + // existing console callers keep working unchanged. Send it explicitly to + // create a Base/Hub-origin booking. + PickupSourceType string `json:"pickup_source_type"` + // Pickuphubid names the base a Base → Customer booking is collected FROM. + // Required when pickup_source_type is "hub"; supplying it is also enough on + // its own, since a booking that names a base is a base-origin booking. The + // base's own address, pincode and coordinates fill in whatever the caller + // left blank, so the dispatch board never has to retype a gate address. + Pickuphubid *int `json:"pickuphubid"` + // Sourceid is accepted as an alias for whichever id the source type implies — + // the app and the console have both used this spelling. With + // pickup_source_type "hub" it is a base id; otherwise a client-site id. + Sourceid *int `json:"sourceid"` Pickupaddress string `json:"pickupaddress"` Pickuppincode string `json:"pickuppincode"` Pickuplatitude float64 `json:"pickuplatitude"` @@ -2202,7 +2218,38 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic // 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. + // A base-origin pickup (Base/Hub → Customer). The base is the actual place the + // rider collects from, so its address and coordinates become the booking's + // pickup point and the row records both the type and the base id — that pair + // is what the rider app reads to title the stop as a Base rather than as the + // rider's own office, and what the dispatch board reads back on the row. + baseID := req.Pickuphubid + if baseID == nil && strings.EqualFold(req.PickupSourceType, constants.PickupSourceHub) { + baseID = req.Sourceid + } + if baseID != nil { + var hub models.Hub + if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *baseID).First(&hub).Error; err != nil { + return nil, &expressBookingValidationError{"pickuphubid does not match a known base"} + } + req.PickupSourceType = constants.PickupSourceHub + req.Pickuphubid = &hub.Hubid + if req.Pickupaddress == "" { + req.Pickupaddress = hub.Address + } + if req.Pickuppincode == "" { + req.Pickuppincode = hub.Pincode + } + if req.Pickuplatitude == 0 && req.Pickuplongitude == 0 { + req.Pickuplatitude, req.Pickuplongitude = hub.Latitude, hub.Longitude + } + } + siteID := req.Tenantlocationid + // sourceid doubles as the client-site id when the source is not a base. + if siteID == nil && baseID == nil { + siteID = req.Sourceid + } if siteID == nil { siteID = req.Pickuplocationid } @@ -2238,10 +2285,34 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic // 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.Tenantlocationid == nil { + if req.Tenantlocationid == nil && req.Pickuphubid == nil { req.Tenantlocationid = matchTenantLocation(req.Tenantid, req.Pickupaddress, req.Pickuplatitude, req.Pickuplongitude) } + // Classify the source once, here, rather than leaving every reader to guess. + // A caller-supplied type wins as long as it is one we know; an unknown word is + // dropped rather than stored, so the column never holds something the app has + // no meaning for. A door pickup is recorded as "customer" explicitly — the + // whole point of the column is that a blank cannot be told apart from an + // address nobody filled in. + switch { + case req.Pickuphubid != nil: + req.PickupSourceType = constants.PickupSourceHub + case strings.EqualFold(req.PickupSourceType, constants.PickupSourceStore): + req.PickupSourceType = constants.PickupSourceStore + case strings.EqualFold(req.PickupSourceType, constants.PickupSourceCustomer): + req.PickupSourceType = constants.PickupSourceCustomer + case strings.EqualFold(req.PickupSourceType, constants.PickupSourceMerchant): + // Honoured even with no site id attached. A merchant collection with no + // configured location is still a shop, and telling the rider "customer + // door" would send them looking for a person who is not there. + req.PickupSourceType = constants.PickupSourceMerchant + case req.Tenantlocationid != nil: + req.PickupSourceType = constants.PickupSourceMerchant + default: + req.PickupSourceType = constants.PickupSourceCustomer + } + tx := db.DB.Begin() customerID := req.Appcustomerid @@ -2276,6 +2347,8 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic Appcustomerid: customerID, Pickuplocationid: req.Pickuplocationid, Tenantlocationid: req.Tenantlocationid, + Pickupsourcetype: req.PickupSourceType, + Pickuphubid: req.Pickuphubid, Pickupaddress: req.Pickupaddress, Pickuppincode: req.Pickuppincode, Pickuplatitude: req.Pickuplatitude, @@ -2525,7 +2598,65 @@ func GetAdminBookingDetails(c *fiber.Ctx) error { if err := q.First(&booking, id).Error; err != nil { return utils.NotFound(c, "booking not found") } - return utils.OK(c, booking) + + // The routing decision and the inputs it was made from, so a support call + // about "why does this say handover instead of delivery" is a lookup rather + // than a reconstruction. Everything here is derived from stored state — no + // new columns, and it stays right if the routing rule changes, because it + // reads the same helpers the pivot does. + var customer models.AppCustomer + db.DB.Where("appcustomerid = ?", booking.Appcustomerid).First(&customer) + sourceType, sourceID, sourceName, sourceAddress := pickupSource(&booking, + customer.Firstname+" "+customer.Lastname) + + routing := fiber.Map{ + "pickup_source_type": sourceType, + "pickup_source_id": sourceID, + "pickup_source_name": sourceName, + "from_address": sourceAddress, + "from_pincode": booking.Pickuppincode, + "to_address": booking.Deliveryaddress, + "destination_pincode": booking.Deliverypincode, + // hyperlocal: same postal area, so no base leg — the collecting rider + // carries it to the receiver. Otherwise it goes through a base. This is the + // decision pickup-complete makes, shown with the inputs it makes it from. + "is_hyperlocal": isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode, + booking.Pickuplatitude, booking.Pickuplongitude, + booking.Deliverylatitude, booking.Deliverylongitude), + } + + // Before pickup the decision has not been taken yet, so the routing result is + // a projection; after pickup it is fact, read off the consignment. + if booking.Consignmentid != nil { + var cn models.Consignment + if db.DB.First(&cn, *booking.Consignmentid).Error == nil { + booking.Consignmentstatus = cn.Status + routing["consignment_state"] = cn.Status + routing["next_action"] = nextActionForConsignment(cn.Status) + routing["next_hub"] = renderBase(loadHub(cn.Currenthubid)) + routing["inwardedat"] = cn.Inwardedat + routing["decided"] = true + } + } else { + routing["consignment_state"] = "" + routing["next_action"] = constants.NextActionPickup + routing["next_hub"] = nil + routing["decided"] = false + } + + // routing rides alongside the booking's own fields rather than nesting them + // under a new key — the console reads this response as a booking object today, + // and moving those fields would break every screen that does. + raw, err := json.Marshal(booking) + if err != nil { + return utils.OK(c, booking) + } + payload := map[string]interface{}{} + if err := json.Unmarshal(raw, &payload); err != nil { + return utils.OK(c, booking) + } + payload["routing"] = routing + return utils.OK(c, payload) } func AdminAssignMiler(c *fiber.Ctx) error { diff --git a/controllers/customerController.go b/controllers/customerController.go index 039a5f4..0ba874e 100644 --- a/controllers/customerController.go +++ b/controllers/customerController.go @@ -421,18 +421,23 @@ func CreateCustomerBooking(c *fiber.Ctx) error { tx := db.DB.Begin() booking := models.PickupBooking{ - Bookingno: generateBookingNo(), - Appcustomerid: customerID, - Pickuplocationid: req.Pickuplocationid, - Pickupaddress: req.Pickupaddress, - Pickuppincode: req.Pickuppincode, - Pickuplatitude: req.Pickuplatitude, - Pickuplongitude: req.Pickuplongitude, - Deliveryaddress: req.Deliveryaddress, - Deliverypincode: req.Deliverypincode, - Deliverylatitude: req.Deliverylatitude, - Deliverylongitude: req.Deliverylongitude, - Bookingsource: "Customer_App", + Bookingno: generateBookingNo(), + Appcustomerid: customerID, + Pickuplocationid: req.Pickuplocationid, + Pickupaddress: req.Pickupaddress, + Pickuppincode: req.Pickuppincode, + Pickuplatitude: req.Pickuplatitude, + Pickuplongitude: req.Pickuplongitude, + Deliveryaddress: req.Deliveryaddress, + Deliverypincode: req.Deliverypincode, + Deliverylatitude: req.Deliverylatitude, + Deliverylongitude: req.Deliverylongitude, + Bookingsource: "Customer_App", + // A B2C booking is always collected at the sender's own door, so the source + // type is recorded rather than left blank — the rider app titles the stop + // with the sender's name and address instead of grouping it under the + // rider's own base. + Pickupsourcetype: constants.PickupSourceCustomer, Status: constants.BookingPendingPickup, Preferredpickupfrom: req.Preferredpickupfrom, Preferredpickupto: req.Preferredpickupto, diff --git a/controllers/hubController.go b/controllers/hubController.go index 0236767..99de847 100644 --- a/controllers/hubController.go +++ b/controllers/hubController.go @@ -13,6 +13,7 @@ import ( "doormile/db" "doormile/dto" "doormile/internal/assignment" + "doormile/internal/legs" "doormile/internal/routing" "doormile/models" "doormile/utils" @@ -70,14 +71,11 @@ func zoneName(pincode string) string { } // haversineKM returns the great-circle distance between two lat/lon points in km. +// haversineKM stays the name the whole controllers package calls, and now +// delegates to internal/legs so the route sequencer measures distance with the +// identical implementation rather than a second copy of it. func haversineKM(lat1, lon1, lat2, lon2 float64) float64 { - const earthRadiusKM = 6371.0 - toRad := func(deg float64) float64 { return deg * math.Pi / 180 } - dLat := toRad(lat2 - lat1) - dLon := toRad(lon2 - lon1) - a := math.Sin(dLat/2)*math.Sin(dLat/2) + - math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2) - return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + return legs.HaversineKM(lat1, lon1, lat2, lon2) } // humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago". @@ -315,15 +313,27 @@ func GetHubUnassignedBookings(c *fiber.Ctx) error { var customer models.AppCustomer db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer) + // What kind of place this is collected from, and which one. A Base/Hub + // pickup carries the base id, so the dispatch board can show that the + // parcel starts at a base rather than at a customer's door — and the row + // the rider app receives carries the same pair. + customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname) + sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName) + response = append(response, fiber.Map{ - "bookingid": b.Bookingid, - "bookingno": b.Bookingno, - "customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), - "pickup_address": b.Pickupaddress, - "pickup_pincode": b.Pickuppincode, - "delivery_address": b.Deliveryaddress, - "parcels": b.Parcels, - "created_at": b.Createdat, + "bookingid": b.Bookingid, + "bookingno": b.Bookingno, + "customer_name": customerName, + "pickup_source_type": sourceType, + "sourceid": sourceID, + "pickuplocationid": sourceID, + "pickup_source_name": sourceName, + "pickup_address": sourceAddress, + "pickup_pincode": b.Pickuppincode, + "delivery_address": b.Deliveryaddress, + "delivery_pincode": b.Deliverypincode, + "parcels": b.Parcels, + "created_at": b.Createdat, }) } @@ -389,17 +399,25 @@ func GetHubBookingsRange(c *fiber.Ctx) error { } } + customerName := strings.TrimSpace(customer.Firstname + " " + customer.Lastname) + sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName) + response = append(response, fiber.Map{ - "bookingid": b.Bookingid, - "bookingno": b.Bookingno, - "customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), - "pickup_address": b.Pickupaddress, - "pickup_pincode": b.Pickuppincode, - "delivery_address": b.Deliveryaddress, - "parcels": b.Parcels, - "status": hubBookingDisplayStatus(b.Status, hasMiler), - "milername": milerName, - "created_at": b.Createdat, + "bookingid": b.Bookingid, + "bookingno": b.Bookingno, + "customer_name": customerName, + "pickup_source_type": sourceType, + "sourceid": sourceID, + "pickuplocationid": sourceID, + "pickup_source_name": sourceName, + "pickup_address": sourceAddress, + "pickup_pincode": b.Pickuppincode, + "delivery_address": b.Deliveryaddress, + "delivery_pincode": b.Deliverypincode, + "parcels": b.Parcels, + "status": hubBookingDisplayStatus(b.Status, hasMiler), + "milername": milerName, + "created_at": b.Createdat, }) } @@ -437,6 +455,10 @@ func renderInboundConsignment(cs models.Consignment) fiber.Map { "temperature": "N/A", "status": cs.Status, "updatedat": cs.Updatedat, + // The physical-receipt fact, distinct from updatedat, which moves on any + // write. Null on rows inwarded before this column existed. + "inwardedat": cs.Inwardedat, + "inbound_status": "received", } } @@ -539,11 +561,18 @@ func CreateInboundScan(c *fiber.Ctx) error { } } + now := time.Now() consignment.Status = constants.ConsignmentInwardedAtHub consignment.Currenthubid = &hubID consignment.Condition = req.Condition consignment.Shelf = recommendedShelf - consignment.Updatedat = time.Now() + consignment.Updatedat = now + // The inbound scan is a physical receipt, so it stamps the same received-at + // fact the rider handover does. Kept first-write-wins: a second scan of the + // same parcel must not move the time it actually arrived. + if consignment.Inwardedat == nil { + consignment.Inwardedat = &now + } if err := db.DB.Save(&consignment).Error; err != nil { return utils.Internal(c, "failed to update consignment") diff --git a/controllers/hubInboundController.go b/controllers/hubInboundController.go new file mode 100644 index 0000000..dd3aa69 --- /dev/null +++ b/controllers/hubInboundController.go @@ -0,0 +1,286 @@ +package controllers + +import ( + "fmt" + "strconv" + "strings" + "time" + + "doormile/constants" + "doormile/db" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// -------------------- +// BASE INBOUND — what is on its way in, and confirming it arrived +// +// The console side of the rider handover. Wire vocabulary stays hub +// (Inwarded_at_Hub, currenthubid); the rider app renders it as Base. +// -------------------- + +// scopeConsignmentsToOwnTenant is the consignment counterpart of +// scopeBookingsToOwnTenant: partner-tenant hub staff see only their own tenant's +// parcels, Doormile staff see everything. Same rule, different table — without +// it a partner's staff would read every other client's parcels passing through +// the same base. +func scopeConsignmentsToOwnTenant(c *fiber.Ctx, query *gorm.DB) *gorm.DB { + staff, err := getCurrentHubStaff(c) + if err != nil || isDoormileStaff(staff) { + return query + } + return query.Where("tenantid = ?", *staff.Tenantid) +} + +// canHubStaffAccessConsignment proves ownership of a consignment addressed by id +// before it is written to, rather than trusting the path parameter. +func canHubStaffAccessConsignment(c *fiber.Ctx, cn *models.Consignment) bool { + staff, err := getCurrentHubStaff(c) + if err != nil { + return false + } + if isDoormileStaff(staff) { + return true + } + return staff.Tenantid != nil && cn.Tenantid == *staff.Tenantid +} + +// GetHubInboundExpected lists parcels a rider is currently carrying towards this +// base — collected, routed here, not yet handed over. Between a rider collecting +// an intercity parcel and inwarding it, nobody at the destination base could see +// it was coming; this is that view. +// +// It reads consignments on Created — collected, in a rider's hands, with a base +// as the next leg — whose current base is this one. Under the compatibility flow +// a hub-routed parcel is marked Inwarded_at_Hub at pickup and so never appears +// here; that is expected, and GetHubInboundToday covers those. +func GetHubInboundExpected(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + + query := db.DB.Where("currenthubid = ? AND status = ? AND deletedat IS NULL", + hubID, constants.ConsignmentCreated) + query = scopeConsignmentsToOwnTenant(c, query) + + var consignments []models.Consignment + if err := query.Order("createdat DESC").Find(&consignments).Error; err != nil { + return utils.Internal(c, "failed to fetch expected inbound consignments") + } + + // Batched lookups — this stays a handful of queries however many parcels are + // in flight towards the base. + ids := make([]int, 0, len(consignments)) + for _, cs := range consignments { + ids = append(ids, cs.Consignmentid) + } + bookingByConsignment := map[int]models.PickupBooking{} + riderIDs := []int{} + customerIDs := []int{} + if len(ids) > 0 { + var bookings []models.PickupBooking + db.DB.Where("consignmentid IN ?", ids).Find(&bookings) + for _, b := range bookings { + if b.Consignmentid != nil { + bookingByConsignment[*b.Consignmentid] = b + } + if b.Assignedmileruserid != nil { + riderIDs = append(riderIDs, *b.Assignedmileruserid) + } + customerIDs = append(customerIDs, b.Appcustomerid) + } + } + riderByID := map[int]models.AppUser{} + if len(riderIDs) > 0 { + var riders []models.AppUser + db.DB.Where("userid IN ?", riderIDs).Find(&riders) + for _, r := range riders { + riderByID[r.Userid] = r + } + } + customerByID := map[int]models.AppCustomer{} + if len(customerIDs) > 0 { + var customers []models.AppCustomer + db.DB.Where("appcustomerid IN ?", customerIDs).Find(&customers) + for _, cu := range customers { + customerByID[cu.Appcustomerid] = cu + } + } + + response := make([]fiber.Map, 0, len(consignments)) + for i := range consignments { + cs := consignments[i] + row := fiber.Map{ + "consignmentid": cs.Consignmentid, + "trackingno": cs.Trackingno, + // destination_base is the base this parcel moves on to after here, and + // is only known once something routes it onward — null more often than + // not. The delivery pincode is the reliable statement of where it ends + // up, so both are given rather than one standing in for the other. + "destination_base": renderBase(loadHub(cs.Destinationhubid)), + "final_destination": cs.Deliverypincode, + "delivery_pincode": cs.Deliverypincode, + "chargeableweight": cs.Chargeableweight, + "current_state": cs.Status, + "inbound_status": "expected", + "next_action": nextActionForConsignment(cs.Status), + "collected_at": cs.Createdat, + "pickup_pincode": cs.Pickuppincode, + } + if b, ok := bookingByConsignment[cs.Consignmentid]; ok { + customerName := "" + if cu, ok := customerByID[b.Appcustomerid]; ok { + customerName = strings.TrimSpace(cu.Firstname + " " + cu.Lastname) + } + sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, customerName) + row["bookingid"] = b.Bookingid + row["bookingno"] = b.Bookingno + row["customer_name"] = customerName + row["pickup_source_type"] = sourceType + row["pickup_source_id"] = sourceID + row["source"] = sourceName + row["pickup_address"] = sourceAddress + row["destination_address"] = b.Deliveryaddress + if b.Assignedmileruserid != nil { + row["rider_userid"] = *b.Assignedmileruserid + if r, ok := riderByID[*b.Assignedmileruserid]; ok { + row["rider"] = r.Authname + row["rider_phone"] = r.Contactno + } + } + } + response = append(response, row) + } + + return utils.List(c, response, int64(len(response))) +} + +// ReconcileHubInbound is the base's side of the rider handover: staff either +// confirm the parcel is physically here, or record that it never arrived despite +// a rider marking it handed over. +// +// received=true is the ordinary case and is idempotent — confirming a parcel +// that is already inwarded re-affirms it rather than failing, because staff +// working through a pile will hit some rows twice. +// +// received=false is the reconciliation path. It deliberately does not quietly +// move the parcel backwards: it raises an exception naming the discrepancy, so a +// parcel a rider swears was handed over and staff never saw becomes a tracked +// open item rather than a disagreement nobody owns. +func ReconcileHubInbound(c *fiber.Ctx) error { + hubID := c.Locals("hubid").(int) + // HubStaffAuth sets userid to the hubstaffaccountid, which is NOT an + // appusers.userid. consignmentexceptions.reportedbyuserid and + // consignmenthistory.userid both carry a real FK to appusers(userid), so + // writing a hub-staff id into either violates it — the row is rejected and the + // whole action 500s. The staff identity goes into the free-text fields + // instead, and the FK-bearing columns are left null. createdby on + // consignmentexceptions carries no FK, so it can hold the staff id. + staffAccountID, _ := c.Locals("userid").(int) + consignmentID, err := strconv.Atoi(c.Params("id")) + if err != nil { + return utils.BadRequest(c, "invalid consignment ID") + } + + var req struct { + // Pointer so an omitted field is never read as "not received". + Received *bool `json:"received"` + Remarks string `json:"remarks"` + } + if err := c.BodyParser(&req); err != nil { + return utils.BadRequest(c, "invalid request body") + } + if req.Received == nil { + return utils.BadRequest(c, "received is required (true = the parcel is physically here, false = it never arrived)") + } + + var consignment models.Consignment + if err := db.DB.Where("consignmentid = ? AND deletedat IS NULL", consignmentID). + First(&consignment).Error; err != nil { + return utils.NotFound(c, "consignment not found") + } + if !canHubStaffAccessConsignment(c, &consignment) { + return utils.NotFound(c, "consignment not found") + } + + now := time.Now() + + if !*req.Received { + description := strings.TrimSpace(req.Remarks) + if description == "" { + description = "Rider recorded a handover at this base but the parcel was not physically received." + } + exception := models.ConsignmentException{ + Consignmentid: consignment.Consignmentid, + Hubid: &hubID, + // Lost is the closest type the consignmentexceptions CHECK constraint + // already allows, and it is honest: a parcel recorded as handed over + // that nobody can find is lost until it turns up. A dedicated + // Handover_Not_Received type would need that constraint widened first. + Exceptiontype: constants.ExceptionLost, + Severity: "High", + Description: fmt.Sprintf("%s (reported by hub staff account %d)", description, staffAccountID), + Status: constants.ExceptionOpen, + Createdby: staffAccountID, + } + if err := db.DB.Create(&exception).Error; err != nil { + return utils.Internal(c, "failed to raise handover exception") + } + db.DB.Create(&models.ConsignmentHistory{ + Consignmentid: consignment.Consignmentid, + Hubid: &hubID, + Eventstatus: consignment.Status, + Remarks: fmt.Sprintf("Handover disputed at base by hub staff account %d: %s", + staffAccountID, description), + }) + + return utils.OK(c, fiber.Map{ + "consignmentid": consignment.Consignmentid, + "trackingno": consignment.Trackingno, + "consignmentstatus": consignment.Status, + "received": false, + "exceptionid": exception.Exceptionid, + "exceptiontype": exception.Exceptiontype, + }) + } + + alreadyInwarded := consignment.Status == constants.ConsignmentInwardedAtHub + if !alreadyInwarded { + consignment.Status = constants.ConsignmentInwardedAtHub + consignment.Currenthubid = &hubID + if consignment.Originhubid == nil { + consignment.Originhubid = &hubID + } + consignment.Updatedat = now + consignment.Updatedby = staffAccountID + } + if consignment.Inwardedat == nil { + consignment.Inwardedat = &now + } + if err := db.DB.Save(&consignment).Error; err != nil { + return utils.Internal(c, "failed to record receipt") + } + + if !alreadyInwarded { + remarks := strings.TrimSpace(req.Remarks) + if remarks == "" { + remarks = "Physical receipt confirmed at base" + } + db.DB.Create(&models.ConsignmentHistory{ + Consignmentid: consignment.Consignmentid, + Hubid: &hubID, + Eventstatus: constants.ConsignmentInwardedAtHub, + Remarks: fmt.Sprintf("%s (hub staff account %d)", remarks, staffAccountID), + }) + } + + return utils.OK(c, fiber.Map{ + "consignmentid": consignment.Consignmentid, + "trackingno": consignment.Trackingno, + "consignmentstatus": consignment.Status, + "inwardedat": consignment.Inwardedat, + "received": true, + "already_received": alreadyInwarded, + }) +} diff --git a/controllers/logisticsHandoverController.go b/controllers/logisticsHandoverController.go new file mode 100644 index 0000000..d16bf31 --- /dev/null +++ b/controllers/logisticsHandoverController.go @@ -0,0 +1,514 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strconv" + "strings" + "time" + + "doormile/constants" + "doormile/db" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" +) + +// -------------------- +// BASE / HUB HANDOVER — the logistics next-leg surface +// +// Vocabulary note, because two words are in play for one thing: the wire says +// hub (inward_at_hub, Inwarded_at_Hub, next_hub, pickup_source_type "hub") and +// the rider app renders that as Base. Nothing here changes a wire value to suit +// the app's wording, and nothing in the app's wording should leak back in here. +// -------------------- + +// hubHandoverEnabled gates the two-step hub flow: a hub-routed parcel stops at +// Created — collected, in the rider's hands, on its way to a base — and only +// reaches Inwarded_at_Hub when the handover is actually recorded, by the rider +// (POST /miler/consignments/:id/inward-at-hub) or by base staff (the console +// inbound scan). +// +// Default OFF, and it must stay off until a rider-app build that calls the +// handover endpoint is live. With it off, pickup-complete keeps marking a +// hub-routed parcel Inwarded_at_Hub the instant it is collected — which is not +// true of where the parcel physically is, but is what the current app and the +// console's inbound views expect. Flipping it early would leave every intercity +// parcel sitting on Created with no button in the rider's app to advance it and +// no row in the base's inbound list. +// +// Read at request time (env MILER_HUB_HANDOVER_ENABLED=true) so it can be turned +// on without a redeploy, same as MILER_COLLECTED_STATE_ENABLED. Everything else +// in this file — next_hub, the handover endpoint itself, next_action on the +// queue read, base master data, inbound visibility — is ungated and safe for the +// current app. +func hubHandoverEnabled() bool { + return strings.EqualFold(os.Getenv("MILER_HUB_HANDOVER_ENABLED"), "true") +} + +// renderBase is the one shape a base is ever returned in, so pickup-complete, +// the booking rows, the handover response and GET /miler/bases cannot drift +// apart. All six fields every time: the id keys the handover, the name is the +// heading the rider reads, address and pincode are what they read at the gate, +// and the coordinates are the only thing that can drive Navigate. Five of six +// still leaves a rider unable to get there. +func renderBase(hub *models.Hub) fiber.Map { + if hub == nil { + return nil + } + return fiber.Map{ + "id": hub.Hubid, + "name": hub.Hubname, + "address": hub.Address, + "pincode": hub.Pincode, + "latitude": hub.Latitude, + "longitude": hub.Longitude, + } +} + +// loadHub reads one base by id, ignoring soft-deleted rows. Returns nil rather +// than an error for a missing id so callers can treat "no base" and "unknown +// base" the same way where that is the right call. +func loadHub(hubID *int) *models.Hub { + if hubID == nil || *hubID == 0 { + return nil + } + var hub models.Hub + if err := db.DB.Where("hubid = ? AND deletedat IS NULL", *hubID).First(&hub).Error; err != nil { + return nil + } + return &hub +} + +// nearestActiveHub finds the closest active base to a point. Used only as a last +// resort when neither the booking nor the rider names one — a parcel with +// nowhere to go is worse than a parcel sent to the nearest gate. Returns nil +// when no active base has usable coordinates. +func nearestActiveHub(lat, lon float64) *models.Hub { + if lat == 0 && lon == 0 { + return nil + } + var hubs []models.Hub + if err := db.DB.Where("deletedat IS NULL AND status = ?", "Active").Find(&hubs).Error; err != nil { + return nil + } + var best *models.Hub + bestKM := 0.0 + for i := range hubs { + h := &hubs[i] + if h.Latitude == 0 && h.Longitude == 0 { + continue + } + d := haversineKM(lat, lon, h.Latitude, h.Longitude) + if best == nil || d < bestKM { + best, bestKM = h, d + } + } + return best +} + +// resolveHandoverHub decides which base a hub-routed parcel is carried to. The +// decision is the backend's, never the app's — the app is told where to go and +// navigates there. +// +// Order, most authoritative first: +// 1. the base the booking was routed to (nearesthubid), when the console or the +// dispatch layer set one. Nothing populates this column today; it is checked +// first so that the moment something does, it wins without another change here. +// 2. the collecting rider's own base — the operational default: a rider brings +// the parcel back to where they work out of. +// 3. the active base nearest the pickup point, for a rider with no base set. +// 4. any base at all, so a parcel is never left with nowhere to go. +func resolveHandoverHub(booking *models.PickupBooking, riderHubID *int) *models.Hub { + if hub := loadHub(booking.Nearesthubid); hub != nil { + return warnIfUnnavigable(hub) + } + if hub := loadHub(riderHubID); hub != nil { + return warnIfUnnavigable(hub) + } + if hub := nearestActiveHub(booking.Pickuplatitude, booking.Pickuplongitude); hub != nil { + return hub + } + var hub models.Hub + if db.DB.Where("deletedat IS NULL").Order("hubid").First(&hub).Error == nil { + return warnIfUnnavigable(&hub) + } + return nil +} + +// warnIfUnnavigable flags a base the rider cannot actually be routed to. The +// correct base is still returned — sending a rider to a different base because +// this one has bad master data would be worse than sending them to the right one +// with a missing pin. It is a data problem, and it needs to be visible as one. +func warnIfUnnavigable(hub *models.Hub) *models.Hub { + if hub.Latitude == 0 && hub.Longitude == 0 { + utils.Warn("base has no coordinates — Navigate will not work for riders sent here", + "hubid", hub.Hubid, "hubname", hub.Hubname) + } + if strings.TrimSpace(hub.Address) == "" { + utils.Warn("base has no address — the rider has nothing to read at the gate", + "hubid", hub.Hubid, "hubname", hub.Hubname) + } + return hub +} + +// derivePickupSourceType classifies where a booking is collected from for rows +// written before pickupsourcetype existed, and as a safety net for any writer +// that forgets to set it. A stored value always wins — this only fills a blank. +// +// A base-origin booking names a base; a client-site pickup names a tenant +// location (a kitchen, branch or depot — "merchant"); everything else is a +// person's door. "customer" is the honest answer for the last case and is +// returned as a value, never as an omission. +func derivePickupSourceType(b *models.PickupBooking) string { + if b.Pickupsourcetype != "" { + return b.Pickupsourcetype + } + if b.Pickuphubid != nil { + return constants.PickupSourceHub + } + if b.Tenantlocationid != nil { + return constants.PickupSourceMerchant + } + return constants.PickupSourceCustomer +} + +// pickupSource resolves the source-type, id, name and address the rider app puts +// at the top of a pickup stop. customerName is the booking's customer, used for +// the door-pickup case so a collection at a house is titled with the sender's +// name rather than the rider's own base name. +// +// sourceID is nil for a customer pickup — there is no configured location and +// inventing one would be a lie. That is precisely why pickup_source_type is +// carried on the row: the app can then tell "no id because it is a front door" +// from "no id because nobody filled it in". +func pickupSource(b *models.PickupBooking, customerName string) (sourceType string, sourceID *int, name, address string) { + sourceType = derivePickupSourceType(b) + address = b.Pickupaddress + + switch sourceType { + case constants.PickupSourceHub: + sourceID = b.Pickuphubid + if hub := loadHub(b.Pickuphubid); hub != nil { + name = hub.Hubname + if address == "" { + address = hub.Address + } + } + case constants.PickupSourceMerchant, constants.PickupSourceStore: + sourceID = b.Tenantlocationid + if b.Tenantlocationid != nil { + var loc models.TenantLocation + if db.DB.Where("tenantlocationid = ?", *b.Tenantlocationid).First(&loc).Error == nil { + name = loc.Locationname + if address == "" { + address = loc.Address + } + } + } + default: + // Customer door: the sender's own name and the address on the booking. + name = strings.TrimSpace(customerName) + } + + if name == "" { + name = strings.TrimSpace(b.Providerlocation) + } + return sourceType, sourceID, name, address +} + +// nextActionForConsignment maps a consignment's state to what the rider does +// next with it. This is the single definition — pickup-complete and the queue +// read both call it, so a poll can never disagree with the answer the pivot +// gave. Anything terminal returns "none" so a finished parcel retires from the +// rider's screen instead of lingering. +func nextActionForConsignment(status string) string { + switch status { + case constants.ConsignmentCreated: + // Collected and still in the rider's hands, routed to a base: carry it + // there and hand it over. Under the compatibility flow a hub-routed + // parcel never sits here — it is already Inwarded_at_Hub. + return constants.NextActionInwardAtHub + case constants.ConsignmentCollectedByMiler: + return constants.NextActionStartDelivery + case constants.ConsignmentOutForDelivery: + return constants.NextActionDeliver + case constants.ConsignmentInwardedAtHub: + return constants.NextActionHandedToHub + default: + // Tripsheet_Loaded, In_Transit, Delivered, RTO, Returned, Missing, + // Damaged — all past this rider's leg. + return constants.NextActionNone + } +} + +// nextHubForConsignment names the base a parcel is on its way to, for a +// consignment still in a rider's hands. A parcel that has already been inwarded +// has no next base — it is at one. +func nextHubForConsignment(cn *models.Consignment) fiber.Map { + if cn == nil || cn.Status != constants.ConsignmentCreated { + return nil + } + return renderBase(loadHub(cn.Currenthubid)) +} + +// -------------------- +// GET /miler/bases — base master data on a rider token +// +// The rider app could previously only see GET /admin/tenants/:id/locations, +// which is a different dataset entirely (a client's own sites) and is closed to +// a miler token anyway: /admin/* requires roles 1/3/4 and a rider is role 5, so +// that route answers 401 for them by design, not by oversight. +// -------------------- + +func MilerGetBases(c *fiber.Ctx) error { + query := db.DB.Where("deletedat IS NULL") + if status := c.Query("status"); status != "" { + query = query.Where("status = ?", status) + } else { + query = query.Where("status = ?", "Active") + } + if appLocationID := c.Query("applocationid"); appLocationID != "" { + query = query.Where("applocationid = ?", appLocationID) + } + + var hubs []models.Hub + if err := query.Find(&hubs).Error; err != nil { + return utils.Internal(c, "failed to fetch bases") + } + + // Ordered nearest-first from wherever the rider last reported being, so the + // base they are most likely to want is at the top. Falls back to id order + // when the rider has no position yet. + milerUserID := c.Locals("userid").(int) + var profile models.MilerProfile + hasPos := db.DB.Where("userid = ?", milerUserID).First(&profile).Error == nil && + (profile.Currentlatitude != 0 || profile.Currentlongitude != 0) + + rows := make([]fiber.Map, 0, len(hubs)) + for i := range hubs { + row := renderBase(&hubs[i]) + if hasPos && (hubs[i].Latitude != 0 || hubs[i].Longitude != 0) { + row["distance_km"] = haversineKM(profile.Currentlatitude, profile.Currentlongitude, + hubs[i].Latitude, hubs[i].Longitude) + } + rows = append(rows, row) + } + if hasPos { + sort.SliceStable(rows, func(i, j int) bool { + di, oki := rows[i]["distance_km"].(float64) + dj, okj := rows[j]["distance_km"].(float64) + switch { + case oki && okj: + return di < dj + case oki: + return true + default: + return false + } + }) + } + + return utils.List(c, rows, int64(len(rows))) +} + +// -------------------- +// POST /miler/consignments/:id/inward-at-hub — the rider handover +// +// The authoritative record that a rider physically handed a parcel in at a base. +// Idempotent (retries at a loading bay with bad signal are normal, and the route +// also carries the shared Idempotency-Key middleware), and it answers with the +// resulting state rather than a bare 200 — every lifecycle transition the app +// makes is checked against the state that comes back. +// -------------------- + +func MilerInwardConsignmentAtHub(c *fiber.Ctx) error { + milerUserID := c.Locals("userid").(int) + consignmentID, err := strconv.Atoi(c.Params("id")) + if err != nil { + return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidInput, "invalid consignment ID") + } + + var req struct { + HubID *int `json:"hub_id"` + // hubid accepted as an alias: the same value has been spelled both ways + // across this API's history and a handover is not worth failing over a + // missing underscore. + HubIDAlt *int `json:"hubid"` + Latitude *float64 `json:"latitude"` + Longitude *float64 `json:"longitude"` + Lat *float64 `json:"lat"` + Lon *float64 `json:"lon"` + } + // A body is optional — a rider handing a parcel into the base it is already + // routed to needs to send nothing at all. + _ = c.BodyParser(&req) + + consignment, code, err := milerConsignmentForRider(milerUserID, consignmentID) + if err != nil { + if code == constants.ErrConsignmentNotFound { + return utils.Fail(c, fiber.StatusNotFound, code, "consignment not found") + } + return utils.Fail(c, fiber.StatusForbidden, code, "this consignment is not assigned to you") + } + + hubID := req.HubID + if hubID == nil { + hubID = req.HubIDAlt + } + if hubID == nil { + // Nothing named: hand it into the base it was routed to. + hubID = consignment.Currenthubid + } + if hubID == nil { + return utils.Fail(c, fiber.StatusBadRequest, constants.ErrHubRequired, + "hub_id is required — this consignment is not routed to a base") + } + hub := loadHub(hubID) + if hub == nil { + return utils.Fail(c, fiber.StatusNotFound, constants.ErrHubNotFound, "hub_id does not match a known base") + } + + // Already inwarded: answer with the state that stands rather than failing, so + // a retry after a dropped response confirms rather than errors. This is also + // what a rider on the compatibility flow hits every time — there, + // pickup-complete already marked the parcel Inwarded_at_Hub. + if consignment.Status == constants.ConsignmentInwardedAtHub { + return utils.OK(c, fiber.Map{ + "consignmentid": consignment.Consignmentid, + "trackingno": consignment.Trackingno, + "consignmentstatus": consignment.Status, + "inwardedat": consignment.Inwardedat, + "hub": renderBase(loadHub(consignment.Currenthubid)), + "next_action": nextActionForConsignment(consignment.Status), + "already_inwarded": true, + }) + } + + // Only a parcel actually in this rider's hands can be handed over. A parcel + // already out for delivery has to be delivered or skipped; a delivered or + // returned one is past this leg entirely. + if consignment.Status != constants.ConsignmentCreated && + consignment.Status != constants.ConsignmentCollectedByMiler { + return utils.Fail(c, fiber.StatusBadRequest, constants.ErrInvalidState, + fmt.Sprintf("consignment is %s — it cannot be handed over at a base from this state", consignment.Status)) + } + + lat, lon := 0.0, 0.0 + if req.Latitude != nil { + lat = *req.Latitude + } else if req.Lat != nil { + lat = *req.Lat + } + if req.Longitude != nil { + lon = *req.Longitude + } else if req.Lon != nil { + lon = *req.Lon + } + + now := time.Now() + tx := db.DB.Begin() + + consignment.Status = constants.ConsignmentInwardedAtHub + consignment.Currenthubid = &hub.Hubid + if consignment.Originhubid == nil { + consignment.Originhubid = &hub.Hubid + } + consignment.Inwardedat = &now + consignment.Updatedat = now + consignment.Updatedby = milerUserID + if err := tx.Save(consignment).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to record the handover") + } + + history := models.ConsignmentHistory{ + Consignmentid: consignment.Consignmentid, + Hubid: &hub.Hubid, + Userid: &milerUserID, + Eventstatus: constants.ConsignmentInwardedAtHub, + Remarks: fmt.Sprintf("Rider handed parcel in at %s (%.5f, %.5f)", + hub.Hubname, lat, lon), + } + if err := tx.Create(&history).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to record handover history") + } + + // The rider's leg ends here, so the assignment closes and they return to the + // pool. riderkms is the distance actually ridden on this leg — pickup point to + // the base gate — and ridercharges the order amount, both written the same way + // MilerDeliverConsignment writes them for a final-mile leg. Without this an + // intercity rider's every job reported zero distance and zero value. + var booking models.PickupBooking + if tx.Where("consignmentid = ?", consignment.Consignmentid).First(&booking).Error == nil { + dropLat, dropLon := lat, lon + if dropLat == 0 && dropLon == 0 { + dropLat, dropLon = hub.Latitude, hub.Longitude + } + riderKms := haversineKM(consignment.Pickuplatitude, consignment.Pickuplongitude, dropLat, dropLon) + + orderAmount := 0.0 + var serviceOpt models.BookingServiceOption + if tx.Where("bookingid = ?", booking.Bookingid).Order("createdat DESC"). + First(&serviceOpt).Error == nil { + orderAmount = serviceOpt.Estimatedprice + } + + if err := tx.Model(&models.BookingAssignment{}). + Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?", + booking.Bookingid, milerUserID, + []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). + Updates(map[string]interface{}{ + "assignmentstatus": constants.AssignmentCompleted, + "completedat": now, + "riderkms": riderKms, + "ridercharges": orderAmount, + }).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to close assignment") + } + } + + if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). + Update("availabilitystatus", constants.MilerAvailable).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to update miler availability") + } + + if err := tx.Commit().Error; err != nil { + return utils.Internal(c, "failed to record the handover") + } + + // Best-effort, on an already-bound subject — a dropped event must never fail + // a handover the rider has physically completed. + if db.Js != nil { + payload := map[string]interface{}{ + "consignmentid": consignment.Consignmentid, + "trackingno": consignment.Trackingno, + "status": constants.ConsignmentInwardedAtHub, + "hubid": hub.Hubid, + "mileruserid": milerUserID, + "inwardedat": now.UnixMilli(), + } + if data, err := json.Marshal(payload); err == nil { + if _, err := db.Js.Publish("booking.status.updated", data); err != nil { + utils.Warn("MilerInwardConsignmentAtHub: NATS publish failed", + "consignment_id", consignment.Consignmentid, "error", err) + } + } + } + + return utils.OK(c, fiber.Map{ + "consignmentid": consignment.Consignmentid, + "trackingno": consignment.Trackingno, + "consignmentstatus": consignment.Status, + "inwardedat": consignment.Inwardedat, + "hub": renderBase(hub), + "next_action": nextActionForConsignment(consignment.Status), + "already_inwarded": false, + }) +} diff --git a/controllers/logisticsHandover_test.go b/controllers/logisticsHandover_test.go new file mode 100644 index 0000000..a8d706b --- /dev/null +++ b/controllers/logisticsHandover_test.go @@ -0,0 +1,149 @@ +package controllers + +import ( + "os" + "testing" + + "doormile/constants" + "doormile/models" +) + +func intPtr(v int) *int { return &v } + +func TestNextActionForConsignment(t *testing.T) { + cases := []struct { + name string + status string + want string + }{ + { + // The whole point of request 27: a hub-routed parcel sits on Created + // while it is being carried to a base, and the app must be able to + // rebuild that leg from server state after a restart. + name: "collected and routed to a base means carry it there", + status: constants.ConsignmentCreated, + want: constants.NextActionInwardAtHub, + }, + { + name: "collected hyperlocal parcel waits for start-delivery", + status: constants.ConsignmentCollectedByMiler, + want: constants.NextActionStartDelivery, + }, + { + name: "out for delivery means deliver", + status: constants.ConsignmentOutForDelivery, + want: constants.NextActionDeliver, + }, + { + name: "already handed in at a base leaves the rider nothing to do", + status: constants.ConsignmentInwardedAtHub, + want: constants.NextActionHandedToHub, + }, + { + name: "delivered is past this rider's leg", + status: constants.ConsignmentDelivered, + want: constants.NextActionNone, + }, + { + name: "in transit between bases is not a rider action", + status: constants.ConsignmentInTransit, + want: constants.NextActionNone, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := nextActionForConsignment(tc.status); got != tc.want { + t.Errorf("nextActionForConsignment(%q) = %q, want %q", tc.status, got, tc.want) + } + }) + } +} + +func TestDerivePickupSourceType(t *testing.T) { + cases := []struct { + name string + booking models.PickupBooking + want string + }{ + { + name: "a stored type always wins", + booking: models.PickupBooking{Pickupsourcetype: constants.PickupSourceStore, Tenantlocationid: intPtr(7)}, + want: constants.PickupSourceStore, + }, + { + name: "a booking naming a base is a base pickup", + booking: models.PickupBooking{Pickuphubid: intPtr(1)}, + want: constants.PickupSourceHub, + }, + { + name: "a booking naming a client site is a merchant pickup", + booking: models.PickupBooking{Tenantlocationid: intPtr(7)}, + want: constants.PickupSourceMerchant, + }, + { + // The case the whole column exists for: a front-door pickup has no + // location id, and "customer" must be a value rather than a blank. + name: "a booking naming no location at all is a customer door", + booking: models.PickupBooking{Pickupaddress: "12 Race Course Road"}, + want: constants.PickupSourceCustomer, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := derivePickupSourceType(&tc.booking); got != tc.want { + t.Errorf("derivePickupSourceType() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestRenderBaseCarriesAllSixFields(t *testing.T) { + // Five of six leaves a rider unable to get there: the id keys the handover, + // the name is the heading, address and pincode are read at the gate, and the + // coordinates are the only thing that can drive Navigate. + hub := models.Hub{ + Hubid: 1, + Hubname: "Coimbatore Hub", + Address: "14 Avinashi Road, Peelamedu, Coimbatore", + Pincode: "641004", + Latitude: 11.0272, + Longitude: 76.9905, + } + + got := renderBase(&hub) + for _, field := range []string{"id", "name", "address", "pincode", "latitude", "longitude"} { + if _, ok := got[field]; !ok { + t.Errorf("renderBase() is missing %q", field) + } + } + if len(got) != 6 { + t.Errorf("renderBase() returned %d fields, want exactly 6: %v", len(got), got) + } + + if renderBase(nil) != nil { + t.Error("renderBase(nil) should be nil, so an unresolved base is absent rather than empty") + } +} + +func TestHubHandoverEnabled(t *testing.T) { + // Default OFF matters operationally: turning it on before a rider-app build + // that can hand a parcel over would strand every intercity parcel on Created + // with no button to advance it. + t.Setenv("MILER_HUB_HANDOVER_ENABLED", "") + os.Unsetenv("MILER_HUB_HANDOVER_ENABLED") + if hubHandoverEnabled() { + t.Error("hub handover must default to off when the env var is unset") + } + + t.Setenv("MILER_HUB_HANDOVER_ENABLED", "TRUE") + if !hubHandoverEnabled() { + t.Error("hub handover should be on for TRUE, matching the case-insensitive read used elsewhere") + } + + t.Setenv("MILER_HUB_HANDOVER_ENABLED", "1") + if hubHandoverEnabled() { + t.Error(`only "true" turns the flow on — "1" must not`) + } +} diff --git a/controllers/logisticsRouting_test.go b/controllers/logisticsRouting_test.go new file mode 100644 index 0000000..d3f8c6a --- /dev/null +++ b/controllers/logisticsRouting_test.go @@ -0,0 +1,137 @@ +package controllers + +import ( + "testing" + + "doormile/constants" +) + +// The bulk-upload test sheet, checked against the rule that actually decides. +// +// krow_talent_app/tests/fixtures/doormile-logistics-test.xlsx carries 16 rows +// and an "Expected Routing" column saying, in words, what each one should do. +// That column is only a claim until something checks it, and the thing that +// decides is here, in Go — so it is checked here rather than restated in a +// JavaScript test, which would only prove that two copies of the rule agree +// with each other. +// +// If a row of the sheet is edited, this table is what says whether the sheet is +// still testing what it says it tests. +// +// Pickup is the sheet's single sender: Jayanthi's kitchen, Edayarpalayam, +// Coimbatore 641025. +const ( + sheetPickupPincode = "641025" + sheetPickupLat = 11.0168 + sheetPickupLng = 76.9558 +) + +func TestBulkTestSheetRoutesAsDocumented(t *testing.T) { + cases := []struct { + row int + receiver string + pincode string + lat, lng float64 + wantLocal bool + wantAction string + wantConsStat string // with the hub-handover flow ON + why string + }{ + // Customer -> Base. A different postal area, so the parcel cannot be + // carried to the receiver by the collecting rider. + {1, "Suresh Kumar", "600001", 13.091, 80.285, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "641 -> 600, Chennai"}, + {2, "Priya Raghavan", "600028", 13.018, 80.256, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "641 -> 600, Chennai"}, + {3, "Anil Reddy", "500081", 17.44, 78.3489, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate, Hyderabad"}, + {4, "Meera Krishnan", "500032", 17.4156, 78.3378, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate with COD"}, + {5, "Rahul Menon", "560001", 12.975, 77.606, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate, Bengaluru"}, + {6, "Divya Nair", "560066", 12.9698, 77.75, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "interstate with COD"}, + {7, "Karthik Subramani", "625001", 9.9195, 78.119, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "same state, different area"}, + {8, "Lakshmi Devi", "636001", 11.664, 78.146, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "same state, different area"}, + + // Customer -> Customer. The control group: if any of these routes to a + // base, the prefix rule has broken. + {9, "Ganesh Iyer", "641004", 11.029, 76.993, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area"}, + {10, "Revathi Balaji", "641012", 11.018, 76.966, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area, COD"}, + {11, "Vignesh Murugan", "641025", 11.008, 76.928, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "identical pincode"}, + {12, "Anitha Selvam", "641038", 11.023, 76.945, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "same 641 area"}, + + // No pincode: the decision falls to straight-line distance. Both + // outcomes are present, because a fallback that only ever answers one + // way is not being tested. + {13, "Mohan Das", "", 11.05, 77.01, true, constants.NextActionDeliver, constants.ConsignmentOutForDelivery, "~8km, inside the 30km fallback"}, + {14, "Sridhar Venkat", "", 13.0827, 80.2707, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "~430km, far outside the fallback"}, + {15, "Bhavani Shankar", "64", 13.06, 80.24, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "2-digit pincode is unusable, distance decides"}, + + // The row that proves the prefix rule outranks distance. + {16, "Ramesh Palanisamy", "642001", 10.658, 77.008, false, constants.NextActionInwardAtHub, constants.ConsignmentCreated, "~40km but 642 is a different area"}, + } + + if len(cases) != 16 { + t.Fatalf("the sheet has 16 rows, this table has %d — they must not drift apart", len(cases)) + } + + for _, tc := range cases { + t.Run(tc.receiver, func(t *testing.T) { + gotLocal := isHyperlocalBooking( + sheetPickupPincode, tc.pincode, + sheetPickupLat, sheetPickupLng, + tc.lat, tc.lng, + ) + if gotLocal != tc.wantLocal { + t.Fatalf("row %d (%s): hyperlocal = %v, want %v — %s", + tc.row, tc.receiver, gotLocal, tc.wantLocal, tc.why) + } + + // What the rider is actually told to do with it, from the same + // helper pickup-complete and the queue read both use. + status := constants.ConsignmentCreated + if gotLocal { + status = constants.ConsignmentOutForDelivery + } + if status != tc.wantConsStat { + t.Errorf("row %d: consignment state %s, want %s", tc.row, status, tc.wantConsStat) + } + if got := nextActionForConsignment(status); got != tc.wantAction { + t.Errorf("row %d: next_action %s, want %s", tc.row, got, tc.wantAction) + } + }) + } +} + +// The sheet is only worth uploading if it actually splits both ways. A file +// that turned out to be all-hyperlocal would pass every assertion above and +// still test nothing — which is exactly the problem with the tenant's own +// export that this sheet was written to replace. +func TestBulkTestSheetExercisesBothLegs(t *testing.T) { + type dest struct { + pincode string + lat, lng float64 + } + dests := []dest{ + {"600001", 13.091, 80.285}, {"600028", 13.018, 80.256}, + {"500081", 17.44, 78.3489}, {"500032", 17.4156, 78.3378}, + {"560001", 12.975, 77.606}, {"560066", 12.9698, 77.75}, + {"625001", 9.9195, 78.119}, {"636001", 11.664, 78.146}, + {"641004", 11.029, 76.993}, {"641012", 11.018, 76.966}, + {"641025", 11.008, 76.928}, {"641038", 11.023, 76.945}, + {"", 11.05, 77.01}, {"", 13.0827, 80.2707}, + {"64", 13.06, 80.24}, {"642001", 10.658, 77.008}, + } + + var local, hub int + for _, d := range dests { + if isHyperlocalBooking(sheetPickupPincode, d.pincode, sheetPickupLat, sheetPickupLng, d.lat, d.lng) { + local++ + } else { + hub++ + } + } + + if hub < 5 { + t.Errorf("only %d rows route through a base — too few to exercise the handover flow", hub) + } + if local < 3 { + t.Errorf("only %d rows go direct to the customer — no control group", local) + } + t.Logf("sheet splits %d base-handover / %d direct-to-customer", hub, local) +} diff --git a/controllers/milerAppController.go b/controllers/milerAppController.go index 9c4c4b8..d3670a8 100644 --- a/controllers/milerAppController.go +++ b/controllers/milerAppController.go @@ -322,34 +322,68 @@ func MilerGetMyBookings(c *fiber.Ctx) error { } } + // Where this parcel is collected FROM, and what kind of place that is, so + // Home can title the stop correctly. Without pickup_source_type every + // logistics pickup was grouped under the rider's own base name and a + // collection at a shop looked identical to one at a house. + // + // sourceid is null for a customer door and that is a real answer, not a + // missing one — the type is what makes the difference legible. pickuplocationid + // mirrors sourceid: the app has used both spellings for the same thing, and + // the booking row never carried either before now. + sourceType, sourceID, sourceName, sourceAddress := pickupSource(&b, + customer.Firstname+" "+customer.Lastname) + + // next_action / next_hub: the leg this parcel is on, rebuilt from server + // state on every poll. pickup-complete used to be the only place that ever + // said it, so a restart mid-leg left the app with nothing authoritative to + // read — consignment status alone cannot separate a hub-routed parcel from + // a freshly-collected hyperlocal one, since both can sit on Created. + nextAction := constants.NextActionPickup + var nextHub fiber.Map + if b.Status == constants.BookingCancelled { + nextAction = constants.NextActionNone + } else if b.Consignmentid != nil { + if cn, ok := codByConsignment[*b.Consignmentid]; ok { + nextAction = nextActionForConsignment(cn.Status) + nextHub = nextHubForConsignment(&cn) + } + } + row := fiber.Map{ - "bookingid": b.Bookingid, - "bookingreference": b.Bookingno, - "status": b.Status, - "stoptype": milerStopType(b.Status), - "consignmentid": b.Consignmentid, - "consignmentstatus": consignmentStatus, - "pickupaddress": b.Pickupaddress, - "pickuplatitude": b.Pickuplatitude, - "pickuplongitude": b.Pickuplongitude, - "deliveryaddress": b.Deliveryaddress, - "deliverylatitude": b.Deliverylatitude, - "deliverylongitude": b.Deliverylongitude, - "customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), - "customerphone": customer.Phone, + "bookingid": b.Bookingid, + "bookingreference": b.Bookingno, + "status": b.Status, + "stoptype": milerStopType(b.Status), + "consignmentid": b.Consignmentid, + "consignmentstatus": consignmentStatus, + "next_action": nextAction, + "next_hub": nextHub, + "pickup_source_type": sourceType, + "sourceid": sourceID, + "pickuplocationid": sourceID, + "pickup_source_name": sourceName, + "pickupaddress": sourceAddress, + "pickuplatitude": b.Pickuplatitude, + "pickuplongitude": b.Pickuplongitude, + "deliveryaddress": b.Deliveryaddress, + "deliverylatitude": b.Deliverylatitude, + "deliverylongitude": b.Deliverylongitude, + "customername": strings.TrimSpace(customer.Firstname + " " + customer.Lastname), + "customerphone": customer.Phone, // Arrival fact: the rider app derives its "Arrived" rung from // pickup-scheduled + a non-null reachedat, so this survives an app // restart without a separate booking status. Null until the rider hits // the reached endpoint. arrivallatitude/longitude are the GPS captured // at that moment (null if the app sent none). - "reachedat": b.Arrivedat, - "arrivallatitude": b.Arrivallatitude, - "arrivallongitude": b.Arrivallongitude, - "parcels": b.Parcels, - "serviceoptions": b.ServiceOptions, - "codamount": codAmount, - "paymentmode": paymentMode, - "createdat": b.Createdat, + "reachedat": b.Arrivedat, + "arrivallatitude": b.Arrivallatitude, + "arrivallongitude": b.Arrivallongitude, + "parcels": b.Parcels, + "serviceoptions": b.ServiceOptions, + "codamount": codAmount, + "paymentmode": paymentMode, + "createdat": b.Createdat, // Route sequencing — 0/empty when the stop was never sequenced. // sequencedat is the authoritative-order signal: non-null means the // console/optimizer fixed this stop's position and the app must follow @@ -442,6 +476,12 @@ func MilerGetConsignment(c *fiber.Ctx) error { "can_start_delivery": consignment.Status == constants.ConsignmentCollectedByMiler, "can_deliver": consignment.Status == constants.ConsignmentOutForDelivery, "can_skip": consignment.Status == constants.ConsignmentOutForDelivery || consignment.Status == constants.ConsignmentCollectedByMiler, + // The same leg information the queue read and pickup-complete give, so a + // single-consignment refresh is as authoritative as a full poll. + "next_action": nextActionForConsignment(consignment.Status), + "next_hub": nextHubForConsignment(consignment), + "can_inward_at_hub": consignment.Status == constants.ConsignmentCreated, + "inwardedat": consignment.Inwardedat, }) } diff --git a/controllers/milerController.go b/controllers/milerController.go index dcc5e49..c4a2c2c 100644 --- a/controllers/milerController.go +++ b/controllers/milerController.go @@ -16,6 +16,7 @@ import ( "doormile/db" "doormile/dto" "doormile/internal/assignment" + "doormile/internal/legs" "doormile/internal/notify" "doormile/internal/routing" "doormile/models" @@ -957,10 +958,7 @@ func BookingPaymentCollect(c *fiber.Ctx) error { // final-mile delivery. Pincodes shorter than 3 characters are treated as // unknown rather than matching, so bad data falls back to the safe hub route. func isHyperlocal(pickupPincode, deliveryPincode string) bool { - if len(pickupPincode) < 3 || len(deliveryPincode) < 3 { - return false - } - return pickupPincode[:3] == deliveryPincode[:3] + return legs.SamePostalArea(pickupPincode, deliveryPincode) } // maxHyperlocalKM bounds the straight-line pickup→delivery distance under which @@ -969,7 +967,7 @@ func isHyperlocal(pickupPincode, deliveryPincode string) bool { // created kitchen→customer bookings (e.g. DailyGrubs) frequently carry accurate // coordinates but no delivery pincode, and must not be wrongly routed through a // hub. When both pincodes are present the prefix rule still wins. -const maxHyperlocalKM = 30.0 +const maxHyperlocalKM = legs.MaxHyperlocalKM // isHyperlocalBooking decides whether a booking can skip the hub and be carried // straight to the final mile. It prefers the pincode-prefix rule (isHyperlocal) @@ -977,18 +975,7 @@ const maxHyperlocalKM = 30.0 // between the pickup and delivery coordinates when a pincode is missing — so a // same-area booking whose address carried no pincode isn't sent to a hub. func isHyperlocalBooking(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool { - // Both pincodes present: the prefix rule decides definitively (a matching - // pincode is hyperlocal, a differing one is genuinely inter-area — don't let - // distance override that). - if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 { - return isHyperlocal(pickupPincode, deliveryPincode) - } - // A pincode is missing: fall back to straight-line distance when we have both - // coordinates. - if pLat != 0 && pLng != 0 && dLat != 0 && dLng != 0 { - return haversineKM(pLat, pLng, dLat, dLng) <= maxHyperlocalKM - } - return false + return legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng) } // collectedStateEnabled gates the two-step hyperlocal delivery flow @@ -1068,15 +1055,29 @@ func BookingPickupComplete(c *fiber.Ctx) error { trackingNo := generateTrackingNo() + // The base this parcel belongs to. Backend decides — the app is told where to + // go and never picks a base itself. resolveHandoverHub prefers the base the + // booking was routed to, then the collecting rider's own base (the previous + // behaviour, and still the operational default), then the nearest active base + // to the pickup point — that last step replaces a fallback that took whichever + // hub row happened to come back first. + handoverHub := resolveHandoverHub(&booking, profile.Hubid) var defaultHubID *int - if profile.Hubid != nil { - defaultHubID = profile.Hubid + if handoverHub != nil { + defaultHubID = &handoverHub.Hubid } else { - utils.Warn("BookingPickupComplete: miler has no assigned hub, falling back to first hub row", "miler_user_id", milerUserID, "booking_id", bookingID) - var hub models.Hub - if tx.First(&hub).Error == nil { - defaultHubID = &hub.Hubid - } + utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID) + } + + // A hub-routed parcel: with the hub-handover flow ON it stops at Created — + // collected, in the rider's hands, on its way to a base — and only reaches + // Inwarded_at_Hub when the handover is actually recorded. With it OFF + // (default, and what the current app expects) it is marked Inwarded_at_Hub + // here, which is not where the parcel physically is but is what the current + // app and the console's inbound views read. + consignmentStatus := constants.ConsignmentInwardedAtHub + if hubHandoverEnabled() { + consignmentStatus = constants.ConsignmentCreated } // Hyperlocal shortcut: pickup and delivery in the same postal area mean no @@ -1088,7 +1089,6 @@ func BookingPickupComplete(c *fiber.Ctx) error { // to Out_for_Delivery, which lets the console tell "collected" from "actively // delivering". With it OFF (default, and what the current app expects) it goes // straight to Out_for_Delivery exactly as before. - consignmentStatus := constants.ConsignmentInwardedAtHub if isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode, booking.Pickuplatitude, booking.Pickuplongitude, booking.Deliverylatitude, booking.Deliverylongitude) { @@ -1138,6 +1138,14 @@ func BookingPickupComplete(c *fiber.Ctx) error { Currenthubid: defaultHubID, } + // Under the compatibility flow the parcel is treated as received at the base + // the moment it is collected, so the received-at fact is stamped here too — + // otherwise every parcel inwarded this way would have a null handover time + // and base reconciliation would have nothing to compare against. + if consignmentStatus == constants.ConsignmentInwardedAtHub { + consignment.Inwardedat = &now + } + var payment models.BookingPayment if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil { if payment.Paymentstatus == constants.PaymentStatusPaid { @@ -1188,9 +1196,31 @@ func BookingPickupComplete(c *fiber.Ctx) error { // they stay Picked_Up and out of the assignment pool until they finish. A // hub-routed parcel was dropped at the hub, so the rider frees up. postPickupAvailability := constants.MilerAvailable - if consignmentStatus == constants.ConsignmentCollectedByMiler { + if consignmentStatus == constants.ConsignmentCollectedByMiler || + consignmentStatus == constants.ConsignmentCreated { + // Created here means hub-routed and still in the rider's hands: they are + // carrying it to a base, so they are not free yet. postPickupAvailability = constants.MilerPickedUp } + + // A parcel that is already inwarded at the base ends this rider's leg, so the + // assignment closes with it. Without this the assignment stayed open forever + // on the compatibility flow and the rider could not go off duty — MilerEndDuty + // refuses while any assignment is still Assigned/Accepted. On the handover + // flow the assignment stays open on purpose and closes at inward-at-hub. + if consignmentStatus == constants.ConsignmentInwardedAtHub { + if err := tx.Model(&models.BookingAssignment{}). + Where("bookingid = ? AND mileruserid = ? AND assignmentstatus IN ?", + bookingID, milerUserID, + []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). + Updates(map[string]interface{}{ + "assignmentstatus": constants.AssignmentCompleted, + "completedat": now, + }).Error; err != nil { + tx.Rollback() + return utils.Internal(c, "failed to close assignment") + } + } if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID). Update("availabilitystatus", postPickupAvailability).Error; err != nil { tx.Rollback() @@ -1221,30 +1251,28 @@ func BookingPickupComplete(c *fiber.Ctx) error { } } - return utils.OK(c, fiber.Map{ + // next_action says what the rider does next; next_hub says where. An + // inward_at_hub with no base named leaves a rider holding a parcel with + // nowhere to take it, so the two travel together — and next_hub carries all + // six fields, because the coordinates are the only thing that can drive + // Navigate and the address and pincode are what the rider reads at the gate. + // + // consignment_id is always present: the delivery leg is keyed on it, and + // without it the app cannot name the parcel it is about to act on. + resp := fiber.Map{ "tracking_no": trackingNo, "consignment_id": consignment.Consignmentid, "consignmentstatus": consignment.Status, + "status": consignment.Status, "booking_no": booking.Bookingno, "booking_status": booking.Status, - "next_action": pickupNextAction(consignment.Status), - }) -} - -// pickupNextAction tells the app what the rider does next after a pickup, so it -// doesn't have to encode the hub-vs-hyperlocal branch itself: -// - Collected_By_Miler → tap start-delivery (collected-state flow on) -// - Out_for_Delivery → deliver directly (hyperlocal, collected-state off) -// - Inwarded_at_Hub → handed to the hub, done for this rider -func pickupNextAction(consignmentStatus string) string { - switch consignmentStatus { - case constants.ConsignmentCollectedByMiler: - return "start_delivery" - case constants.ConsignmentOutForDelivery: - return "deliver" - default: - return "handed_to_hub" + "next_action": nextActionForConsignment(consignment.Status), } + if consignment.Status == constants.ConsignmentCreated || + consignment.Status == constants.ConsignmentInwardedAtHub { + resp["next_hub"] = renderBase(handoverHub) + } + return utils.OK(c, resp) } func BookingVehicleRequiredEscalate(c *fiber.Ctx) error { diff --git a/docs/DEV_ONBOARDING.md b/docs/DEV_ONBOARDING.md new file mode 100644 index 0000000..537edfd --- /dev/null +++ b/docs/DEV_ONBOARDING.md @@ -0,0 +1,271 @@ +# Doormile Backend — Developer Onboarding & Working Memory + +Read this before touching the backend. It is the portable version of knowledge +that otherwise lives only in one machine's Claude session memory. It covers two +things a new dev (or a fresh Claude session on another machine) needs: + +1. **How we use Claude on this project** — the project-memory file, the skills, + the working conventions. +2. **Operational knowledge that isn't in the code** — deploy topology, build + gotchas, production landmines, and the incident history behind current + design choices. + +Related docs already in this repo: +- [`CLAUDE.md`](../CLAUDE.md) — the full project memory (architecture, data + model, route surface). Start there for *what the system is*. +- [`docs/doormile-flow.md`](doormile-flow.md) — end-to-end booking/assignment flow. +- [`docs/miler-app-api.md`](miler-app-api.md), [`docs/express-console-api.md`](express-console-api.md) — API contracts. +- [`docs/logistics-base-handover.md`](logistics-base-handover.md) — the pickup-source + and base-handover flow (requests 25–31), including the state transitions. +- [`docs/jupiter2doormile.md`](jupiter2doormile.md) — the legacy→new migration map. +- [`docs/test-booking-runbook.md`](test-booking-runbook.md) — how to run a test booking. +- [`skills.md`](../skills.md) — note on the installed Claude skill pack. + +--- + +## Part 1 — How Claude is used on this project + +### 1.1 Project memory (`CLAUDE.md`) +`CLAUDE.md` at the repo root is the single source of project context, loaded +automatically into every Claude session in this repo. It is checked into git, so +it travels to every machine and every dev. If you change how the system works, +update `CLAUDE.md` in the same change — it is treated as authoritative. + +Sections in `CLAUDE.md` are tagged **[verified this session]** (confirmed +against source) vs **[carried forward]** (reported by a prior session, not +re-verified). Respect the distinction — don't treat carried-forward claims as +confirmed. + +### 1.2 Session memory (machine-local — this is why this doc exists) +Claude also keeps per-fact memory files under +`~/.claude/projects//memory/`, indexed by `MEMORY.md`. These are +**not in git** and **do not travel** to another machine or dev. They accumulate +operational facts, incidents, and gotchas across sessions. + +Part 2 below is a distillation of those files into a form the whole team can +read. When a memory fact changes, update *both* the memory file (for Claude) and +this doc (for humans). + +### 1.3 Claude skills in use +The repo has the `addyosmani/agent-skills` pack installed at `.agents/skills/` +and symlinked into `.claude/skills/`. These are third-party, broad-trigger +skills that run with full agent permissions. Roster (see `skills.md` for the +full table): + +`api-and-interface-design`, `browser-testing-with-devtools`, +`ci-cd-and-automation`, `code-review-and-quality`, `code-simplification`, +`context-engineering`, `debugging-and-error-recovery`, +`deprecation-and-migration`, `documentation-and-adrs`, +`doubt-driven-development`, `frontend-ui-engineering`, +`git-workflow-and-versioning`, `idea-refine`, `incremental-implementation`, +`interview-me`, `observability-and-instrumentation`, +`performance-optimization`, `planning-and-task-breakdown`, +`security-and-hardening`, `shipping-and-launch`, `source-driven-development`, +`spec-driven-development`, `test-driven-development`, `using-agent-skills`. + +**How we treat them:** available on request, *not* auto-adopted over the +conventions in `CLAUDE.md` §7. The established conventions win: use the `utils` +response helpers, the `constants` enums, reuse `AssignMilerToBooking` and +`haversineKM`, prefer minimal-leverage fixes. Invoke a skill explicitly when its +scope fits; don't let a broad trigger override house style. + +### 1.4 Standing working preferences (from `CLAUDE.md`) +- Direct, honest technical assessments. Don't declare victory early. +- Production-grade from the start, minimal-effort highest-leverage fixes. +- **Warn before any consequential server/schema change.** +- Once a decision is made, proceed and report — but re-ask on money/data-correctness. + +--- + +## Part 2 — Operational knowledge (not derivable from the code) + +### 2.1 Build & toolchain +- Go **1.25** (`go.mod` says `go 1.25.0`). Module `doormile`. +- On the primary dev Mac, Go is installed at `/Users/tenext/go` but **not on + PATH**. Prefix: `export PATH="/Users/tenext/go/bin:$PATH"` before any `go` + command, or a bare `go build` returns "command not found". +- `go build ./...`, `go vet ./...`, `go test ./...` all pass repo-wide. +- `scratch/*.go` are throwaway `func main()` scripts tagged `//go:build ignore` + so the toolchain skips them. If a `main redeclared` error appears, it's a + stripped build tag under `scratch/`, **not** a real app problem. +- `go build .` emits a ~65MB `doormile` binary in the repo root; it's + gitignored — delete it, don't commit it. +- Tests exist only for pure logic (`isHyperlocal`, `calculateVolumetricWeight`, + `ParsePage`). Everything else is verified by build+vet, review, or live test. + +### 2.2 Deploy topology — how a change reaches production +- Live backend runs on **k3s** (not plain docker), server `66.116.225.226` port + **4422** (SSH, key-based auth from the primary Mac; `KUBECONFIG=/etc/rancher/k3s/k3s.yaml`). +- Workload: **statefulset `doormile`**, namespace `doormile`, **3 replicas**. + Binary is `/app/server` in the pod; image + `docker.io/doormile/doormile-backend:latest`, `imagePullPolicy: Always`. + Pods report `APP_ENV=staging`. +- **There is NO CI.** Nothing watches the repo. A `kubectl rollout restart` + alone rebuilds nothing — it re-pulls the same image digest. +- **A change reaches prod only by a manual build+push** (needs Docker daemon up + + Docker Hub creds): + ``` + docker build -t doormile/doormile-backend:latest . + docker push doormile/doormile-backend:latest + kubectl rollout restart statefulset/doormile -n doormile + ``` +- **The image builds from the working tree** (`ADD . /app/`). Uncommitted local + edits and any throwaway file under `cmd/`/`scratch/` get baked in. **Always + `git status` before building**, and **confirm code is committed *after* a + deploy** — `git log origin/main` tells you nothing about what's running. +- **Verify what's actually live** rather than assuming: `kubectl exec -n + doormile doormile-0 -- ls -la /app` shows the binary's build date; or probe a + response field only the new code emits. Group auth (`/admin/*`, `/hub/*`, + `/miler/*`) returns 401 for unmatched routes, so a 404-vs-401 probe can't tell + you if a route exists — use an authenticated request. + +### 2.3 Env vars & the source-of-truth manifest +- Env is **inline on the statefulset spec** (`.spec.template.spec.containers[0].env`), + no ConfigMap/envFrom. Set a flag with + `kubectl -n doormile set env statefulset/doormile KEY=value` (auto-rolls). A + plain restart does **not** add a var that isn't already in the spec. +- **Source-of-truth manifest**: `/opt/kubernetes/manifests/doormile/miletruth.yaml` + (git repo at `/opt/kubernetes/.git`; second copy under `/root/kubernetes/...`). + A `kubectl apply` of it **overwrites** live `set env` changes — so any live + flag change must also be written into this manifest or the next deploy reverts + it. +- The manifest supplies DB/Redis/NATS passwords via `secretKeyRef` + (`doormile-secrets`) while the live pods carry literals — verify that secret + exists before relying on `kubectl apply`. +- **Known live flag:** `MILER_COLLECTED_STATE_ENABLED=true` (hyperlocal two-step + pickup). `TRUSTED_PROXIES` must be set (api.doormile.com sits behind a + reverse proxy) or per-IP rate limits collapse all clients into one bucket. +- **`MILER_HUB_HANDOVER_ENABLED` — default off, and must stay off** until a rider + build that calls `POST /miler/consignments/:id/inward-at-hub` is live. On, a + hub-routed parcel stops at `Created` until the rider records the handover; off, + pickup-complete marks it `Inwarded_at_Hub` immediately, which is what the + deployed app expects. Flipping it early strands every intercity parcel on + `Created` with no button in the app to advance it and no row in any base's + received list. See [`logistics-base-handover.md`](logistics-base-handover.md). + +### 2.4 Timezone convention (subtle — read before touching any time field) +The DB and backend time helpers run on **IST (Asia/Kolkata) wall-clock**. +`dbLocation = Asia/Kolkata`; `DBNow()`/`DBToday()` (`utils/helper.go`) return +IST wall-clock digits *tagged as UTC*. Consequence: clients (e.g. the miler app) +that send a timestamp — such as `logdate` on `POST /miler/logs` — should send +**IST wall-clock (phone local time in India), not UTC**. Sending UTC misaligns +Redis zset scores and time-window queries by 5h30m. + +### 2.5 Postgres CHECK constraints predate the codebase +Status-column CHECK constraints are **not** created by GORM AutoMigrate — they +predate this codebase. Adding a new status *constant* in Go is not enough; the +DB rejects it with **SQLSTATE 23514**. Before adding any status enum value, +widen the matching `*_status_check` constraint in `migrations/migrate.go`. This +is also why the base-handover reconciliation path raises a `Lost` exception +rather than a more precise `Handover_Not_Received` — the latter would need +`consignmentexceptions` widened first. +Constraints exist on: `consignments`, `pickupbookings`, +`milerprofiles.availabilitystatus`, `bookingassignments`, +`consignmentexceptions`, `tripsheets`. `consignmenthistory` has no status check. +(This bit us live: `consignments_status_check` was missing `Collected_By_Miler` +and `Cancelled`, 500-ing every hyperlocal pickup and admin cancel.) + +### 2.6 Miler telemetry pipeline (HTTP → Redis; there is NO MQTT) +MQTT is a jupiter concept; Doormile has none. Miler telemetry is 4 HTTP +endpoints, identity always taken from `c.Locals("userid")` (never the body): +- `PUT /miler/location` → Postgres (`MilerProfile` lat/lng/pincode) + Redis + (`miler:gps:{userid}` 30min TTL, `milers:locations` GEO set feeding dispatch). +- `POST /miler/logs` → **Redis-only** periodic telemetry point + (`miler_periodic_log:{userid}:{ts}` + zsets, scored by timestamp). +- `POST /miler/status` → Redis-only (`miler_status:{userid}`). +- `POST /miler/consignments/logs` → Redis (list+zset) + Postgres + `ConsignmentHistory`; takes an **array** (batch). + +Device sensors (GPS/speed/heading/accuracy, battery/is_charging, connection) +come from Flutter plugins regardless of transport — MQTT isn't needed to collect +them. Console reads of the trail use **newest-first** fetch +(`ZRevRangeByScore`), then reverse to chronological, so a `?limit=N` window +keeps the *latest* fixes (fixed in `6e5da09` — previously `?limit=1` returned +the day's first blank early-boot ping). + +### 2.7 Route optimizer +`routes.workolik.com` (env `ROUTE_OPTIMIZER_URL`) = the rider-bike FastAPI +service (OR-Tools + Valhalla). Doormile endpoint +`POST /api/v1/optimization/doormile/sequence`, contract matches +`internal/routing/optimizer.go`. As of `f6d339a`, +`routing.SequenceMilerStopsAsync` fires after every assignment path. A `step=0` +in prod is never the service being down — it means the rider had <2 active +stops, a stop had missing coords, or the assignment predated the wiring. + +### 2.8 NATS ownership +Doormile has its **own** NATS (`nats://66.116.226.161:4223`, user `doormile`), +separate from jupiter's (`nats.workolik.com:4222`). Streams are declared by the +Go app in `db/streams.go` (`EnsureStreams`, add-only — never deletes/drops). +**Adding a `js.Publish` without adding its subject to `streamSubjects` silently +drops the event.** Publishing is best-effort: `if db.Js != nil { ... }`, +warn-log on failure, never fail the request. Do not run the old Python +`setup_jetstream.py` scripts — they used to clobber the subject list (now +neutered to read-only, but that change lives only on disk, not in git). + +### 2.9 Config gotcha: milers need `configid = 1001` +`LoginMiler`/`VerifyMilerPin` look up `WHERE contactno = ? AND configid = 1001`. +`AppUser.configid` column-defaults to `1`, so any miler created without +explicitly setting configid authenticates against nothing and returns a +misleading `404 no miler account found` even though the row exists and is +Active. `CreateMiler` now defaults it to 1001. **When a miler "doesn't exist" +but the row is visibly there, check `configid` first.** Same trap applies to +`AppCustomer`. + +--- + +## Part 3 — Access control status (know before adding client logins) + +- **Admin/express console (`/admin/*`) has NO tenant scoping.** `LoginAdmin` + hardcodes `tenantid = 0` in the JWT and no admin handler filters by tenant — + every console login sees every tenant's data. **Do not create a client-facing + `doormile_auth` login** until this is fixed (mirror the `HubStaffAccount.Tenantid + *int` pattern: nil = Doormile staff/unrestricted, set = client/scoped). +- **Hub console** is only partially scoped: `scopeBookingsToOwnTenant` is applied + at ~3 of ~20 hub handlers that return booking/consignment data. +- Recurring flaw class in this codebase: **trusting a client-supplied + identifier** (body `userid`, path `:userid`, request `tenantid`). When + reviewing any handler, confirm identity comes from the token and ownership is + proven before read/write. (Several account-takeover/IDOR bugs of this shape + were fixed 2026-08-05.) + +--- + +## Part 4 — Deliberate decisions (do not re-raise unprompted) + +These are conscious calls by the project owner, recorded so they aren't +re-litigated: +- **`.env` and the Firebase key are committed to git.** Flagged as critical, + deliberately deferred. Config also hardcodes the same values as `getEnv` + fallbacks. Don't re-raise unless the owner opens the topic. **Do not add new + secret literals to any committed file.** +- **`/crm/*` stays unauthenticated** — the field-sales Flutter app sends no + token. Revisit only when that app can send a key. +- **B2C `PickupBooking.Tenantid` stays nil** — whether to attribute + direct-to-consumer traffic to a Doormile-ops tenant is a business decision. +- **Delivery OTP stays off for DailyGrubs.** +- Hyperlocal is decided per-booking with a 30km coord fallback (Option A); a + tenant-level service-type flag (Option B) was deferred. +- `PartnerInfo` vs `Tenant` naming, and `Customer` (legacy) vs `AppCustomer` + (new B2C) duplication — flagged, not acted on. + +--- + +## Part 5 — Credentials & test accounts (where they live, not the values) + +Secrets are **not** reproduced here (see Part 4). Pointers: +- **App/DB/Redis/NATS secrets** — `.env` (committed) and the `doormile-secrets` + k8s secret referenced by `miletruth.yaml`. +- **Live production DB** — `logistics` on `31.97.228.132:5433`. Read-only + inspection via a throwaway Go program using `.env` creds is the safe path. +- **Live backend Redis** — the manifest and `.env` have historically drifted + (stale `31.97.228.132:6379` vs live `66.116.226.255:6380`); confirm which the + running pods actually use before trusting either. +- **Test rosters** live in Claude session memory (machine-local): the Coimbatore + miler roster (6 riders, PIN `1234`), 3 customer app test accounts, and the + DailyGrubs onboarding (tenant 13, master admin `developer@doormile.com`). Ask + the owner for current values rather than assuming — they rotate. + +> **Note on production writes:** the auto-mode classifier blocks production +> writes (kubectl set env, DB UPDATE/ALTER) inconsistently. Do **not** route +> around a block — hand the exact command to the owner to run. Read-only +> inspection is fine. diff --git a/docs/logistics-base-handover.md b/docs/logistics-base-handover.md new file mode 100644 index 0000000..cd561b8 --- /dev/null +++ b/docs/logistics-base-handover.md @@ -0,0 +1,360 @@ +# Logistics pickup-source and base-handover flow + +The backend contract for requests 25–31 on the Miler logistics line. Written as +the answer to that register: what shipped, what the wire values are, and the +state transitions for each of the three journeys. + +**Vocabulary.** The wire says *hub* — `inward_at_hub`, `Inwarded_at_Hub`, +`next_hub`, `pickup_source_type: "hub"`. The rider app renders that as *Base*. +Nothing here changes a wire value to match the app's wording, and the app's +wording never leaks back into this API. Console and backend keep saying hub. + +--- + +## The flag + +`MILER_HUB_HANDOVER_ENABLED` (env, read per request, **default off**). + +| | off (today) | on | +|---|---|---| +| A hub-routed parcel at pickup-complete | `Inwarded_at_Hub` immediately | `Created` — collected, in the rider's hands | +| `next_action` returned | `handed_to_hub` | `inward_at_hub` | +| Rider's assignment | closed at pickup-complete | closed at the handover | +| Rider availability after pickup | `Available` | `Picked_Up` (still carrying) | +| Base sees it on `/hub/inbound/expected` | no — it is already received | yes | + +Off is not a placeholder: it is what the currently deployed rider app expects. A +build that cannot call the handover endpoint would, with the flag on, collect an +intercity parcel and have no way to advance it — the parcel would sit on +`Created` in the rider's queue and appear in no base's received list. Turn it on +when a rider build that calls `inward-at-hub` is live: + +```bash +kubectl -n doormile set env statefulset/doormile MILER_HUB_HANDOVER_ENABLED=true +``` + +Write it into `/opt/kubernetes/manifests/doormile/miletruth.yaml` at the same +time, or the next `kubectl apply` reverts it (see `DEV_ONBOARDING.md` §2.3). + +**Everything else below is ungated** and live regardless of the flag: `next_hub`, +the handover endpoint, `next_action`/`next_hub` on the queue read, +`pickup_source_type`, base master data, inbound visibility, reconciliation and +the routing block. + +--- + +## State transitions — the three journeys + +`consignmentstatus` is the consignment's own state; `booking.status` moves to +`Converted_To_Consignment` at pickup-complete in all three and stops there. + +### Base/Hub H1 → Customer + +Pickup source is a base; the parcel then goes to a person. Routing is decided by +pincode, exactly as for any other pickup — a base-origin booking delivering into +the same postal area is hyperlocal. + +| Step | Call | `consignmentstatus` | `next_action` | +|---|---|---|---| +| assigned | — | (no consignment yet) | `pickup` | +| collected at the base | `POST /miler/bookings/:id/pickup-complete` | `Collected_By_Miler` | `start_delivery` | +| heading out | `POST /miler/consignments/:id/start-delivery` | `Out_for_Delivery` | `deliver` | +| delivered | `POST /miler/consignments/:id/deliver` | `Delivered` | `none` | + +The booking row carries `pickup_source_type: "hub"` and `sourceid` / +`pickuplocationid` = the base id, so Home names the base as the pickup source +rather than the rider's own office. + +With `MILER_COLLECTED_STATE_ENABLED` off, pickup-complete goes straight to +`Out_for_Delivery` / `deliver` and there is no start-delivery step. That flag is +already `true` in production. + +### Customer → Customer (hyperlocal) + +Identical to the table above from pickup-complete onward; the only difference is +`pickup_source_type: "customer"` and `sourceid: null`, with the sender's own name +and address on the row. + +### Customer → Base (intercity / interstate) + +| Step | Call | `consignmentstatus` | `next_action` | `next_hub` | +|---|---|---|---|---| +| assigned | — | (no consignment yet) | `pickup` | null | +| collected | `POST /miler/bookings/:id/pickup-complete` | `Created` | `inward_at_hub` | the base, six fields | +| handed over at the base | `POST /miler/consignments/:id/inward-at-hub` | `Inwarded_at_Hub` | `handed_to_hub` | null | + +After `Inwarded_at_Hub` the parcel is the network's problem, not the rider's — +tripsheet, transit, and a final-mile rider at the other end. + +**With the flag off**, the middle row does not exist: pickup-complete returns +`Inwarded_at_Hub` / `handed_to_hub` directly, still with `next_hub` populated so +the app can name the base. `inward-at-hub` called against such a parcel answers +200 with the state that stands and `already_inwarded: true`, rather than failing. + +--- + +## What changed, request by request + +### 25 — `next_hub` on pickup-complete + +`POST /miler/bookings/:bookingid/pickup-complete` now returns `next_hub` whenever +the parcel's next leg is a base, with all six fields: + +```jsonc +{ + "tracking_no": "DM...", + "consignment_id": 4821, // always present + "consignmentstatus": "Created", + "status": "Created", // alias, same value + "booking_no": "BK...", + "booking_status": "Converted_To_Consignment", + "next_action": "inward_at_hub", + "next_hub": { + "id": 1, + "name": "Coimbatore Hub", + "address": "14 Avinashi Road, Peelamedu, Coimbatore", + "pincode": "641004", + "latitude": 11.0272, + "longitude": 76.9905 + } +} +``` + +`next_hub` is absent for a hyperlocal parcel — there is no base leg. + +**Which base.** `resolveHandoverHub`, in order: the base the booking was routed +to (`nearesthubid`, nothing populates this column today — it is checked first so +that it wins the moment something does), then the collecting rider's own base +(the operational default), then the nearest **active** base to the pickup point, +then any base at all. The app never chooses; it navigates to what it is given. + +The nearest-active-base step replaced a fallback that took whichever hub row came +back first from an unordered query. + +### 26 — the handover mutation + +``` +POST /miler/consignments/:id/inward-at-hub +Idempotency-Key: + +{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 } +``` + +`hubid` is accepted as an alias for `hub_id`; `lat`/`lon` for +`latitude`/`longitude`. The whole body is optional — with nothing sent, the parcel +is handed into the base it was already routed to. + +```jsonc +{ + "consignmentid": 4821, + "trackingno": "DM...", + "consignmentstatus": "Inwarded_at_Hub", + "inwardedat": "2026-09-02T14:22:10Z", + "hub": { "id": 1, "name": "...", "address": "...", "pincode": "...", + "latitude": 11.0272, "longitude": 76.9905 }, + "next_action": "handed_to_hub", + "already_inwarded": false +} +``` + +It names the resulting state, per the rule request 15 exists for. Idempotent +twice over: the route carries the shared `Idempotency-Key` middleware, and a +parcel already inwarded answers 200 with `already_inwarded: true` rather than a +4xx — a retry after a dropped response confirms instead of erroring. + +Side effects, all in one transaction: status and `currenthubid` set, `inwardedat` +stamped, a `consignmenthistory` row written with the rider's coordinates, the +`BookingAssignment` closed as `Completed` with `riderkms` (pickup → base gate) and +`ridercharges`, and the rider returned to `Available`. Before this, an intercity +rider's every job reported zero distance and zero value on `/miler/earnings`. + +Errors: `CONSIGNMENT_NOT_FOUND` (404), `CONSIGNMENT_NOT_ASSIGNED` (403), +`HUB_REQUIRED` / `HUB_NOT_FOUND` (400/404), `INVALID_STATE` (400) for a parcel +already out for delivery or past this leg. + +### 27 — `next_action` and `next_hub` on the queue read + +Every row of `GET /miler/bookings` now carries both, derived from server state on +each read by the same helper pickup-complete uses — the pivot's answer and the +poll's answer cannot drift. + +| consignment state | `next_action` | `next_hub` | +|---|---|---| +| no consignment yet | `pickup` | null | +| `Created` | `inward_at_hub` | the base | +| `Collected_By_Miler` | `start_delivery` | null | +| `Out_for_Delivery` | `deliver` | null | +| `Inwarded_at_Hub` | `handed_to_hub` | null | +| anything terminal | `none` | null | + +`GET /miler/consignments/:consignmentid` carries the same pair, plus +`can_inward_at_hub` and `inwardedat`, so a single-parcel refresh is as +authoritative as a full poll. + +### 28 — `pickup_source_type` on the booking row + +On the row, never on a location master — a customer-door pickup has no location +id at all, so a type held against locations could never classify one. + +```jsonc +{ + "bookingid": 4821, + "pickup_source_type": "hub", // hub | customer | merchant | store + "sourceid": 1, // null for a customer door + "pickuplocationid": 1, // alias, same value + "pickup_source_name": "Coimbatore Hub", + "pickupaddress": "14 Avinashi Road, Peelamedu, Coimbatore", + "pickuppincode": "641004", + "deliverypincode": "600001" +} +``` + +Sent on every booking, with `"customer"` as a value rather than an omission. +`pickuplocationid` on this row is the source id — not the `pickuplocationid` +column on `pickupbookings`, which foreign-keys to `appcustomerlocations` and is a +different concept. The booking row never carried either spelling before, so +nothing is being redefined out from under a reader. + +Storage is the new `pickupbookings.pickupsourcetype` column, written at creation. +Rows created before it existed are classified on read: names a base → `hub`, +names a client site → `merchant`, otherwise → `customer`. A stored value always +wins. An unrecognised type is dropped at write rather than stored, so the column +never holds a word the app has no meaning for. + +### 29 — base master data + +`Hub` already carried all six fields; what was missing was a route a rider token +could read. `/admin/tenants/:id/locations` is a different dataset — a client's own +sites, not bases — and `/admin/*` requires roles 1/3/4 while a rider is role 5, so +that 401 is by design, not an oversight. + +``` +GET /miler/bases ?status=Active (default) &applocationid= +``` + +Returns `{id, name, address, pincode, latitude, longitude}` per base, plus +`distance_km` and nearest-first ordering when the rider has reported a position. + +`GET /admin/hubs` (console) already returns full hub rows and is unchanged. + +### 30 — inbound visibility and receiving + +``` +GET /hub/inbound/expected on the way in, not yet handed over +POST /hub/inbound/:id/reconcile { "received": true|false, "remarks": "..." } +``` + +`expected` lists consignments on `Created` whose current base is this one — +rider, source and source type, customer, pickup and destination address, +destination pincode, current state, `inbound_status: "expected"`. Tenant-scoped: +partner-tenant staff see only their own client's parcels +(`scopeConsignmentsToOwnTenant`, the consignment counterpart of the existing +booking scoping). + +`reconcile` is the receiving side. `received: true` inwards the parcel and is +idempotent — staff working through a pile will hit rows twice. `received: false` +is the dispute path: it does **not** quietly move the parcel backwards, it raises +an open `ConsignmentException` naming the discrepancy, so a parcel a rider swears +was handed over and staff never saw becomes a tracked item rather than an +argument nobody owns. + +The exception type is `Lost` — the closest value the `consignmentexceptions` +CHECK constraint already permits. A dedicated `Handover_Not_Received` type would +need that constraint widened first (see `DEV_ONBOARDING.md` §2.5). + +The pre-existing console inwarding path (`POST /hub/bookings/:id/inbound`) still +works and now stamps `inwardedat` too. + +### 31 — the routing decision on booking detail + +`GET /admin/bookings/:id` keeps every field it returned and adds `routing` +alongside them: + +```jsonc +"routing": { + "pickup_source_type": "customer", + "pickup_source_id": null, + "pickup_source_name": "Anitha R", + "from_address": "12 Race Course Road, Coimbatore", + "from_pincode": "641018", + "to_address": "44 Mount Road, Chennai", + "destination_pincode": "600002", + "is_hyperlocal": false, + "consignment_state": "Created", + "next_action": "inward_at_hub", + "next_hub": { "id": 1, "name": "Coimbatore Hub", ... }, + "inwardedat": null, + "decided": true +} +``` + +`decided` is false before pickup, when the routing result is a projection from +the captured from/to rather than a decision that has been taken. `is_hyperlocal` +is computed by the same helper pickup-complete uses, so the shown reason cannot +disagree with the actual routing. + +--- + +## Route sequencing knows about the base + +`internal/routing` orders a rider's active stops via the Route Optimization API. +It read `pickupbookings.deliverylatitude` for every assignment, with no idea +whether the parcel was hub-routed — so a Coimbatore → Chennai booking told the +optimizer the rider was riding 430 km to the receiver, when the real next stop is +a base a few kilometres away. One such destination in a rider's set also drags +the ordering of every genuine local stop beside it, because the solver is +optimising a journey nobody is going to make. + +`dropForLeg` now decides where THIS rider's leg ends: the receiver for a +hyperlocal parcel, the base for a hub-routed one. The base comes from the same +order of preference as `resolveHandoverHub` — the booking's `nearesthubid` if +anything set it, otherwise the rider's own base — joined in by the stop query. A +hub-routed stop with no usable base coordinates is left unsequenced rather than +pointed at the receiver: one missing stop is better than a skewed route. + +The final destination is not lost. It is simply not this leg — it belongs to +whoever carries the parcel out of the base. + +**`internal/legs`** exists for this. The hyperlocal rule is needed by +`controllers` (which state a consignment lands in) and by `internal/routing` +(where the leg ends), and `controllers` already imports `internal/routing`, so +routing cannot import back. Rather than keep a second copy of the rule — the +shape that has bitten this codebase before — it lives in a package both import. +`controllers.haversineKM`, `isHyperlocal` and `isHyperlocalBooking` are now thin +delegates, so their existing call sites and tests are unchanged. + +--- + +## Schema + +Three additive, nullable columns, applied by `AutoMigrate` on the next deploy. No +CHECK constraint needed widening — `Created` was already permitted on +`consignments`. + +| Table | Column | Why | +|---|---|---| +| `pickupbookings` | `pickupsourcetype varchar(20)` | request 28 | +| `pickupbookings` | `pickuphubid int` | base-origin pickups; distinct from `nearesthubid`, which is the base a parcel is routed **to** | +| `consignments` | `inwardedat timestamp` | the physical-receipt fact, distinct from `updatedat`, which moves on every write | + +--- + +## Still open on this line + +- **15** — `reached` persists the arrival fact (`arrivedat`, returned as + `reachedat` on the booking row) but the booking status does not move to + `Arrived_At_Pickup`. Half done; not touched by this work. +- **16** — console rendering for `Arrived_At_Pickup` and `Collected_By_Miler`. +- **14** — a failed-delivery outcome; `skip` still leaves the consignment + `Out_for_Delivery`. +- **`At_Customer` — answered.** It means **arrived at the pickup**. It is a + `milerprofiles.availabilitystatus` value, not a booking or consignment state, + so it says where the *rider* is rather than where the *parcel* is, and the only + thing that writes it is `POST /miler/bookings/:bookingid/reached` + (`BookingReachedCustomer`, `milerController.go`) — the pickup-arrival action. + Nothing sets it on a delivery leg; a rider heading to a receiver goes + `On_Delivery`. The name is misleading and predates the current lifecycle. +- **`reject` — answered.** `RejectMilerAssignment` accepts the reason **either + way**: it parses the JSON body first and falls back to `?reason=`, defaulting to + "Rejected by rider" if neither is present. The doc/deployed disagreement was + settled by accepting both, so the app can keep sending both. diff --git a/docs/miler-app-api.md b/docs/miler-app-api.md index ce3d058..845e0b3 100644 --- a/docs/miler-app-api.md +++ b/docs/miler-app-api.md @@ -1,6 +1,6 @@ # Doormile Miler App — API reference -The rider-app surface only (`/miler/*`). 38 routes: 3 auth + 35 authenticated. +The rider-app surface only (`/miler/*`). 45 routes: 3 auth + 42 authenticated. Base URL `https://api.doormile.com/api/v1`. This supersedes "Miler App API Contract v1.0" where the two disagree — several @@ -126,6 +126,44 @@ prefix it's hyperlocal and the consignment goes straight to `Out_for_Delivery` in the rider's hands. Otherwise it routes via the hub. The consignment inherits the **booking's** tenant, not the rider's. +It returns `next_action` and, when the next leg is a base, `next_hub` with all +six fields (`id, name, address, pincode, latitude, longitude`) — the app never +picks a base itself. `consignment_id` is always present. + +```jsonc +{ "consignment_id": 4821, "consignmentstatus": "Created", + "next_action": "inward_at_hub", + "next_hub": { "id": 1, "name": "Coimbatore Hub", + "address": "14 Avinashi Road, Peelamedu, Coimbatore", + "pincode": "641004", "latitude": 11.0272, "longitude": 76.9905 } } +``` + +## The base handover + +``` +POST /miler/consignments/:id/inward-at-hub Idempotency-Key supported +{ "hub_id": 1, "latitude": 11.0272, "longitude": 76.9905 } +→ { consignmentstatus: "Inwarded_at_Hub", inwardedat, hub, next_action, + already_inwarded } +``` + +The authoritative record that a rider handed a parcel in at a base. The body is +optional (it defaults to the base the parcel is routed to); `hubid`, `lat` and +`lon` are accepted as aliases. Answers with the resulting state, never a bare +200. A parcel already inwarded answers 200 with `already_inwarded: true`. + +``` +GET /miler/bases ?status=Active &applocationid= +``` + +Base master data on a rider token — the six fields per base, plus `distance_km` +and nearest-first ordering once the rider has reported a position. +`/admin/tenants/:id/locations` is a different dataset (a client's own sites) and +is closed to role 5 by design. + +**Wording:** the wire says hub, the rider app says Base. Full contract and state +transitions in [`logistics-base-handover.md`](logistics-base-handover.md). + ## Delivery | Method | Path | Body | @@ -151,6 +189,20 @@ the **booking's** tenant, not the rider's. | GET | `/miler/bookings` | `?status=&date=YYYY-MM-DD` | | GET | `/miler/earnings` | `?period=daily\|weekly\|monthly&date=YYYY-MM-DD` | +Every `/miler/bookings` row carries the leg and the pickup source, rebuilt from +server state on each read, so a poll or a cold restart needs no local cache: + +| Field | Values | +|---|---| +| `next_action` | `pickup`, `inward_at_hub`, `start_delivery`, `deliver`, `handed_to_hub`, `none` | +| `next_hub` | the six base fields, or null when the next leg isn't a base | +| `pickup_source_type` | `hub`, `customer`, `merchant`, `store` — always sent, `customer` is a value not an omission | +| `sourceid` / `pickuplocationid` | the base or client-site id; null for a customer door | +| `pickup_source_name` | the base/site name, or the sender's name for a door pickup | + +An unrecognised `pickup_source_type` should be treated as a generic pickup — new +values may be added. + `bonuspoints` stays zero — nothing writes it yet. That's known and deliberate. ## Telemetry (Redis-backed, high frequency) @@ -235,4 +287,9 @@ build a UI that depends on it. decided. 2. `bonuspoints` is never written. 3. `assignments/:id/reject` and `bookings/:id/vehicle-required` have never had a - real request against them. + real request against them. (`reject` accepts its reason in the body *or* as + `?reason=`, preferring the body — both spellings are honoured.) +4. `At_Customer` on `milerprofiles.availabilitystatus` means **arrived at the + pickup** — it is written only by `POST /miler/bookings/:bookingid/reached`. + The name predates the current lifecycle; a rider heading to a receiver is + `On_Delivery`. diff --git a/go.mod b/go.mod index 1b99351..a1d8438 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,17 @@ module doormile go 1.25.0 require ( + firebase.google.com/go/v4 v4.20.0 github.com/gofiber/fiber/v2 v2.52.10 + github.com/gofiber/websocket/v2 v2.2.1 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.12.3 + github.com/nats-io/nats.go v1.31.0 github.com/redis/go-redis/v9 v9.16.0 go.uber.org/zap v1.27.1 golang.org/x/crypto v0.51.0 + google.golang.org/api v0.279.0 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.25.12 ) @@ -25,7 +29,6 @@ require ( cloud.google.com/go/longrunning v1.0.0 // indirect cloud.google.com/go/monitoring v1.29.0 // indirect cloud.google.com/go/storage v1.62.1 // indirect - firebase.google.com/go/v4 v4.20.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect @@ -41,7 +44,6 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/gofiber/websocket/v2 v2.2.1 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect @@ -58,7 +60,6 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/nats-io/nats.go v1.31.0 // indirect github.com/nats-io/nkeys v0.4.5 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect @@ -86,7 +87,6 @@ require ( golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/api v0.279.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect diff --git a/internal/legs/legs.go b/internal/legs/legs.go new file mode 100644 index 0000000..9ef98e3 --- /dev/null +++ b/internal/legs/legs.go @@ -0,0 +1,71 @@ +// Package legs answers one question, in one place: does a parcel go straight to +// its receiver, or does it go via a base first? +// +// It exists because two packages need that answer and neither can import the +// other. controllers decides it at pickup-complete (which state the consignment +// lands in, and which base the rider is sent to); internal/routing needs it when +// it sequences a rider's stops, because a hub-routed parcel's next stop is the +// BASE, not the address on the booking. controllers already imports +// internal/routing, so routing cannot import back — and a second copy of the +// rule in routing is exactly the kind of duplication this codebase has been +// bitten by before (the rival status table in dispatchShared, the batch windows +// that drifted between two pages). +// +// So the rule lives here and both call it. Neither owns it. +package legs + +import "math" + +// HaversineKM is the straight-line distance between two points, in kilometres. +// One definition for the whole codebase — controllers.haversineKM delegates to +// it rather than keeping a second copy. +func HaversineKM(lat1, lon1, lat2, lon2 float64) float64 { + const earthRadiusKM = 6371.0 + toRad := func(deg float64) float64 { return deg * math.Pi / 180 } + dLat := toRad(lat2 - lat1) + dLon := toRad(lon2 - lon1) + a := math.Sin(dLat/2)*math.Sin(dLat/2) + + math.Cos(toRad(lat1))*math.Cos(toRad(lat2))*math.Sin(dLon/2)*math.Sin(dLon/2) + return earthRadiusKM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) +} + +// MaxHyperlocalKM bounds the straight-line pickup→delivery distance under which +// a booking with a missing or unusable pincode is still treated as hyperlocal. +// It is ONLY consulted when the pincode rule cannot decide: console-created +// kitchen→customer bookings frequently carry accurate coordinates and no +// delivery pincode, and must not be routed through a base because of it. +const MaxHyperlocalKM = 30.0 + +// SamePostalArea reports whether two pincodes fall in the same 3-digit postal +// area. Pincodes shorter than 3 characters are treated as unknown rather than +// matching, so bad data falls back to the safe hub route instead of quietly +// claiming two parcels belong together. +func SamePostalArea(pickupPincode, deliveryPincode string) bool { + if len(pickupPincode) < 3 || len(deliveryPincode) < 3 { + return false + } + return pickupPincode[:3] == deliveryPincode[:3] +} + +// IsHyperlocal decides whether a parcel can skip the base and be carried +// straight to the receiver by the collecting rider. +// +// The pincode-prefix rule decides outright when both pincodes are present — a +// matching prefix is hyperlocal, a differing one is genuinely inter-area, and +// distance does not get a vote. Pollachi is 40km from Coimbatore, closer than +// plenty of runs treated as local, and it is still a different postal area. +// +// Only when a pincode is missing or too short does the straight-line distance +// decide instead. With neither pincodes nor coordinates the answer is false, +// which routes via a base — the safe direction to be wrong in, because a parcel +// that reaches a base can still be forwarded, while one handed to a rider who +// cannot reach the receiver is stuck. +func IsHyperlocal(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool { + if len(pickupPincode) >= 3 && len(deliveryPincode) >= 3 { + return SamePostalArea(pickupPincode, deliveryPincode) + } + if pLat != 0 && pLng != 0 && dLat != 0 && dLng != 0 { + return HaversineKM(pLat, pLng, dLat, dLng) <= MaxHyperlocalKM + } + return false +} diff --git a/internal/legs/legs_test.go b/internal/legs/legs_test.go new file mode 100644 index 0000000..7cc8ac5 --- /dev/null +++ b/internal/legs/legs_test.go @@ -0,0 +1,117 @@ +package legs + +import "testing" + +// The one definition of "does this parcel go via a base?". +// +// It lives in its own package because two callers need it and neither can +// import the other: controllers decides it at pickup-complete, internal/routing +// needs it to know where a rider's leg ends. A second copy in routing was the +// alternative, and this codebase has been bitten by that shape before — a rival +// status table that drifted until the map and the list showed the same parcel +// two different colours. +// +// So the rule is tested here, once, and both callers inherit it. + +func TestSamePostalArea(t *testing.T) { + cases := []struct { + name string + pickup string + delivery string + want bool + }{ + {"same 3-digit area is one zone", "641012", "641004", true}, + {"an identical pincode is trivially one zone", "641012", "641012", true}, + {"Coimbatore to Chennai is not", "641012", "600001", false}, + {"Coimbatore to Pollachi is not, though it is close", "641012", "642001", false}, + {"a short pincode proves nothing", "64", "641004", false}, + {"an empty pincode proves nothing", "", "641004", false}, + {"two empty pincodes do not match each other", "", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := SamePostalArea(tc.pickup, tc.delivery); got != tc.want { + t.Errorf("SamePostalArea(%q, %q) = %v, want %v", tc.pickup, tc.delivery, got, tc.want) + } + }) + } +} + +func TestIsHyperlocalPrefersThePincodeRule(t *testing.T) { + // Coimbatore pickup, Pollachi delivery: ~40 km apart, which is inside the + // distance fallback — but they are different postal areas, and when both + // pincodes are present the prefix rule decides outright. Distance does not + // get a vote, or a parcel would be routed one way on Tuesday and another on + // Wednesday because a geocoder moved a pin. + if IsHyperlocal("641025", "642001", 11.0168, 76.9558, 10.658, 77.008) { + t.Error("a different postal area must route via a base even when it is close") + } + + // The mirror: same area, far apart. A 3-digit area can be large, and the + // prefix still decides. + if !IsHyperlocal("641025", "641999", 11.0168, 76.9558, 11.6, 77.5) { + t.Error("the same postal area must stay hyperlocal even when it is a long way across") + } +} + +func TestIsHyperlocalFallsBackToDistance(t *testing.T) { + // Console-created bookings frequently carry accurate coordinates and no + // delivery pincode. Routing those through a base because of a missing field + // would send a kitchen-to-doorstep lunch order on a tour of the network. + if !IsHyperlocal("641025", "", 11.0168, 76.9558, 11.05, 77.01) { + t.Error("a short hop with no pincode should stay hyperlocal on the distance fallback") + } + if IsHyperlocal("641025", "", 11.0168, 76.9558, 13.0827, 80.2707) { + t.Error("a 430km haul with no pincode is not hyperlocal whatever the fallback") + } +} + +func TestIsHyperlocalIsFalseWhenNothingCanBeProven(t *testing.T) { + // No usable pincodes and no usable coordinates. False routes via a base, + // which is the safe direction to be wrong in: a parcel that reaches a base + // can still be forwarded, while one handed to a rider who cannot reach the + // receiver is simply stuck. + if IsHyperlocal("", "", 0, 0, 0, 0) { + t.Error("with nothing to decide on, the answer must be the safe one") + } + if IsHyperlocal("64", "60", 0, 0, 0, 0) { + t.Error("unusable pincodes and no coordinates must not resolve to hyperlocal") + } +} + +func TestIsHyperlocalTreatsAMissingCoordinateAsMissing(t *testing.T) { + // 0,0 is in the Gulf of Guinea. Treating it as a real position would make + // every un-geocoded booking look like a 7,000 km haul — or, worse, let two + // of them look like neighbours. + if IsHyperlocal("641025", "", 11.0168, 76.9558, 0, 0) { + t.Error("a 0,0 delivery point is an unset value, not a location near anything") + } +} + +func TestHaversineKM(t *testing.T) { + // A known pair: Coimbatore to Chennai is roughly 430 km great-circle. + km := HaversineKM(11.0168, 76.9558, 13.0827, 80.2707) + if km < 400 || km > 460 { + t.Errorf("Coimbatore→Chennai = %.0f km, expected roughly 430", km) + } + + if d := HaversineKM(11.0168, 76.9558, 11.0168, 76.9558); d != 0 { + t.Errorf("a point is %v km from itself, want 0", d) + } + + // Symmetric, or distance-based decisions would depend on argument order. + a := HaversineKM(11.0168, 76.9558, 12.9716, 77.5946) + b := HaversineKM(12.9716, 77.5946, 11.0168, 76.9558) + if a != b { + t.Errorf("distance is not symmetric: %v vs %v", a, b) + } +} + +func TestMaxHyperlocalKMBoundary(t *testing.T) { + // The fallback threshold is a real operational number, not a magic + // constant — a rider is expected to carry a parcel this far, and not + // further. Guarding it stops a silent widening. + if MaxHyperlocalKM != 30.0 { + t.Errorf("MaxHyperlocalKM = %v; changing it changes which parcels riders carry end to end", MaxHyperlocalKM) + } +} diff --git a/internal/routing/dropforleg_test.go b/internal/routing/dropforleg_test.go new file mode 100644 index 0000000..7fcfb62 --- /dev/null +++ b/internal/routing/dropforleg_test.go @@ -0,0 +1,141 @@ +package routing + +import ( + "testing" + + "doormile/internal/legs" +) + +// Where a rider's leg actually ends. +// +// The bug these tests exist to prevent, found while testing the base-handover +// flow with a bulk sheet of intercity orders: the sequencer read +// pickupbookings.deliverylatitude for every active assignment, with no idea +// whether the parcel was hub-routed. For a Coimbatore → Chennai booking that +// told the route optimizer the rider was riding 430 km to the receiver, when +// the rider's real next stop is a base a few kilometres away. +// +// Wrong on its own — a 430 km "stop" is not a stop anyone makes — and worse in +// company: one such destination in a rider's set drags the ordering of every +// genuine local stop beside it, because the solver is optimising a journey +// nobody is going to make. + +// Coimbatore pickup, and the local base a rider hands parcels to. +const ( + pickPin = "641025" + pickLat, pickLng = 11.0168, 76.9558 + baseLat, baseLng = 11.0272, 76.9905 + localPin = "641004" + localLat, localLng = 11.029, 76.993 + chennaiPin = "600001" + chennaiLat, chennaiLng = 13.091, 80.285 +) + +func TestDropForLeg(t *testing.T) { + cases := []struct { + name string + deliveryPin string + dLat, dLng float64 + baseLat, baseLng float64 + wantLat, wantLng float64 + wantOK bool + why string + }{ + { + name: "a hyperlocal parcel ends at the receiver", + deliveryPin: localPin, dLat: localLat, dLng: localLng, + baseLat: baseLat, baseLng: baseLng, + wantLat: localLat, wantLng: localLng, wantOK: true, + why: "same postal area — the collecting rider carries it all the way", + }, + { + name: "an intercity parcel ends at the base, not in Chennai", + deliveryPin: chennaiPin, dLat: chennaiLat, dLng: chennaiLng, + baseLat: baseLat, baseLng: baseLng, + wantLat: baseLat, wantLng: baseLng, wantOK: true, + why: "this is the whole fix — the rider rides to the base", + }, + { + name: "a nearby but different postal area still ends at the base", + deliveryPin: "642001", dLat: 10.658, dLng: 77.008, + baseLat: baseLat, baseLng: baseLng, + wantLat: baseLat, wantLng: baseLng, wantOK: true, + why: "Pollachi is 40km away; the prefix rule decides, not the distance", + }, + { + name: "no pincode and a short hop still ends at the receiver", + deliveryPin: "", dLat: 11.05, dLng: 77.01, + baseLat: baseLat, baseLng: baseLng, + wantLat: 11.05, wantLng: 77.01, wantOK: true, + why: "the distance fallback puts it inside 30km", + }, + { + name: "no pincode and a long haul ends at the base", + deliveryPin: "", dLat: chennaiLat, dLng: chennaiLng, + baseLat: baseLat, baseLng: baseLng, + wantLat: baseLat, wantLng: baseLng, wantOK: true, + why: "the distance fallback puts it far outside 30km", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + lat, lng, ok := dropForLeg(pickPin, tc.deliveryPin, + pickLat, pickLng, tc.dLat, tc.dLng, tc.baseLat, tc.baseLng) + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v — %s", ok, tc.wantOK, tc.why) + } + if lat != tc.wantLat || lng != tc.wantLng { + t.Errorf("drop = (%v, %v), want (%v, %v) — %s", lat, lng, tc.wantLat, tc.wantLng, tc.why) + } + }) + } +} + +func TestDropForLegNeverSendsARiderInterstate(t *testing.T) { + // The regression guard, stated as the thing that actually matters rather + // than as a coordinate comparison: whatever the destination, the drop the + // sequencer is given must be somewhere a rider can plausibly ride to. + for _, d := range []struct { + name string + pin string + lat, lng float64 + }{ + {"Chennai", "600001", 13.091, 80.285}, + {"Hyderabad", "500081", 17.44, 78.3489}, + {"Bengaluru", "560066", 12.9698, 77.75}, + {"Madurai", "625001", 9.9195, 78.119}, + } { + t.Run(d.name, func(t *testing.T) { + lat, lng, ok := dropForLeg(pickPin, d.pin, pickLat, pickLng, d.lat, d.lng, baseLat, baseLng) + if !ok { + t.Fatal("a stop with a usable base must still be sequenced") + } + if lat == d.lat && lng == d.lng { + t.Fatalf("%s was passed to the optimizer as the rider's own drop", d.name) + } + if km := legs.HaversineKM(pickLat, pickLng, lat, lng); km > 50 { + t.Errorf("drop is %.0f km from the pickup — no rider is making that leg", km) + } + }) + } +} + +func TestDropForLegSkipsAHubRoutedStopWithNoBase(t *testing.T) { + // Falling back to the receiver here would be the original bug wearing a + // different hat: better to leave one stop unsequenced than to skew the + // ordering of every other stop the rider is carrying. + _, _, ok := dropForLeg(pickPin, chennaiPin, pickLat, pickLng, chennaiLat, chennaiLng, 0, 0) + if ok { + t.Error("a hub-routed stop with no base coordinates must be left out, not pointed at the receiver") + } +} + +func TestDropForLegKeepsAHyperlocalStopWithNoBase(t *testing.T) { + // A hyperlocal parcel never needed a base, so a missing one is irrelevant + // to it and must not cost the rider a stop. + lat, lng, ok := dropForLeg(pickPin, localPin, pickLat, pickLng, localLat, localLng, 0, 0) + if !ok || lat != localLat || lng != localLng { + t.Errorf("hyperlocal stop = (%v, %v, %v), want the receiver and ok", lat, lng, ok) + } +} diff --git a/internal/routing/optimizer.go b/internal/routing/optimizer.go index 85b538d..cf78b2f 100644 --- a/internal/routing/optimizer.go +++ b/internal/routing/optimizer.go @@ -17,6 +17,7 @@ import ( "doormile/constants" "doormile/db" + "doormile/internal/legs" "doormile/models" "doormile/utils" ) @@ -183,13 +184,31 @@ func loadActiveStops(milerUserID int) ([]stop, error) { Pickuplongitude float64 Deliverylatitude float64 Deliverylongitude float64 + Pickuppincode string + Deliverypincode string + // The base this rider would hand a hub-routed parcel to: the one the + // booking was routed to if anything set it, otherwise the rider's own. + // Same order of preference as controllers.resolveHandoverHub. + Baselatitude float64 + Baselongitude float64 } + // A rider's leg does NOT always end at the address on the booking. For an + // intercity parcel the rider carries it to a base and hands it over there; + // the receiver is somebody else's problem, on another vehicle, days later. + // The base coordinates are joined in here so the sequencer can use them as + // the real end of the leg — see the substitution below. if err := db.DB.Table("bookingassignments AS ba"). Select(`ba.bookingassignmentid, ba.bookingid, b.bookingno, b.pickuplatitude, b.pickuplongitude, - b.deliverylatitude, b.deliverylongitude`). + b.deliverylatitude, b.deliverylongitude, + b.pickuppincode, b.deliverypincode, + COALESCE(bh.latitude, rh.latitude, 0) AS baselatitude, + COALESCE(bh.longitude, rh.longitude, 0) AS baselongitude`). Joins("JOIN pickupbookings AS b ON b.bookingid = ba.bookingid"). + Joins("LEFT JOIN hubs AS bh ON bh.hubid = b.nearesthubid AND bh.deletedat IS NULL"). + Joins("LEFT JOIN milerprofiles AS mp ON mp.userid = ba.mileruserid"). + Joins("LEFT JOIN hubs AS rh ON rh.hubid = mp.hubid AND rh.deletedat IS NULL"). Where("ba.mileruserid = ? AND ba.assignmentstatus IN ?", milerUserID, []string{constants.AssignmentAssigned, constants.AssignmentAccepted}). @@ -209,19 +228,60 @@ func loadActiveStops(milerUserID int) ([]stop, error) { "assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid) continue } + dropLat, dropLng, ok := dropForLeg( + r.Pickuppincode, r.Deliverypincode, + r.Pickuplatitude, r.Pickuplongitude, + r.Deliverylatitude, r.Deliverylongitude, + r.Baselatitude, r.Baselongitude) + if !ok { + // Routed to a base, but no base has usable coordinates. Ordering it + // against the far-away receiver would distort every other stop, so it + // is left out of the route rather than allowed to skew it. + utils.Warn("routing: hub-routed stop has no base coordinates, leaving it unsequenced", + "assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid) + continue + } + stops = append(stops, stop{ AssignmentID: r.Bookingassignmentid, BookingID: r.Bookingid, BookingNo: r.Bookingno, PickupLat: r.Pickuplatitude, PickupLng: r.Pickuplongitude, - DeliveryLat: r.Deliverylatitude, - DeliveryLng: r.Deliverylongitude, + DeliveryLat: dropLat, + DeliveryLng: dropLng, }) } return stops, nil } +// dropForLeg is where THIS rider's leg ends — which is not always the address +// on the booking. +// +// A hyperlocal parcel ends at the receiver. A hub-routed one ends at a base: the +// rider hands it over there and the receiver is somebody else's leg, on another +// vehicle, possibly days later. Sequencing a hub-routed parcel against the +// receiver's coordinates asks the optimizer to plan a ride to another state — +// wrong on its own terms, since a 430km "stop" is not a stop a rider makes, and +// worse in company: one intercity destination in the set drags the ordering of +// every genuine local stop beside it, because the solver is optimising a journey +// nobody is going to make. +// +// The final destination is not lost. It is simply not this leg. +// +// ok is false when the parcel is hub-routed and no base has usable coordinates. +// The caller drops the stop rather than falling back to the receiver, because a +// stop in the wrong country is more damaging to the route than a missing one. +func dropForLeg(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng, baseLat, baseLng float64) (lat, lng float64, ok bool) { + if legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng) { + return dLat, dLng, true + } + if baseLat != 0 || baseLng != 0 { + return baseLat, baseLng, true + } + return 0, 0, false +} + // optimize calls the Route Optimization API and maps its answer back onto our // assignment ids. func optimize(stops []stop) ([]Result, error) { diff --git a/models/audit.go b/models/audit.go index df11870..cce47ec 100644 --- a/models/audit.go +++ b/models/audit.go @@ -71,8 +71,14 @@ type Consignment struct { Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"` Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"` Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"` - Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc. - Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan + // Inwardedat is when the parcel was physically received at a base — written + // by the rider handover (POST /miler/consignments/:id/inward-at-hub) and by + // the console inbound scan. Distinct from Updatedat, which moves on every + // write: the handover time is a business fact staff reconcile against, so it + // needs a column of its own. Null until the parcel is actually received. + Inwardedat *time.Time `json:"inwardedat" gorm:"column:inwardedat"` + Condition string `json:"condition" gorm:"column:condition;size:50"` // recorded at hub inbound scan: Good, Damaged, etc. + Shelf string `json:"shelf" gorm:"column:shelf;size:50"` // hub storage location assigned at inbound scan // Deliveryotp is issued when the consignment goes out for delivery and is // given to the receiver, not the rider — it is the only proof the parcel // reached the right person. Never serialised outward: returning it in an API diff --git a/models/booking.go b/models/booking.go index 1dbedd2..c1e24a8 100644 --- a/models/booking.go +++ b/models/booking.go @@ -24,7 +24,20 @@ type PickupBooking struct { // 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"` + Tenantlocationid *int `json:"tenantlocationid" gorm:"column:tenantlocationid;index"` + // Pickupsourcetype says what KIND of place this booking is collected from — + // one of constants.PickupSource*. It is stored on the booking row, not looked + // up from a location master, because a customer-door pickup has no location + // id at all: only the row can tell "no location because it is a front door" + // apart from "no location because nobody filled it in". Empty on rows written + // before this column existed; derivePickupSourceType classifies those from + // what they do carry, so the API never returns a blank type. + Pickupsourcetype string `json:"pickup_source_type" gorm:"column:pickupsourcetype;size:20"` + // Pickuphubid is set only when Pickupsourcetype is "hub" — the base the + // parcel is collected FROM (Base → Customer). It is a separate column from + // Nearesthubid, which is the base a parcel is routed TO. Conflating them + // would make a base-origin booking look like a base-destination one. + Pickuphubid *int `json:"pickuphubid" gorm:"column:pickuphubid;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"` diff --git a/routes/routes.go b/routes/routes.go index 9b673d7..89374ce 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -193,6 +193,16 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { milerAuth.Post("/consignments/:id/start-delivery", middlewares.Idempotency(), controllers.MilerStartDelivery) milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment) milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery) + // Rider handover at a base. The authoritative record that a hub-routed parcel + // physically changed hands; answers with the resulting state rather than a + // bare 200, and carries the shared idempotency middleware because riders retry + // on bad signal at a loading bay. + milerAuth.Post("/consignments/:id/inward-at-hub", middlewares.Idempotency(), controllers.MilerInwardConsignmentAtHub) + + // Base master data on a rider token — id, name, address, pincode and + // coordinates for every active base. /admin/tenants/:id/locations is a + // different dataset (a client's own sites) and is closed to role 5 by design. + milerAuth.Get("/bases", controllers.MilerGetBases) // Earnings milerAuth.Get("/earnings", controllers.MilerGetEarnings) @@ -366,6 +376,10 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { hubAuth.Get("/inbound/today", controllers.GetHubInboundToday) hubAuth.Get("/inbound", controllers.GetHubInboundRange) hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan) + // What riders are carrying towards this base but have not handed over yet, + // and the matching receive/dispute action for when they arrive. + hubAuth.Get("/inbound/expected", controllers.GetHubInboundExpected) + hubAuth.Post("/inbound/:id/reconcile", controllers.ReconcileHubInbound) hubAuth.Post("/bookings/:id/assign-miler", controllers.HubAssignMiler) hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign) hubAuth.Post("/bookings/batch-assign", controllers.HubBatchAssign) diff --git a/routes/routes_logistics_test.go b/routes/routes_logistics_test.go new file mode 100644 index 0000000..c6beee2 --- /dev/null +++ b/routes/routes_logistics_test.go @@ -0,0 +1,257 @@ +package routes_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "doormile/config" + "doormile/routes" + "doormile/utils" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/recover" +) + +// Real HTTP requests against the real router. +// +// WHAT THIS DOES AND DOES NOT PROVE. +// +// Every route in this file is behind AuthMiddleware + RoleCheckMiddleware, and +// both reject before the handler runs — so a request with a wrong-role or absent +// token never reaches a line that touches Postgres. That makes it possible to +// exercise the entire HTTP surface with no database, and it is worth doing: +// it catches a mistyped path, a route registered under the wrong group, a +// missing middleware, and a param route shadowing a static one. Those are real +// bugs that compile perfectly and that unit tests over pure functions cannot see. +// +// It does NOT prove a handler works. Nothing here reaches a query, a +// transaction, or a response body built from real rows. A 200 from these +// endpoints has never been observed and this file does not claim one. +// +// The line is drawn deliberately: everything up to the handler is tested here; +// everything from the handler inwards needs a database and is still unverified. + +const jwtSecret = "test-secret-for-routing-only" + +func newApp() *fiber.App { + // Recover is what main.go installs too. Here it also acts as a guardrail: + // with no database configured, any request that DID reach a handler would + // nil-panic and take the whole test binary down. Nothing in this file is + // supposed to get that far — recover turns a mistake into a failed test + // rather than a crashed run. + app := fiber.New() + app.Use(recover.New()) + routes.RegisterRoutes(app, &config.Config{JWTSecret: jwtSecret}) + return app +} + +// token mints a valid JWT for a role, so the role gate can be exercised +// independently of whether a token parses at all. +func token(t *testing.T, userID, roleID int) string { + t.Helper() + tok, err := utils.GenerateToken(userID, "test@doormile.com", roleID, 0, 1001, jwtSecret) + if err != nil { + t.Fatalf("could not mint a %d-role token: %v", roleID, err) + } + return tok +} + +func do(t *testing.T, app *fiber.App, method, path, bearer, body string) (int, string) { + t.Helper() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req := httptest.NewRequest(method, path, rdr) + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + resp, err := app.Test(req, int(10*time.Second/time.Millisecond)) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + out, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(out) +} + +// The routes added for the base-handover flow, with the role each one requires. +var newRoutes = []struct { + method string + path string + wantRole int + body string +}{ + {http.MethodPost, "/api/v1/miler/consignments/42/inward-at-hub", 5, `{"hub_id":7}`}, + {http.MethodGet, "/api/v1/miler/bases", 5, ""}, + {http.MethodGet, "/api/v1/hub/inbound/expected", 6, ""}, + {http.MethodPost, "/api/v1/hub/inbound/42/reconcile", 6, `{"received":true}`}, +} + +// Routes that already existed and whose responses this work changed. +var changedRoutes = []struct { + method string + path string + wantRole int + body string +}{ + {http.MethodPost, "/api/v1/miler/bookings/42/pickup-complete", 5, ""}, + {http.MethodGet, "/api/v1/miler/bookings", 5, ""}, + {http.MethodGet, "/api/v1/miler/consignments/42", 5, ""}, + {http.MethodGet, "/api/v1/admin/bookings/42", 1, ""}, + {http.MethodPost, "/api/v1/admin/expressbooking", 1, `{"tenantid":1,"pickuppincode":"641004","parcels":[{"weight":1}]}`}, + {http.MethodGet, "/api/v1/hub/bookings/unassigned", 6, ""}, + {http.MethodGet, "/api/v1/hub/inbound/today", 6, ""}, + {http.MethodPost, "/api/v1/customer/bookings", 9, `{"pickuppincode":"641004"}`}, +} + +func allRoutes() []struct { + method string + path string + wantRole int + body string +} { + return append(append([]struct { + method string + path string + wantRole int + body string + }{}, newRoutes...), changedRoutes...) +} + +// A route that is registered rejects an anonymous request with 401. One that is +// NOT registered falls through to Fiber's own 404 — which is exactly how a +// mistyped path hides, since both "fail". +func TestEveryRouteIsRegistered(t *testing.T) { + app := newApp() + for _, r := range allRoutes() { + t.Run(r.method+" "+r.path, func(t *testing.T) { + status, body := do(t, app, r.method, r.path, "", r.body) + if status == http.StatusNotFound { + t.Fatalf("route is NOT registered — got 404: %s", body) + } + if status != http.StatusUnauthorized { + t.Errorf("anonymous request should be 401, got %d: %s", status, body) + } + }) + } +} + +// The gate that produced the "insufficient permissions" report: a valid token of +// the wrong role must be refused, and refused BEFORE the handler runs — with no +// database configured, a handler that executed would panic on a nil db.DB, so a +// clean 403 is itself the proof that nothing downstream ran. +func TestWrongRoleIsRefusedBeforeTheHandlerRuns(t *testing.T) { + app := newApp() + // One role from each group, so every case is covered by some wrong role. + roles := map[int]string{1: "admin", 5: "miler", 6: "hub staff", 9: "customer"} + + for _, r := range allRoutes() { + for role, name := range roles { + if role == r.wantRole { + continue + } + // Admin roles 1/3/4 are interchangeable; only test a genuinely wrong one. + if r.wantRole == 1 && (role == 3 || role == 4) { + continue + } + t.Run(fmt.Sprintf("%s as %s", r.path, name), func(t *testing.T) { + status, body := do(t, app, r.method, r.path, token(t, 1, role), r.body) + if status != http.StatusForbidden && status != http.StatusUnauthorized { + t.Errorf("a %s token on a role-%d route returned %d, want 403/401: %s", + name, r.wantRole, status, body) + } + }) + } + } +} + +// The refusal has to be machine-readable, not just a status code — the console +// and the rider app both branch on the body. +func TestRefusalBodyIsWellFormed(t *testing.T) { + app := newApp() + status, body := do(t, app, http.MethodGet, "/api/v1/miler/bases", token(t, 1, 1), "") + if status != http.StatusForbidden { + t.Fatalf("admin token on a miler route: got %d, want 403", status) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + t.Fatalf("refusal body is not JSON: %q", body) + } + if parsed["success"] != false { + t.Errorf(`refusal should carry "success": false, got %v`, parsed["success"]) + } + if parsed["message"] != "insufficient permissions for this resource" { + t.Errorf("unexpected refusal message: %v", parsed["message"]) + } +} + +// A malformed or unsigned token must never be accepted as a valid session. +func TestGarbageTokensAreRejected(t *testing.T) { + app := newApp() + for _, tok := range []string{ + "not-a-jwt", + "eyJhbGciOiJub25lIn0.eyJyb2xlaWQiOjV9.", // alg:none, roleid 5 + "", + } { + status, _ := do(t, app, http.MethodGet, "/api/v1/miler/bases", tok, "") + if status != http.StatusUnauthorized { + t.Errorf("token %q returned %d, want 401", tok, status) + } + } +} + +// A token signed with the wrong secret must not open a session — the check that +// stops a token minted elsewhere from being trusted here. +func TestTokenSignedWithAnotherSecretIsRejected(t *testing.T) { + app := newApp() + foreign, err := utils.GenerateToken(1, "x@y.z", 5, 0, 1001, "a-different-secret") + if err != nil { + t.Fatal(err) + } + if status, _ := do(t, app, http.MethodGet, "/api/v1/miler/bases", foreign, ""); status != http.StatusUnauthorized { + t.Errorf("foreign-signed token returned %d, want 401", status) + } +} + +// `/miler/bases` is static and `/miler/consignments/:consignmentid` is dynamic. +// Fiber matches in registration order, so a param route registered first would +// swallow a static sibling — the bug the Orders route table has a comment about. +func TestStaticRoutesAreNotShadowedByParamRoutes(t *testing.T) { + app := newApp() + + // Probed with a token of the WRONG role on purpose. A 403 proves the request + // matched this route and reached its role gate; a 404 would mean it matched + // nothing, which is how a param route swallowing a static sibling shows up. + // Using the right role instead would enter the handler and hit the database, + // which is not what this file tests. + cases := []struct { + path string + wrongRole int + }{ + {"/api/v1/miler/bases", 1}, // static, sits beside /consignments/:id + {"/api/v1/hub/inbound/expected", 5}, // static, sits beside /inbound/:id/reconcile + {"/api/v1/miler/consignments/logs", 1}, // static, sits beside /consignments/:id + } + + for _, c := range cases { + t.Run(c.path, func(t *testing.T) { + status, body := do(t, app, http.MethodGet, c.path, token(t, 1, c.wrongRole), "") + if status == http.StatusNotFound { + t.Fatalf("404 — the route is shadowed or unregistered: %s", body) + } + if status != http.StatusForbidden { + t.Errorf("got %d, want 403 (matched the route, refused the role): %s", status, body) + } + }) + } +} diff --git a/scratch/check_booking_paging.go b/scratch/check_booking_paging.go new file mode 100644 index 0000000..6d155f6 --- /dev/null +++ b/scratch/check_booking_paging.go @@ -0,0 +1,104 @@ +//go:build ignore + +// Read-only: does the console's page-by-page drain actually see every booking? +// +// GetAdminBookings runs Offset/Limit with NO ORDER BY. In Postgres that makes +// the row order across pages unspecified, so a paged drain can legally return +// the same row twice and never return another. This replays the exact 6 pages +// the console fetches and compares the union against the table. +// +// go run scratch/check_booking_paging.go +package main + +import ( + "fmt" + "log" + "sort" + + "doormile/config" + "doormile/db" + + "github.com/joho/godotenv" +) + +const pageSize = 100 // what the server actually returns: min(100, requested) + +type row struct{ Bookingid int } + +func main() { + _ = godotenv.Load() + cfg := config.Load() + db.Connect(cfg) + if db.DB == nil { + log.Fatal("no DB connection") + } + + var total int64 + db.DB.Raw(`SELECT count(*) FROM pickupbookings`).Scan(&total) + pages := int((total + pageSize - 1) / pageSize) + fmt.Printf("bookings: %d, pages of %d: %d\n\n", total, pageSize, pages) + + // The truth: every id that exists. + var all []row + db.DB.Raw(`SELECT bookingid FROM pickupbookings`).Scan(&all) + truth := map[int]bool{} + for _, r := range all { + truth[r.Bookingid] = true + } + + // The drain, exactly as the console runs it — unordered Offset/Limit. + seen := map[int]int{} + for p := 0; p < pages; p++ { + var page []row + db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT ? OFFSET ?`, pageSize, p*pageSize).Scan(&page) + for _, r := range page { + seen[r.Bookingid]++ + } + fmt.Printf(" page %d: %d rows\n", p+1, len(page)) + } + + missed := []int{} + for id := range truth { + if seen[id] == 0 { + missed = append(missed, id) + } + } + dupes := []int{} + for id, n := range seen { + if n > 1 { + dupes = append(dupes, id) + } + } + sort.Sort(sort.Reverse(sort.IntSlice(missed))) + sort.Sort(sort.Reverse(sort.IntSlice(dupes))) + + fmt.Printf("\nunordered drain saw %d distinct of %d\n", len(seen), len(truth)) + fmt.Printf(" MISSED %d: %v\n", len(missed), head(missed, 20)) + fmt.Printf(" DUPLICATED %d: %v\n", len(dupes), head(dupes, 20)) + + // The same drain with a deterministic order — the proposed fix. + seenOrdered := map[int]int{} + for p := 0; p < pages; p++ { + var page []row + db.DB.Raw(`SELECT bookingid FROM pickupbookings ORDER BY bookingid DESC LIMIT ? OFFSET ?`, + pageSize, p*pageSize).Scan(&page) + for _, r := range page { + seenOrdered[r.Bookingid]++ + } + } + missedOrdered := 0 + for id := range truth { + if seenOrdered[id] == 0 { + missedOrdered++ + } + } + fmt.Printf("\nwith ORDER BY bookingid DESC: saw %d distinct, missed %d\n", + len(seenOrdered), missedOrdered) +} + +func head(xs []int, n int) []int { + if len(xs) > n { + return xs[:n] + } + return xs +} diff --git a/scratch/check_bookingno.go b/scratch/check_bookingno.go new file mode 100644 index 0000000..4405621 --- /dev/null +++ b/scratch/check_bookingno.go @@ -0,0 +1,71 @@ +//go:build ignore + +package main + +import ( + "fmt" + "log" + + "doormile/config" + "doormile/db" + + "github.com/joho/godotenv" +) + +func main() { + _ = godotenv.Load() + cfg := config.Load() + db.Connect(cfg) + if db.DB == nil { + log.Fatal("no DB") + } + + fmt.Println("=== unique constraints on bookingno / trackingno ===") + type c struct{ Conname, Def string } + var cs []c + db.DB.Raw(`SELECT c.conname, pg_get_constraintdef(c.oid) def + FROM pg_constraint c JOIN pg_class t ON t.oid=c.conrelid + WHERE t.relname IN ('pickupbookings','consignments') AND c.contype='u'`).Scan(&cs) + if len(cs) == 0 { + fmt.Println(" NONE — nothing stops a duplicate at the database level") + } + for _, x := range cs { + fmt.Printf(" %s: %s\n", x.Conname, x.Def) + } + + fmt.Println("\n=== duplicate booking numbers ===") + type d struct { + Bookingno string + N int64 + } + var ds []d + db.DB.Raw(`SELECT bookingno, count(*) n FROM pickupbookings + GROUP BY 1 HAVING count(*)>1 ORDER BY n DESC LIMIT 10`).Scan(&ds) + fmt.Printf(" %d duplicated\n", len(ds)) + for _, x := range ds { + fmt.Printf(" %s x%d\n", x.Bookingno, x.N) + } + + fmt.Println("\n=== all-zero random part (would mean rand.Read failed) ===") + var zeros int64 + db.DB.Raw(`SELECT count(*) FROM pickupbookings WHERE bookingno LIKE 'DM-BK-00000000-%'`).Scan(&zeros) + fmt.Printf(" %d\n", zeros) + + fmt.Println("\n=== how many bookings share a timestamp suffix ===") + type s struct { + Suffix string + N int64 + } + var ss []s + db.DB.Raw(`SELECT split_part(bookingno,'-',4) suffix, count(*) n + FROM pickupbookings GROUP BY 1 ORDER BY n DESC LIMIT 5`).Scan(&ss) + for _, x := range ss { + fmt.Printf(" suffix %-8s %d bookings\n", x.Suffix, x.N) + } + + fmt.Println("\n=== duplicate tracking numbers ===") + var td []d + db.DB.Raw(`SELECT trackingno bookingno, count(*) n FROM consignments + GROUP BY 1 HAVING count(*)>1 LIMIT 5`).Scan(&td) + fmt.Printf(" %d duplicated\n", len(td)) +} diff --git a/scratch/check_customer_bookings.go b/scratch/check_customer_bookings.go new file mode 100644 index 0000000..af712f5 --- /dev/null +++ b/scratch/check_customer_bookings.go @@ -0,0 +1,115 @@ +//go:build ignore + +// Read-only: why a customer-app booking may not reach the console. +// +// go run scratch/check_customer_bookings.go +package main + +import ( + "fmt" + "log" + + "doormile/config" + "doormile/db" + + "github.com/joho/godotenv" +) + +func main() { + _ = godotenv.Load() + cfg := config.Load() + db.Connect(cfg) + if db.DB == nil { + log.Fatal("no DB connection") + } + + fmt.Println("=== 1. How many bookings exist, by source ===") + type srcRow struct { + Bookingsource string + N int64 + } + var srcs []srcRow + db.DB.Raw(`SELECT COALESCE(NULLIF(bookingsource,''),'(blank)') AS bookingsource, count(*) AS n + FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&srcs) + var total int64 + for _, s := range srcs { + fmt.Printf(" %-16s %d\n", s.Bookingsource, s.N) + total += s.N + } + fmt.Printf(" %-16s %d\n", "TOTAL", total) + + fmt.Println("\n=== 2. The 10 newest bookings ===") + type bRow struct { + Bookingid int + Bookingno string + Bookingsource string + Status string + Tenantid *int + Pickuplatitude float64 + Pickuppincode string + Createdat string + } + var newest []bRow + db.DB.Raw(`SELECT bookingid, bookingno, bookingsource, status, tenantid, + pickuplatitude, pickuppincode, createdat::text + FROM pickupbookings ORDER BY bookingid DESC LIMIT 10`).Scan(&newest) + for _, b := range newest { + tenant := "NULL" + if b.Tenantid != nil { + tenant = fmt.Sprintf("%d", *b.Tenantid) + } + fmt.Printf(" #%-6d %-14s src=%-14s status=%-24s tenant=%-5s lat=%-10.4f pin=%-7s %s\n", + b.Bookingid, b.Bookingno, b.Bookingsource, b.Status, tenant, + b.Pickuplatitude, b.Pickuppincode, b.Createdat) + } + + fmt.Println("\n=== 3. Customer-app bookings specifically ===") + var appBookings []bRow + db.DB.Raw(`SELECT bookingid, bookingno, bookingsource, status, tenantid, + pickuplatitude, pickuppincode, createdat::text + FROM pickupbookings WHERE bookingsource = 'Customer_App' + ORDER BY bookingid DESC LIMIT 10`).Scan(&appBookings) + if len(appBookings) == 0 { + fmt.Println(" none at all") + } + for _, b := range appBookings { + fmt.Printf(" #%-6d %-14s status=%-24s lat=%-10.4f pin=%-7s %s\n", + b.Bookingid, b.Bookingno, b.Status, b.Pickuplatitude, b.Pickuppincode, b.Createdat) + } + + fmt.Println("\n=== 4. Customer-app bookings with no pickup coordinates ===") + var noCoords int64 + db.DB.Raw(`SELECT count(*) FROM pickupbookings + WHERE bookingsource = 'Customer_App' + AND (pickuplatitude = 0 OR pickuplongitude = 0 + OR pickuplatitude IS NULL OR pickuplongitude IS NULL)`).Scan(&noCoords) + fmt.Printf(" %d (these cannot be assigned a rider and fail the console's zone filter)\n", noCoords) + + fmt.Println("\n=== 5. What one page of the console's own query returns ===") + // The console drains GET /admin/bookings, which runs no ORDER BY. This is + // the same shape: LIMIT/OFFSET with no ordering. + var page1, page1again []idRow + db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET 0`).Scan(&page1) + db.DB.Raw(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET 0`).Scan(&page1again) + fmt.Printf(" unordered page 1, call A: %v\n", ids(page1)) + fmt.Printf(" unordered page 1, call B: %v\n", ids(page1again)) + + var lastPage []idRow + offset := total - 5 + if offset < 0 { + offset = 0 + } + db.DB.Raw(fmt.Sprintf(`SELECT bookingid FROM pickupbookings LIMIT 5 OFFSET %d`, offset)).Scan(&lastPage) + fmt.Printf(" unordered LAST page: %v\n", ids(lastPage)) + fmt.Println(" (if the newest ids appear only on the last page, a truncated drain never sees them)") +} + +type idRow struct{ Bookingid int } + +func ids(rows []idRow) []int { + out := make([]int, 0, len(rows)) + for _, r := range rows { + out = append(out, r.Bookingid) + } + return out +} diff --git a/scratch/check_handover_schema.go b/scratch/check_handover_schema.go new file mode 100644 index 0000000..9694b58 --- /dev/null +++ b/scratch/check_handover_schema.go @@ -0,0 +1,136 @@ +//go:build ignore + +// Read-only verification for the base-handover work. Reads information_schema +// and pg_constraint only — no writes, no DDL, no migrations. +// +// go run scratch/check_handover_schema.go +package main + +import ( + "fmt" + "log" + + "doormile/config" + "doormile/db" + + "github.com/joho/godotenv" +) + +func main() { + _ = godotenv.Load() + cfg := config.Load() + db.Connect(cfg) + if db.DB == nil { + log.Fatal("no DB connection") + } + + fmt.Println("=== 1. New columns (expected ABSENT until AutoMigrate runs) ===") + type col struct { + Table string + Name string + } + for _, want := range []col{ + {"pickupbookings", "pickupsourcetype"}, + {"pickupbookings", "pickuphubid"}, + {"consignments", "inwardedat"}, + } { + var n int64 + db.DB.Raw(`SELECT count(*) FROM information_schema.columns + WHERE table_name = ? AND column_name = ?`, want.Table, want.Name).Scan(&n) + fmt.Printf(" %-16s %-18s present=%v\n", want.Table, want.Name, n > 0) + } + + fmt.Println("\n=== 2. CHECK constraints on the tables we write ===") + type chk struct { + Conname string + Def string + } + var checks []chk + db.DB.Raw(`SELECT c.conname, pg_get_constraintdef(c.oid) AS def + FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid + WHERE c.contype = 'c' + AND t.relname IN ('consignments','consignmentexceptions','consignmenthistory', + 'pickupbookings','bookingassignments','milerprofiles') + ORDER BY t.relname, c.conname`).Scan(&checks) + for _, c := range checks { + fmt.Printf(" %s\n %s\n", c.Conname, c.Def) + } + + fmt.Println("\n=== 3. Foreign keys on the user/id columns we write ===") + type fk struct { + Table string + Conname string + Def string + } + var fks []fk + db.DB.Raw(`SELECT t.relname AS table, c.conname, pg_get_constraintdef(c.oid) AS def + FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid + WHERE c.contype = 'f' + AND t.relname IN ('consignmentexceptions','consignmenthistory','consignments', + 'tripsheets','bookingassignments','pickupbookings') + ORDER BY t.relname, c.conname`).Scan(&fks) + if len(fks) == 0 { + fmt.Println(" (none)") + } + for _, f := range fks { + fmt.Printf(" %-24s %s\n", f.Table, f.Def) + } + + fmt.Println("\n=== 4. NOT NULL columns on the tables we insert into ===") + type nn struct { + Table string + Column string + Def *string + } + var nns []nn + db.DB.Raw(`SELECT table_name AS table, column_name AS column, column_default AS def + FROM information_schema.columns + WHERE table_name IN ('consignmentexceptions','consignmenthistory') + AND is_nullable = 'NO' + ORDER BY table_name, ordinal_position`).Scan(&nns) + for _, c := range nns { + d := "(no default)" + if c.Def != nil { + d = *c.Def + } + fmt.Printf(" %-24s %-20s %s\n", c.Table, c.Column, d) + } + + fmt.Println("\n=== 5. Hub master data completeness (request 29) ===") + type hubRow struct { + Total int64 + NoAddress int64 + NoPincode int64 + NoCoords int64 + ActiveTotal int64 + } + var h hubRow + db.DB.Raw(`SELECT count(*) AS total, + count(*) FILTER (WHERE address IS NULL OR address = '') AS no_address, + count(*) FILTER (WHERE pincode IS NULL OR pincode = '') AS no_pincode, + count(*) FILTER (WHERE latitude IS NULL OR latitude = 0 OR longitude IS NULL OR longitude = 0) AS no_coords, + count(*) FILTER (WHERE status = 'Active') AS active_total + FROM hubs WHERE deletedat IS NULL`).Scan(&h) + fmt.Printf(" hubs=%d active=%d missing_address=%d missing_pincode=%d missing_coords=%d\n", + h.Total, h.ActiveTotal, h.NoAddress, h.NoPincode, h.NoCoords) + + fmt.Println("\n=== 6. Consignment status distribution (what is live now) ===") + type sc struct { + Status string + N int64 + } + var scs []sc + db.DB.Raw(`SELECT status, count(*) AS n FROM consignments + WHERE deletedat IS NULL GROUP BY status ORDER BY n DESC`).Scan(&scs) + for _, s := range scs { + fmt.Printf(" %-22s %d\n", s.Status, s.N) + } + + fmt.Println("\n=== 7. Open assignments on already-converted bookings (the off-duty bug) ===") + var stuck int64 + db.DB.Raw(`SELECT count(*) FROM bookingassignments a + JOIN pickupbookings b ON b.bookingid = a.bookingid + WHERE a.assignmentstatus IN ('Assigned','Accepted') + AND b.status = 'Converted_To_Consignment'`).Scan(&stuck) + fmt.Printf(" assignments still open on a converted booking: %d\n", stuck) +} diff --git a/scratch/check_latest.go b/scratch/check_latest.go new file mode 100644 index 0000000..16521a3 --- /dev/null +++ b/scratch/check_latest.go @@ -0,0 +1,62 @@ +//go:build ignore + +package main + +import ( + "fmt" + "log" + + "doormile/config" + "doormile/db" + + "github.com/joho/godotenv" +) + +func main() { + _ = godotenv.Load() + cfg := config.Load() + db.Connect(cfg) + if db.DB == nil { + log.Fatal("no DB") + } + type b struct { + Bookingid int + Bookingno string + Bookingsource string + Status string + Tenantid *int + Appcustomerid int + Pickuppincode string + Pickuplatitude float64 + Createdat string + } + var rows []b + db.DB.Raw(`SELECT bookingid,bookingno,bookingsource,status,tenantid,appcustomerid, + pickuppincode,pickuplatitude,createdat::text + FROM pickupbookings ORDER BY bookingid DESC LIMIT 12`).Scan(&rows) + fmt.Println("=== 12 newest bookings (any source) ===") + for _, r := range rows { + tn := "NULL" + if r.Tenantid != nil { + tn = fmt.Sprint(*r.Tenantid) + } + fmt.Printf(" #%-5d src=%-13q status=%-24s tenant=%-5s cust=%-4d pin=%-7s lat=%.4f %s\n", + r.Bookingid, r.Bookingsource, r.Status, tn, r.Appcustomerid, + r.Pickuppincode, r.Pickuplatitude, r.Createdat) + } + + fmt.Println("\n=== exact distinct bookingsource values (byte-for-byte) ===") + type s struct { + V string + N int64 + } + var ss []s + db.DB.Raw(`SELECT '['||bookingsource||']' v, count(*) n FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&ss) + for _, x := range ss { + fmt.Printf(" %-20s %d\n", x.V, x.N) + } + + var appTotal int64 + db.DB.Raw(`SELECT count(*) FROM pickupbookings WHERE bookingsource='Customer_App'`).Scan(&appTotal) + fmt.Printf("\nCustomer_App bookings the console page should list: %d\n", appTotal) +} diff --git a/scratch/check_miler23.go b/scratch/check_miler23.go new file mode 100644 index 0000000..12dc088 --- /dev/null +++ b/scratch/check_miler23.go @@ -0,0 +1,37 @@ +//go:build ignore + +package main + +import ( + "fmt" + "log" + + "doormile/config" + "doormile/db" + + "github.com/joho/godotenv" +) + +func main() { + _ = godotenv.Load() + cfg := config.Load() + db.Connect(cfg) + if db.DB == nil { + log.Fatal("no DB") + } + var avail string + db.DB.Raw(`SELECT availabilitystatus FROM milerprofiles WHERE userid = 23`).Scan(&avail) + fmt.Printf("miler 23 availability: %s\n", avail) + + var open int64 + db.DB.Raw(`SELECT count(*) FROM bookingassignments ba + JOIN pickupbookings b ON b.bookingid = ba.bookingid + WHERE ba.mileruserid = 23 AND ba.assignmentstatus IN ('Assigned','Accepted')`).Scan(&open) + fmt.Printf("miler 23 open assignments: %d\n", open) + + var onCancelled int64 + db.DB.Raw(`SELECT count(*) FROM bookingassignments ba + JOIN pickupbookings b ON b.bookingid = ba.bookingid + WHERE ba.assignmentstatus IN ('Assigned','Accepted') AND b.status = 'Cancelled'`).Scan(&onCancelled) + fmt.Printf("\nACROSS ALL RIDERS — open assignments on a CANCELLED booking: %d\n", onCancelled) +} diff --git a/scratch/check_phone.go b/scratch/check_phone.go new file mode 100644 index 0000000..143e82a --- /dev/null +++ b/scratch/check_phone.go @@ -0,0 +1,38 @@ +//go:build ignore +package main + +import ( + "fmt" + "log" + + "doormile/config" + "doormile/db" + + "github.com/joho/godotenv" +) + +func main() { + _ = godotenv.Load() + cfg := config.Load() + db.Connect(cfg) + if db.DB == nil { + log.Fatal("no DB") + } + type c struct { + Appcustomerid int + Firstname string + Phone string + Configid int + Status string + } + var rows []c + db.DB.Raw(`SELECT appcustomerid,firstname,phone,configid,status FROM appcustomers + WHERE phone IN ('9876543210') OR appcustomerid = 5`).Scan(&rows) + if len(rows) == 0 { + fmt.Println("no appcustomer with phone 9876543210, and no id 5") + } + for _, r := range rows { + fmt.Printf(" id=%d name=%q phone=%s configid=%d status=%s\n", + r.Appcustomerid, r.Firstname, r.Phone, r.Configid, r.Status) + } +} diff --git a/scratch/check_two_bookings.go b/scratch/check_two_bookings.go new file mode 100644 index 0000000..3ed32d0 --- /dev/null +++ b/scratch/check_two_bookings.go @@ -0,0 +1,80 @@ +//go:build ignore + +package main + +import ( + "fmt" + "log" + + "doormile/config" + "doormile/db" + + "github.com/joho/godotenv" +) + +func main() { + _ = godotenv.Load() + cfg := config.Load() + db.Connect(cfg) + if db.DB == nil { + log.Fatal("no DB") + } + + type b struct { + Bookingid int + Bookingno string + Status string + Createdat string + Updatedat string + Assignedmileruserid *int + Pickuplatitude float64 + Pickuplongitude float64 + Pickuppincode string + Deliverypincode string + Deliveryaddress string + Appcustomerid int + } + var rows []b + db.DB.Raw(`SELECT bookingid, bookingno, status, createdat::text, updatedat::text, + assignedmileruserid, pickuplatitude, pickuplongitude, pickuppincode, + deliverypincode, deliveryaddress, appcustomerid + FROM pickupbookings WHERE bookingid IN (546,547,179) ORDER BY bookingid DESC`).Scan(&rows) + for _, r := range rows { + miler := "none" + if r.Assignedmileruserid != nil { + miler = fmt.Sprintf("%d", *r.Assignedmileruserid) + } + fmt.Printf("#%d %s\n status=%s created=%s\n updated=%s miler=%s customer=%d\n pickup=(%.4f,%.4f) %s -> %s drop=%q\n\n", + r.Bookingid, r.Bookingno, r.Status, r.Createdat, r.Updatedat, miler, + r.Appcustomerid, r.Pickuplatitude, r.Pickuplongitude, r.Pickuppincode, + r.Deliverypincode, r.Deliveryaddress) + } + + fmt.Println("=== assignments on those bookings ===") + type a struct { + Bookingid int + Assignmentstatus string + Assignedat string + Remarks string + } + var as []a + db.DB.Raw(`SELECT bookingid, assignmentstatus, assignedat::text, remarks + FROM bookingassignments WHERE bookingid IN (546,547,179)`).Scan(&as) + if len(as) == 0 { + fmt.Println(" none — no rider was ever assigned") + } + for _, x := range as { + fmt.Printf(" booking %d: %s at %s %q\n", x.Bookingid, x.Assignmentstatus, x.Assignedat, x.Remarks) + } + + fmt.Println("\n=== how many bookings sit in each status ===") + type s struct { + Status string + N int64 + } + var ss []s + db.DB.Raw(`SELECT status, count(*) n FROM pickupbookings GROUP BY 1 ORDER BY n DESC`).Scan(&ss) + for _, x := range ss { + fmt.Printf(" %-26s %d\n", x.Status, x.N) + } +}