RECORDGOD/app/admin_routes.py
type-two b46930d468 feat(staff): time clock — clock on/off = shift = timecard
staff_shift table (one open shift per staff via partial unique index). /admin/clock
(status+today total), /clock/in (idempotent), /clock/out; /admin/timecards (admin)
= per-staff hours + recent shifts over a window. search.html top bar gets a live
clock widget (🟢 On 2h14m · Clock off · Log out; Clock on when off) ticking every 30s;
Log out warns if still clocked on. admin Staff tab gains a Timecards report.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:40:02 +10:00

521 lines
25 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, require_admin
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"
class StaffEditIn(BaseModel):
name: str | None = None
role: str | None = None
active: bool | 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, 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)
r = (await db.execute(text(
"INSERT INTO staff (name, token, role) VALUES (:n, :t, :r) RETURNING id"),
{"n": body.name.strip(), "t": token, "r": role})).scalar()
await db.commit()
return {"ok": True, "id": r, "token": token} # token returned ONCE — they paste it into /search to log in
@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, count(*) AS shifts,
round((sum(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} GROUP BY sh.staff_id, st.name ORDER BY hours DESC"""), params)).mappings()]
return {"shifts": shifts, "totals": totals, "days": days}
@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}
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}
@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}