253 lines
12 KiB
Python
253 lines
12 KiB
Python
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
|
|
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("/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.condition, 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
|
|
condition: str | None = None
|
|
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}
|
|
|
|
|
|
@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}
|
|
|
|
|
|
@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}
|
|
|
|
|
|
@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}
|