diff --git a/app/admin_routes.py b/app/admin_routes.py index 7976dc2..357b877 100644 --- a/app/admin_routes.py +++ b/app/admin_routes.py @@ -494,6 +494,116 @@ async def reports(ident=Depends(require_token), db=Depends(get_db)): return {"ok": True, "summary": summary, "by_month": by_month, "top": top} +# ── Reports workbench — parameterized + lazy (each chart fetched on demand) ─────────────────── +_DONE = "s.status = 'completed'" + + +def _period(days: int, frm, to): + if frm and to: + return "s.sale_date >= :p_from AND s.sale_date < (:p_to::date + 1)", {"p_from": frm, "p_to": to} + if days and days > 0: + return "s.sale_date >= now() - make_interval(days => :p_days)", {"p_days": days} + return "TRUE", {} # days<=0 = all time + + +@router.get("/report/kpis") +async def report_kpis(days: int = Query(30), frm: str | None = Query(None, alias="from"), + to: str | None = None, ident=Depends(require_token), db=Depends(get_db)): + where, p = _period(days, frm, to) + row = dict((await db.execute(text(f""" + SELECT count(*) AS sales, coalesce(sum(s.total),0)::float AS revenue, + coalesce(sum(s.discount_amount),0)::float AS discounts, + coalesce(sum(s.tax_amount),0)::float AS tax, + coalesce(avg(s.total),0)::float AS avg_sale, + count(DISTINCT s.customer_id) FILTER (WHERE s.customer_id IS NOT NULL AND s.customer_id<>0) AS customers + FROM sales s WHERE {where} AND {_DONE}"""), p)).mappings().first()) + row["units"] = (await db.execute(text( + f"SELECT coalesce(sum(si.qty),0) FROM sale_items si JOIN sales s ON s.id=si.sale_id WHERE {where} AND {_DONE}" + ), p)).scalar() + snap = dict((await db.execute(text(""" + SELECT count(*) FILTER (WHERE in_stock) AS in_stock, + coalesce(sum(price) FILTER (WHERE in_stock),0)::float AS stock_value, + count(*) FILTER (WHERE in_stock AND crate_id IS NOT NULL) AS located + FROM inventory WHERE store_id=1"""))).mappings().first()) + row["in_stock"] = snap["in_stock"]; row["stock_value"] = snap["stock_value"]; row["located"] = snap["located"] + row["open_laybys"] = (await db.execute(text("SELECT count(*) FROM sales WHERE payment_status='hold'"))).scalar() + return row + + +@router.get("/report/series") +async def report_series(days: int = Query(30), bucket: str = Query("day"), + frm: str | None = Query(None, alias="from"), to: str | None = None, + ident=Depends(require_token), db=Depends(get_db)): + where, p = _period(days, frm, to) + bucket = bucket if bucket in ("day", "week", "month") else "day" + fmt = {"day": "YYYY-MM-DD", "week": 'IYYY-"W"IW', "month": "YYYY-MM"}[bucket] + rows = [dict(r) for r in (await db.execute(text(f""" + SELECT to_char(date_trunc('{bucket}', s.sale_date), '{fmt}') AS bucket, + count(*) AS sales, coalesce(sum(s.total),0)::float AS revenue + FROM sales s WHERE {where} AND {_DONE} AND s.sale_date IS NOT NULL + GROUP BY 1 ORDER BY 1"""), p)).mappings()] + return {"series": rows, "bucket": bucket} + + +_BREAKDOWN = { + "payment": "SELECT coalesce(nullif(s.payment_method,''),'?') AS name, count(*) AS count, coalesce(sum(s.total),0)::float AS revenue FROM sales s WHERE {where} AND {done} GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}", + "weekday": "SELECT trim(to_char(s.sale_date,'Dy')) AS name, extract(dow from s.sale_date) AS _o, count(*) AS count, coalesce(sum(s.total),0)::float AS revenue FROM sales s WHERE {where} AND {done} GROUP BY 1,2 ORDER BY _o", + "hour": "SELECT lpad(extract(hour from s.sale_date)::text,2,'0')||':00' AS name, count(*) AS count, coalesce(sum(s.total),0)::float AS revenue FROM sales s WHERE {where} AND {done} GROUP BY 1 ORDER BY 1", + "condition": "SELECT coalesce(nullif(i.condition,''),'—') AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku WHERE {where} AND {done} GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}", + "kind": "SELECT coalesce(nullif(i.kind,''),'—') AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku WHERE {where} AND {done} GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}", + "genre": "SELECT g.genre_name AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku JOIN disc_release_genre g ON g.release_id=i.release_id WHERE {where} AND {done} AND g.genre_name<>'' GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}", + "style": "SELECT st.style_name AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku JOIN disc_release_style st ON st.release_id=i.release_id WHERE {where} AND {done} AND st.style_name<>'' GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}", +} + + +@router.get("/report/breakdown") +async def report_breakdown(dim: str, days: int = Query(30), metric: str = Query("revenue"), + limit: int = Query(12), frm: str | None = Query(None, alias="from"), + to: str | None = None, ident=Depends(require_token), db=Depends(get_db)): + if dim not in _BREAKDOWN: + raise HTTPException(400, "bad dim") + where, p = _period(days, frm, to) + sql = _BREAKDOWN[dim].format(where=where, done=_DONE, + order=("revenue" if metric == "revenue" else "count"), + lim=max(1, min(limit, 50))) + return {"dim": dim, "metric": metric, "rows": [dict(r) for r in (await db.execute(text(sql), p)).mappings()]} + + +_TOP = { + "release": "SELECT coalesce(min(si.item_name),'Release '||i.release_id::text) AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku WHERE {where} AND {done} AND i.release_id IS NOT NULL GROUP BY i.release_id ORDER BY revenue DESC LIMIT {lim}", + "artist": "SELECT da.name AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku JOIN disc_release_artist ra ON ra.release_id=i.release_id JOIN disc_artist da ON da.id=ra.artist_id WHERE {where} AND {done} AND da.name<>'' GROUP BY da.name ORDER BY revenue DESC LIMIT {lim}", + "label": "SELECT rl.label_name AS name, count(*) AS count, coalesce(sum(si.line_total),0)::float AS revenue FROM sales s JOIN sale_items si ON si.sale_id=s.id JOIN inventory i ON i.sku=si.sku JOIN disc_release_label rl ON rl.release_id=i.release_id WHERE {where} AND {done} AND rl.label_name<>'' GROUP BY rl.label_name ORDER BY revenue DESC LIMIT {lim}", + "customer": "SELECT trim(c.first_name||' '||coalesce(c.last_name,'')) AS name, count(*) AS count, coalesce(sum(s.total),0)::float AS revenue FROM sales s JOIN customer c ON c.id=s.customer_id WHERE {where} AND {done} AND s.customer_id IS NOT NULL AND s.customer_id<>0 GROUP BY c.id ORDER BY revenue DESC LIMIT {lim}", +} + + +@router.get("/report/top") +async def report_top(dim: str, days: int = Query(30), limit: int = Query(10), + frm: str | None = Query(None, alias="from"), to: str | None = None, + ident=Depends(require_token), db=Depends(get_db)): + if dim not in _TOP: + raise HTTPException(400, "bad dim") + where, p = _period(days, frm, to) + sql = _TOP[dim].format(where=where, done=_DONE, lim=max(1, min(limit, 50))) + return {"dim": dim, "rows": [dict(r) for r in (await db.execute(text(sql), p)).mappings()]} + + +_STOCK = { + "format": "SELECT coalesce(nullif(f.name,''),'—') AS name, count(DISTINCT i.sku) AS count, coalesce(sum(i.price),0)::float AS value FROM inventory i JOIN disc_release_format f ON f.release_id=i.release_id WHERE i.in_stock AND i.store_id=1 GROUP BY 1 ORDER BY count DESC LIMIT 12", + "genre": "SELECT g.genre_name AS name, count(DISTINCT i.sku) AS count, coalesce(sum(i.price),0)::float AS value FROM inventory i JOIN disc_release_genre g ON g.release_id=i.release_id WHERE i.in_stock AND i.store_id=1 AND g.genre_name<>'' GROUP BY 1 ORDER BY count DESC LIMIT 12", + "condition": "SELECT coalesce(nullif(condition,''),'—') AS name, count(*) AS count, coalesce(sum(price),0)::float AS value FROM inventory WHERE in_stock AND store_id=1 GROUP BY 1 ORDER BY count DESC", + "kind": "SELECT coalesce(nullif(kind,''),'—') AS name, count(*) AS count, coalesce(sum(price),0)::float AS value FROM inventory WHERE in_stock AND store_id=1 GROUP BY 1 ORDER BY count DESC", + "price": "SELECT CASE WHEN price<10 THEN '< $10' WHEN price<20 THEN '$10–20' WHEN price<35 THEN '$20–35' WHEN price<60 THEN '$35–60' WHEN price<100 THEN '$60–100' ELSE '$100+' END AS name, min(price) AS _o, count(*) AS count, coalesce(sum(price),0)::float AS value FROM inventory WHERE in_stock AND store_id=1 AND price IS NOT NULL GROUP BY 1 ORDER BY _o", +} + + +@router.get("/report/stock") +async def report_stock(dim: str, ident=Depends(require_token), db=Depends(get_db)): + if dim not in _STOCK: + raise HTTPException(400, "bad dim") + return {"dim": dim, "rows": [dict(r) for r in (await db.execute(text(_STOCK[dim]))).mappings()]} + + @router.get("/orders") async def orders(ident=Depends(require_token), db=Depends(get_db)): base = await vault.get_secret(db, "woo_base_url") diff --git a/site/admin.html b/site/admin.html index 62bc6fc..8a7136a 100644 --- a/site/admin.html +++ b/site/admin.html @@ -5,6 +5,7 @@ RecordGod — admin +