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

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