import secrets import httpx from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import text from . import vault from .auth import require_token, require_admin, hash_password from .db import get_db # The MEGA admin — back-office cockpit, all admin-gated. Inspired by WowPlatter's wp-admin nav # (Dashboard / Inventory / Orders / Import / Sales / Reports), but on RecordGod's own data. router = APIRouter(prefix="/admin", tags=["admin"]) @router.get("/stats") async def stats(ident=Depends(require_token), db=Depends(get_db)): s = ident["store_id"] row = (await db.execute(text(""" SELECT (SELECT count(*) FROM inventory WHERE store_id=:s) AS items, (SELECT count(*) FROM inventory WHERE store_id=:s AND in_stock) AS in_stock, (SELECT count(*) FROM inventory WHERE store_id=:s AND kind='vinyl') AS vinyl, (SELECT count(*) FROM inventory WHERE store_id=:s AND kind<>'vinyl') AS other_goods, (SELECT coalesce(sum(price),0)::float FROM inventory WHERE store_id=:s AND in_stock) AS stock_value, (SELECT count(*) FROM inventory WHERE staged) AS staged, (SELECT count(*) FROM crate) AS crates, (SELECT count(*) FROM sales) AS sales, (SELECT coalesce(sum(total),0)::float FROM sales) AS sales_total """), {"s": s})).mappings().first() return {"ok": True, **dict(row)} @router.get("/me") async def me(ident=Depends(require_token), db=Depends(get_db)): """Who am I + role — the dash hides admin-only tabs for staff (the backend still enforces).""" if ident.get("staff_id"): await db.execute(text("UPDATE staff SET last_seen=now() WHERE id=:i"), {"i": ident["staff_id"]}) await db.commit() return {"name": ident.get("name"), "role": ident.get("role"), "staff_id": ident.get("staff_id")} # ── Staff accounts (ADMIN ONLY) — operators get a token + role; tokens shown once on create/reset ── class StaffIn(BaseModel): name: str role: str = "staff" email: str | None = None phone: str | None = None pay_rate: float | None = None password: str | None = None class StaffEditIn(BaseModel): name: str | None = None role: str | None = None active: bool | None = None email: str | None = None phone: str | None = None pay_rate: float | None = None @router.get("/staff") async def staff_list(ident=Depends(require_admin), db=Depends(get_db)): rows = await db.execute(text( "SELECT id, name, role, active, email, phone, pay_rate, password_hash IS NOT NULL AS has_password, " "created_at, last_seen FROM staff ORDER BY active DESC, name")) return {"staff": [dict(r) for r in rows.mappings()]} @router.post("/staff") async def staff_add(body: StaffIn, ident=Depends(require_admin), db=Depends(get_db)): if not body.name.strip(): raise HTTPException(400, "name required") role = body.role if body.role in ("admin", "staff") else "staff" token = "rg_" + secrets.token_urlsafe(18) ph = hash_password(body.password) if body.password else None r = (await db.execute(text( "INSERT INTO staff (name, token, role, email, phone, pay_rate, password_hash) " "VALUES (:n, :t, :r, :e, :p, :pr, :ph) RETURNING id"), {"n": body.name.strip(), "t": token, "r": role, "e": (body.email or None), "p": body.phone, "pr": body.pay_rate, "ph": ph})).scalar() await db.commit() return {"ok": True, "id": r, "token": token} # token returned ONCE (break-glass); normal sign-in = email + password class SetPwIn(BaseModel): password: str @router.post("/staff/{sid}/password") async def staff_set_password(sid: int, body: SetPwIn, ident=Depends(require_admin), db=Depends(get_db)): """Admin sets/resets a staff member's password (e.g. onboarding, or a forgotten password).""" if len(body.password) < 6: raise HTTPException(400, "password too short (min 6 characters)") r = await db.execute(text("UPDATE staff SET password_hash=:h WHERE id=:i"), {"h": hash_password(body.password), "i": sid}) await db.commit() if not r.rowcount: raise HTTPException(404, "not found") return {"ok": True} @router.post("/staff/{sid}") async def staff_edit(sid: int, body: StaffEditIn, ident=Depends(require_admin), db=Depends(get_db)): fields = {k: v for k, v in body.model_dump().items() if v is not None} if "role" in fields and fields["role"] not in ("admin", "staff"): raise HTTPException(400, "bad role") if not fields: return {"ok": True, "unchanged": True} sets = ", ".join(f"{k} = :{k}" for k in fields) r = await db.execute(text(f"UPDATE staff SET {sets} WHERE id = :i"), {**fields, "i": sid}) await db.commit() if not r.rowcount: raise HTTPException(404, "not found") return {"ok": True} @router.post("/staff/{sid}/token") async def staff_reset_token(sid: int, ident=Depends(require_admin), db=Depends(get_db)): token = "rg_" + secrets.token_urlsafe(18) r = await db.execute(text("UPDATE staff SET token = :t WHERE id = :i"), {"t": token, "i": sid}) await db.commit() if not r.rowcount: raise HTTPException(404, "not found") return {"ok": True, "token": token} # ── Time clock — staff clock on/off; the open shift + today's total drive the workstation widget ── @router.get("/clock") async def clock_status(ident=Depends(require_token), db=Depends(get_db)): sid = ident.get("staff_id") if not sid: return {"staff": False} cur = (await db.execute(text( "SELECT id, clock_in FROM staff_shift WHERE staff_id=:s AND clock_out IS NULL ORDER BY clock_in DESC LIMIT 1" ), {"s": sid})).mappings().first() today = (await db.execute(text(""" SELECT coalesce(sum(extract(epoch FROM (coalesce(clock_out, now()) - clock_in))), 0)::bigint FROM staff_shift WHERE staff_id=:s AND clock_in >= date_trunc('day', now())"""), {"s": sid})).scalar() return {"staff": True, "name": ident["name"], "open": dict(cur) if cur else None, "today_seconds": today} @router.post("/clock/in") async def clock_in(ident=Depends(require_token), db=Depends(get_db)): sid = ident.get("staff_id") if not sid: raise HTTPException(400, "only staff clock in") cur = (await db.execute(text( "SELECT id FROM staff_shift WHERE staff_id=:s AND clock_out IS NULL LIMIT 1"), {"s": sid})).first() if cur: return {"ok": True, "already_open": True} await db.execute(text("INSERT INTO staff_shift (staff_id) VALUES (:s)"), {"s": sid}) await db.commit() return {"ok": True, "clocked_in": True} @router.post("/clock/out") async def clock_out(ident=Depends(require_token), db=Depends(get_db)): sid = ident.get("staff_id") if not sid: raise HTTPException(400, "only staff clock out") r = await db.execute(text( "UPDATE staff_shift SET clock_out=now() WHERE staff_id=:s AND clock_out IS NULL"), {"s": sid}) await db.commit() return {"ok": True, "closed": r.rowcount} @router.get("/timecards") async def timecards(days: int = Query(14), staff_id: int | None = None, ident=Depends(require_admin), db=Depends(get_db)): """The timecard report (admin): per-staff hour totals + recent shifts over the window.""" where = ["sh.clock_in >= now() - make_interval(days => :d)"] params = {"d": days} if staff_id: where.append("sh.staff_id = :sid"); params["sid"] = staff_id w = " AND ".join(where) shifts = [dict(r) for r in (await db.execute(text(f""" SELECT sh.id, sh.staff_id, st.name, sh.clock_in, sh.clock_out, round((extract(epoch FROM (coalesce(sh.clock_out, now()) - sh.clock_in))/3600.0)::numeric, 2) AS hours FROM staff_shift sh JOIN staff st ON st.id = sh.staff_id WHERE {w} ORDER BY sh.clock_in DESC LIMIT 500"""), params)).mappings()] totals = [dict(r) for r in (await db.execute(text(f""" SELECT sh.staff_id, st.name, st.pay_rate, count(*) AS shifts, round((sum(extract(epoch FROM (coalesce(sh.clock_out, now()) - sh.clock_in)))/3600.0)::numeric, 2) AS hours, round((sum(extract(epoch FROM (coalesce(sh.clock_out, now()) - sh.clock_in)))/3600.0 * coalesce(st.pay_rate, 0))::numeric, 2) AS pay FROM staff_shift sh JOIN staff st ON st.id = sh.staff_id WHERE {w} GROUP BY sh.staff_id, st.name, st.pay_rate ORDER BY hours DESC"""), params)).mappings()] return {"shifts": shifts, "totals": totals, "days": days} # ── New stock (distro purchases awaiting approval) — confirm release_id + set retail, then publish ── @router.get("/newstock") async def newstock_list(ident=Depends(require_token), db=Depends(get_db)): rows = [dict(r) for r in (await db.execute(text(""" SELECT i.sku, i.title, i.identifier AS barcode, i.release_id, i.cost_price, i.price, i.condition_type, i.cost_source, i.est_market_value, COALESCE(dc.thumb, dr.thumb) AS thumb, dr.artists_sort AS artist, dr.year, (i.attributes->>'resolved')::bool AS resolved, i.attributes->>'slug' AS slug FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id LEFT JOIN disc_release dr ON dr.id = i.release_id WHERE i.store_id = :s AND i.staged AND i.cost_source IS NOT NULL ORDER BY i.cost_source DESC, i.title NULLS LAST, i.sku"""), {"s": ident["store_id"]})).mappings()] return {"items": rows, "count": len(rows)} class NewStockIn(BaseModel): release_id: int | None = None title: str | None = None price: float | None = None condition_type: str | None = None publish: bool = False @router.post("/newstock/{sku}") async def newstock_update(sku: str, body: NewStockIn, ident=Depends(require_token), db=Depends(get_db)): """Staff approve a distro copy: re-pick the release / set retail / flip new-used, then publish (un-stage).""" sets, params = [], {"sku": sku, "sid": ident["store_id"]} if body.release_id is not None: sets.append("release_id = :rid"); params["rid"] = body.release_id if body.title is not None: sets.append("title = :t"); params["t"] = body.title if body.price is not None: sets.append("price = :p"); params["p"] = body.price if body.condition_type in ("new", "used"): sets.append("condition_type = :ct"); params["ct"] = body.condition_type if body.publish: sets += ["staged = false", "status = 'publish'", "in_stock = true"] if not sets: return {"ok": True, "unchanged": True} sets.append("updated_at = now()") r = await db.execute(text( f"UPDATE inventory SET {', '.join(sets)} WHERE sku = :sku AND store_id = :sid"), params) await db.commit() if not r.rowcount: raise HTTPException(404, "not found") return {"ok": True, "published": body.publish} @router.post("/newstock/publish-all") async def newstock_publish_all(ident=Depends(require_token), db=Depends(get_db)): """Publish every resolved copy that already has a retail price (the bulk 'ship it' button).""" r = await db.execute(text(""" UPDATE inventory SET staged = false, status = 'publish', in_stock = true, updated_at = now() WHERE store_id = :s AND staged AND cost_source IS NOT NULL AND release_id IS NOT NULL AND price IS NOT NULL"""), {"s": ident["store_id"]}) await db.commit() return {"ok": True, "published": r.rowcount} @router.get("/inventory") async def inventory(q: str = Query(""), kind: str = Query(""), crate_id: int | None = Query(None), page: int = Query(1, ge=1), ident=Depends(require_token), db=Depends(get_db)): per = 50 where = ["i.store_id = :s"] params = {"s": ident["store_id"], "per": per, "off": (page - 1) * per} if q: where.append("(i.sku ILIKE :q OR coalesce(i.title,dc.title) ILIKE :q OR dc.artist ILIKE :q)") params["q"] = f"%{q}%" if kind: where.append("i.kind = :k") params["k"] = kind if crate_id: where.append("i.crate_id = :cid") params["cid"] = crate_id w = " AND ".join(where) items = [dict(r) for r in (await db.execute(text(f""" SELECT i.sku, i.kind, i.release_id, coalesce(i.title, dc.title) AS title, dc.artist, i.price::float AS price, i.cost_price::float AS cost_price, i.condition_type, i.condition, i.sleeve_cond, i.slot_number, i.notes, i.in_stock, i.crate_id, c.label_text AS crate, dc.thumb, i.target_price::float AS target FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id LEFT JOIN crate c ON c.id = i.crate_id WHERE {w} ORDER BY i.updated_at DESC NULLS LAST LIMIT :per OFFSET :off """), params)).mappings()] cparams = {k: v for k, v in params.items() if k not in ("per", "off")} total = (await db.execute(text( f"SELECT count(*) FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id=i.release_id WHERE {w}" ), cparams)).scalar() return {"ok": True, "items": items, "page": page, "per": per, "total": total} # ── Inventory CRUD (the WowPlatter Inventory screen, RecordGod-side) ─────────────────────────── class InvItemIn(BaseModel): sku: str | None = None kind: str = "vinyl" release_id: int | None = None identifier: str | None = None title: str | None = None price: float | None = None qty: int = 1 condition: str | None = None sleeve_cond: str | None = None brand: str | None = None crate_id: int | None = None slot_number: int | None = None notes: str | None = None in_stock: bool = True class InvEditIn(BaseModel): price: float | None = None cost_price: float | None = None condition: str | None = None condition_type: str | None = None # 'new' | 'used' sleeve_cond: str | None = None title: str | None = None crate_id: int | None = None slot_number: int | None = None notes: str | None = None in_stock: bool | None = None target_price: float | None = None kind: str | None = None class BulkIn(BaseModel): skus: list[str] action: str # set_price | adjust_pct | set_crate | mark_sold | restock | delete value: float | int | str | None = None @router.post("/inventory/add") async def inv_add(body: InvItemIn, ident=Depends(require_token), db=Depends(get_db)): sku = (body.sku or "").strip() or ("RG" + secrets.token_hex(4).upper()) title = body.title if body.release_id and not title: # pull the cached title if intaking by release r = (await db.execute(text("SELECT title FROM disc_cache WHERE release_id=:r"), {"r": body.release_id})).first() title = r[0] if r else None await db.execute(text(""" INSERT INTO inventory (sku, store_id, kind, release_id, identifier, title, price, qty, in_stock, condition, sleeve_cond, brand, crate_id, slot_number, notes, status, created_at, updated_at) VALUES (:sku, :s, :kind, :rid, :ident, :title, :price, :qty, :in_stock, :cond, :sleeve, :brand, :crate, :slot, :notes, :status, now(), now()) ON CONFLICT (sku) DO NOTHING """), {"sku": sku, "s": ident["store_id"], "kind": body.kind, "rid": body.release_id, "ident": body.identifier, "title": title, "price": body.price, "qty": body.qty, "in_stock": body.in_stock, "cond": body.condition, "sleeve": body.sleeve_cond, "brand": body.brand, "crate": body.crate_id, "slot": body.slot_number, "notes": body.notes, "status": "publish" if body.in_stock else "sold"}) await db.commit() return {"ok": True, "sku": sku} @router.post("/inventory/{sku}/edit") async def inv_edit(sku: str, body: InvEditIn, ident=Depends(require_token), db=Depends(get_db)): fields = {k: v for k, v in body.model_dump().items() if v is not None} if not fields: return {"ok": True, "unchanged": True} sets = ", ".join(f"{k} = :{k}" for k in fields) + ", updated_at = now()" r = await db.execute(text(f"UPDATE inventory SET {sets} WHERE sku = :sku AND store_id = :s"), {**fields, "sku": sku, "s": ident["store_id"]}) await db.commit() if not r.rowcount: raise HTTPException(404, "sku not found") return {"ok": True, "updated": r.rowcount} @router.post("/inventory/{sku}/delete") async def inv_delete(sku: str, ident=Depends(require_token), db=Depends(get_db)): r = await db.execute(text("DELETE FROM inventory WHERE sku = :sku AND store_id = :s"), {"sku": sku, "s": ident["store_id"]}) await db.commit() return {"ok": True, "deleted": r.rowcount} class ImportIn(BaseModel): lines: list[str] crate_id: int | None = None price: float | None = None condition: str | None = None kind: str = "vinyl" @router.post("/inventory/import") async def inv_import(body: ImportIn, ident=Depends(require_token), db=Depends(get_db)): """Bulk stock-IN: each line = Release ID / barcode / catno → resolved against the local disc_release mirror → a new inventory item. (Distinct from the POS Import, which marks items SOLD.)""" added, not_found = [], [] for raw in body.lines: ln = raw.strip() if not ln: continue rid = title = None if ln.isdigit(): r = (await db.execute(text("SELECT id, title FROM disc_release WHERE id=:r"), {"r": int(ln)})).first() if r: rid, title = r[0], r[1] if not rid: # barcode / identifier r = (await db.execute(text("""SELECT dr.id, dr.title FROM disc_release_identifier i JOIN disc_release dr ON dr.id=i.release_id WHERE i.value=:v LIMIT 1"""), {"v": ln})).first() if r: rid, title = r[0], r[1] if not rid: # catalogue number r = (await db.execute(text("""SELECT dr.id, dr.title FROM disc_release_label l JOIN disc_release dr ON dr.id=l.release_id WHERE l.catno=:v LIMIT 1"""), {"v": ln})).first() if r: rid, title = r[0], r[1] if not rid: not_found.append(ln) continue sku = "RG" + secrets.token_hex(4).upper() await db.execute(text(""" INSERT INTO inventory (sku, store_id, kind, release_id, title, price, qty, in_stock, condition, crate_id, status, created_at, updated_at) VALUES (:sku,:s,:kind,:rid,:title,:price,1,true,:cond,:crate,'publish',now(),now()) ON CONFLICT (sku) DO NOTHING """), {"sku": sku, "s": ident["store_id"], "kind": body.kind, "rid": rid, "title": title, "price": body.price, "cond": body.condition, "crate": body.crate_id}) added.append({"sku": sku, "release_id": rid, "title": title}) await db.commit() return {"ok": True, "added": len(added), "items": added, "not_found": not_found} @router.post("/inventory/bulk") async def inv_bulk(body: BulkIn, ident=Depends(require_token), db=Depends(get_db)): if not body.skus: raise HTTPException(400, "no skus") p = {"skus": body.skus, "s": ident["store_id"], "v": body.value} sql = { "set_price": "UPDATE inventory SET price=:v, updated_at=now() WHERE sku=ANY(:skus) AND store_id=:s", "adjust_pct": "UPDATE inventory SET price=round(price*(1+:v/100.0),2), updated_at=now() WHERE sku=ANY(:skus) AND store_id=:s", "set_crate": "UPDATE inventory SET crate_id=:v, updated_at=now() WHERE sku=ANY(:skus) AND store_id=:s", "mark_sold": "UPDATE inventory SET in_stock=false, status='sold', sold_date=now(), updated_at=now() WHERE sku=ANY(:skus) AND store_id=:s", "restock": "UPDATE inventory SET in_stock=true, status='publish', sold_date=NULL, updated_at=now() WHERE sku=ANY(:skus) AND store_id=:s", "delete": "DELETE FROM inventory WHERE sku=ANY(:skus) AND store_id=:s", }.get(body.action) if not sql: raise HTTPException(400, f"unknown action {body.action}") r = await db.execute(text(sql), p) await db.commit() return {"ok": True, "affected": r.rowcount} class CustEditIn(BaseModel): first_name: str | None = None last_name: str | None = None email: str | None = None phone: str | None = None address: str | None = None notes: str | None = None mailing_optin: bool | None = None @router.get("/customers") async def customers_list(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)): where, params = "", {} if q.strip(): where = ("WHERE c.first_name ILIKE :q OR c.last_name ILIKE :q OR c.email ILIKE :q " "OR (c.first_name || ' ' || coalesce(c.last_name,'')) ILIKE :q") params = {"q": f"%{q.strip()}%"} rows = await db.execute(text(f""" SELECT c.id, trim(c.first_name || ' ' || coalesce(c.last_name,'')) AS name, c.email, c.phone, c.is_guest, c.mailing_optin, count(s.id) AS orders, coalesce(sum(s.total),0)::float AS spent, max(s.sale_date) AS last_order FROM customer c LEFT JOIN sales s ON s.customer_id = c.id {where} GROUP BY c.id ORDER BY spent DESC NULLS LAST, name LIMIT 300 """), params) return {"customers": [dict(r) for r in rows.mappings()]} @router.get("/customers/{cid}") async def customer_detail(cid: int, ident=Depends(require_token), db=Depends(get_db)): c = (await db.execute(text("SELECT * FROM customer WHERE id=:i"), {"i": cid})).mappings().first() if not c: raise HTTPException(404, "customer not found") sales = [dict(r) for r in (await db.execute(text(""" SELECT s.id, s.sale_number, s.total::float AS total, s.sale_date, s.payment_method, s.status, (SELECT count(*) FROM sale_items si WHERE si.sale_id = s.id) AS items FROM sales s WHERE s.customer_id = :i ORDER BY s.sale_date DESC NULLS LAST LIMIT 100 """), {"i": cid})).mappings()] stats = dict((await db.execute(text(""" SELECT count(*) AS orders, coalesce(sum(total),0)::float AS spent, max(sale_date) AS last_order FROM sales WHERE customer_id = :i """), {"i": cid})).mappings().first()) return {"customer": dict(c), "sales": sales, "stats": stats} @router.post("/customers/{cid}") async def customer_edit(cid: int, body: CustEditIn, ident=Depends(require_token), db=Depends(get_db)): fields = {k: v for k, v in body.model_dump().items() if v is not None} if not fields: return {"ok": True, "unchanged": True} sets = ", ".join(f"{k} = :{k}" for k in fields) r = await db.execute(text(f"UPDATE customer SET {sets} WHERE id = :i"), {**fields, "i": cid}) await db.commit() if not r.rowcount: raise HTTPException(404, "not found") return {"ok": True, "updated": r.rowcount} @router.get("/wantlist") async def wantlist_list(status: str = Query("pending"), ident=Depends(require_token), db=Depends(get_db)): """Customer record-requests captured by the public storefront /wantlist form.""" where, params = "", {} if status and status != "all": where = "WHERE w.status = :s"; params = {"s": status} rows = await db.execute(text(f""" SELECT w.id, w.release_id, w.artist, w.title, w.format, w.max_price::float AS max_price, w.name, w.email, w.phone, w.delivery_preference, w.postcode, w.notes, w.status, w.created_at, w.actioned_at FROM wantlist w {where} ORDER BY w.created_at DESC LIMIT 500 """), params) counts = {r["status"]: r["n"] for r in (await db.execute(text( "SELECT status, count(*) AS n FROM wantlist GROUP BY status"))).mappings()} return {"requests": [dict(r) for r in rows.mappings()], "counts": counts} class WantStatusIn(BaseModel): status: str # pending | found | fulfilled | cancelled @router.post("/wantlist/{wid}") async def wantlist_action(wid: int, body: WantStatusIn, ident=Depends(require_token), db=Depends(get_db)): if body.status not in ("pending", "found", "fulfilled", "cancelled"): raise HTTPException(422, "bad status") actioned = "now()" if body.status != "pending" else "NULL" r = await db.execute(text( f"UPDATE wantlist SET status=:s, actioned_at={actioned} WHERE id=:i"), {"s": body.status, "i": wid}) await db.commit() if not r.rowcount: raise HTTPException(404, "not found") return {"ok": True} @router.get("/crates") async def crates(ident=Depends(require_token), db=Depends(get_db)): rows = await db.execute(text(""" SELECT c.id, c.name, c.label_text, c.purpose, count(i.sku) FILTER (WHERE i.in_stock) AS items, coalesce(sum(i.price) FILTER (WHERE i.in_stock), 0)::float AS value FROM crate c LEFT JOIN inventory i ON i.crate_id = c.id GROUP BY c.id, c.name, c.label_text, c.purpose ORDER BY items DESC NULLS LAST, c.id """)) return {"ok": True, "crates": [dict(r) for r in rows.mappings()]} @router.get("/storemap") async def storemap(ident=Depends(require_token), db=Depends(get_db)): """Top-down store map: room + racks + crates at their real positions, with live item counts. Powers the clickable store-navigator (the WowPlatter 'Search' workstation, RecordGod-side).""" sp = (await db.execute(text( "SELECT * FROM virtual_space WHERE visible='y' ORDER BY (is_default='y') DESC LIMIT 1" ))).mappings().first() space = {"room_width": float(sp["room_width"] or 12), "room_depth": float(sp["room_depth"] or 12)} if sp else {"room_width": 12, "room_depth": 12} ctypes = {r["id"]: {"w": float(r["width"] or 0.35), "d": float(r["depth"] or 0.5)} for r in (await db.execute(text("SELECT id, width, depth FROM virtual_crate_type"))).mappings()} crates = [dict(r) for r in (await db.execute(text(""" SELECT c.id, c.name, c.label_text, c.crate_purpose, c.crate_type_id, c.pos_x::float AS x, c.pos_z::float AS z, c.rotation_y::float AS rot, (SELECT count(*) FROM inventory i WHERE i.crate_id = c.id AND i.in_stock) AS items FROM virtual_crate c WHERE c.visible='y' """))).mappings()] racks = [dict(r) for r in (await db.execute(text(""" SELECT r.pos_x::float AS x, r.pos_z::float AS z, r.rotation_y::float AS rot, rt.width::float AS w, rt.depth::float AS d FROM virtual_rack r LEFT JOIN virtual_rack_type rt ON rt.id = r.rack_type_id WHERE r.visible='y' """))).mappings()] return {"ok": True, "space": space, "crate_types": ctypes, "crates": crates, "racks": racks} @router.get("/reports") async def reports(ident=Depends(require_token), db=Depends(get_db)): summary = dict((await db.execute(text(""" SELECT count(*) AS orders, coalesce(sum(total),0)::float AS revenue, coalesce(avg(total),0)::float AS avg_order FROM sales """))).mappings().first()) by_month = [dict(r) for r in (await db.execute(text(""" SELECT to_char(date_trunc('month', sale_date),'YYYY-MM') AS month, count(*) AS orders, coalesce(sum(total),0)::float AS revenue FROM sales WHERE sale_date IS NOT NULL GROUP BY 1 ORDER BY 1 DESC LIMIT 12 """))).mappings()] top = [dict(r) for r in (await db.execute(text(""" SELECT item_name, count(*) AS qty, coalesce(sum(line_total),0)::float AS revenue FROM sale_items WHERE item_name IS NOT NULL GROUP BY item_name ORDER BY revenue DESC LIMIT 10 """))).mappings()] return {"ok": True, "summary": summary, "by_month": by_month, "top": top} # ── Reports workbench — parameterized + lazy (each chart fetched on demand) ─────────────────── # migrated history is status='paid'; new POS sales are 'completed' — count both, exclude open holds _DONE = "s.status IN ('completed','paid')" 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 to_char(s.sale_date,'HH24')||'h' AS name, count(*) AS count, 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", "format": "SELECT coalesce(nullif(f.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_format f ON f.release_id=i.release_id WHERE {where} AND {done} GROUP BY 1 ORDER BY {order} DESC LIMIT {lim}", "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()]} # ── System — read-only Postgres health (admin). No maintenance buttons: PG autovacuums, infra owns backups. @router.get("/system") async def system(ident=Depends(require_admin), db=Depends(get_db)): info = dict((await db.execute(text(""" SELECT pg_database_size(current_database()) AS db_bytes, split_part(version(), ' ', 2) AS pg_version, extract(epoch FROM (now() - pg_postmaster_start_time()))::bigint AS uptime_s, (SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()) AS conns, (SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND state = 'active') AS active, (SELECT round(sum(blks_hit) * 100.0 / nullif(sum(blks_hit + blks_read), 0), 1) FROM pg_stat_database WHERE datname = current_database()) AS cache_hit """))).mappings().first()) tables = [dict(r) for r in (await db.execute(text(""" SELECT relname AS name, pg_total_relation_size(relid) AS bytes, n_live_tup AS rows FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 15 """))).mappings()] fresh = dict((await db.execute(text(""" SELECT (SELECT max(updated_at) FROM inventory) AS inventory_updated, (SELECT max(sale_date) FROM sales) AS last_sale, (SELECT count(*) FROM inventory) AS inventory_rows, (SELECT count(*) FROM disc_release) AS releases """))).mappings().first()) return {"db": info, "tables": tables, "fresh": fresh} @router.get("/orders") async def orders(ident=Depends(require_token), db=Depends(get_db)): base = await vault.get_secret(db, "woo_base_url") ck = await vault.get_secret(db, "woo_consumer_key") cs = await vault.get_secret(db, "woo_consumer_secret") if not (base and ck and cs): return {"ok": False, "reason": "WooCommerce not connected — add keys in Connections"} try: async with httpx.AsyncClient(timeout=20, follow_redirects=True) as c: r = await c.get(base.rstrip("/") + "/wp-json/wc/v3/orders", params={"per_page": 20, "orderby": "date", "order": "desc"}, auth=(ck, cs)) except Exception as e: return {"ok": False, "reason": f"connection error: {e}"} if r.status_code != 200: return {"ok": False, "reason": f"Woo API HTTP {r.status_code}"} out = [] for o in r.json(): b = o.get("billing", {}) name = (b.get("first_name", "") + " " + b.get("last_name", "")).strip() or b.get("email", "") out.append({"id": o["id"], "number": o.get("number"), "status": o.get("status"), "total": o.get("total"), "currency": o.get("currency"), "date": (o.get("date_created") or "")[:10], "customer": name, "items": len(o.get("line_items", []))}) return {"ok": True, "orders": out}