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:
116
server/internal/store/api_sales.go
Normal file
116
server/internal/store/api_sales.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Reading individual sales.
|
||||
//
|
||||
// The conversion report has aggregated `purchases` since it existed and
|
||||
// nothing could read a row of it, so "revenue was 41,000 last week" could not
|
||||
// be checked against a till. These are the reads that make that number
|
||||
// falsifiable from outside.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/loyaly/behavision-server/internal/api"
|
||||
)
|
||||
|
||||
const saleCols = `
|
||||
p.id::text, p.occurred_at, p.site_id::text, si.name, si.slug,
|
||||
p.amount, p.currency, p.visitor_id::text, v.number, v.label,
|
||||
p.visit_id::text, p.items, p.source, p.external_ref`
|
||||
|
||||
func scanSale(row pgx.Row) (api.Sale, error) {
|
||||
var s api.Sale
|
||||
var at time.Time
|
||||
// LEFT JOINed: a sale with no visitor is an ordinary walk-in nobody
|
||||
// identified, and it is still revenue.
|
||||
var visitorID, visitorLabel, visitID *string
|
||||
var number *int64
|
||||
var items []byte
|
||||
if err := row.Scan(&s.ID, &at, &s.SiteID, &s.Site, &s.SiteSlug,
|
||||
&s.Amount, &s.Currency, &visitorID, &number, &visitorLabel,
|
||||
&visitID, &items, &s.Source, &s.ExternalRef); err != nil {
|
||||
return api.Sale{}, err
|
||||
}
|
||||
s.OccurredAt = at.UTC().Format(time.RFC3339)
|
||||
if visitorID != nil {
|
||||
s.VisitorID = *visitorID
|
||||
}
|
||||
if visitorLabel != nil {
|
||||
s.VisitorLabel = *visitorLabel
|
||||
}
|
||||
if number != nil {
|
||||
s.VisitorRef = api.VisitorRef(*number)
|
||||
}
|
||||
if visitID != nil {
|
||||
s.VisitID = *visitID
|
||||
}
|
||||
// items is jsonb defaulting to '[]', but a null column would otherwise
|
||||
// unmarshal into a nil slice and serialise as null - and a client mapping
|
||||
// over it breaks on the first sale recorded without a basket.
|
||||
s.Items = []string{}
|
||||
if len(items) > 0 {
|
||||
_ = json.Unmarshal(items, &s.Items)
|
||||
if s.Items == nil {
|
||||
s.Items = []string{}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Sales lists purchases newest first, within one tenant.
|
||||
//
|
||||
// Bounded by `limit` and the date window rather than by a cursor. 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 simultaneous visits from the arrivals feed
|
||||
// before `visits.seq` existed. Offering a cursor here would imply a delivery
|
||||
// guarantee this table cannot make; narrowing the window is honest and is what
|
||||
// a sales list is browsed by anyway.
|
||||
func (s *Store) Sales(ctx context.Context, q api.SaleQuery) ([]api.Sale, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT `+saleCols+`
|
||||
FROM purchases p
|
||||
JOIN sites si ON si.id = p.site_id
|
||||
LEFT JOIN visitors v ON v.id = p.visitor_id
|
||||
WHERE p.client_id = $1::uuid
|
||||
AND p.occurred_at >= $2 AND p.occurred_at < $3
|
||||
AND ($4 = '' OR p.site_id = $4::uuid)
|
||||
AND ($5 = '' OR p.visitor_id = $5::uuid)
|
||||
ORDER BY p.occurred_at DESC, p.id
|
||||
LIMIT $6`,
|
||||
q.ClientID, q.From, q.To, q.SiteID, q.VisitorID, q.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []api.Sale
|
||||
for rows.Next() {
|
||||
sale, err := scanSale(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, sale)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Sale is one purchase, scoped to the tenant. A row belonging to somebody else
|
||||
// reads as absent, never as forbidden.
|
||||
func (s *Store) Sale(ctx context.Context, clientID, id string) (api.Sale, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
SELECT `+saleCols+`
|
||||
FROM purchases p
|
||||
JOIN sites si ON si.id = p.site_id
|
||||
LEFT JOIN visitors v ON v.id = p.visitor_id
|
||||
WHERE p.client_id = $1::uuid AND p.id::text = $2`, clientID, id)
|
||||
sale, err := scanSale(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return api.Sale{}, nil
|
||||
}
|
||||
return sale, err
|
||||
}
|
||||
Reference in New Issue
Block a user