feat(scanner): scan-to-slot assigner (replace/append/prepend/insert + Test preview + recover-last) + editable crate info (name/label); hdr Content-Type fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-22 23:46:14 +10:00
parent 19c6e03c70
commit 83466389af
2 changed files with 152 additions and 1 deletions

View File

@ -130,6 +130,104 @@ async def release_info(release_id: int, ident=Depends(require_token), db=Depends
return {"ok": True, "release": dict(dr) if dr else None, "items": items}
# ── Scanner — assign scanned items to a crate's slots (Replace / Append / Prepend / Insert) ────
class CrateEditIn(BaseModel):
name: str | None = None
label_text: str | None = None
crate_purpose: str | None = None
@router.post("/crate/{crate_id}")
async def crate_edit(crate_id: int, body: CrateEditIn, 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 virtual_crate SET {sets} WHERE id = :i"), {**fields, "i": crate_id})
await db.commit()
if not r.rowcount:
raise HTTPException(404, "crate not found")
return {"ok": True}
class ScanAssignIn(BaseModel):
crate_id: int
lines: list[str]
mode: str = "replace" # replace | append | prepend | insert
insert_at: int | None = None
dry_run: bool = False
async def _resolve_sku(db, ln):
"""A scanned line → the inventory SKU it refers to (exact sku, else release_id, else barcode)."""
ln = ln.strip()
if not ln:
return None
r = (await db.execute(text("SELECT sku FROM inventory WHERE sku = :v LIMIT 1"), {"v": ln})).first()
if r:
return r[0]
if ln.isdigit():
r = (await db.execute(text("SELECT sku FROM inventory WHERE release_id = :v AND in_stock "
"ORDER BY (crate_id IS NULL) DESC, sku LIMIT 1"), {"v": int(ln)})).first()
if r:
return r[0]
r = (await db.execute(text("""SELECT i.sku FROM disc_release_identifier di JOIN inventory i ON i.release_id = di.release_id
WHERE di.value = :v AND i.in_stock ORDER BY (i.crate_id IS NULL) DESC, i.sku LIMIT 1"""), {"v": ln})).first()
return r[0] if r else None
@router.post("/scan")
async def scan_assign(body: ScanAssignIn, ident=Depends(require_token), db=Depends(get_db)):
resolved, not_found = [], []
for ln in body.lines:
if not ln.strip():
continue
sku = await _resolve_sku(db, ln)
if sku:
resolved.append(sku)
else:
not_found.append(ln.strip())
res = list(dict.fromkeys(resolved)) # unique, keep scan order
existing = [r[0] for r in (await db.execute(text(
"SELECT sku FROM inventory WHERE crate_id = :c AND in_stock ORDER BY slot_number NULLS LAST, sku"
), {"c": body.crate_id})).all()]
keep = [s for s in existing if s not in res] # existing items not in the scan
if body.mode == "replace":
order = res
elif body.mode == "append":
order = keep + res
elif body.mode == "prepend":
order = res + keep
elif body.mode == "insert":
at = max(0, (body.insert_at or 1) - 1)
order = keep[:at] + res + keep[at:]
else:
raise HTTPException(400, "bad mode")
if body.dry_run:
meta = {}
if order:
rows = await db.execute(text("""SELECT i.sku, coalesce(i.title, dc.title) AS title, dc.artist
FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id WHERE i.sku = ANY(:s)"""),
{"s": order})
meta = {r["sku"]: r for r in rows.mappings()}
plan = [{"slot": n, "sku": s, "title": (meta.get(s) or {}).get("title"),
"artist": (meta.get(s) or {}).get("artist")} for n, s in enumerate(order, 1)]
return {"ok": True, "preview": True, "plan": plan, "not_found": not_found, "count": len(order)}
if body.mode == "replace": # Replace All: items dropped from the crate get unfiled
removed = [s for s in existing if s not in order]
if removed:
await db.execute(text("UPDATE inventory SET crate_id=NULL, slot_number=NULL, updated_at=now() "
"WHERE sku = ANY(:s)"), {"s": removed})
for n, sku in enumerate(order, 1):
await db.execute(text("UPDATE inventory SET crate_id=:c, slot_number=:n, updated_at=now() WHERE sku=:sku"),
{"c": body.crate_id, "n": n, "sku": sku})
await db.execute(text("UPDATE virtual_crate SET updated_at=now() WHERE id=:c"), {"c": body.crate_id})
await db.commit()
return {"ok": True, "assigned": len(order), "not_found": not_found}
# ── Reorganize — A-Z re-file planner (server scans + applies; client plans the moves) ──────────
class ScanIn(BaseModel):
crate_ids: list[int]

View File

@ -56,7 +56,10 @@
<div class="panel">
<div class="bar" style="justify-content:space-between">
<div><span id="ccName" class="muted">Crate contents</span> <span id="ccMeta" class="muted"></span></div>
<div class="bar" id="ccBtns" style="display:none"><button class="ghost" style="padding:3px 8px" onclick="scanToggle()">🔫 Scan</button><button class="ghost" style="padding:3px 8px" onclick="crateEditToggle()">✏️ edit</button></div>
</div>
<div id="crateEdit" style="display:none;margin-top:8px"></div>
<div id="scanPanel" style="display:none;margin-top:8px"></div>
<div id="ccList" style="max-height:26vh;overflow:auto;margin-top:8px"><div class="muted">pick a crate on the map →</div></div>
</div>
<div class="panel">
@ -111,7 +114,7 @@
<script>
const $=s=>document.querySelector(s);
let TOKEN=localStorage.getItem('rg_token')||'';
const hdr=()=>({'Authorization':'Bearer '+TOKEN});
const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'});
const get=u=>fetch(u,{headers:hdr()}).then(r=>r.json());
const money=n=>n==null?'—':'$'+Number(n).toFixed(2);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
@ -252,6 +255,7 @@ function stepLevel(d){ ST.level=Math.max(1,Math.min(ST.rack.levels||1, ST.level+
async function loadCrate(id){
ST.selCrate=id; if(ST.view==='rack') drawRack();
const d=await get('/nav/crate/'+id); const c=d.crate;
ST.crateInfo=c; $('#ccBtns').style.display='flex'; $('#scanPanel').style.display='none'; $('#crateEdit').style.display='none';
$('#ccName').innerHTML='📦 '+esc(c.label_text||c.name||('Crate '+c.id));
$('#ccName').className='pink';
const ls = c.last_scanned ? ` · scanned ${String(c.last_scanned).slice(0,10)}${c.updated_by?' by '+esc(c.updated_by):''}` : '';
@ -296,6 +300,55 @@ async function showRelease(rid){
<div class="muted" style="margin:4px 0">Inventory copies (${d.items.length})</div>${relItems(d.items)}`;
}
// ── editable crate info ──
function crateEditToggle(){
const p=$('#crateEdit'); if(p.style.display!=='none'){ p.style.display='none'; return; }
$('#scanPanel').style.display='none'; const c=ST.crateInfo||{};
p.style.display='block';
p.innerHTML=`<div class="bar"><input id="ceName" placeholder="name" value="${esc(c.name||'')}" style="flex:1">
<input id="ceLabel" placeholder="label / genre" value="${esc(c.label_text||'')}" style="flex:1">
<button onclick="crateEditSave()">Save</button></div>`;
}
async function crateEditSave(){
await fetch('/nav/crate/'+ST.selCrate,{method:'POST',headers:hdr(),body:JSON.stringify({name:$('#ceName').value,label_text:$('#ceLabel').value})});
$('#crateEdit').style.display='none'; loadCrate(ST.selCrate);
}
// ── scanner (assign scanned items to the active crate's slots) ──
function scanToggle(){
const p=$('#scanPanel'); if(p.style.display!=='none'){ p.style.display='none'; return; }
$('#crateEdit').style.display='none';
const last=localStorage.getItem('rg_lastscan')||'', c=ST.crateInfo||{};
p.style.display='block';
p.innerHTML=`<div class="muted">🔫 Scan into <b class="pink">${esc(c.label_text||('Crate '+ST.selCrate))}</b> — Release IDs / SKUs / barcodes, one per line</div>
<textarea id="scanLines" rows="5" style="width:100%;margin-top:4px"></textarea>
<div class="bar" style="margin-top:4px;flex-wrap:wrap"><select id="scanMode" onchange="$('#scanAt').style.display=this.value==='insert'?'inline-block':'none'">
<option value="replace">Replace all</option><option value="append">Add at end</option><option value="prepend">Add at start</option><option value="insert">Insert at slot…</option></select>
<input id="scanAt" type="number" placeholder="slot" style="width:60px;display:none">
<button class="ghost" onclick="scanTest()">Test</button><button onclick="scanProcess()">Process</button>
<button class="ghost" onclick="$('#scanLines').value=''">Clear</button>${last?'<button class="ghost" onclick="scanRecover()">↺ recover last</button>':''}</div>
<div id="scanOut" class="muted" style="margin-top:6px;max-height:24vh;overflow:auto"></div>`;
}
function scanBody(dry){
return { crate_id:ST.selCrate, lines:$('#scanLines').value.split('\n').map(s=>s.trim()).filter(Boolean),
mode:$('#scanMode').value, insert_at:($('#scanAt').value?parseInt($('#scanAt').value):null), dry_run:!!dry };
}
async function scanTest(){
const b=scanBody(true); if(!b.lines.length) return;
const d=await fetch('/nav/scan',{method:'POST',headers:hdr(),body:JSON.stringify(b)}).then(x=>x.json());
$('#scanOut').innerHTML=`<b>Preview — ${d.count} slots:</b>`+(d.plan||[]).map(p=>`<div>${p.slot}. ${esc(p.title||p.sku)} <span class="muted">${esc(p.artist||'')}</span></div>`).join('')
+(d.not_found.length?`<div class="oos">not found: ${d.not_found.map(esc).join(', ')}</div>`:'');
}
async function scanProcess(){
const b=scanBody(false); if(!b.lines.length) return;
if(b.mode==='replace' && !confirm('Replace all — items not scanned will be unfiled from this crate. Continue?')) return;
localStorage.setItem('rg_lastscan', $('#scanLines').value);
const d=await fetch('/nav/scan',{method:'POST',headers:hdr(),body:JSON.stringify(b)}).then(x=>x.json());
$('#scanOut').innerHTML=`<span class="ok">✓ ${d.assigned} assigned to slots</span>`+(d.not_found.length?`<div class="oos">not found: ${d.not_found.map(esc).join(', ')}</div>`:'');
loadCrate(ST.selCrate);
}
function scanRecover(){ $('#scanLines').value=localStorage.getItem('rg_lastscan')||''; }
// ── collection editor ──
function showEditor(){ $('#colPanel').style.display='none'; $('#editPanel').style.display=''; }
function edCancel(){ ST.edit=null; $('#editPanel').style.display='none'; $('#colPanel').style.display=''; }