package tools import ( "context" "fmt" "sort" "strings" "time" "nearle/models" ) // "Which orders are stuck?" — the first tool, and a real one. // // A delivery that is still `pending` some time after it was handed out is a job // nobody has picked up. The rider may not have seen it, may have no device, may // have put the phone down. The console cannot tell which, and does not need to: // the wait itself is the fact worth surfacing, and every one of these is a // customer waiting without knowing why. // // ── Derived, never remembered ─────────────────────────────────────────────── // // Nothing stores "this job went unaccepted". It is computed from `assigntime` // and `orderstatus`, both of which every delivery read already returns, so the // answer does not depend on anybody having been watching when it happened. // // ── Why the two thresholds ────────────────────────────────────────────────── // // Ten minutes is worth a look; twenty-five needs somebody now. A single // threshold either cries wolf at three minutes — which trains people to ignore // it, and an ignored flag is worse than none — or stays silent until the // customer has already called. const ( // StuckLookMinutes is when a wait becomes worth a glance. StuckLookMinutes = 10 // StuckNowMinutes is when it needs a person. StuckNowMinutes = 25 // stuckMaxRows caps one answer. A model handed four hundred rows summarises // them into a sentence nobody can check; a dispatcher can act on ten. stuckMaxRows = 50 ) // DeliveryReader is the one thing this tool needs from the rest of the app. // // Narrowed to a single method so the tool can be tested without a database, and // so it cannot quietly grow a second dependency. The real implementation is // `services.DeliveriesService`. type DeliveryReader interface { GetDeliveries(input models.DeliveryQuery) []models.Deliveryinfo } // StuckOrder is one row of the answer. // // Field names are what the model will read back to a person, so they say what // they mean: `WaitingMinutes`, not `delta`. type StuckOrder struct { Deliveryid int `json:"deliveryid"` Orderid string `json:"orderid"` Rider string `json:"rider,omitempty"` Branch string `json:"branch,omitempty"` Customer string `json:"customer,omitempty"` AssignedAt string `json:"assigned_at"` WaitingMinutes int `json:"waiting_minutes"` // "look" or "now" — which bucket the wait falls in. Returned rather than // left to the model to work out from the number, so the threshold is // decided in one place and cannot be re-invented in a sentence. Urgency string `json:"urgency"` Action string `json:"action"` } // StuckOrders builds the tool. // // `now` is injected so the tests can ask what the board looked like at a fixed // instant. Production passes `time.Now`. func StuckOrders(deliveries DeliveryReader, now func() time.Time) Tool { if now == nil { now = time.Now } return Tool{ Name: "stuck_orders", Description: "Deliveries a rider has been given but has not accepted yet, oldest first. " + "Use for questions about jobs that are stuck, not moving, unaccepted, or riders who have not started. " + "Returns the wait in minutes and what to do about each one.", Scope: ScopeRead, Schema: Schema{Fields: []Field{{ Name: "minutes_waiting", Description: "Only count jobs unaccepted for at least this many minutes. Defaults to 10.", Kind: KindInt, Min: 1, Max: 720, Default: StuckLookMinutes, }}}, Handler: func(_ context.Context, req Request) (Result, error) { threshold := req.Int("minutes_waiting") if threshold <= 0 { threshold = StuckLookMinutes } rows := deliveries.GetDeliveries(models.DeliveryQuery{ Tenantid: req.Caller.Tenantid, // The caller's own branch when they have one. A branch user asking // "what is stuck?" means their shop; an admin with no home branch // means all of them. Locationid: req.Caller.Locationid, Pagesize: 500, Pageno: 1, }) at := now() stuck := make([]StuckOrder, 0, 8) for _, row := range rows { waited, ok := unacceptedFor(row, at) if !ok || waited < time.Duration(threshold)*time.Minute { continue } minutes := int(waited.Minutes()) urgency, action := "look", "Check the rider has seen it." if minutes >= StuckNowMinutes { urgency, action = "now", "Call the rider, or give the job to somebody else." } stuck = append(stuck, StuckOrder{ Deliveryid: row.Deliveryid, Orderid: row.Orderid, // `ridername` is not reliably a name — on tenant 916 every // rider has delivery statuses in that column too, and for two // of five the status is the MORE common value. Excluded by // vocabulary rather than by a hand-written list. Rider: riderName(row.Ridername), Branch: row.Locationname, Customer: row.Deliverycustomer, AssignedAt: row.Assigntime, WaitingMinutes: minutes, Urgency: urgency, Action: action, }) } // Worst first, then longest waiting. This is a worklist, not a log: // the row to deal with next belongs at the top. sort.SliceStable(stuck, func(i, j int) bool { if stuck[i].Urgency != stuck[j].Urgency { return stuck[i].Urgency == "now" } return stuck[i].WaitingMinutes > stuck[j].WaitingMinutes }) result := Result{ Count: len(stuck), Source: "/admin/dispatch", Scope: scopeWords(req.Caller), } if len(stuck) > stuckMaxRows { result.Truncated = true result.Note = fmt.Sprintf( "%d jobs are waiting; the %d longest are listed. Say so — do not describe this as the full list.", len(stuck), stuckMaxRows) stuck = stuck[:stuckMaxRows] } result.Rows = stuck return result, nil }, } } // unacceptedFor is how long a job has sat with nobody accepting it. // // Only `pending` counts. A job the rider accepted, picked up or delivered is // not stuck however old it is, and `rejected` or `skipped` is a different // problem with a different answer — those are somebody's to reassign, not to // chase. // // A stamp that will not parse returns false rather than 1970. Reading an // unparseable `assigntime` as the epoch would report every such row as fifty // years late, which is the kind of number that gets a whole screen ignored. func unacceptedFor(row models.Deliveryinfo, now time.Time) (time.Duration, bool) { if strings.ToLower(strings.TrimSpace(row.Orderstatus)) != "pending" { return 0, false } assigned, ok := parseStamp(row.Assigntime) if !ok { return 0, false } waited := now.Sub(assigned) if waited < 0 { // A clock ahead of ours, not a job from the future. return 0, false } return waited, true } // parseStamp reads `assigntime` as the writer actually writes it. // // Local wall-clock with no zone — `2026-09-23 14:05:31` — which is what // `stampNow` produces. Parsed in the server's own location rather than UTC, // because reading local digits as UTC would put every job five and a half hours // out in India and turn a fresh assignment into a four-hour wait. func parseStamp(raw string) (time.Time, bool) { text := strings.TrimSpace(raw) if text == "" { return time.Time{}, false } for _, layout := range []string{ "2006-01-02 15:04:05", "2006-01-02T15:04:05", time.RFC3339, } { if at, err := time.ParseInLocation(layout, text, time.Local); err == nil { return at, true } } return time.Time{}, false } // riderName keeps a name and drops a status wearing one. // // `deliveries.ridername` carries both. Measured on tenant 916: rider 897 has // "Varun" 69 times and "delivered" 75, so neither "first non-empty" nor "most // common" finds the name. The statuses are excluded by vocabulary, so a status // added to the ladder is excluded the same day. func riderName(raw string) string { name := strings.ToLower(strings.TrimSpace(raw)) if name == "" { return "" } for _, status := range []string{ "pending", "accepted", "arrived", "picked", "active", "skipped", "rejected", "delivered", "cancelled", "waiting", } { if name == status { return "" } } return strings.TrimSpace(raw) } // scopeWords says what the answer covers, in words a person would use. func scopeWords(caller Caller) string { if caller.Locationid > 0 { return "this branch" } return "all branches" }