agent
This commit is contained in:
272
services/tools/shopfloor.go
Normal file
272
services/tools/shopfloor.go
Normal file
@@ -0,0 +1,272 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"nearle/models"
|
||||
)
|
||||
|
||||
// The three Console questions: what is waiting on me, where is stock running
|
||||
// out, and are the tills talking to us.
|
||||
//
|
||||
// Grouped because they are one question in three parts — "what needs me today?"
|
||||
// — and because each is a thin read over a service that already exists.
|
||||
|
||||
/* ── What needs my approval? ───────────────────────────────────────────── */
|
||||
|
||||
// ApprovalReader is the stock-request read.
|
||||
type ApprovalReader interface {
|
||||
GetStockRequests(tenantID int, locationID int, status string, date string, pageNo int, pageSize int) ([]models.StockRequest, error)
|
||||
}
|
||||
|
||||
// PendingApproval is one request waiting on somebody.
|
||||
type PendingApproval struct {
|
||||
Requestid int `json:"requestid"`
|
||||
Product string `json:"product"`
|
||||
Branch string `json:"branch"`
|
||||
Quantity int `json:"quantity"`
|
||||
Requested string `json:"requested"`
|
||||
// Days the request has been sitting. The number that decides whether this
|
||||
// is routine or somebody's shelf has been empty for a week.
|
||||
WaitingDays int `json:"waiting_days"`
|
||||
}
|
||||
|
||||
// PendingApprovals builds the tool.
|
||||
func PendingApprovals(requests ApprovalReader, now func() time.Time) Tool {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return Tool{
|
||||
Name: "pending_approvals",
|
||||
Description: "Stock requests from branches that are waiting for approval, longest wait first. " +
|
||||
"Use for questions about what needs approving, what is waiting on the owner, or requests from branches.",
|
||||
Scope: ScopeRead,
|
||||
Schema: Schema{},
|
||||
Handler: func(_ context.Context, req Request) (Result, error) {
|
||||
// "Pending" capitalised, because that is the column's default and
|
||||
// what every row carries. The repository matches it as given.
|
||||
rows, err := requests.GetStockRequests(req.Caller.Tenantid, req.Caller.Locationid, "Pending", "", 1, 200)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
at := now()
|
||||
out := make([]PendingApproval, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
waiting := 0
|
||||
if !row.Created.IsZero() {
|
||||
waiting = int(at.Sub(row.Created).Hours() / 24)
|
||||
if waiting < 0 {
|
||||
waiting = 0
|
||||
}
|
||||
}
|
||||
out = append(out, PendingApproval{
|
||||
Requestid: row.Requestid,
|
||||
Product: row.Productname,
|
||||
Branch: row.Locationname,
|
||||
Quantity: row.Qty,
|
||||
Requested: row.Created.Format("2006-01-02"),
|
||||
WaitingDays: waiting,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].WaitingDays > out[j].WaitingDays })
|
||||
|
||||
return Result{
|
||||
Rows: out,
|
||||
Count: len(out),
|
||||
Source: "/admin/inventory",
|
||||
Scope: scopeWords(req.Caller),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Where is stock running out? ───────────────────────────────────────── */
|
||||
|
||||
// StockReader is the per-branch stock read.
|
||||
type StockReader interface {
|
||||
GetProductStocks(tenantID, locationID string) ([]models.Productstocks, error)
|
||||
}
|
||||
|
||||
// LowStockLine is one product about to run out, or already out.
|
||||
type LowStockLine struct {
|
||||
Productid int `json:"productid"`
|
||||
Product string `json:"product"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
// "out" or "low". Returned rather than left to the model to infer from the
|
||||
// number, so the threshold is decided once and cannot be re-invented in a
|
||||
// sentence.
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
// lowStockDefault is when a shelf is worth mentioning.
|
||||
//
|
||||
// Five, and it is a guess rather than a measurement — nothing in this backend
|
||||
// records a reorder level per product. Named and adjustable rather than buried,
|
||||
// because the right number differs between a pharmacy and a grocer, and the
|
||||
// person asking knows which they are.
|
||||
const lowStockDefault = 5
|
||||
|
||||
// LowStock builds the tool.
|
||||
func LowStock(stocks StockReader) Tool {
|
||||
return Tool{
|
||||
Name: "low_stock",
|
||||
Description: "Products that have run out or are nearly out, lowest first. " +
|
||||
"Use for questions about stock running low, empty shelves, or what needs reordering.",
|
||||
Scope: ScopeRead,
|
||||
Schema: Schema{Fields: []Field{{
|
||||
Name: "at_or_below",
|
||||
Description: "Count a product as low at this quantity or less. Defaults to 5.",
|
||||
Kind: KindInt,
|
||||
Min: 0,
|
||||
Max: 10000,
|
||||
Default: lowStockDefault,
|
||||
}}},
|
||||
Handler: func(_ context.Context, req Request) (Result, error) {
|
||||
threshold := req.Int("at_or_below")
|
||||
branch := ""
|
||||
if req.Caller.Locationid > 0 {
|
||||
branch = strconv.Itoa(req.Caller.Locationid)
|
||||
}
|
||||
|
||||
rows, err := stocks.GetProductStocks(strconv.Itoa(req.Caller.Tenantid), branch)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
out := make([]LowStockLine, 0, 16)
|
||||
for _, row := range rows {
|
||||
if row.Quantity > threshold {
|
||||
continue
|
||||
}
|
||||
state := "low"
|
||||
if row.Quantity <= 0 {
|
||||
state = "out"
|
||||
}
|
||||
out = append(out, LowStockLine{
|
||||
Productid: row.Productid,
|
||||
Product: row.Productname,
|
||||
Quantity: row.Quantity,
|
||||
State: state,
|
||||
})
|
||||
}
|
||||
|
||||
// Emptiest first, then by name so two products on the same count do
|
||||
// not swap places between refetches.
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].Quantity != out[j].Quantity {
|
||||
return out[i].Quantity < out[j].Quantity
|
||||
}
|
||||
return out[i].Product < out[j].Product
|
||||
})
|
||||
|
||||
result := Result{Count: len(out), Source: "/admin/inventory", Scope: scopeWords(req.Caller)}
|
||||
if len(out) > stuckMaxRows {
|
||||
result.Truncated = true
|
||||
result.Note = fmt.Sprintf(
|
||||
"%d products are at or below %d; the %d emptiest are listed. Say so — this is not the full list.",
|
||||
len(out), threshold, stuckMaxRows)
|
||||
out = out[:stuckMaxRows]
|
||||
}
|
||||
result.Rows = out
|
||||
return result, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Any tills not syncing? ────────────────────────────────────────────── */
|
||||
|
||||
// TillReader is the terminal presence read.
|
||||
//
|
||||
// Presence lives in Redis under a TTL, so a till that loses power ages out of
|
||||
// the board by itself rather than leaving a row claiming it is online. That
|
||||
// means an ABSENT terminal is the signal, and this tool has to say so — a list
|
||||
// of the tills that are fine answers the opposite of the question asked.
|
||||
type TillReader interface {
|
||||
LocationHealth(ctx context.Context, locationID string) ([]map[string]string, error)
|
||||
}
|
||||
|
||||
// TillStatus is one terminal.
|
||||
type TillStatus struct {
|
||||
Terminal string `json:"terminal"`
|
||||
State string `json:"state"`
|
||||
LastSeen string `json:"last_seen,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// TillsNotSyncing builds the tool.
|
||||
func TillsNotSyncing(tills TillReader) Tool {
|
||||
return Tool{
|
||||
Name: "till_status",
|
||||
Description: "Which in-store terminals are reporting in and which have gone quiet. " +
|
||||
"Use for questions about tills, terminals, POS machines, or sales not syncing from a shop.",
|
||||
Needs: RequiresBranch,
|
||||
Scope: ScopeRead,
|
||||
Schema: Schema{},
|
||||
Handler: func(ctx context.Context, req Request) (Result, error) {
|
||||
rows, err := tills.LocationHealth(ctx, strconv.Itoa(req.Caller.Locationid))
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
out := make([]TillStatus, 0, len(rows))
|
||||
quiet := 0
|
||||
for _, row := range rows {
|
||||
status := TillStatus{
|
||||
Terminal: firstOf(row, "terminalid", "terminal_id", "terminal"),
|
||||
State: strings.ToLower(firstOf(row, "state", "status")),
|
||||
LastSeen: firstOf(row, "lastseen", "last_seen", "updated"),
|
||||
Detail: firstOf(row, "message", "detail"),
|
||||
}
|
||||
if status.State == "" {
|
||||
// A heartbeat that expired leaves no state behind. Absence
|
||||
// is the fact, so it is named rather than left blank.
|
||||
status.State = "no heartbeat"
|
||||
}
|
||||
if status.State != "online" && status.State != "ok" {
|
||||
quiet++
|
||||
}
|
||||
out = append(out, status)
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Terminal < out[j].Terminal })
|
||||
|
||||
note := "Every till at this branch is reporting in."
|
||||
if quiet > 0 {
|
||||
note = fmt.Sprintf("%d of %d tills are not reporting in. A till that is switched off looks the same as one that cannot reach us.", quiet, len(out))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
note = "No terminals have reported from this branch at all. That is either a shop with no till, or a till that has never connected."
|
||||
}
|
||||
|
||||
return Result{
|
||||
Rows: out,
|
||||
Count: quiet,
|
||||
Source: "/admin/console",
|
||||
Scope: "this branch",
|
||||
Note: note,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// firstOf reads whichever key this row happens to use.
|
||||
//
|
||||
// Presence rows are built from a Redis hash rather than a struct, so the
|
||||
// spellings are whatever the writer used. Reading one name and finding nothing
|
||||
// would report every till as having no heartbeat.
|
||||
func firstOf(row map[string]string, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(row[key]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user