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

145 lines
5.4 KiB
Go

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
}