Sales you can read, not only sum, and a home screen in one call
Three routes over data the server already stores.
GET /api/sales and /api/sales/{id}. The purchases table has existed
since the conversion report did, and nothing could read a row of it - so
"revenue was 41,000 last week" was a number that could not be checked
against a till. The list carries the customer reference the product
actually shows people (V-42) beside the uuid, and a sale with NO
customer is listed rather than joined away: an unidentified walk-in is
still revenue, and an inner join would make this disagree with the
conversion report computed over the same rows.
No cursor, deliberately. A keyset cursor needs a monotonic
server-assigned column and purchases has none; ordering by
(occurred_at, id) with a random uuid tie-break is exactly the shape that
silently dropped four of six simultaneous visits from the arrivals feed
before visits.seq existed. Offering one here would imply a delivery
guarantee this table cannot make, so the list is bounded by the date
window and a limit - which is how a sales list is browsed anyway.
GET /api/dashboard/summary. Four calls a client had to make and then
combine, which is how the desktop Footfall screen once produced its
headline by adding the daily bars up: silently too high, because a
customer who came twice is one person and two bucket-visitors. The
combining happens here, against Footfall and SiteHealth rather than new
SQL - a second definition of "unique visitor" or of "online" drifts, and
a home screen that disagrees with the report it links to is the one
nobody trusts afterwards. fraction_below_gate travels with the count for
the same reason it does everywhere else: it is what says whether the
headcount is a number or a floor.
Today is cut in the shop's timezone. In the one market this ships to,
UTC is five and a half hours wrong.
An unknown shop filter is a 400, not an ignored parameter. This API has
already been bitten once by a silently ignored filter handing back the
whole estate, which is a wrong number nobody would question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
131
server/internal/api/handlers_sales.go
Normal file
131
server/internal/api/handlers_sales.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Sales a person can read, and the merchant home screen.
|
||||
//
|
||||
// Both are reads over data the server already holds. Nothing here computes a
|
||||
// number a report does not already compute: where a figure exists behind
|
||||
// /api/reports it is asked for rather than re-derived, because two definitions
|
||||
// of "unique visitor" or of "online" drift, and the screen that disagrees with
|
||||
// the report it links to is the one nobody trusts afterwards.
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Server) handleSales(w http.ResponseWriter, r *http.Request) {
|
||||
// Same window, same site parameter, same parsing as every report. `site`
|
||||
// and `site_id` are both accepted, and an unknown one is a 400 rather than
|
||||
// being silently ignored - an ignored filter returns the whole estate,
|
||||
// which is a wrong number nobody would question.
|
||||
rq, err := s.reportQuery(r)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
q := SaleQuery{
|
||||
ClientID: rq.ClientID, SiteID: rq.SiteID,
|
||||
From: rq.From, To: rq.To,
|
||||
Limit: queryInt(r, "limit", 50, 200),
|
||||
}
|
||||
if raw := trim(r.URL.Query().Get("customer")); raw != "" {
|
||||
// A customer may be named by uuid or by "V-42", the reference the
|
||||
// product actually shows people.
|
||||
id, err := s.visitorIDFor(r.Context(), rq.ClientID, raw)
|
||||
if err != nil {
|
||||
s.serverError(w, "resolve customer", err)
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
badRequest(w, "no customer called "+raw)
|
||||
return
|
||||
}
|
||||
q.VisitorID = id
|
||||
}
|
||||
|
||||
rows, err := s.Store.Sales(r.Context(), q)
|
||||
if err != nil {
|
||||
s.serverError(w, "sales", err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []Sale{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
func (s *Server) handleSale(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
sale, err := s.Store.Sale(r.Context(), p.ClientID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
s.serverError(w, "sale", err)
|
||||
return
|
||||
}
|
||||
if sale.ID == "" {
|
||||
// Another tenant's sale reads as absent, never as forbidden.
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such sale.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sale)
|
||||
}
|
||||
|
||||
// handleDashboard is the merchant home screen in one request.
|
||||
//
|
||||
// It existed as four calls a client had to make and then combine, which is how
|
||||
// the desktop Footfall screen once computed its headline by adding the daily
|
||||
// bars up - silently too high, because a customer who came twice is one person
|
||||
// and two bucket-visitors. The combining happens here, against the same
|
||||
// functions the reports use.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
rq, err := s.reportQuery(r)
|
||||
if err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
// Today, in the shop's own timezone. A dashboard that says "today" and
|
||||
// means UTC is wrong by five and a half hours in the one market this
|
||||
// currently ships to.
|
||||
loc, lerr := time.LoadLocation(rq.Timezone)
|
||||
if lerr != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := s.now().In(loc)
|
||||
rq.From = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
rq.To = rq.From.AddDate(0, 0, 1)
|
||||
rq.Bucket = "day"
|
||||
|
||||
_, totals, err := s.Store.Footfall(r.Context(), rq)
|
||||
if err != nil {
|
||||
s.serverError(w, "dashboard footfall", err)
|
||||
return
|
||||
}
|
||||
sites, err := s.Store.SiteHealth(r.Context(), rq.ClientID)
|
||||
if err != nil {
|
||||
s.serverError(w, "dashboard sites", err)
|
||||
return
|
||||
}
|
||||
|
||||
out := DashboardSummary{
|
||||
Date: rq.From.Format("2006-01-02"),
|
||||
Visitors: totals.UniqueVisitors,
|
||||
Visits: totals.Visits,
|
||||
// Carried from the report rather than recomputed: the share of faces
|
||||
// too poor to enrol is what says whether the count above is a number
|
||||
// or a floor, and it has to travel with it.
|
||||
FractionBelowGate: totals.FractionBelowGate,
|
||||
WorstSite: totals.WorstSite,
|
||||
Timezone: rq.Timezone,
|
||||
}
|
||||
for _, site := range sites {
|
||||
// One shop asked for narrows the tally to it; otherwise the estate.
|
||||
if rq.SiteID != "" && site.SiteID != rq.SiteID {
|
||||
continue
|
||||
}
|
||||
out.SitesTotal++
|
||||
if site.Online {
|
||||
out.SitesOnline++
|
||||
}
|
||||
out.CamerasTotal += site.CamerasTotal
|
||||
out.CamerasUp += site.CamerasUp
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
Reference in New Issue
Block a user