145 lines
5.4 KiB
Go
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
|
|
}
|