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