updates on the api endpoints on the customer page and more
This commit is contained in:
64
CLAUDE.md
64
CLAUDE.md
@@ -45,7 +45,7 @@ customers.
|
|||||||
prior sessions]**
|
prior sessions]**
|
||||||
|
|
||||||
- **Backend**: Go + Fiber, deployed on **Kubernetes**, at `api.doormile.com`.
|
- **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
|
This is the primary booking/assignment API and the primary trigger for
|
||||||
miler assignment, calling the AI decision layer with a 5-second timeout
|
miler assignment, calling the AI decision layer with a 5-second timeout
|
||||||
fallback so a slow AI response never blocks a booking.
|
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
|
`MilerSkipDelivery`, added this session). `ConsignmentHistory` (event
|
||||||
log), `ConsignmentException` (Lost/Damaged/Misrouted/Receiver_Refused/
|
log), `ConsignmentException` (Lost/Damaged/Misrouted/Receiver_Refused/
|
||||||
Missing_Contents/Undeliverable).
|
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
|
- `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
|
(`Roleid`: 1 admin, 3 manager, 4 rep/exec, 5 miler, 6 hub staff via a
|
||||||
separate `HubStaffAccount` table). `MilerProfile` — actual rider profile
|
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)
|
## 9. Current blockers & open work (whole-project level)
|
||||||
|
|
||||||
**[carried forward]**
|
**[carried forward]**
|
||||||
|
|||||||
@@ -75,6 +75,37 @@ const (
|
|||||||
ErrOtpInvalid = "OTP_INVALID"
|
ErrOtpInvalid = "OTP_INVALID"
|
||||||
ErrIdempotencyInProgress = "IDEMPOTENCY_IN_PROGRESS" // an identical keyed request is still running
|
ErrIdempotencyInProgress = "IDEMPOTENCY_IN_PROGRESS" // an identical keyed request is still running
|
||||||
ErrEmailInUse = "EMAIL_IN_USE"
|
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
|
// Payment Modes
|
||||||
|
|||||||
@@ -2143,6 +2143,22 @@ type AdminBookingRequest struct {
|
|||||||
// column of that name foreign-keys to appcustomerlocations, not to a
|
// column of that name foreign-keys to appcustomerlocations, not to a
|
||||||
// client's sites.
|
// 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"`
|
Pickupaddress string `json:"pickupaddress"`
|
||||||
Pickuppincode string `json:"pickuppincode"`
|
Pickuppincode string `json:"pickuppincode"`
|
||||||
Pickuplatitude float64 `json:"pickuplatitude"`
|
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
|
// against the earlier documentation. It is the wrong column: it foreign-keys
|
||||||
// to appcustomerlocations, so a tenantlocations id in it fails the insert.
|
// to appcustomerlocations, so a tenantlocations id in it fails the insert.
|
||||||
// Both names resolve to Tenantlocationid.
|
// 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
|
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 {
|
if siteID == nil {
|
||||||
siteID = req.Pickuplocationid
|
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
|
// 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,
|
// 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.
|
// 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)
|
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()
|
tx := db.DB.Begin()
|
||||||
|
|
||||||
customerID := req.Appcustomerid
|
customerID := req.Appcustomerid
|
||||||
@@ -2276,6 +2347,8 @@ func createExpressBooking(req AdminBookingRequest, autoAssign bool) (*models.Pic
|
|||||||
Appcustomerid: customerID,
|
Appcustomerid: customerID,
|
||||||
Pickuplocationid: req.Pickuplocationid,
|
Pickuplocationid: req.Pickuplocationid,
|
||||||
Tenantlocationid: req.Tenantlocationid,
|
Tenantlocationid: req.Tenantlocationid,
|
||||||
|
Pickupsourcetype: req.PickupSourceType,
|
||||||
|
Pickuphubid: req.Pickuphubid,
|
||||||
Pickupaddress: req.Pickupaddress,
|
Pickupaddress: req.Pickupaddress,
|
||||||
Pickuppincode: req.Pickuppincode,
|
Pickuppincode: req.Pickuppincode,
|
||||||
Pickuplatitude: req.Pickuplatitude,
|
Pickuplatitude: req.Pickuplatitude,
|
||||||
@@ -2525,7 +2598,65 @@ func GetAdminBookingDetails(c *fiber.Ctx) error {
|
|||||||
if err := q.First(&booking, id).Error; err != nil {
|
if err := q.First(&booking, id).Error; err != nil {
|
||||||
return utils.NotFound(c, "booking not found")
|
return utils.NotFound(c, "booking not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
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 {
|
func AdminAssignMiler(c *fiber.Ctx) error {
|
||||||
|
|||||||
@@ -433,6 +433,11 @@ func CreateCustomerBooking(c *fiber.Ctx) error {
|
|||||||
Deliverylatitude: req.Deliverylatitude,
|
Deliverylatitude: req.Deliverylatitude,
|
||||||
Deliverylongitude: req.Deliverylongitude,
|
Deliverylongitude: req.Deliverylongitude,
|
||||||
Bookingsource: "Customer_App",
|
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,
|
Status: constants.BookingPendingPickup,
|
||||||
Preferredpickupfrom: req.Preferredpickupfrom,
|
Preferredpickupfrom: req.Preferredpickupfrom,
|
||||||
Preferredpickupto: req.Preferredpickupto,
|
Preferredpickupto: req.Preferredpickupto,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"doormile/db"
|
"doormile/db"
|
||||||
"doormile/dto"
|
"doormile/dto"
|
||||||
"doormile/internal/assignment"
|
"doormile/internal/assignment"
|
||||||
|
"doormile/internal/legs"
|
||||||
"doormile/internal/routing"
|
"doormile/internal/routing"
|
||||||
"doormile/models"
|
"doormile/models"
|
||||||
"doormile/utils"
|
"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 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 {
|
func haversineKM(lat1, lon1, lat2, lon2 float64) float64 {
|
||||||
const earthRadiusKM = 6371.0
|
return legs.HaversineKM(lat1, lon1, lat2, lon2)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago".
|
// humanizeRelativeTime renders a timestamp as "5 min ago" / "2 hrs ago" / "3 days ago".
|
||||||
@@ -315,13 +313,25 @@ func GetHubUnassignedBookings(c *fiber.Ctx) error {
|
|||||||
var customer models.AppCustomer
|
var customer models.AppCustomer
|
||||||
db.DB.Where("appcustomerid = ?", b.Appcustomerid).First(&customer)
|
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{
|
response = append(response, fiber.Map{
|
||||||
"bookingid": b.Bookingid,
|
"bookingid": b.Bookingid,
|
||||||
"bookingno": b.Bookingno,
|
"bookingno": b.Bookingno,
|
||||||
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
"customer_name": customerName,
|
||||||
"pickup_address": b.Pickupaddress,
|
"pickup_source_type": sourceType,
|
||||||
|
"sourceid": sourceID,
|
||||||
|
"pickuplocationid": sourceID,
|
||||||
|
"pickup_source_name": sourceName,
|
||||||
|
"pickup_address": sourceAddress,
|
||||||
"pickup_pincode": b.Pickuppincode,
|
"pickup_pincode": b.Pickuppincode,
|
||||||
"delivery_address": b.Deliveryaddress,
|
"delivery_address": b.Deliveryaddress,
|
||||||
|
"delivery_pincode": b.Deliverypincode,
|
||||||
"parcels": b.Parcels,
|
"parcels": b.Parcels,
|
||||||
"created_at": b.Createdat,
|
"created_at": b.Createdat,
|
||||||
})
|
})
|
||||||
@@ -389,13 +399,21 @@ 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{
|
response = append(response, fiber.Map{
|
||||||
"bookingid": b.Bookingid,
|
"bookingid": b.Bookingid,
|
||||||
"bookingno": b.Bookingno,
|
"bookingno": b.Bookingno,
|
||||||
"customer_name": strings.TrimSpace(customer.Firstname + " " + customer.Lastname),
|
"customer_name": customerName,
|
||||||
"pickup_address": b.Pickupaddress,
|
"pickup_source_type": sourceType,
|
||||||
|
"sourceid": sourceID,
|
||||||
|
"pickuplocationid": sourceID,
|
||||||
|
"pickup_source_name": sourceName,
|
||||||
|
"pickup_address": sourceAddress,
|
||||||
"pickup_pincode": b.Pickuppincode,
|
"pickup_pincode": b.Pickuppincode,
|
||||||
"delivery_address": b.Deliveryaddress,
|
"delivery_address": b.Deliveryaddress,
|
||||||
|
"delivery_pincode": b.Deliverypincode,
|
||||||
"parcels": b.Parcels,
|
"parcels": b.Parcels,
|
||||||
"status": hubBookingDisplayStatus(b.Status, hasMiler),
|
"status": hubBookingDisplayStatus(b.Status, hasMiler),
|
||||||
"milername": milerName,
|
"milername": milerName,
|
||||||
@@ -437,6 +455,10 @@ func renderInboundConsignment(cs models.Consignment) fiber.Map {
|
|||||||
"temperature": "N/A",
|
"temperature": "N/A",
|
||||||
"status": cs.Status,
|
"status": cs.Status,
|
||||||
"updatedat": cs.Updatedat,
|
"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.Status = constants.ConsignmentInwardedAtHub
|
||||||
consignment.Currenthubid = &hubID
|
consignment.Currenthubid = &hubID
|
||||||
consignment.Condition = req.Condition
|
consignment.Condition = req.Condition
|
||||||
consignment.Shelf = recommendedShelf
|
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 {
|
if err := db.DB.Save(&consignment).Error; err != nil {
|
||||||
return utils.Internal(c, "failed to update consignment")
|
return utils.Internal(c, "failed to update consignment")
|
||||||
|
|||||||
286
controllers/hubInboundController.go
Normal file
286
controllers/hubInboundController.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
514
controllers/logisticsHandoverController.go
Normal file
514
controllers/logisticsHandoverController.go
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
149
controllers/logisticsHandover_test.go
Normal file
149
controllers/logisticsHandover_test.go
Normal file
@@ -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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
137
controllers/logisticsRouting_test.go
Normal file
137
controllers/logisticsRouting_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -322,6 +322,34 @@ 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{
|
row := fiber.Map{
|
||||||
"bookingid": b.Bookingid,
|
"bookingid": b.Bookingid,
|
||||||
"bookingreference": b.Bookingno,
|
"bookingreference": b.Bookingno,
|
||||||
@@ -329,7 +357,13 @@ func MilerGetMyBookings(c *fiber.Ctx) error {
|
|||||||
"stoptype": milerStopType(b.Status),
|
"stoptype": milerStopType(b.Status),
|
||||||
"consignmentid": b.Consignmentid,
|
"consignmentid": b.Consignmentid,
|
||||||
"consignmentstatus": consignmentStatus,
|
"consignmentstatus": consignmentStatus,
|
||||||
"pickupaddress": b.Pickupaddress,
|
"next_action": nextAction,
|
||||||
|
"next_hub": nextHub,
|
||||||
|
"pickup_source_type": sourceType,
|
||||||
|
"sourceid": sourceID,
|
||||||
|
"pickuplocationid": sourceID,
|
||||||
|
"pickup_source_name": sourceName,
|
||||||
|
"pickupaddress": sourceAddress,
|
||||||
"pickuplatitude": b.Pickuplatitude,
|
"pickuplatitude": b.Pickuplatitude,
|
||||||
"pickuplongitude": b.Pickuplongitude,
|
"pickuplongitude": b.Pickuplongitude,
|
||||||
"deliveryaddress": b.Deliveryaddress,
|
"deliveryaddress": b.Deliveryaddress,
|
||||||
@@ -442,6 +476,12 @@ func MilerGetConsignment(c *fiber.Ctx) error {
|
|||||||
"can_start_delivery": consignment.Status == constants.ConsignmentCollectedByMiler,
|
"can_start_delivery": consignment.Status == constants.ConsignmentCollectedByMiler,
|
||||||
"can_deliver": consignment.Status == constants.ConsignmentOutForDelivery,
|
"can_deliver": consignment.Status == constants.ConsignmentOutForDelivery,
|
||||||
"can_skip": consignment.Status == constants.ConsignmentOutForDelivery || consignment.Status == constants.ConsignmentCollectedByMiler,
|
"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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"doormile/db"
|
"doormile/db"
|
||||||
"doormile/dto"
|
"doormile/dto"
|
||||||
"doormile/internal/assignment"
|
"doormile/internal/assignment"
|
||||||
|
"doormile/internal/legs"
|
||||||
"doormile/internal/notify"
|
"doormile/internal/notify"
|
||||||
"doormile/internal/routing"
|
"doormile/internal/routing"
|
||||||
"doormile/models"
|
"doormile/models"
|
||||||
@@ -957,10 +958,7 @@ func BookingPaymentCollect(c *fiber.Ctx) error {
|
|||||||
// final-mile delivery. Pincodes shorter than 3 characters are treated as
|
// 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.
|
// unknown rather than matching, so bad data falls back to the safe hub route.
|
||||||
func isHyperlocal(pickupPincode, deliveryPincode string) bool {
|
func isHyperlocal(pickupPincode, deliveryPincode string) bool {
|
||||||
if len(pickupPincode) < 3 || len(deliveryPincode) < 3 {
|
return legs.SamePostalArea(pickupPincode, deliveryPincode)
|
||||||
return false
|
|
||||||
}
|
|
||||||
return pickupPincode[:3] == deliveryPincode[:3]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxHyperlocalKM bounds the straight-line pickup→delivery distance under which
|
// 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
|
// created kitchen→customer bookings (e.g. DailyGrubs) frequently carry accurate
|
||||||
// coordinates but no delivery pincode, and must not be wrongly routed through a
|
// coordinates but no delivery pincode, and must not be wrongly routed through a
|
||||||
// hub. When both pincodes are present the prefix rule still wins.
|
// 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
|
// isHyperlocalBooking decides whether a booking can skip the hub and be carried
|
||||||
// straight to the final mile. It prefers the pincode-prefix rule (isHyperlocal)
|
// 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
|
// 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.
|
// 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 {
|
func isHyperlocalBooking(pickupPincode, deliveryPincode string, pLat, pLng, dLat, dLng float64) bool {
|
||||||
// Both pincodes present: the prefix rule decides definitively (a matching
|
return legs.IsHyperlocal(pickupPincode, deliveryPincode, pLat, pLng, dLat, dLng)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectedStateEnabled gates the two-step hyperlocal delivery flow
|
// collectedStateEnabled gates the two-step hyperlocal delivery flow
|
||||||
@@ -1068,15 +1055,29 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
trackingNo := generateTrackingNo()
|
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
|
var defaultHubID *int
|
||||||
if profile.Hubid != nil {
|
if handoverHub != nil {
|
||||||
defaultHubID = profile.Hubid
|
defaultHubID = &handoverHub.Hubid
|
||||||
} else {
|
} else {
|
||||||
utils.Warn("BookingPickupComplete: miler has no assigned hub, falling back to first hub row", "miler_user_id", milerUserID, "booking_id", bookingID)
|
utils.Warn("BookingPickupComplete: no base could be resolved for this pickup", "miler_user_id", milerUserID, "booking_id", bookingID)
|
||||||
var hub models.Hub
|
|
||||||
if tx.First(&hub).Error == nil {
|
|
||||||
defaultHubID = &hub.Hubid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// 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
|
// delivering". With it OFF (default, and what the current app expects) it goes
|
||||||
// straight to Out_for_Delivery exactly as before.
|
// straight to Out_for_Delivery exactly as before.
|
||||||
consignmentStatus := constants.ConsignmentInwardedAtHub
|
|
||||||
if isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
|
if isHyperlocalBooking(booking.Pickuppincode, booking.Deliverypincode,
|
||||||
booking.Pickuplatitude, booking.Pickuplongitude,
|
booking.Pickuplatitude, booking.Pickuplongitude,
|
||||||
booking.Deliverylatitude, booking.Deliverylongitude) {
|
booking.Deliverylatitude, booking.Deliverylongitude) {
|
||||||
@@ -1138,6 +1138,14 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
|||||||
Currenthubid: defaultHubID,
|
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
|
var payment models.BookingPayment
|
||||||
if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil {
|
if tx.Where("bookingid = ?", bookingID).First(&payment).Error == nil {
|
||||||
if payment.Paymentstatus == constants.PaymentStatusPaid {
|
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
|
// 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.
|
// hub-routed parcel was dropped at the hub, so the rider frees up.
|
||||||
postPickupAvailability := constants.MilerAvailable
|
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
|
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).
|
if err := tx.Model(&models.MilerProfile{}).Where("userid = ?", milerUserID).
|
||||||
Update("availabilitystatus", postPickupAvailability).Error; err != nil {
|
Update("availabilitystatus", postPickupAvailability).Error; err != nil {
|
||||||
tx.Rollback()
|
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,
|
"tracking_no": trackingNo,
|
||||||
"consignment_id": consignment.Consignmentid,
|
"consignment_id": consignment.Consignmentid,
|
||||||
"consignmentstatus": consignment.Status,
|
"consignmentstatus": consignment.Status,
|
||||||
|
"status": consignment.Status,
|
||||||
"booking_no": booking.Bookingno,
|
"booking_no": booking.Bookingno,
|
||||||
"booking_status": booking.Status,
|
"booking_status": booking.Status,
|
||||||
"next_action": pickupNextAction(consignment.Status),
|
"next_action": nextActionForConsignment(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"
|
|
||||||
}
|
}
|
||||||
|
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 {
|
func BookingVehicleRequiredEscalate(c *fiber.Ctx) error {
|
||||||
|
|||||||
271
docs/DEV_ONBOARDING.md
Normal file
271
docs/DEV_ONBOARDING.md
Normal file
@@ -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/<project>/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.
|
||||||
360
docs/logistics-base-handover.md
Normal file
360
docs/logistics-base-handover.md
Normal file
@@ -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: <optional, same middleware as pickup-complete>
|
||||||
|
|
||||||
|
{ "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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Doormile Miler App — API reference
|
# 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`.
|
Base URL `https://api.doormile.com/api/v1`.
|
||||||
|
|
||||||
This supersedes "Miler App API Contract v1.0" where the two disagree — several
|
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
|
in the rider's hands. Otherwise it routes via the hub. The consignment inherits
|
||||||
the **booking's** tenant, not the rider's.
|
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
|
## Delivery
|
||||||
|
|
||||||
| Method | Path | Body |
|
| 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/bookings` | `?status=&date=YYYY-MM-DD` |
|
||||||
| GET | `/miler/earnings` | `?period=daily\|weekly\|monthly&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.
|
`bonuspoints` stays zero — nothing writes it yet. That's known and deliberate.
|
||||||
|
|
||||||
## Telemetry (Redis-backed, high frequency)
|
## Telemetry (Redis-backed, high frequency)
|
||||||
@@ -235,4 +287,9 @@ build a UI that depends on it.
|
|||||||
decided.
|
decided.
|
||||||
2. `bonuspoints` is never written.
|
2. `bonuspoints` is never written.
|
||||||
3. `assignments/:id/reject` and `bookings/:id/vehicle-required` have never had a
|
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`.
|
||||||
|
|||||||
8
go.mod
8
go.mod
@@ -3,13 +3,17 @@ module doormile
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
firebase.google.com/go/v4 v4.20.0
|
||||||
github.com/gofiber/fiber/v2 v2.52.10
|
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/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lib/pq v1.12.3
|
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
|
github.com/redis/go-redis/v9 v9.16.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.51.0
|
||||||
|
google.golang.org/api v0.279.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
gorm.io/driver/postgres v1.5.11
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
@@ -25,7 +29,6 @@ require (
|
|||||||
cloud.google.com/go/longrunning v1.0.0 // indirect
|
cloud.google.com/go/longrunning v1.0.0 // indirect
|
||||||
cloud.google.com/go/monitoring v1.29.0 // indirect
|
cloud.google.com/go/monitoring v1.29.0 // indirect
|
||||||
cloud.google.com/go/storage v1.62.1 // 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/detectors/gcp v1.32.0 // indirect
|
||||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.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
|
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-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // 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-jwt/jwt/v4 v4.5.2 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/google/s2a-go v0.1.9 // 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-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // 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/nkeys v0.4.5 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // 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/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.37.0 // indirect
|
||||||
golang.org/x/time v0.15.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/appengine/v2 v2.0.6 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect
|
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
|
||||||
|
|||||||
71
internal/legs/legs.go
Normal file
71
internal/legs/legs.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
117
internal/legs/legs_test.go
Normal file
117
internal/legs/legs_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
141
internal/routing/dropforleg_test.go
Normal file
141
internal/routing/dropforleg_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"doormile/constants"
|
"doormile/constants"
|
||||||
"doormile/db"
|
"doormile/db"
|
||||||
|
"doormile/internal/legs"
|
||||||
"doormile/models"
|
"doormile/models"
|
||||||
"doormile/utils"
|
"doormile/utils"
|
||||||
)
|
)
|
||||||
@@ -183,13 +184,31 @@ func loadActiveStops(milerUserID int) ([]stop, error) {
|
|||||||
Pickuplongitude float64
|
Pickuplongitude float64
|
||||||
Deliverylatitude float64
|
Deliverylatitude float64
|
||||||
Deliverylongitude 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").
|
if err := db.DB.Table("bookingassignments AS ba").
|
||||||
Select(`ba.bookingassignmentid, ba.bookingid, b.bookingno,
|
Select(`ba.bookingassignmentid, ba.bookingid, b.bookingno,
|
||||||
b.pickuplatitude, b.pickuplongitude,
|
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("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 ?",
|
Where("ba.mileruserid = ? AND ba.assignmentstatus IN ?",
|
||||||
milerUserID,
|
milerUserID,
|
||||||
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
[]string{constants.AssignmentAssigned, constants.AssignmentAccepted}).
|
||||||
@@ -209,19 +228,60 @@ func loadActiveStops(milerUserID int) ([]stop, error) {
|
|||||||
"assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid)
|
"assignment_id", r.Bookingassignmentid, "booking_id", r.Bookingid)
|
||||||
continue
|
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{
|
stops = append(stops, stop{
|
||||||
AssignmentID: r.Bookingassignmentid,
|
AssignmentID: r.Bookingassignmentid,
|
||||||
BookingID: r.Bookingid,
|
BookingID: r.Bookingid,
|
||||||
BookingNo: r.Bookingno,
|
BookingNo: r.Bookingno,
|
||||||
PickupLat: r.Pickuplatitude,
|
PickupLat: r.Pickuplatitude,
|
||||||
PickupLng: r.Pickuplongitude,
|
PickupLng: r.Pickuplongitude,
|
||||||
DeliveryLat: r.Deliverylatitude,
|
DeliveryLat: dropLat,
|
||||||
DeliveryLng: r.Deliverylongitude,
|
DeliveryLng: dropLng,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return stops, nil
|
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
|
// optimize calls the Route Optimization API and maps its answer back onto our
|
||||||
// assignment ids.
|
// assignment ids.
|
||||||
func optimize(stops []stop) ([]Result, error) {
|
func optimize(stops []stop) ([]Result, error) {
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ type Consignment struct {
|
|||||||
Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"`
|
Returninitiatedat *time.Time `json:"returninitiatedat" gorm:"column:returninitiatedat"`
|
||||||
Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"`
|
Returndeliveredat *time.Time `json:"returndeliveredat" gorm:"column:returndeliveredat"`
|
||||||
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
|
Parentconsignmentid *int `json:"parentconsignmentid" gorm:"column:parentconsignmentid"`
|
||||||
|
// 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.
|
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
|
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
|
// Deliveryotp is issued when the consignment goes out for delivery and is
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ type PickupBooking struct {
|
|||||||
// different table entirely; writing a tenantlocations id into it violates
|
// different table entirely; writing a tenantlocations id into it violates
|
||||||
// that foreign key. This is what per-site reporting groups by.
|
// 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"`
|
Pickupaddress string `json:"pickupaddress" gorm:"column:pickupaddress;not null"`
|
||||||
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
|
Pickuppincode string `json:"pickuppincode" gorm:"column:pickuppincode;not null"`
|
||||||
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
|
Pickuplatitude float64 `json:"pickuplatitude" gorm:"column:pickuplatitude;not null"`
|
||||||
|
|||||||
@@ -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/start-delivery", middlewares.Idempotency(), controllers.MilerStartDelivery)
|
||||||
milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment)
|
milerAuth.Post("/consignments/:id/deliver", middlewares.Idempotency(), controllers.MilerDeliverConsignment)
|
||||||
milerAuth.Post("/consignments/:id/skip", controllers.MilerSkipDelivery)
|
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
|
// Earnings
|
||||||
milerAuth.Get("/earnings", controllers.MilerGetEarnings)
|
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/today", controllers.GetHubInboundToday)
|
||||||
hubAuth.Get("/inbound", controllers.GetHubInboundRange)
|
hubAuth.Get("/inbound", controllers.GetHubInboundRange)
|
||||||
hubAuth.Post("/bookings/:id/inbound", controllers.CreateInboundScan)
|
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/assign-miler", controllers.HubAssignMiler)
|
||||||
hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign)
|
hubAuth.Post("/bookings/:id/auto-assign", controllers.HubAutoAssign)
|
||||||
hubAuth.Post("/bookings/batch-assign", controllers.HubBatchAssign)
|
hubAuth.Post("/bookings/batch-assign", controllers.HubBatchAssign)
|
||||||
|
|||||||
257
routes/routes_logistics_test.go
Normal file
257
routes/routes_logistics_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
104
scratch/check_booking_paging.go
Normal file
104
scratch/check_booking_paging.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
71
scratch/check_bookingno.go
Normal file
71
scratch/check_bookingno.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
115
scratch/check_customer_bookings.go
Normal file
115
scratch/check_customer_bookings.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
136
scratch/check_handover_schema.go
Normal file
136
scratch/check_handover_schema.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
62
scratch/check_latest.go
Normal file
62
scratch/check_latest.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
37
scratch/check_miler23.go
Normal file
37
scratch/check_miler23.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
38
scratch/check_phone.go
Normal file
38
scratch/check_phone.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
scratch/check_two_bookings.go
Normal file
80
scratch/check_two_bookings.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user