273 lines
8.9 KiB
Go
273 lines
8.9 KiB
Go
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 ""
|
|
}
|