feat(inventory): CRUD backend (add/edit/delete/bulk) — first cream from the WowPlatter audit; + migration audit doc

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-22 22:39:17 +10:00
parent 0b679ba079
commit 4ece321b76
3 changed files with 361 additions and 1 deletions

View File

@ -0,0 +1,192 @@
# WowPlatter → RecordGod — feature migration audit
Cycle through every WowPlatter wp-admin page, assess **cream vs bloat**, and spec what to migrate.
Legend: **🟢 CREAM** (migrate) · **🔴 BLOAT** (drop) · **🟡 UNSURE** (regroup with John) · **✅ DONE** (already in RecordGod).
Menu = 2 roots (WowPlatter / SOUND) + 28 submenu pages. Source: `admin/partials/*`, `admin/class-*-ajax-handler.php`.
---
## STOCK group
### 1. Dashboard — `dashboard.php` · ✅ DONE (parity to extend)
Purpose: at-a-glance — Top Performers (last 30 days), stock value, counts, quick actions.
RecordGod: `/admin` dashboard already has counts/stock-value/sales/quick-actions. **Migrate the "Top Performers (30d)"
+ best-sellers widget** onto it (from `sale_items` we already have). Verdict: 🟢 (small add to the existing dash).
### 2. Search — `search.php` · ✅ DONE / in progress
Scanner / Returns / Reorganize / Collections / Stock Finder + Store→Rack→Crate→Item navigator.
RecordGod: navigator (store→rack→crate→locate), Collections (CRUD + editor + fallback locator), Reorganize **backend**
(`/nav/reorg/scan,apply,compress`) all built. **Pending UIs: Scanner (scan-to-slot), Returns, Reorganize walkthrough.**
Verdict: 🟢 core — finish the 3 pending UIs.
### 3. Inventory — `inventory/{main,list,form,bulk,meta-catalog,discogs-marketplace,ebay-marketplace}.php` · 🟢 CREAM (vital, not yet built)
The actual stock manager. **list.php** = Current Stock table (search, edit, delete, bulk actions). **form.php** = add/edit
one item. **bulk.php** = 4 tabs: IMPORT / DISCOGS / SYNC / BULK ACTIONS (import missing release data+images, push to Woo).
ajax: `add_inventory_item`, `delete_inventory_item`, `sync_inventory_to_woo`, `create_woo_product_from_inventory`,
`import_missing_data`, `inventory_changes_log`. RecordGod has the `inventory` TABLE + intake API but **no list/edit/bulk UI**.
**Migrate: the inventory list (search/filter/edit/delete/bulk) + single-item add/edit form.** Skip eBay-marketplace tab
(🔴, not used). Woo-sync stays (commerce). Verdict: 🟢 — THE core back-office screen still missing.
### 4. Import — `import/{main,other,reference-conversion}.php` · 🟢 CREAM
Bulk-get-stock-in: paste release IDs / catnos / barcodes → match to Discogs → create inventory. **reference-conversion** =
turn catalog numbers / identifiers into release_ids (`convert_references`, `convert_discogs_references`). ajax:
`execute_discogs_import`, `execute_discogs_selected_import`, `check_import_progress`, `cancel_import`, `convert_*_references`.
RecordGod: has intake API + the disc_release mirror (so matching is local now). → **Migrate: the bulk import UI
(paste list → resolve against disc_release/identifier → review → create inventory).** Verdict: 🟢.
### 5. Print — `print.php` + `label-designer.php` · 🟢 CREAM
Tabs: Preview / Settings / Label Designer. Thermal/price-label printing with a template designer. ajax:
`ajax_{save,get,delete}_label_template`, `get_label_templates_by_type`. RecordGod: PriceGod does label printing via the
rfid-daemon; RecordGod itself has no label UI. → **Migrate: label templates + print (artist/title/price/QR/SKU), reuse the
PriceGod/daemon print path.** Designer is nice-to-have. Verdict: 🟢 (templates + print core; 🟡 the full visual designer).
### 6. Reports — `reports/audio.php` (+ sales/stock reports) · 🟡 UNSURE (depth)
The Reports menu surfaces sales/stock/audio analytics. RecordGod has a basic `/admin/reports` (revenue, by-month, top items).
**Question for John: how deep do reports need to go?** Basic sales+stock reports = 🟢; the audio/Beatport/MusicBrainz
report tabs lean enrichment-niche. Verdict: 🟡 — confirm which report tabs are cream.
### 7. Analyse — `analyse.php` · 🟡 UNSURE
"Analyse Inventory" — analysis + data-cleaning tools (find dupes, missing data, anomalies). Could be genuinely useful
(stock hygiene) or overlap with Import's "missing data". → **Question: is this a distinct tool you use, or covered by
Import + Inventory bulk?** Verdict: 🟡.
### 8. Wiki — `wiki.php` · 🟡 UNSURE
"Wiki Lookup & Editor" — per-release wiki/notes lookup + editing (wiki_extract enrichment). Niche enrichment; DealGod/
discogs already carry notes. → **Question: do you actually use the wiki editor, or is the release `notes`/`description`
field enough?** Verdict: 🟡 (lean 🔴).
### 9. Other — (misc tools) · 🟡 UNSURE
Catch-all "Other" menu — need to confirm what's parked here (likely odds-and-ends tools). Verdict: 🟡 — confirm contents.
### 10. Virtual Store — `virtual/*` · ✅ DONE
3D store ported to RecordGod (raw Three.js, coordinate convention fixed, 3 rooms render). Editing tools (rack/crate
placement) still WP-side — 🟡 do we need an in-RecordGod scene editor, or keep placing via the navigator? Verdict: ✅ core done.
### 11. Gig Guide — `gig-guide.php` · ✅ DONE (lives in DealGod)
GigGod is already in DealGod (`live_gig` + `/api/gigs/structured` + `/touring`); WowPlatter/RecordGod consume via API.
Verdict: ✅ — no migration, consume the DealGod API.
---
## SALES group
### 12. Sales — `sales/*` · ✅ DONE
POS (ring-up/discounts/payment-plans/locate/past-sales/promotions/import/settings) built in RecordGod `/pos`.
Pending: Email-receipt + Square tabs. Verdict: ✅ core done.
### 13. Customers — `customers.php` · 🟢 CREAM
Full CRM: tabs Overview / Sales / Loyalty / Wantlist / Mailing / Messages / Info per customer. RecordGod has a thin
`customer` table + POS picker only. → **Migrate: the customer record page (history, wantlist, contact, mailing prefs).**
Verdict: 🟢 (Loyalty/Messages tabs depend on those features below).
### 14. Wantlist — `wantlist.php` · 🟡 UNSURE (lean 🟢)
Customer wantlists — "who wants what" → buy signals + notify-on-arrival. Genuinely useful for a record store (match
incoming stock to wants). → **Question: priority? It pairs with Customers + intake.** Verdict: 🟡→🟢.
### 15. Inbox — (customer messages) · 🟡 UNSURE
Customer message inbox (ties to Messaging/Beeper). → **Question: do you run customer messaging through here, or external
(email/socials)?** Verdict: 🟡.
### 16. Loyalty — `loyalty-{users,reports,settings}.php` · 🟡 UNSURE
Points/tiers/rewards program (`loyalty_ledger`, `loyalty_tiers`, `loyalty_settings`). Real feature but is a loyalty program
cream for the shop right now? → **Question: are you running loyalty, or is it aspirational?** Verdict: 🟡.
---
## FRONTEND group
### 17. Profile Editor — `profile-editor.php` · 🟡 UNSURE
Customer-facing profile editing (frontend account). Storefront concern; RecordGod's storefront is nascent. Verdict: 🟡.
### 18. Pages — `pages.php` · 🟢 CREAM (partly done)
Storefront page builder: tabs General / Sales / Header / Welcome / FAQ / SEO / URL-structure / Placeholder. RecordGod has
the `/builder` (theme + brand import). → **Migrate the content bits (welcome/FAQ/SEO/header copy) into the builder.**
Verdict: 🟢 (fold into the existing builder).
### 19. Audio — `audio.php` · 🟡 UNSURE (split)
Tabs: MusicBrainz / Streaming & Harvester / Beatport BPM-Key / Reports. = audio enrichment + track previews + Apple Music
harvester. **Streaming/preview audio on the storefront = potentially 🟢 cream** (sell records by letting people hear them);
**the MusicBrainz/Beatport enrichment = DealGod/discogs territory (🟡).** → **Question: do you want audio previews on the
RecordGod storefront?** Verdict: 🟡 — split preview (cream) from enrichment (DealGod).
### 20. Audio Reports — `reports/audio.php` · 🔴 BLOAT (lean)
Reporting on the audio-harvest process. Internal tooling for the enrichment pipeline — not a shop feature. Verdict: 🔴/🟡.
### 21. Messaging — `messaging-settings.php` + `channels/` · 🟡 UNSURE
Customer messaging config + channels (Beeper bridge = WhatsApp/etc). Powerful but heavy. → **Question: channel messaging =
keep or external?** Verdict: 🟡 (lean 🔴 for v1).
---
## INTEGRATIONS & SYSTEM
### 22. Shipping — `shipping/{main,rates,labels}.php` · 🟢 CREAM (if you ship)
AusPost rates + label creation (`auspost_create_labels`), zones/flat-rates. Fulfillment for mail-order. → **Migrate if
RecordGod handles fulfillment; else Woo handles it.** Verdict: 🟡→🟢 — confirm RecordGod owns shipping vs Woo.
### 23. AI — `ai-tabs/{main,agent,chatbot,connect,prompts}.php` · 🔴 BLOAT (defer)
AI chatbot/agent + prompt config. Side quest, not the seller loop (PriceGod plan already cut AI). Verdict: 🔴 defer.
### 24. Beatport — `beatport-match.php` · 🟡 UNSURE
BPM/Key matcher (Search / Work Queue) via browser extension. DJ-store enrichment — useful for YOUR dance catalog, niche
generally. Overlaps DealGod enrichment. → **Question: BPM/key valuable enough to carry?** Verdict: 🟡.
### 25. Blagginate — `blagginate.php` · 🔴 BLOAT
YouTube-cookie tracklist/metadata processor. PriceGod plan explicitly cut it. Verdict: 🔴 drop.
### 26. Ads — `ads-admin-display.php` + `ads/` · 🔴 BLOAT (lean)
Advertising management. Niche, not core to stock/sell. → **Question: is this used?** Verdict: 🔴/🟡.
### 27. Connect — `connect/{main,auspost,dealgod,discogs,ebay,facebook,google,listenbrainz,mail,recaptcha,serp,square}.php` · 🟢 CREAM
THE integration hub — store all third-party credentials + test connections. RecordGod already has `/settings/secrets`
(Fernet vault) + connection tests for **discogs/woo/dealgod**. → **Migrate the rest: square, mail(SMTP), auspost,
facebook, google** as needed. eBay/listenbrainz/serp/recaptcha = 🟡 (only if used). Verdict: 🟢 — extend the vault/settings.
### 28. System — `system.php` + `tools/{debug,logging,shortcodes,wpcli}.php` + `settings/{endpoints,general,performance}.php` · 🟡 UNSURE
Admin plumbing: debug, logs, shortcodes, WP-CLI, endpoint registry, perf. Most is WP-specific (🔴 — RecordGod has its own
infra). **General settings (currency/tax/store info) = 🟢** (some already in POS settings). Verdict: 🟡 — cherry-pick
general settings; drop WP plumbing.
---
## SUMMARY
**🟢 CREAM — migrate (priority order):**
1. **Inventory list/edit/bulk** (the missing core stock screen)
2. **Import** (bulk stock-in, now matchable against the local disc_release mirror)
3. **Print / labels** (price labels + templates)
4. **Customers** CRM record page
5. **Connect** — extend the vault with square/mail/auspost/etc.
6. **Pages** content → fold into `/builder`
7. **Dashboard** Top-Performers widget
8. Finish **Search** (Scanner/Returns/Reorganize UIs), **Sales** (email/Square)
**🔴 BLOAT — drop:** Blagginate, AI chatbot, Ads (likely), Audio Reports, WP system/tools plumbing.
**✅ DONE:** Search (mostly), Sales, Virtual Store, Gig Guide (DealGod).
**RESOLVED — regroup with John (2026-06-22):**
- **Reports / Analyse** → 🟢 FULL reports + a standalone **Analyse** tool (dupes/missing-data/anomalies).
- **Wantlist** → 🟢 migrate **with intake-match + notify-on-arrival** (match incoming stock to customer wants).
- **Loyalty** → 🟢 migrate (data + UI) **but keep INACTIVE** — "needs a lot of work", don't activate the program yet.
- **Wiki** → 🔴 DROP — superseded by local enwiki/Discogs/MusicBrainz lookups (built before the local data centre existed).
- **Audio** → 🟢 migrate, but **INTERNALIZE**: replace API-key enrichment with local **MusicBrainz** (full MB on Ultra)
+ enwiki + Discogs lookups; KEEP the **Apple Music ID** feature (saves id as JSON to **preserve country**) + storefront
**previews** + **Beatport**. ⭐ **Needs its own careful page-by-page review** — what each piece does, what's API vs
internalisable. (ajax surface: batch_musicbrainz_*, discogs_to_musicbrainz_lookup, force_process_apple_id, apple_repair_*,
bp_search_beatport, batch_harvest_*, audio_player.)
- **Beatport** BPM/Key → 🟢 migrate (valuable for the dance catalog).
- **Messaging / Inbox** → 🔴 DROP in-app (Beeper/channels) — keep customer messaging external (email/socials).
- **Profile Editor** → 🟡 LATER — storefront not far enough along.
- **Shipping** → 🟢 migrate **own AusPost rates (table-based, NO API, no paid WP plugin)** — rates imported from the AusPost
guide PDF. ⚠️ **Rates +5% on 2026-07-01** (~9 days). Need a low-effort **rate-update path** (download new PDF → Gemini
extracts the tables → load), run ~yearly.
- **Ads** → 🟢 migrate — **post-ads-to-Meta API** + **Discogs marketplace management**. Not used yet but wanted in record-store software.
- **"Other"** menu (`wowplatter-other` / `display_other_page`) → confirm contents during execution (catch-all).
- **Virtual scene editor** → 🟢 BUILD the full **3D rack/crate placement editor inside RecordGod**.
**FINAL EXECUTION ORDER (cream):** 1) Inventory list/edit/bulk · 2) Import · 3) Print/labels · 4) Customers CRM ·
5) Connect (extend vault) + Shipping (AusPost rates, before Jul 1) · 6) Wantlist · 7) Reports+Analyse · 8) finish Search
(Scanner/Returns/Reorganize) + Sales (email/Square) · 9) Audio internalization review+build · 10) Beatport · 11) Ads
(Meta + Discogs marketplace) · 12) Loyalty (inactive) · 13) 3D scene editor · 14) Pages→builder · 15) Dashboard widget.
DROP: Wiki, Messaging/Inbox, Blagginate, AI chatbot, Audio-Reports, WP system plumbing. LATER: Profile Editor.

View File

@ -1,5 +1,8 @@
import secrets
import httpx import httpx
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text from sqlalchemy import text
from . import vault from . import vault
@ -63,6 +66,109 @@ async def inventory(q: str = Query(""), kind: str = Query(""), crate_id: int | N
return {"ok": True, "items": items, "page": page, "per": per, "total": total} 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") @router.get("/crates")
async def crates(ident=Depends(require_token), db=Depends(get_db)): async def crates(ident=Depends(require_token), db=Depends(get_db)):
rows = await db.execute(text(""" rows = await db.execute(text("""

62
disc_images_backfill.py Normal file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Copy WowPlatter's already-downloaded release-cover WebPs into RecordGod's hashed image tree.
WowPlatter downloaded 60k+ covers to wp-content/uploads/wowplatter/release/<NN>/release-<id>.webp
(all WebP, ~150px, tiny). RecordGod serves /img/r/<release_id> from DISC_IMAGE_DIR/<h[:2]>/<h>.webp
where h = sha256(release_id) so we stage them under that scheme, then rsync to the VPS image dir.
python disc_images_backfill.py # build /tmp/rg_disc_images
rsync -a /tmp/rg_disc_images/ root@<vps>:/opt/recordgod/disc_images/
Server-side i.discogs fetch is Cloudflare-403'd, so these local copies are how RecordGod self-hosts.
"""
import hashlib
import pathlib
import re
import shutil
import sys
import pymysql
WP = "/opt/homebrew/var/www/monsterrobot.localsite"
UPLOADS = WP + "/wp-content/uploads/"
STAGE = pathlib.Path("/tmp/rg_disc_images")
def creds():
t = pathlib.Path(WP + "/wp-config.php").read_text()
g = lambda k: re.search(rf"'{k}',\s*'([^']*)'", t).group(1)
return g("DB_NAME"), g("DB_USER"), g("DB_PASSWORD")
def main():
name, user, pw = creds()
c = pymysql.connect(unix_socket="/opt/homebrew/var/mysql/mysql.sock", user=user, password=pw,
database=name).cursor()
# one primary cover per release (fall back to any image if no primary)
c.execute("""SELECT release_id, local_url FROM wp_rmp_disc_release_image
WHERE local_url IS NOT NULL ORDER BY release_id, (type='primary') DESC""")
seen, copied, missing = set(), 0, 0
for release_id, local_url in c.fetchall():
if release_id in seen:
continue
seen.add(release_id)
m = re.search(r"/wp-content/uploads/(.+)$", local_url)
if not m:
continue
src = pathlib.Path(UPLOADS + m.group(1))
if not src.exists():
missing += 1
continue
h = hashlib.sha256(str(release_id).encode()).hexdigest()
dst = STAGE / h[:2] / f"{h}.webp"
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(src, dst)
copied += 1
if copied % 5000 == 0:
print(f" {copied}", file=sys.stderr)
print(f"staged {copied} covers to {STAGE} ({missing} files missing on disk)", file=sys.stderr)
if __name__ == "__main__":
main()