From 9a0bf41fbe43c65d1239b0815089e1a38de0c2f3 Mon Sep 17 00:00:00 2001 From: type-two Date: Wed, 24 Jun 2026 11:57:36 +1000 Subject: [PATCH] =?UTF-8?q?feat(intake):=20Heal=20sweep=20=E2=80=94=20back?= =?UTF-8?q?fill=20missing=20catalog=20metadata=20(salvaged=20self-healing?= =?UTF-8?q?=20pass)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WowPlatter self-heal idea, freed from its 3-hour import: one button scans in-stock items for gaps (title/weight/cover) and backfills. Phase 1 = local backfill from the disc_cache mirror (instant, unbounded UPDATE…FROM); Phase 2 = bounded Discogs re-fetch for release_ids missing from the mirror or without cover art (rate-limited, re-run for the rest). Refactored _enrich → _fetch_release so Heal can force-refetch (not just cache-miss). New /admin/intake/heal/{scan,run} + a 🩹 Heal admin view. Live first run: 30,202 rows had no title/weight (migration never denormalized them → broke shipping quotes); 30,193 healed instantly from the mirror, 239 needed Discogs (60/batch). Real win. Co-Authored-By: Claude Opus 4.8 --- app/intake_routes.py | 82 ++++++++++++++++++++++++++++++++++++++++++++ site/admin.html | 36 ++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/app/intake_routes.py b/app/intake_routes.py index fd28b56..97555f6 100644 --- a/app/intake_routes.py +++ b/app/intake_routes.py @@ -71,6 +71,12 @@ async def _enrich(db, rid): hit = await _cache_hit(db, rid) if hit: return dict(hit) + return await _fetch_release(db, rid) + + +async def _fetch_release(db, rid): + """ALWAYS hit the Discogs API for this release and grow the mirror (release+artist+label+thumb), + ignoring any existing cache row. Used by Heal to backfill missing cover art / metadata.""" try: c, ok = await _client(db) async with c: @@ -514,6 +520,82 @@ async def gsheet_import(ident=Depends(require_token), db=Depends(get_db)): return {"ok": True, "staged": staged, "skipped": skipped} +# --- Heal: backfill missing catalog metadata (the salvaged self-healing pass) ---- +# WowPlatter healed gaps inline during a 3-hour import; on metal it's a bounded one-button sweep. +# Phase 1 = LOCAL backfill from the mirror (free, instant). Phase 2 = bounded Discogs re-fetch for +# release_ids missing from the mirror or without cover art (rate-limited → re-run for the rest). + +# in-stock rows in this store whose release_id is missing from the mirror, or whose cached cover is blank +_NEEDS_API = """release_id IS NOT NULL AND ( + NOT EXISTS (SELECT 1 FROM disc_cache dc WHERE dc.release_id=i.release_id) + OR EXISTS (SELECT 1 FROM disc_cache dc WHERE dc.release_id=i.release_id + AND (dc.thumb IS NULL OR dc.thumb='')))""" + + +@router.get("/heal/scan") +async def heal_scan(ident=Depends(require_token), db=Depends(get_db)): + """Dry-run: count the gaps before healing anything.""" + sid = {"sid": ident["store_id"]} + base = "FROM inventory i WHERE i.store_id=:sid AND i.in_stock" + + async def n(extra): + return (await db.execute(text(f"SELECT count(*) {base} AND {extra}"), sid)).scalar() + + async def nd(extra): + return (await db.execute(text(f"SELECT count(DISTINCT i.release_id) {base} AND {extra}"), sid)).scalar() + + return { + "ok": True, + "no_release_id": await n("i.release_id IS NULL"), # can't auto-heal (needs matching) + "missing_title": await n("i.release_id IS NOT NULL AND i.title IS NULL"), + "missing_weight": await n("i.release_id IS NOT NULL AND i.weight_g IS NULL"), + "local_fixable": await n("i.release_id IS NOT NULL AND (i.title IS NULL OR i.weight_g IS NULL) " + "AND EXISTS (SELECT 1 FROM disc_cache dc WHERE dc.release_id=i.release_id " + "AND (dc.title IS NOT NULL OR dc.weight IS NOT NULL))"), + "api_needed": await nd(_NEEDS_API), # distinct releases needing Discogs + } + + +class HealIn(BaseModel): + limit: int = 60 # cap Discogs fetches per run (rate limit) — re-run for the remainder + + +@router.post("/heal/run") +async def heal_run(body: HealIn = HealIn(), ident=Depends(require_token), db=Depends(get_db)): + sid = ident["store_id"] + # Phase 1 — local backfill from the mirror (instant, unbounded) + local = (await db.execute(text(""" + UPDATE inventory i SET title = COALESCE(i.title, dc.title), + weight_g = COALESCE(i.weight_g, dc.weight), updated_at = now() + FROM disc_cache dc + WHERE dc.release_id = i.release_id AND i.store_id = :sid AND i.in_stock + AND (i.title IS NULL OR i.weight_g IS NULL) + AND (dc.title IS NOT NULL OR dc.weight IS NOT NULL)"""), {"sid": sid})).rowcount + await db.commit() + + # Phase 2 — bounded Discogs re-fetch for releases missing from the mirror / without cover + rids = [r[0] for r in (await db.execute(text( + f"SELECT DISTINCT i.release_id FROM inventory i " + f"WHERE i.store_id=:sid AND i.in_stock AND {_NEEDS_API} LIMIT :lim"), + {"sid": sid, "lim": body.limit}))] + enriched = 0 + for rid in rids: + meta = await _fetch_release(db, rid) # always fetches → fills cover + grows mirror + if meta: + await db.execute(text( + "UPDATE inventory SET title=COALESCE(title,:t), weight_g=COALESCE(weight_g,:w), " + "updated_at=now() WHERE release_id=:r AND store_id=:sid AND in_stock"), + {"t": meta.get("title"), "w": meta.get("weight"), "r": rid, "sid": sid}) + enriched += 1 + await asyncio.sleep(0.2) # ponytail: gentle on the Discogs rate limit + await db.commit() + + remaining = (await db.execute(text( + f"SELECT count(DISTINCT i.release_id) FROM inventory i " + f"WHERE i.store_id=:sid AND i.in_stock AND {_NEEDS_API}"), {"sid": sid})).scalar() + return {"ok": True, "local_healed": local, "api_enriched": enriched, "remaining_api": remaining} + + def _selfcheck(): # SKU from a Google-Form timestamp (both ISO and en-AU slash formats) → YYYYMMDDHHMMSS assert _sku_from_ts("2025-01-30T14:01:02.821Z") == "20250130140102" diff --git a/site/admin.html b/site/admin.html index 4e2d105..2ed0281 100644 --- a/site/admin.html +++ b/site/admin.html @@ -71,6 +71,7 @@ 📝 Wantlist
Catalog
📥 Intake + 🩹 Heal 🗺️ Store map 🗄️ Crates 📈 Reports @@ -108,7 +109,7 @@ document.querySelectorAll('nav a[data-v]').forEach(a => a.onclick = () => go(a.d function go(v){ if(ROLE!=='admin' && (v==='connections'||v==='staff'||v==='system')) v='dashboard'; // staff can't reach admin views document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v)); - ({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn, staff:vStaff, system:vSystem}[v])(); + ({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, heal:vHeal, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn, staff:vStaff, system:vSystem}[v])(); } // ---------- Dashboard (fast tiles first, charts lazy) ---------- @@ -672,6 +673,39 @@ async function inkImport(){ m.innerHTML = d.ok ? `✓ staged ${d.staged} new${d.skipped?` · ${d.skipped} already stocked`:''}` : ''+escapeH(d.error||'failed')+''; } +// ---------- Heal (backfill missing catalog metadata) ---------- +function vHeal(){ + $('#content').innerHTML = '

Heal · backfill missing catalog metadata

' + + '
' + + '

Scans in-stock items for gaps — missing title, weight, or cover art — and backfills from your local Discogs mirror. Anything the mirror doesn’t have is re-fetched from Discogs (rate-limited, so it heals in batches).

' + + ' ' + + '
'; +} +async function healScan(){ + const o=$('#heal_out'); o.innerHTML='scanning…'; + const d=await(await fetch('/admin/intake/heal/scan',{headers:hdr()})).json(); + if(!d.ok){ o.innerHTML='scan failed'; return; } + const row=(label,n,note)=>`${label}${n}${note||''}`; + o.innerHTML='' + + row('Missing title', d.missing_title, d.local_fixable?`${d.local_fixable} fixable instantly from the mirror`:'') + + row('Missing weight', d.missing_weight, 'blocks shipping quotes') + + row('Need Discogs re-fetch', d.api_needed, 'not in mirror / no cover art') + + row('No release_id', d.no_release_id, 'can’t auto-heal — needs matching') + + '
'; + const fixable = d.missing_title + d.missing_weight + d.api_needed; + $('#heal_run').style.display = fixable ? '' : 'none'; + if(!fixable) o.innerHTML += '
✓ Nothing to heal — catalog is clean.
'; +} +async function healRun(){ + const o=$('#heal_out'), b=$('#heal_run'); b.disabled=true; b.textContent='healing…'; + const d=await(await fetch('/admin/intake/heal/run',{method:'POST',headers:hdr(),body:'{}'})).json(); + b.disabled=false; b.textContent='🩹 Heal now'; + if(!d.ok){ o.innerHTML='heal failed'; return; } + o.innerHTML=`
✓ ${d.local_healed} healed locally · ${d.api_enriched} re-fetched from Discogs
` + + (d.remaining_api ? `
${d.remaining_api} still need Discogs (rate-limited) —
` + : '
All gaps closed. Re-scan to confirm.
'); +} + // ---------- Crates ---------- async function vCrates(){ const m = $('#content'); m.innerHTML = '

Crates

loading…
';