From 83466389afe5522fcd278938619909df9169c226 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 22 Jun 2026 23:46:14 +1000 Subject: [PATCH] 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 --- app/navigator_routes.py | 98 +++++++++++++++++++++++++++++++++++++++++ site/search.html | 55 ++++++++++++++++++++++- 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/app/navigator_routes.py b/app/navigator_routes.py index 57c8546..e86ab66 100644 --- a/app/navigator_routes.py +++ b/app/navigator_routes.py @@ -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] diff --git a/site/search.html b/site/search.html index 3b7e63d..64b67a5 100644 --- a/site/search.html +++ b/site/search.html @@ -56,7 +56,10 @@
Crate contents
+
+ +
pick a crate on the map →
@@ -111,7 +114,7 @@