A customer's history now says whether they bought anything

"When was this customer last in" and "did they buy" are one question
staff ask in one breath, and answering it meant two calls and a join in
the client. Each visit row carries purchases, spend and currency.

LATERAL, not a join onto purchases. A plain join returns the visit TWICE
when it holds two sales, which would make a customer look like they came
more often than they did - a wrong number of exactly the kind this
product is otherwise careful about, arrived at by adding a feature.

Mixed currencies on one visit report the count and NO figure. Adding
rupees to dollars produces something that looks like money and is not,
and the sales still happened, so the count is the honest part to keep.

A purchase with no visit_id is deliberately absent: it belongs to the
customer rather than to a moment, and GET /api/sales?customer=V-42 lists
it. The two surfaces together cover every sale exactly once.

Both properties are asserted in the LIVE store tests, because both live
in the SQL. An in-memory fake asserting that a LATERAL does not duplicate
a row would only be checking the fake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-29 14:49:23 +05:30
parent c7312d31b4
commit dae18d651b
3 changed files with 148 additions and 3 deletions

View File

@@ -94,9 +94,19 @@ func (s *Store) VisitorHistory(ctx context.Context, clientID, visitorID string,
rows, err := s.pool.Query(ctx, `
SELECT vi.id::text, vi.occurred_at, si.name, vi.camera_id,
vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes
vi.is_new_visitor, vi.similarity, vi.quality, vi.attributes,
pu.n, pu.total, pu.cur, pu.currencies
FROM visits vi
JOIN sites si ON si.id = vi.site_id
-- LATERAL rather than a join onto purchases directly: two sales on one
-- visit would otherwise return that visit twice and the customer would
-- appear to have been in more often than they were.
LEFT JOIN LATERAL (
SELECT count(*) AS n, sum(p.amount) AS total,
max(p.currency) AS cur, count(DISTINCT p.currency) AS currencies
FROM purchases p
WHERE p.visit_id = vi.id AND p.client_id = vi.client_id
) pu ON true
WHERE vi.client_id = $1 AND vi.visitor_id = $2::uuid
ORDER BY vi.occurred_at DESC
LIMIT $3`, clientID, visitorID, limit)
@@ -109,11 +119,20 @@ func (s *Store) VisitorHistory(ctx context.Context, clientID, visitorID string,
for rows.Next() {
var v api.VisitRow
var at time.Time
var sim, qual *float64
var sim, qual, total *float64
var nPurchases, nCurrencies int
var cur *string
if err := rows.Scan(&v.ID, &at, &v.Site, &v.CameraID, &v.IsNew,
&sim, &qual, &v.Attributes); err != nil {
&sim, &qual, &v.Attributes,
&nPurchases, &total, &cur, &nCurrencies); err != nil {
return nil, err
}
v.Purchases = nPurchases
// One currency or none. Mixed is left as a count with no figure
// rather than a sum that means nothing.
if nCurrencies == 1 && total != nil && cur != nil {
v.Spend, v.Currency = *total, *cur
}
v.OccurredAt = at.UTC().Format(time.RFC3339)
if sim != nil {
v.Similarity = *sim