package tools import ( "context" "fmt" "sort" "nearle/models" ) // "Which branch is underperforming?" and "Why is the cancel rate high?" // // One tool, two questions, because they are answered from the same row. A // branch is judged on what it completes and what it loses, and splitting that // into two tools would let a model answer one of them without ever seeing the // other half of the picture. // // ── Why this reads orderstatus and not deliverystatus ─────────────────────── // // `orders.deliverystatus` is an empty string on all 181 rows of tenant 1147, so // a cancel rate computed from it is zero everywhere, forever, with no error. // `orderstatus` carries `delivered` and `cancelled` correctly — those are two of // the three statuses the backend does mirror onto the order — so the totals here // are sound even though the middle of the delivery journey never arrives. // BranchReader is the one thing this tool needs. type BranchReader interface { GetLocationOrderSummary(tenantID int) ([]models.Ordersummarylocation, error) } // BranchRow is one branch, with the arithmetic already done. // // `CancelRate` is computed here rather than left to the model. A model asked to // divide two numbers in a sentence will usually get it right and will sometimes // not, and there is no way to tell which from the answer — so the number it // reads out is one this code produced. type BranchRow struct { Locationid int `json:"locationid"` Branch string `json:"branch"` Orders int `json:"orders"` Delivered int `json:"delivered"` Cancelled int `json:"cancelled"` Outstanding int `json:"outstanding"` CancelRate float64 `json:"cancel_rate_percent"` DeliveryRate float64 `json:"delivered_percent"` // Set only when this branch stands out against the others, and says why in // words. Absent on a branch that is simply ordinary — a flag on every row // is a flag on none. Note string `json:"note,omitempty"` } // standoutCancelRate is how far above the tenant's own average a branch has to // sit before it is worth naming. // // Relative, not absolute. A 9% cancel rate is poor in a business averaging 3% // and unremarkable in one averaging 11%, and a fixed threshold would either // flag every branch of the second or none of the first. const standoutCancelRate = 1.5 // BranchPerformance builds the tool. func BranchPerformance(branches BranchReader) Tool { return Tool{ Name: "branch_performance", Description: "Orders, deliveries and cancellations for each branch, with the cancel rate worked out. " + "Use for questions about which branch is doing badly or well, comparing branches, or why cancellations are high. " + "Names the branches that stand out against this business's own average.", Scope: ScopeRead, Schema: Schema{}, Handler: func(_ context.Context, req Request) (Result, error) { summary, err := branches.GetLocationOrderSummary(req.Caller.Tenantid) if err != nil { return Result{}, err } rows := make([]BranchRow, 0, len(summary)) totalOrders, totalCancelled := 0, 0 for _, branch := range summary { // A branch that has never taken an order has no rate. Reporting // 0% would read as "nothing is cancelled here", which is a claim // about performance rather than the absence of any. if branch.Total <= 0 { rows = append(rows, BranchRow{ Locationid: branch.Locationid, Branch: branch.Locationname, Note: "No orders yet, so there is no rate to report.", }) continue } totalOrders += branch.Total totalCancelled += branch.Cancelled rows = append(rows, BranchRow{ Locationid: branch.Locationid, Branch: branch.Locationname, Orders: branch.Total, Delivered: branch.Delivered, Cancelled: branch.Cancelled, Outstanding: branch.Total - branch.Delivered - branch.Cancelled, CancelRate: percent(branch.Cancelled, branch.Total), // Named `delivered_percent` rather than "success": an order // still in progress is not a failure, and calling the // remainder failure would make every busy hour look bad. DeliveryRate: percent(branch.Delivered, branch.Total), }) } average := percent(totalCancelled, totalOrders) for i := range rows { if rows[i].Orders > 0 && average > 0 && rows[i].CancelRate >= average*standoutCancelRate { rows[i].Note = fmt.Sprintf( "Cancels %.1f%% against %.1f%% across the business — worth looking at.", rows[i].CancelRate, average) } } // Worst first: this is a question about what needs attention, and // the branch that needs it belongs at the top. sort.SliceStable(rows, func(i, j int) bool { return rows[i].CancelRate > rows[j].CancelRate }) return Result{ Rows: rows, Count: len(rows), Source: "/admin/reports", Scope: "all branches", Note: fmt.Sprintf( "Across the business: %d orders, %d cancelled, %.1f%%. Compare a branch against that figure, not against zero.", totalOrders, totalCancelled, average), }, nil }, } } // percent is one place, so every rate in every tool rounds the same way. // // Guards the zero denominator rather than leaving it to produce NaN, which // serialises as `null` and reads to a model as "no data" instead of "no orders". func percent(part, whole int) float64 { if whole <= 0 { return 0 } return float64(int((float64(part)/float64(whole)*100)*10+0.5)) / 10 }