feat(intake): Heal sweep — backfill missing catalog metadata (salvaged self-healing pass)

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-24 11:57:36 +10:00
parent beabfe1297
commit 9a0bf41fbe
2 changed files with 117 additions and 1 deletions

View File

@ -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"

View File

@ -71,6 +71,7 @@
<a data-v="wantlist">📝 Wantlist</a>
<div class="sec">Catalog</div>
<a data-v="intake">📥 Intake</a>
<a data-v="heal">🩹 Heal</a>
<a data-v="storemap">🗺️ Store map</a>
<a data-v="crates">🗄️ Crates</a>
<a data-v="reports">📈 Reports</a>
@ -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 ? `<span style="color:var(--ok)">✓ staged ${d.staged} new</span>${d.skipped?` · ${d.skipped} already stocked`:''}` : '<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
}
// ---------- Heal (backfill missing catalog metadata) ----------
function vHeal(){
$('#content').innerHTML = '<h2>Heal <span class="muted" style="font-size:13px">· backfill missing catalog metadata</span></h2>'
+ '<div class="card" style="max-width:680px">'
+ '<p class="muted" style="margin-top:0">Scans in-stock items for gaps — missing title, weight, or cover art — and backfills from your local Discogs mirror. Anything the mirror doesnt have is re-fetched from Discogs (rate-limited, so it heals in batches).</p>'
+ '<button onclick="healScan()">Scan for gaps</button> <button onclick="healRun()" id="heal_run" style="display:none">🩹 Heal now</button>'
+ '<div id="heal_out" style="margin-top:14px"></div></div>';
}
async function healScan(){
const o=$('#heal_out'); o.innerHTML='<span class="muted">scanning…</span>';
const d=await(await fetch('/admin/intake/heal/scan',{headers:hdr()})).json();
if(!d.ok){ o.innerHTML='<span style="color:var(--warn)">scan failed</span>'; return; }
const row=(label,n,note)=>`<tr style="border-top:1px solid var(--line)"><td style="padding:5px 10px 5px 0">${label}</td><td style="text-align:right;font-weight:600">${n}</td><td class="muted" style="padding-left:12px;font-size:12px">${note||''}</td></tr>`;
o.innerHTML='<table style="border-collapse:collapse">'
+ 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, 'cant auto-heal — needs matching')
+ '</table>';
const fixable = d.missing_title + d.missing_weight + d.api_needed;
$('#heal_run').style.display = fixable ? '' : 'none';
if(!fixable) o.innerHTML += '<div style="color:var(--ok);margin-top:10px">✓ Nothing to heal — catalog is clean.</div>';
}
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='<span style="color:var(--warn)">heal failed</span>'; return; }
o.innerHTML=`<div style="color:var(--ok)">✓ ${d.local_healed} healed locally · ${d.api_enriched} re-fetched from Discogs</div>`
+ (d.remaining_api ? `<div style="margin-top:8px">${d.remaining_api} still need Discogs (rate-limited) — <button onclick="healRun()">Heal next batch</button></div>`
: '<div class="muted" style="margin-top:8px">All gaps closed. Re-scan to confirm.</div>');
}
// ---------- Crates ----------
async function vCrates(){
const m = $('#content'); m.innerHTML = '<h2>Crates</h2><div class="muted">loading…</div>';