248 lines
8.5 KiB
Go
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"
|
|
}
|