288 lines
7.8 KiB
Go
288 lines
7.8 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// Queries holds the database connection pool.
|
|
// All database operations are methods on this type.
|
|
// This pattern (a "repository") keeps SQL out of your handlers.
|
|
type Queries struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func New(pool *pgxpool.Pool) *Queries {
|
|
return &Queries{pool: pool}
|
|
}
|
|
|
|
// ---- Receipts ----
|
|
|
|
type CreateReceiptParams struct {
|
|
StoreName string
|
|
ReceiptDate time.Time
|
|
ImagePath string
|
|
City string
|
|
Country string
|
|
}
|
|
|
|
type Receipt struct {
|
|
ID uuid.UUID
|
|
StoreName string
|
|
ReceiptDate time.Time
|
|
ImagePath string
|
|
City string
|
|
Country string
|
|
SubmittedAt time.Time
|
|
}
|
|
|
|
func (q *Queries) CreateReceipt(ctx context.Context, p CreateReceiptParams) (Receipt, error) {
|
|
var r Receipt
|
|
err := q.pool.QueryRow(ctx, `
|
|
INSERT INTO receipts (store_name, receipt_date, image_path, city, country)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id, store_name, receipt_date, image_path, city, country, submitted_at
|
|
`, p.StoreName, p.ReceiptDate, p.ImagePath, p.City, p.Country,
|
|
).Scan(&r.ID, &r.StoreName, &r.ReceiptDate, &r.ImagePath, &r.City, &r.Country, &r.SubmittedAt)
|
|
|
|
if err != nil {
|
|
return Receipt{}, fmt.Errorf("create receipt: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func (q *Queries) GetReceiptByID(ctx context.Context, id uuid.UUID) (Receipt, error) {
|
|
var r Receipt
|
|
err := q.pool.QueryRow(ctx, `
|
|
SELECT id, store_name, receipt_date, image_path, city, country, submitted_at
|
|
FROM receipts WHERE id = $1
|
|
`, id).Scan(&r.ID, &r.StoreName, &r.ReceiptDate, &r.ImagePath, &r.City, &r.Country, &r.SubmittedAt)
|
|
|
|
if err != nil {
|
|
return Receipt{}, fmt.Errorf("get receipt: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// ---- Line Items ----
|
|
|
|
type CreateLineItemParams struct {
|
|
ReceiptID uuid.UUID
|
|
RawName string
|
|
CanonicalName string // may be empty if not matched yet
|
|
PriceCents int
|
|
Quantity float64
|
|
}
|
|
|
|
func (q *Queries) CreateLineItem(ctx context.Context, p CreateLineItemParams) error {
|
|
var canonical *string
|
|
if p.CanonicalName != "" {
|
|
canonical = &p.CanonicalName
|
|
}
|
|
|
|
_, err := q.pool.Exec(ctx, `
|
|
INSERT INTO line_items (receipt_id, raw_name, canonical_name, price_cents, quantity)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
`, p.ReceiptID, p.RawName, canonical, p.PriceCents, p.Quantity)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("create line item: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- Canonical Items ----
|
|
|
|
type CanonicalItem struct {
|
|
Name string
|
|
DisplayName string
|
|
Category string
|
|
Unit string
|
|
Aliases []string
|
|
}
|
|
|
|
func (q *Queries) ListCanonicalItems(ctx context.Context) ([]CanonicalItem, error) {
|
|
rows, err := q.pool.Query(ctx, `
|
|
SELECT name, display_name, category, unit, aliases
|
|
FROM canonical_items
|
|
ORDER BY category, display_name
|
|
`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list canonical items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var items []CanonicalItem
|
|
for rows.Next() {
|
|
var item CanonicalItem
|
|
if err := rows.Scan(&item.Name, &item.DisplayName, &item.Category, &item.Unit, &item.Aliases); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
// ---- Price History ----
|
|
|
|
type PricePoint struct {
|
|
YearMonth time.Time
|
|
AvgPriceCents int
|
|
SampleCount int
|
|
}
|
|
|
|
// GetPriceHistory returns monthly average prices for a canonical item.
|
|
func (q *Queries) GetPriceHistory(ctx context.Context, canonicalName string) ([]PricePoint, error) {
|
|
rows, err := q.pool.Query(ctx, `
|
|
SELECT year_month, avg_price_cents, sample_count
|
|
FROM price_snapshots
|
|
WHERE canonical_name = $1
|
|
ORDER BY year_month ASC
|
|
`, canonicalName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get price history: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var points []PricePoint
|
|
for rows.Next() {
|
|
var p PricePoint
|
|
if err := rows.Scan(&p.YearMonth, &p.AvgPriceCents, &p.SampleCount); err != nil {
|
|
return nil, err
|
|
}
|
|
points = append(points, p)
|
|
}
|
|
return points, rows.Err()
|
|
}
|
|
|
|
// TopMover represents the biggest price change over a period.
|
|
type TopMover struct {
|
|
CanonicalName string
|
|
DisplayName string
|
|
Category string
|
|
PriceThen int
|
|
PriceNow int
|
|
PctChange float64
|
|
}
|
|
|
|
// GetTopMovers finds items with the largest price change over the last N months.
|
|
func (q *Queries) GetTopMovers(ctx context.Context, months int, limit int) ([]TopMover, error) {
|
|
rows, err := q.pool.Query(ctx, `
|
|
WITH
|
|
-- Most recent snapshot per item
|
|
latest AS (
|
|
SELECT DISTINCT ON (canonical_name)
|
|
canonical_name, avg_price_cents AS price_now, year_month
|
|
FROM price_snapshots
|
|
ORDER BY canonical_name, year_month DESC
|
|
),
|
|
-- Snapshot closest to N months ago
|
|
old AS (
|
|
SELECT DISTINCT ON (ps.canonical_name)
|
|
ps.canonical_name, ps.avg_price_cents AS price_then
|
|
FROM price_snapshots ps
|
|
WHERE ps.year_month <= (now() - ($1 || ' months')::interval)::date
|
|
ORDER BY ps.canonical_name, ps.year_month DESC
|
|
)
|
|
SELECT
|
|
l.canonical_name,
|
|
ci.display_name,
|
|
ci.category,
|
|
o.price_then,
|
|
l.price_now,
|
|
ROUND(((l.price_now - o.price_then)::numeric / o.price_then) * 100, 1) AS pct_change
|
|
FROM latest l
|
|
JOIN old o ON l.canonical_name = o.canonical_name
|
|
JOIN canonical_items ci ON l.canonical_name = ci.name
|
|
ORDER BY ABS(pct_change) DESC
|
|
LIMIT $2
|
|
`, months, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get top movers: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var movers []TopMover
|
|
for rows.Next() {
|
|
var m TopMover
|
|
if err := rows.Scan(&m.CanonicalName, &m.DisplayName, &m.Category,
|
|
&m.PriceThen, &m.PriceNow, &m.PctChange); err != nil {
|
|
return nil, err
|
|
}
|
|
movers = append(movers, m)
|
|
}
|
|
return movers, rows.Err()
|
|
}
|
|
|
|
// InflationSummary returns the overall purchasing power change since a base year.
|
|
type InflationSummary struct {
|
|
BaseYear int
|
|
CurrentYear int
|
|
BasketThen int // average cents across tracked items at base year
|
|
BasketNow int // average cents across tracked items now
|
|
PurchasingPower float64 // e.g. 0.58 means $1 in baseYear = $0.58 today
|
|
TotalPctChange float64 // e.g. 72.4 means prices are 72.4% higher
|
|
}
|
|
|
|
func (q *Queries) GetInflationSummary(ctx context.Context, baseYear int) (InflationSummary, error) {
|
|
var s InflationSummary
|
|
err := q.pool.QueryRow(ctx, `
|
|
WITH
|
|
base AS (
|
|
SELECT AVG(avg_price_cents)::int AS avg_price
|
|
FROM price_snapshots
|
|
WHERE EXTRACT(YEAR FROM year_month) = $1
|
|
),
|
|
current AS (
|
|
SELECT AVG(avg_price_cents)::int AS avg_price
|
|
FROM price_snapshots
|
|
WHERE year_month >= date_trunc('year', now()) - interval '1 year'
|
|
)
|
|
SELECT
|
|
$1,
|
|
EXTRACT(YEAR FROM now())::int,
|
|
base.avg_price,
|
|
current.avg_price,
|
|
ROUND((base.avg_price::numeric / current.avg_price) * 100, 1),
|
|
ROUND(((current.avg_price - base.avg_price)::numeric / base.avg_price) * 100, 1)
|
|
FROM base, current
|
|
`, baseYear).Scan(
|
|
&s.BaseYear, &s.CurrentYear,
|
|
&s.BasketThen, &s.BasketNow,
|
|
&s.PurchasingPower, &s.TotalPctChange,
|
|
)
|
|
if err != nil {
|
|
return InflationSummary{}, fmt.Errorf("get inflation summary: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// RebuildSnapshots re-aggregates all line_items into price_snapshots.
|
|
// Run this as a nightly cron job or after a batch of new submissions.
|
|
func (q *Queries) RebuildSnapshots(ctx context.Context) error {
|
|
_, err := q.pool.Exec(ctx, `
|
|
INSERT INTO price_snapshots (canonical_name, year_month, avg_price_cents, sample_count)
|
|
SELECT
|
|
canonical_name,
|
|
date_trunc('month', r.receipt_date)::date AS year_month,
|
|
ROUND(AVG(li.unit_price_cents))::int AS avg_price_cents,
|
|
COUNT(*) AS sample_count
|
|
FROM line_items li
|
|
JOIN receipts r ON li.receipt_id = r.id
|
|
WHERE li.canonical_name IS NOT NULL
|
|
GROUP BY canonical_name, year_month
|
|
ON CONFLICT (canonical_name, year_month)
|
|
DO UPDATE SET
|
|
avg_price_cents = EXCLUDED.avg_price_cents,
|
|
sample_count = EXCLUDED.sample_count
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("rebuild snapshots: %w", err)
|
|
}
|
|
return nil
|
|
}
|