updates
This commit is contained in:
@@ -13,6 +13,40 @@ const (
|
||||
MilerBlocked = "Blocked"
|
||||
)
|
||||
|
||||
// MilerWorkingStatuses are the states in which a miler may be GIVEN more work.
|
||||
//
|
||||
// A miler carrying an order is still a miler on the road. Courier rounds are
|
||||
// multi-stop by nature, and every candidate query used to test
|
||||
// `availabilitystatus = 'Available'`, which treats the first booking as a
|
||||
// lock: the moment a rider took one order they vanished from every assignment
|
||||
// path, and the per-rider load caps that exist precisely to govern this never
|
||||
// got a chance to run. Whether a rider can take another job is a question
|
||||
// about how much they are already carrying — counted from their open
|
||||
// assignments — not about whether they are carrying anything at all.
|
||||
//
|
||||
// Offline, Break and Blocked are the only states that take a miler out. They
|
||||
// are excluded by naming the ones that are in, so a status added later is
|
||||
// off-duty until someone decides otherwise.
|
||||
var MilerWorkingStatuses = []string{
|
||||
MilerAvailable,
|
||||
MilerAssigned,
|
||||
MilerOnPickup,
|
||||
MilerAtCustomer,
|
||||
MilerPickedUp,
|
||||
MilerOnDelivery,
|
||||
}
|
||||
|
||||
// MilerCanTakeWork reports whether a miler in this state may receive another
|
||||
// booking. Load is capped separately, by counting open assignments.
|
||||
func MilerCanTakeWork(status string) bool {
|
||||
for _, s := range MilerWorkingStatuses {
|
||||
if s == status {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Booking sources — where a booking originated. Left as their stored literals:
|
||||
// "CRM_Console" predates the outward express rename and is an existing DB value.
|
||||
const (
|
||||
|
||||
57
constants/miler_work_test.go
Normal file
57
constants/miler_work_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package constants
|
||||
|
||||
import "testing"
|
||||
|
||||
// Who may be handed another booking.
|
||||
//
|
||||
// The rule these lock down replaced `availabilitystatus == "Available"`, which
|
||||
// treated a rider's first order as a lock: from then on they were invisible to
|
||||
// every assignment path, and the per-rider load caps that exist to decide this
|
||||
// never ran. A courier round is multi-stop; carrying something is the normal
|
||||
// state of a working miler, not a reason to be skipped.
|
||||
func TestMilerCanTakeWork(t *testing.T) {
|
||||
cases := []struct {
|
||||
status string
|
||||
want bool
|
||||
why string
|
||||
}{
|
||||
{MilerAvailable, true, "idle and on duty"},
|
||||
{MilerAssigned, true, "holding stops — the case the old rule wrongly excluded"},
|
||||
{MilerOnPickup, true, "mid-collection, still adding to the round"},
|
||||
{MilerAtCustomer, true, "at a door, next stop can still be planned"},
|
||||
{MilerPickedUp, true, "parcels in hand"},
|
||||
{MilerOnDelivery, true, "running the round"},
|
||||
|
||||
{MilerOffline, false, "not on duty"},
|
||||
{MilerBreak, false, "on a break — do not pile work on"},
|
||||
{MilerBlocked, false, "blocked by ops"},
|
||||
|
||||
{"", false, "unknown status is off duty, never a default yes"},
|
||||
{"available", false, "the stored values are capitalised; a case slip must not silently pass"},
|
||||
{"Retired", false, "a status nobody has taught this rule about is off duty"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := MilerCanTakeWork(tc.status); got != tc.want {
|
||||
t.Errorf("MilerCanTakeWork(%q) = %v, want %v (%s)", tc.status, got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The off-duty states are excluded by omission, so a status added to the enum
|
||||
// later is off duty until somebody decides otherwise. This fails if a new
|
||||
// constant is added to the list without being considered here.
|
||||
func TestMilerWorkingStatusesExcludesOffDuty(t *testing.T) {
|
||||
offDuty := []string{MilerOffline, MilerBreak, MilerBlocked}
|
||||
for _, bad := range offDuty {
|
||||
for _, s := range MilerWorkingStatuses {
|
||||
if s == bad {
|
||||
t.Errorf("%q must not be in MilerWorkingStatuses", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(MilerWorkingStatuses) != 6 {
|
||||
t.Errorf("MilerWorkingStatuses has %d entries, expected 6 — a status was added or removed; "+
|
||||
"confirm it should receive work before updating this count", len(MilerWorkingStatuses))
|
||||
}
|
||||
}
|
||||
@@ -1993,16 +1993,32 @@ func HubBatchAssign(c *fiber.Ctx) error {
|
||||
return utils.OK(c, fiber.Map{"assigned": 0, "skipped": 0, "results": []fiber.Map{}})
|
||||
}
|
||||
|
||||
// Every rider on duty at this hub, not only the idle ones. capPerRider is
|
||||
// what limits a round; requiring Available made that limit unreachable,
|
||||
// because a rider stopped being Available the moment they took the first
|
||||
// booking of the very batch being built.
|
||||
var riderProfiles []models.MilerProfile
|
||||
if err := db.DB.Where("hubid = ? AND availabilitystatus = ?", hubID, constants.MilerAvailable).
|
||||
if err := db.DB.Where("hubid = ? AND availabilitystatus IN ?", hubID, constants.MilerWorkingStatuses).
|
||||
Find(&riderProfiles).Error; err != nil {
|
||||
return utils.Internal(c, "failed to fetch available riders")
|
||||
}
|
||||
|
||||
candidates := make([]*batchRiderCandidate, 0, len(riderProfiles))
|
||||
for _, mp := range riderProfiles {
|
||||
// Seed the count with what the rider is ALREADY holding. capPerRider
|
||||
// has to mean "stops in hand", not "stops added by this call" — now
|
||||
// that busy riders are eligible, counting only this call's additions
|
||||
// would hand five more to someone already carrying five.
|
||||
var openStops int64
|
||||
db.DB.Model(&models.BookingAssignment{}).
|
||||
Where("mileruserid = ? AND assignmentstatus IN ?", mp.Userid, []string{
|
||||
constants.AssignmentAssigned,
|
||||
constants.AssignmentAccepted,
|
||||
}).
|
||||
Count(&openStops)
|
||||
candidates = append(candidates, &batchRiderCandidate{
|
||||
userid: mp.Userid, lat: mp.Currentlatitude, lon: mp.Currentlongitude,
|
||||
assigned: int(openStops),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
692
docs/customer-app-integration-handbook.md
Normal file
692
docs/customer-app-integration-handbook.md
Normal file
@@ -0,0 +1,692 @@
|
||||
# Doormile Backend — Customer App Integration Handbook
|
||||
|
||||
**For:** the developer building `doormile_customer_app` (Flutter)
|
||||
**Backend:** `doormile_backend` @ `main` (Go · Fiber v2 · PostgreSQL · Redis · NATS)
|
||||
**Base URL:** `https://api.doormile.com/api/v1`
|
||||
**Namespace:** everything you call lives under `/customer/*` — 28 routes, no exceptions
|
||||
**Verified against source:** 16 Sep 2026
|
||||
|
||||
> This is the *practical* handbook. Two companion docs already exist and are still correct:
|
||||
> `docs/customer-app-api.md` (the full change record and design rationale) and
|
||||
> `docs/customer-app-api-crisp.md` (the terse endpoint reference).
|
||||
> This file is what you need to actually ship — the flows, the gotchas, and the things that will
|
||||
> waste your week if nobody tells you.
|
||||
|
||||
---
|
||||
|
||||
## 0. Read this first — two things are blocking you today
|
||||
|
||||
### 0.1 There is no SMS gateway. Nobody can sign in.
|
||||
|
||||
`sms.Register()` had **zero callers** in the entire backend until this week. The package shipped with a
|
||||
logging sink standing in for a real gateway, and the sink was never replaced. So:
|
||||
|
||||
```
|
||||
POST /customer/auth/otp/request
|
||||
-> backend generates a 4-digit code
|
||||
-> writes it to the APPLICATION LOG
|
||||
-> returns { success: true } <-- a lie
|
||||
-> no text is ever sent
|
||||
```
|
||||
|
||||
The endpoint reports success. No customer has ever received a code.
|
||||
|
||||
**What changed:** `sms.Configure()` is now called at boot (`main.go`), and the log sink now *refuses*
|
||||
in production instead of pretending. The boot log says plainly which transport it got, and
|
||||
`GET /api/v1/ready` reports it:
|
||||
|
||||
```json
|
||||
{ "sms": { "transport": "log", "configured": false } }
|
||||
```
|
||||
|
||||
**What has NOT changed:** there is still no provider. `SMS_GATEWAY_URL` is unset. That is a
|
||||
procurement item (an Indian provider plus DLT template registration), not something you or I can code
|
||||
around.
|
||||
|
||||
**How you get in *today* — pick one:**
|
||||
|
||||
| Method | How | Notes |
|
||||
|---|---|---|
|
||||
| **Staging OTP** (best) | Ops sets `CX_STAGING_OTP=1234` on a **non-production** deployment | A fixed code that always verifies. Refused outright when `ENV=production` — `internal/sms/sms.go:105`. **Not yet set on the cluster.** |
|
||||
| **Read the log** | Ask backend/ops for the code out of the pod log | Works right now, no deploy needed. Tedious. |
|
||||
| **Paste a token** | `--dart-define=DM_DEV_TOKEN=eyJ...` | A **real** server-issued token. Everything after it is genuinely authorised. Expires in 1 hour unless you also pass `DM_DEV_REFRESH_TOKEN`. |
|
||||
|
||||
### 0.2 `DM_MOCK=true` bookings never reach the server. At all.
|
||||
|
||||
This is the root cause of *"I created a booking in the app and it doesn't show in the admin console."*
|
||||
|
||||
`DevDoormileApi.createBooking()` (`lib/data/dev_doormile_api.dart:440`) builds a `Booking` object in
|
||||
memory and pushes it onto a local list. **It never opens a socket.** Nothing is POSTed, nothing is
|
||||
stored, nothing exists.
|
||||
|
||||
Because sign-in was impossible (§0.1), offline mode became the only practical way into the app — and
|
||||
every booking made that way was fiction. The database confirms it: the newest `Customer_App` booking
|
||||
is **id 565, dated 8 Sep 2026**. The count has not moved since.
|
||||
|
||||
**Rule:** a booking is only real if `AppConfig.useDevData == false`. If the Account screen says
|
||||
`DEV DATA (offline)`, nothing you do on that screen reaches Doormile.
|
||||
|
||||
Use `DM_LOGIN_AS` + `DM_LOGIN_CODE` instead when you want to skip the login *screen* without faking
|
||||
the login — it runs the real `otp/request` + `otp/verify` pair and the token it gets back is the
|
||||
server's.
|
||||
|
||||
---
|
||||
|
||||
## 1. Connection basics
|
||||
|
||||
### 1.1 Base URL
|
||||
|
||||
| Environment | URL |
|
||||
|---|---|
|
||||
| Production | `https://api.doormile.com/api/v1` |
|
||||
| Staging | *not yet provisioned* — staging currently points at production |
|
||||
| Local | `http://10.0.2.2:8080/api/v1` (Android emulator to host) |
|
||||
|
||||
Every customer path is then prefixed `/customer`, so a full URL looks like:
|
||||
|
||||
```
|
||||
https://api.doormile.com/api/v1/customer/bookings
|
||||
```
|
||||
|
||||
### 1.2 Headers
|
||||
|
||||
| Header | When | Value |
|
||||
|---|---|---|
|
||||
| `Authorization` | every authenticated call | `Bearer <accessToken>` |
|
||||
| `Content-Type` | every POST/PUT/PATCH | `application/json` |
|
||||
| `Idempotency-Key` | `POST /bookings`, `POST /auth/otp/verify` | any stable unique string per logical action (mint a UUID when the user taps the button) |
|
||||
| `X-Client` | optional | `doormile-cx/1.0.0+1` |
|
||||
| `X-Platform` | optional | `android` / `ios` |
|
||||
|
||||
> **Flutter Web only:** the backend's CORS `AllowHeaders` is
|
||||
> `Origin,Content-Type,Accept,Authorization,Idempotency-Key` (`main.go:169`).
|
||||
> `X-Client` and `X-Platform` are **not** in it, so a browser preflight will reject them.
|
||||
> On a native build CORS does not apply and they are fine. If you target web, either drop those
|
||||
> two headers or ask backend to add them.
|
||||
|
||||
### 1.3 Timeouts
|
||||
|
||||
| Call | Budget |
|
||||
|---|---|
|
||||
| Reads | 15s |
|
||||
| `POST /bookings` | 30s — it writes across several tables; better to wait than orphan a booking the server did create |
|
||||
|
||||
---
|
||||
|
||||
## 2. The response envelope
|
||||
|
||||
Customer endpoints use their **own** envelope (`utils/response_cx.go`), deliberately different from
|
||||
the miler/console one. Do not copy parsing code from another Doormile client.
|
||||
|
||||
### 2.1 Success
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { }, "message": "" }
|
||||
```
|
||||
|
||||
`message` is **always present** on success — empty string, never omitted.
|
||||
|
||||
### 2.2 List
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [],
|
||||
"total": 48,
|
||||
"nextCursor": "540",
|
||||
"message": ""
|
||||
}
|
||||
```
|
||||
|
||||
- `data` is **always an array**, never `null`. Type it as a list.
|
||||
- `nextCursor` is `null` on the last page.
|
||||
- `total` is the size of the *filtered* set — the count matches the tab you asked for.
|
||||
|
||||
### 2.3 Error
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "That code has expired. Request a new one.",
|
||||
"error": { "code": "invalid_otp" }
|
||||
}
|
||||
```
|
||||
|
||||
The machine-readable code is **nested under `error.code`**, not at the top level. `message` is
|
||||
customer-safe English — render it verbatim in your error state.
|
||||
|
||||
### 2.4 Error codes
|
||||
|
||||
| Code | HTTP | Meaning | What the app should do |
|
||||
|---|---|---|---|
|
||||
| `invalid` | 400 | Malformed or rejected request | Show `message`, let them fix it |
|
||||
| `invalid_name` | 400 | Name failed validation at signup | Focus the name field |
|
||||
| `invalid_otp` | 401 | Wrong or expired code | Clear the field, offer resend |
|
||||
| `unauthorized` | 401 | Missing or expired access token | Refresh once, then sign out |
|
||||
| `forbidden` | 403 | Not your resource | Go back |
|
||||
| `not_found` | 404 | No such booking or order | Go back, refresh the list |
|
||||
| `conflict` | 409 | Already done, or in progress | Refresh and re-read state |
|
||||
| `unserviceable` | 400 | Outside operating cities | Show the serviceability message |
|
||||
| `rate_limited` | 429 | Throttle hit | Back off, show a countdown |
|
||||
| `server_error` | 500 | We broke | Generic retry state |
|
||||
|
||||
---
|
||||
|
||||
## 3. Authentication
|
||||
|
||||
No passwords anywhere. A 4-digit code to a phone **or** an email address.
|
||||
|
||||
### 3.1 The flow
|
||||
|
||||
```
|
||||
+- new user ---> POST /customer/auth/signup {name, phone, email}
|
||||
| |
|
||||
user -+ v
|
||||
+- returning --> POST /customer/auth/otp/request {identifier}
|
||||
|
|
||||
v (code delivered - see 0.1)
|
||||
POST /customer/auth/otp/verify {identifier, code}
|
||||
+ Idempotency-Key
|
||||
|
|
||||
v
|
||||
{ accessToken, refreshToken, expiresIn, customer }
|
||||
|
|
||||
+------------------+------------------+
|
||||
v v
|
||||
use for 1 hour POST /customer/auth/refresh
|
||||
{refreshToken} -> new pair
|
||||
```
|
||||
|
||||
### 3.2 Session response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": {
|
||||
"accessToken": "eyJhbGciOi...",
|
||||
"refreshToken": "9f2c... (64 hex chars)",
|
||||
"expiresIn": 3600,
|
||||
"customer": { "id": 1, "name": "", "phone": "", "email": "" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Lifetimes and limits — code against these exactly
|
||||
|
||||
| Thing | Value | Source |
|
||||
|---|---|---|
|
||||
| Access token TTL | **1 hour** | `cxAccessTTL` |
|
||||
| Refresh token TTL | **60 days** | `cxRefreshTTL` |
|
||||
| OTP TTL | **5 minutes** | `cxOtpTTL` |
|
||||
| OTP length | **4 digits** | `cxOtpLength` |
|
||||
| Resend cooldown | **30 seconds** | `cxResendWait` |
|
||||
| Verify attempts per code | **3**, then the code dies | `cxOtpMaxVerify` |
|
||||
| Codes per identifier per hour | **5** | `cxOtpMaxRequests` |
|
||||
| Credential endpoint throttle | **10/min shared** across `otp/request`, `signup`, `otp/verify`, `refresh` | `authLimiter()` |
|
||||
|
||||
That last one matters: the budget is **shared**. An aggressive resend loop will 429 the verify call
|
||||
too. Put a real 30-second countdown on the resend button.
|
||||
|
||||
### 3.4 Token handling
|
||||
|
||||
- Persist the refresh token. 60 days means a returning customer should never see the login screen.
|
||||
- Refresh **once** on a 401, then give up and sign out. Do not loop — the throttle is shared.
|
||||
- The refresh token is stored **hashed** server-side. If you lose it, it cannot be recovered; the
|
||||
customer signs in again.
|
||||
|
||||
### 3.5 `POST /auth/otp/verify` needs an `Idempotency-Key`
|
||||
|
||||
The client retries over flaky networks, and a replayed verify must return the **original** session
|
||||
rather than mint a second one. Send a key.
|
||||
|
||||
> **Fixed this week:** the idempotency middleware used to cache any status below 500 for 24 hours,
|
||||
> including the **401** from a mistyped code. One typo locked a customer out for a day — confirmed
|
||||
> live, the retry came back carrying `Idempotent-Replay: true`. Only 2xx is cached now
|
||||
> (`middlewares/idempotency.go:104`). A wrong code now genuinely re-executes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Endpoint reference — all 28
|
||||
|
||||
### 4.1 Public (no token)
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| POST | `/customer/auth/otp/request` | Send a code to a phone or email |
|
||||
| POST | `/customer/auth/signup` | Create an account |
|
||||
| POST | `/customer/auth/otp/verify` | Exchange code for a session · **Idempotency-Key** |
|
||||
| POST | `/customer/auth/refresh` | Exchange refresh token for a new pair |
|
||||
| GET | `/customer/serviceability/states` | State picker |
|
||||
| GET | `/customer/serviceability/states/:stateCode/districts` | District picker |
|
||||
| GET | `/customer/pickup-slots` | Bookable time slots |
|
||||
| GET | `/customer/config/booking-limits?lat=&lng=` | `maxPackages`, `maxDestinations` |
|
||||
|
||||
> The booking form is explorable **before** sign-in by design. Do not put a login wall on the first
|
||||
> screen.
|
||||
|
||||
### 4.2 Authenticated (`Bearer` + role 9)
|
||||
|
||||
**Session and profile**
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/customer/auth/me` | Who am I |
|
||||
| POST | `/customer/auth/logout` | Revoke this session |
|
||||
| GET · PUT | `/customer/profile` | Read / update profile |
|
||||
|
||||
**Saved addresses**
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| GET · POST | `/customer/locations` |
|
||||
| PUT · DELETE | `/customer/locations/:id` |
|
||||
|
||||
**Push**
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| POST | `/customer/devices` | Register an FCM token |
|
||||
| DELETE | `/customer/devices/:token` | Unregister |
|
||||
|
||||
> One row per device token, not one column per customer — a phone and a tablet must both receive the
|
||||
> delivery notification. Re-register on every token rotation.
|
||||
|
||||
**Places** — proxied, never keyed
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| GET | `/customer/places/reverse-geocode?lat=&lng=` |
|
||||
| GET | `/customer/places/search?q=` |
|
||||
|
||||
> The legacy rider app shipped a Google Maps key inside the binary and it had to be revoked. **You
|
||||
> are never handed a key.** Ask the backend, the backend asks the geocoder.
|
||||
|
||||
**Pricing**
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| POST | `/customer/fare/estimate` |
|
||||
|
||||
**Bookings**
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| POST | `/customer/bookings` | Create · **Idempotency-Key** · city-gated |
|
||||
| GET | `/customer/bookings?status=&limit=&cursor=` | List |
|
||||
| GET | `/customer/bookings/:reference` | Detail / tracking poll |
|
||||
| POST | `/customer/bookings/:reference/cancel` | Cancel |
|
||||
| PATCH | `/customer/bookings/:reference/destinations/:index` | Edit one destination |
|
||||
| GET | `/customer/orders/:trackingId` | One order, for `doormile://track/...` deep links |
|
||||
|
||||
**QA only**
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| POST | `/customer/ops/bookings/:reference/stage` |
|
||||
|
||||
> Refused unless `ENV` is non-production **and** `CX_ALLOW_STAGE_OVERRIDE=true` — two independent
|
||||
> switches, because either one wrong in production would let any customer mark their own parcel
|
||||
> delivered. It exists so every tracking state is reachable for design QA, which means **your debug
|
||||
> stepper can be deleted.**
|
||||
|
||||
---
|
||||
|
||||
## 5. The payloads that matter
|
||||
|
||||
### 5.1 `POST /customer/fare/estimate`
|
||||
|
||||
Call this on every route and package-count change. It is cheap, cached 60s, and a failed estimate
|
||||
**must never block a booking**.
|
||||
|
||||
```json
|
||||
{
|
||||
"pickup": { "lat": 11.0168, "lng": 76.9558 },
|
||||
"destinations": [
|
||||
{ "stateCode": "TN", "districtCode": "CBE", "packageCount": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"min": 180,
|
||||
"max": 240,
|
||||
"paymentMethod": "UPI · Cash at doorstep",
|
||||
"parcel": "2 parcels",
|
||||
"routeKm": 12.4
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 `POST /customer/bookings`
|
||||
|
||||
```json
|
||||
{
|
||||
"pickup": {
|
||||
"title": "Home",
|
||||
"sub": "12 Gandhi St, RS Puram",
|
||||
"lat": 11.0168,
|
||||
"lng": 76.9558
|
||||
},
|
||||
"slotId": "2026-09-16T10:00",
|
||||
"destinations": [
|
||||
{
|
||||
"stateCode": "TN",
|
||||
"districtCode": "CBE",
|
||||
"packageCount": 2,
|
||||
"details": {
|
||||
"street": "45 Cross Cut Rd",
|
||||
"building": "Flat 3B",
|
||||
"landmark": "opp. the bakery",
|
||||
"recipientName": "Priya",
|
||||
"recipientPhone": "9876543210",
|
||||
"instructions": "Ring twice",
|
||||
"pin": { "lat": 11.0041, "lng": 76.9662 },
|
||||
"codAmount": 500
|
||||
}
|
||||
}
|
||||
],
|
||||
"estimate": { "min": 180, "max": 240 },
|
||||
"remarks": "Handle with care"
|
||||
}
|
||||
```
|
||||
|
||||
Field notes that will bite you:
|
||||
|
||||
| Field | Why it matters |
|
||||
|---|---|
|
||||
| `estimate` | What the customer was **shown on Review**. Recorded for dispute audit — when the settled price is questioned months later, the number on the screen is the fact that matters. The server validates it against its own quote and rejects a tampered band. |
|
||||
| `remarks` | **Top-level, not inside a destination.** Lands in `PickupBooking.Notes`, which the admin Orders table displays and searches. Put it in the wrong place and every booking reaches the console with an empty note. |
|
||||
| `codAmount` | Money collected at that door **on the customer's behalf**. Doormile is the carrier, not the seller. |
|
||||
| `details.pin` | Overrides the district centroid with an exact drop pin. Send it whenever you have one. |
|
||||
|
||||
Caps: `maxDestinations` and `maxPackages` from `/config/booking-limits` (hard ceiling 25,
|
||||
`cxAbsoluteMaxDestinations`). Re-fetch the limits when the pickup point moves — they vary by city.
|
||||
|
||||
**City gate:** pickup must be in an operating city, matched on the 3-digit pincode prefix —
|
||||
`641` Coimbatore, `600` Chennai, `560` Bengaluru, `500` Hyderabad, `629` Nagercoil. Anything else is
|
||||
refused. The backend resolves your `lat`/`lng` to a pincode itself, so you do not send one.
|
||||
|
||||
### 5.3 `GET /customer/bookings`
|
||||
|
||||
| Param | Values | Default |
|
||||
|---|---|---|
|
||||
| `status` | `active` · `completed` · `cancelled` | all |
|
||||
| `limit` | 1–**50** | 20 |
|
||||
| `cursor` | the `nextCursor` from the previous page | — |
|
||||
|
||||
**Keyset pagination, not offset.** Offsets drift when a new booking lands mid-scroll and show the
|
||||
same row twice. Pass the cursor back verbatim; stop when `nextCursor` is `null`.
|
||||
|
||||
---
|
||||
|
||||
## 6. The booking object
|
||||
|
||||
One shape. The list row and the detail read are **identical** — build one parser.
|
||||
|
||||
```json
|
||||
{
|
||||
"reference": "DM2609160042",
|
||||
"stage": "on_the_way",
|
||||
"status": "active",
|
||||
"cancellable": true,
|
||||
"createdAt": 1789564800000,
|
||||
|
||||
"pickup": { "title": "Home", "sub": "12 Gandhi St", "lat": 11.0168, "lng": 76.9558 },
|
||||
"slotId": "2026-09-16T10:00",
|
||||
|
||||
"destinations": [
|
||||
{
|
||||
"stateCode": "TN", "stateName": "Tamil Nadu",
|
||||
"districtCode": "CBE", "districtName": "Coimbatore",
|
||||
"packageCount": 2,
|
||||
"district": { "code": "CBE", "name": "Coimbatore", "available": true,
|
||||
"hub": "CBE Central", "promise": "Same day" },
|
||||
"details": { "street": "45 Cross Cut Rd", "recipientName": "Priya", "codAmount": 500 },
|
||||
"trackingId": null,
|
||||
"stage": null,
|
||||
"verification": null
|
||||
}
|
||||
],
|
||||
|
||||
"miler": { "name": "Ravi", "vehicle": "TN 37 AB 1234", "phone": "9000000000",
|
||||
"rating": 4.8, "trips": 412, "vehicleType": "bike" },
|
||||
"deliveryAgent": null,
|
||||
|
||||
"milerDistanceKm": 2.4,
|
||||
"milerEtaMinutes": 9,
|
||||
"milersInZone": 0,
|
||||
|
||||
"routeKm": 12.4,
|
||||
"expectedDelivery": 1789600000000,
|
||||
|
||||
"fare": { "min": 180, "max": 240,
|
||||
"paymentMethod": "UPI · Cash at doorstep", "parcel": "2 parcels" },
|
||||
"amountPaid": null,
|
||||
"deliveredAt": null,
|
||||
"cancelReason": null,
|
||||
|
||||
"history": [ { "stage": "booked", "at": 1789564800000, "actor": "customer" } ]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.1 Contract guarantees — your type declarations depend on these
|
||||
|
||||
- `pickup` and `slotId` are present on **every** booking, cancelled ones included.
|
||||
- `destinations[].stateName` and `districtName` are **always populated**. Render
|
||||
`"Coimbatore, Tamil Nadu"` from them; never look a code up client-side.
|
||||
- All timestamps are **epoch milliseconds**, integers.
|
||||
|
||||
### 6.2 Nullability — when each field appears
|
||||
|
||||
| Field | Null until |
|
||||
|---|---|
|
||||
| `miler` | a rider is assigned (stage >= `assigned`) |
|
||||
| `milerDistanceKm` / `milerEtaMinutes` | **only** at `on_the_way` and `arrived` — after pickup the number would describe a journey that already ended |
|
||||
| `milersInZone` | `0` except on a **single-booking read** at stage `booked` (it is a Redis GEOSEARCH; running it across a 20-row list would put 20 of them behind one page load) |
|
||||
| `destinations[].trackingId` | `order_created` |
|
||||
| `destinations[].stage` | `order_created` |
|
||||
| `destinations[].verification` | `picked_up` — the weight and photos are what the rider recorded at the door |
|
||||
| `amountPaid` | `picked_up` |
|
||||
| `deliveryAgent` | an order is out for delivery |
|
||||
| `deliveredAt` | every destination is delivered |
|
||||
| `cancelReason` | cancelled |
|
||||
|
||||
Parcel photo URLs are **signed and expire in 30 minutes**. Long enough to open the receipt; short
|
||||
enough that a forwarded link is dead. Do not cache them — re-fetch the booking.
|
||||
|
||||
---
|
||||
|
||||
## 7. The stage machine
|
||||
|
||||
### 7.1 Nine stages
|
||||
|
||||
The wire format is **lowercase snake_case, verbatim**. Your client rolls these into its seven
|
||||
milestones and falls back to `booked` on an unknown key — **silently**. A new stage added without an
|
||||
app release therefore makes a parcel look un-started. Never rename; coordinate.
|
||||
|
||||
| # | `stage` | Means | Set by |
|
||||
|---|---|---|---|
|
||||
| 0 | `booked` | Pickup requested, nobody assigned | booking create |
|
||||
| 1 | `assigned` | A rider was allotted the job | assignment |
|
||||
| 2 | `on_the_way` | Rider heading over — distance/ETA live | rider taps Accept |
|
||||
| 3 | `arrived` | Rider at the door — **last cancellable stage** | rider taps Reached |
|
||||
| 4 | `picked_up` | Weighed, photographed, price settled | pickup-complete |
|
||||
| 5 | `order_created` | One tracking number minted per destination | pickup-complete |
|
||||
| 6 | `in_transit` | In the Doormile network — **per order** | hub inward / tripsheet |
|
||||
| 7 | `out_for_delivery` | Delivery agent carrying it | start-delivery |
|
||||
| 8 | `delivered` | Handed over | deliver |
|
||||
|
||||
### 7.2 `status` — three values
|
||||
|
||||
| Value | When |
|
||||
|---|---|
|
||||
| `active` | stages 0–7 |
|
||||
| `completed` | stage reaches `delivered` |
|
||||
| `cancelled` | customer, ops, or rider stand-down. **Terminal** — late rider telemetry cannot resurrect it |
|
||||
|
||||
### 7.3 Which leg is which
|
||||
|
||||
```
|
||||
customer's door --(1)--> HUB --(2)--> recipient's door
|
||||
| |
|
||||
order_created out_for_delivery
|
||||
+- in_transit -+
|
||||
```
|
||||
|
||||
`in_transit` is the **middle** leg — the only stage where no rider is holding the parcel. The
|
||||
first-mile ride (customer to hub) is still `order_created`; the last mile is `out_for_delivery`.
|
||||
|
||||
### 7.4 Four behaviours that will look like bugs and are not
|
||||
|
||||
**A hyperlocal booking never shows `in_transit`.** Same postal area means no hub leg — the same rider
|
||||
carries it door to door. The stage jumps `order_created` to `out_for_delivery`. Your milestone UI
|
||||
must tolerate a skipped rung.
|
||||
|
||||
**Stages 6–8 take the *slowest* destination.** Three parcels, one still at a hub, and the booking
|
||||
stays `in_transit`. Deliberate: showing "Delivered" while a parcel is in Kerala is worse than being
|
||||
pessimistic. Per-destination progress is on `destinations[].stage` — use that for the per-parcel rows.
|
||||
|
||||
**`booked` can be reached *backwards*.** If a rider cancels or skips, the pickup is not cancelled —
|
||||
it returns to the pool, `stage` walks back to `booked`, `miler` goes null. Your tracking screen must
|
||||
handle a rider card disappearing. History entries for `assigned` and `on_the_way` stay, because those
|
||||
things did happen.
|
||||
|
||||
**`in_transit` currently fires before the parcel reaches the hub.** With `MILER_HUB_HANDOVER_ENABLED`
|
||||
off (the default, and it is unset everywhere), pickup-complete stamps `Inwarded_at_Hub` immediately.
|
||||
The customer sees "In transit" while the rider is still standing at their door. Known — the backend
|
||||
comment says so in as many words. Do not build UI that assumes the parcel is physically at a hub.
|
||||
|
||||
### 7.5 Cancellation window
|
||||
|
||||
`cancellable` is on the booking — **use it, do not compute it.** It closes after `arrived`
|
||||
(rank <= 3). The server re-checks on the cancel call regardless; the flag is a hint for hiding the
|
||||
button, never the authority. Expect a `409` if the stage moved between render and tap, and handle it
|
||||
by refreshing rather than erroring.
|
||||
|
||||
### 7.6 `history`
|
||||
|
||||
Append-only, one entry per stage **actually reached**, with the real time and the real actor
|
||||
(`customer` · `miler` · `ops` · `system`). **Nothing is backfilled.** A booking that predates this
|
||||
surface has a short history — a short honest history beats a long invented one, because the customer
|
||||
cannot tell which entries were guessed. Render what you get; do not pad it.
|
||||
|
||||
A cancellation rides the event log with `remarks` rather than as a stage, because `cancelled` is not
|
||||
one of the nine. Read `status` and `cancelReason` for the display.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rules the app must implement
|
||||
|
||||
1. **Idempotency keys on `POST /bookings` and `POST /auth/otp/verify`.** Mint a UUID when the user
|
||||
taps, reuse it across retries, discard it on success. A duplicate pickup is unacceptable.
|
||||
2. **Keyset pagination.** Pass `nextCursor` back; never construct offsets.
|
||||
3. **Refresh once on 401, then sign out.** The throttle is shared across all credential endpoints.
|
||||
4. **Fetch `/config/booking-limits` when the pickup point moves.** Caps vary by city and are
|
||||
deliberately not hardcoded anywhere in the UI.
|
||||
5. **A failed fare estimate must not block the booking.** Let them proceed on the last good quote.
|
||||
6. **Never cache signed photo URLs.** 30-minute expiry.
|
||||
7. **Render `message` verbatim** on any failure. It is written to be customer-safe.
|
||||
8. **Poll the detail endpoint while tracking is open.** It is the canonical read and is built for it
|
||||
— the whole bundle loads in batch specifically so a poll is not six queries.
|
||||
|
||||
---
|
||||
|
||||
## 9. Backend quirks worth knowing
|
||||
|
||||
| Quirk | Impact on you |
|
||||
|---|---|
|
||||
| `Arrived_At_Pickup` is a declared status that is **never written** | Arrival is stored as a fact (`arrivedat` plus GPS), not a status. You get it via `stage: "arrived"`. Do not look for the status string. |
|
||||
| `Picked_Up` is **transient** | Written and overwritten to `Converted_To_Consignment` inside the same transaction. You will effectively never observe it. |
|
||||
| Two response envelopes exist in this backend | `/customer/*` uses `CxOK`/`CxFail` (nested `error.code`). `/miler/*` and `/admin/*` use `OK`/`Fail` (top-level code). Never copy parsing code across. |
|
||||
| `/miler/verify-pin` returns its payload **outside** the envelope | A known inconsistency that cost the miler client a release. It is not repeated on your surface — every `/customer/*` response, auth included, puts its payload in `data`. |
|
||||
| `ENV` is currently **not** `production` on `api.doormile.com` | Confirmed via a CORS probe: an `OPTIONS` from an un-allowlisted origin was echoed back. This means `CX_STAGING_OTP` *would* work there — and also that the production safety guard is inert. Flag for ops. |
|
||||
|
||||
---
|
||||
|
||||
## 10. Build flags
|
||||
|
||||
```bash
|
||||
# Real backend, real login - what a release build does
|
||||
flutter run --dart-define=DM_ENV=prod
|
||||
```
|
||||
|
||||
```bash
|
||||
# Skip the login SCREEN, keep the login REAL <-- use this for day-to-day dev
|
||||
flutter run --dart-define=DM_LOGIN_AS=9876543210 --dart-define=DM_LOGIN_CODE=1234
|
||||
```
|
||||
|
||||
```bash
|
||||
# Paste a real token you already hold
|
||||
flutter run --dart-define=DM_DEV_TOKEN=eyJ... --dart-define=DM_DEV_REFRESH_TOKEN=abc...
|
||||
```
|
||||
|
||||
```bash
|
||||
# Offline fake - UI work only. NOTHING reaches a server.
|
||||
flutter run --dart-define=DM_MOCK=true
|
||||
```
|
||||
|
||||
| Flag | Default | Effect |
|
||||
|---|---|---|
|
||||
| `DM_ENV` | `staging` | `prod` · `staging` · `dev` |
|
||||
| `DM_API_BASE` | — | Overrides the per-environment base URL |
|
||||
| `DM_MOCK` | `false` | Offline fake API. **Never reaches a server.** |
|
||||
| `DM_DEV_LOGIN` | `true` | With `DM_MOCK`, opens straight on Home |
|
||||
| `DM_LOGIN_AS` / `DM_LOGIN_CODE` | — | Real auto sign-in against the real API |
|
||||
| `DM_DEV_TOKEN` | — | A real server-issued access token |
|
||||
| `DM_ALLOW_STAGE_OVERRIDE` | `false` | Offers the QA stage stepper (server must also allow it) |
|
||||
|
||||
Every one of these is guarded by `!kReleaseMode`. **No define can put any of them in a release
|
||||
build**, and each is named on the Account screen whenever it is on.
|
||||
|
||||
Backend-side flags that change what you observe:
|
||||
|
||||
| Var | Default | Effect on the app |
|
||||
|---|---|---|
|
||||
| `CX_STAGING_OTP` | unset | A fixed code that always verifies. Ignored when `ENV=production`. |
|
||||
| `CX_ALLOW_STAGE_OVERRIDE` | unset | Enables `POST /ops/bookings/:ref/stage` |
|
||||
| `SMS_GATEWAY_URL` | unset | Unset means codes go to the log and no text is sent |
|
||||
| `MILER_HUB_HANDOVER_ENABLED` | unset | Off means `in_transit` fires at pickup, not at the hub |
|
||||
| `MILER_COLLECTED_STATE_ENABLED` | unset | Off means hyperlocal goes straight to `out_for_delivery` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Pre-release checklist
|
||||
|
||||
- [ ] `DM_MOCK` is off and the Account screen shows a real base URL, not `DEV DATA (offline)`
|
||||
- [ ] A booking made on the build appears in the admin console within 15 seconds
|
||||
- [ ] Idempotency keys sent on `POST /bookings` and `POST /auth/otp/verify`
|
||||
- [ ] Resend button has a real 30-second countdown
|
||||
- [ ] A 401 triggers exactly **one** refresh, then sign-out
|
||||
- [ ] Pagination uses `nextCursor`, never an offset
|
||||
- [ ] Tracking UI survives a skipped `in_transit` (hyperlocal)
|
||||
- [ ] Tracking UI survives the rider card disappearing (release back to `booked`)
|
||||
- [ ] An unknown `stage` value does not crash — falls back to `booked` and logs
|
||||
- [ ] Cancel button driven by `cancellable`, and a `409` refreshes rather than errors
|
||||
- [ ] Photo URLs re-fetched, not cached
|
||||
- [ ] Debug stage stepper removed (the server's QA endpoint replaces it)
|
||||
- [ ] `X-Client` / `X-Platform` dropped if you ship a web target
|
||||
|
||||
---
|
||||
|
||||
## 12. Where the source is
|
||||
|
||||
| Topic | File |
|
||||
|---|---|
|
||||
| Routes and middleware wiring | `routes/routes.go:105-171` |
|
||||
| Response envelope | `utils/response_cx.go` |
|
||||
| Auth, OTP, sessions | `controllers/cxAuthController.go` |
|
||||
| Booking create / list / detail / cancel | `controllers/cxBookingController.go` |
|
||||
| **The booking JSON shape** | `controllers/cxBookingView.go` |
|
||||
| Stage machine and timeline | `internal/cxstage/stage.go` |
|
||||
| Stage and status constants | `constants/constants.go:190-230` |
|
||||
| Consignment to stage mapping | `controllers/cxConsignmentHooks.go` |
|
||||
| Fare | `controllers/cxFareController.go` |
|
||||
| Serviceability, slots, limits | `controllers/cxCatalogueController.go` |
|
||||
| City gate | `middlewares/city_gate.go` |
|
||||
| Idempotency | `middlewares/idempotency.go` |
|
||||
| SMS gateway | `internal/sms/` |
|
||||
|
||||
Full contract and design rationale: `docs/customer-app-api.md`
|
||||
Terse endpoint list: `docs/customer-app-api-crisp.md`
|
||||
Machine-readable: `docs/openapi-customer.yaml`
|
||||
@@ -140,7 +140,12 @@ func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, [
|
||||
continue
|
||||
}
|
||||
|
||||
if profile.Availabilitystatus != constants.MilerAvailable {
|
||||
// Carrying an order is not a reason to be skipped — maxActive below
|
||||
// is what decides how much one miler can hold. Testing for Available
|
||||
// here made that cap unreachable: a rider was eligible only while
|
||||
// idle, so activeCount was always 0 and the multi-stop round the cap
|
||||
// was written for could never be built.
|
||||
if !constants.MilerCanTakeWork(profile.Availabilitystatus) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -152,7 +157,7 @@ func collectEligibleCandidates(nearby []redis.GeoLocation) ([]*milerCandidate, [
|
||||
}).
|
||||
Count(&activeCount)
|
||||
|
||||
if activeCount >= maxActive {
|
||||
if activeCount >= maxActiveBookings() {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"doormile/constants"
|
||||
@@ -21,9 +24,28 @@ const (
|
||||
retryDelay = 2 * time.Minute
|
||||
geoRadiusKm = 10.0
|
||||
geoMaxCount = 10
|
||||
maxActive = 3
|
||||
|
||||
// defaultMaxActive is how many open stops one miler may hold at once.
|
||||
// Override with MILER_MAX_ACTIVE_BOOKINGS — how many parcels a rider can
|
||||
// realistically run in one round is an operational call, not a constant,
|
||||
// and it differs between a dense city round and an intercity leg.
|
||||
defaultMaxActive = 3
|
||||
)
|
||||
|
||||
// maxActiveBookings reads the per-miler concurrent-stop cap, read per call so
|
||||
// it can be changed without a redeploy. A non-numeric or non-positive value
|
||||
// falls back to the default rather than uncapping the fleet by typo.
|
||||
func maxActiveBookings() int64 {
|
||||
if v := strings.TrimSpace(os.Getenv("MILER_MAX_ACTIVE_BOOKINGS")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
return int64(n)
|
||||
}
|
||||
utils.Warn("MILER_MAX_ACTIVE_BOOKINGS is not a positive integer, using the default",
|
||||
"value", v, "default", defaultMaxActive)
|
||||
}
|
||||
return defaultMaxActive
|
||||
}
|
||||
|
||||
type milerCandidate struct {
|
||||
profile models.MilerProfile
|
||||
distanceKm float64
|
||||
|
||||
32
internal/assignment/maxactive_test.go
Normal file
32
internal/assignment/maxactive_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package assignment
|
||||
|
||||
import "testing"
|
||||
|
||||
// The per-miler concurrent-stop cap.
|
||||
//
|
||||
// This is the knob that now decides how much one rider carries, because
|
||||
// eligibility no longer stops at the first order. It is read per call so it
|
||||
// can be changed without a redeploy — and a typo must never uncap the fleet.
|
||||
func TestMaxActiveBookings(t *testing.T) {
|
||||
cases := []struct {
|
||||
env string
|
||||
want int64
|
||||
why string
|
||||
}{
|
||||
{"", defaultMaxActive, "unset falls back to the default"},
|
||||
{"10", 10, "a plain number is honoured"},
|
||||
{" 8 ", 8, "surrounding whitespace is tolerated"},
|
||||
{"1", 1, "one stop at a time is a legitimate policy"},
|
||||
{"0", defaultMaxActive, "zero would assign to nobody — treated as unset, not as a cap"},
|
||||
{"-4", defaultMaxActive, "negative is meaningless here"},
|
||||
{"lots", defaultMaxActive, "a typo must not uncap the fleet"},
|
||||
{"3.5", defaultMaxActive, "not an integer"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Setenv("MILER_MAX_ACTIVE_BOOKINGS", tc.env)
|
||||
if got := maxActiveBookings(); got != tc.want {
|
||||
t.Errorf("MILER_MAX_ACTIVE_BOOKINGS=%q gave %d, want %d (%s)", tc.env, got, tc.want, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
180
scratch/fix_miler_hubs.go
Normal file
180
scratch/fix_miler_hubs.go
Normal file
@@ -0,0 +1,180 @@
|
||||
//go:build ignore
|
||||
|
||||
// Give every Available miler the hub it plainly belongs to.
|
||||
//
|
||||
// Why this is needed: assignment everywhere filters
|
||||
// `hubid = ? AND availabilitystatus = 'Available'`, and that intersection was
|
||||
// empty — all 15 Available milers had hubid NULL, while all 15 milers that
|
||||
// had a hub were Offline or already busy. Two seed runs populated disjoint
|
||||
// sets, so no rider was reachable by HubBatchAssign, HubAutoAssign, or the
|
||||
// auto-assign retry loop.
|
||||
//
|
||||
// The hub is not guessed. Each miler is matched to the NEAREST ACTIVE HUB
|
||||
// WITHIN ITS OWN applocationid — a rider is never handed a hub in another
|
||||
// city, and within a city the closest one wins. The seeded GPS sits almost
|
||||
// exactly on a hub in every case, so this reproduces the intended pairing
|
||||
// rather than inventing one.
|
||||
//
|
||||
// Run: go run scratch/fix_miler_hubs.go (plan only, writes nothing)
|
||||
// go run scratch/fix_miler_hubs.go --apply (writes, in one transaction)
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"doormile/config"
|
||||
"doormile/db"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type hub struct {
|
||||
Hubid int
|
||||
Hubname string
|
||||
Applocationid int
|
||||
Latitude float64
|
||||
Longitude float64
|
||||
}
|
||||
|
||||
type miler struct {
|
||||
Userid int
|
||||
Displayname string
|
||||
Applocationid int
|
||||
Currentlatitude float64
|
||||
Currentlongitude float64
|
||||
}
|
||||
|
||||
func haversineKM(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
const R = 6371
|
||||
toRad := func(d float64) float64 { return d * math.Pi / 180 }
|
||||
dLat, dLon := toRad(lat2-lat1), 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 R * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
}
|
||||
|
||||
func main() {
|
||||
apply := false
|
||||
for _, a := range os.Args[1:] {
|
||||
if a == "--apply" {
|
||||
apply = true
|
||||
}
|
||||
}
|
||||
|
||||
_ = godotenv.Load()
|
||||
db.Connect(config.Load())
|
||||
if db.DB == nil {
|
||||
fmt.Println("no database connection")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var hubs []hub
|
||||
db.DB.Raw(`SELECT hubid, COALESCE(hubname,'') AS hubname,
|
||||
COALESCE(applocationid,0) AS applocationid,
|
||||
COALESCE(latitude,0) AS latitude, COALESCE(longitude,0) AS longitude
|
||||
FROM hubs
|
||||
WHERE deletedat IS NULL AND status = 'Active'
|
||||
AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
AND latitude <> 0 AND longitude <> 0`).Scan(&hubs)
|
||||
|
||||
var milers []miler
|
||||
db.DB.Raw(`SELECT userid, COALESCE(displayname,'') AS displayname,
|
||||
COALESCE(applocationid,0) AS applocationid,
|
||||
COALESCE(currentlatitude,0) AS currentlatitude,
|
||||
COALESCE(currentlongitude,0) AS currentlongitude
|
||||
FROM milerprofiles
|
||||
WHERE availabilitystatus = 'Available' AND hubid IS NULL
|
||||
ORDER BY applocationid, userid`).Scan(&milers)
|
||||
|
||||
fmt.Printf("candidate hubs: %d milers to fix: %d\n\n", len(hubs), len(milers))
|
||||
|
||||
type change struct {
|
||||
userid int
|
||||
name string
|
||||
hubid int
|
||||
hubname string
|
||||
km float64
|
||||
}
|
||||
var changes []change
|
||||
var skipped []string
|
||||
|
||||
for _, m := range milers {
|
||||
best := -1
|
||||
bestKM := math.MaxFloat64
|
||||
for _, h := range hubs {
|
||||
// Never cross a city boundary, whatever the distance says.
|
||||
if h.Applocationid != m.Applocationid {
|
||||
continue
|
||||
}
|
||||
d := haversineKM(m.Currentlatitude, m.Currentlongitude, h.Latitude, h.Longitude)
|
||||
if d < bestKM {
|
||||
bestKM, best = d, h.Hubid
|
||||
}
|
||||
}
|
||||
if best == -1 {
|
||||
skipped = append(skipped, fmt.Sprintf("user=%d %s (no active hub at applocationid=%d)",
|
||||
m.Userid, m.Displayname, m.Applocationid))
|
||||
continue
|
||||
}
|
||||
name := ""
|
||||
for _, h := range hubs {
|
||||
if h.Hubid == best {
|
||||
name = h.Hubname
|
||||
}
|
||||
}
|
||||
changes = append(changes, change{m.Userid, m.Displayname, best, name, bestKM})
|
||||
}
|
||||
|
||||
fmt.Println("PLAN — nearest active hub in the miler's own city:")
|
||||
for _, c := range changes {
|
||||
fmt.Printf(" user=%-5d %-28s -> hub %-4d %-30s (%.2f km)\n",
|
||||
c.userid, c.name, c.hubid, c.hubname, c.km)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
fmt.Println("\nSKIPPED (left untouched):")
|
||||
for _, s := range skipped {
|
||||
fmt.Println(" " + s)
|
||||
}
|
||||
}
|
||||
|
||||
// Rollback is trivial and worth printing either way: every row being
|
||||
// written currently holds NULL, so undoing this is one statement.
|
||||
ids := make([]string, 0, len(changes))
|
||||
for _, c := range changes {
|
||||
ids = append(ids, fmt.Sprint(c.userid))
|
||||
}
|
||||
fmt.Printf("\nROLLBACK:\n UPDATE milerprofiles SET hubid = NULL WHERE userid IN (%s);\n",
|
||||
strings.Join(ids, ","))
|
||||
|
||||
if !apply {
|
||||
fmt.Println("\n(plan only — nothing written. Re-run with --apply to commit.)")
|
||||
return
|
||||
}
|
||||
|
||||
tx := db.DB.Begin()
|
||||
for _, c := range changes {
|
||||
// The hubid IS NULL guard makes this idempotent and means a concurrent
|
||||
// write cannot be clobbered: if someone set a hub in the meantime,
|
||||
// theirs stands.
|
||||
res := tx.Exec(`UPDATE milerprofiles SET hubid = ?, updatedat = NOW()
|
||||
WHERE userid = ? AND hubid IS NULL`, c.hubid, c.userid)
|
||||
if res.Error != nil {
|
||||
tx.Rollback()
|
||||
fmt.Println("FAILED, rolled back:", res.Error)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
fmt.Println("commit failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("\nAPPLIED: %d milers updated.\n", len(changes))
|
||||
|
||||
var eligible int64
|
||||
db.DB.Raw(`SELECT COUNT(*) FROM milerprofiles
|
||||
WHERE availabilitystatus = 'Available' AND hubid IS NOT NULL`).Scan(&eligible)
|
||||
fmt.Println("Available AND hubid set is now:", eligible)
|
||||
}
|
||||
Reference in New Issue
Block a user