Files
backend_fiesta/services/tools/stuckorders.go
2026-09-23 17:26:13 +05:30

248 lines
8.5 KiB
Go

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"
}