From a817b9d163447fdb4f369eeac7ca7eac642f39ac Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 22 Jun 2026 22:48:43 +1000 Subject: [PATCH] =?UTF-8?q?feat(import):=20bulk=20stock-in=20=E2=80=94=20r?= =?UTF-8?q?esolve=20Release=20ID/barcode/catno=20against=20disc=5Frelease?= =?UTF-8?q?=20=E2=86=92=20create=20inventory=20(UI=20+=20endpoint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/admin_routes.py | 49 +++++++++++++++++++++++++++++++++++++++++++++ site/admin.html | 25 ++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/app/admin_routes.py b/app/admin_routes.py index 9e9d71f..e056d79 100644 --- a/app/admin_routes.py +++ b/app/admin_routes.py @@ -149,6 +149,55 @@ async def inv_delete(sku: str, ident=Depends(require_token), db=Depends(get_db)) return {"ok": True, "deleted": r.rowcount} +class ImportIn(BaseModel): + lines: list[str] + crate_id: int | None = None + price: float | None = None + condition: str | None = None + kind: str = "vinyl" + + +@router.post("/inventory/import") +async def inv_import(body: ImportIn, ident=Depends(require_token), db=Depends(get_db)): + """Bulk stock-IN: each line = Release ID / barcode / catno → resolved against the local disc_release + mirror → a new inventory item. (Distinct from the POS Import, which marks items SOLD.)""" + added, not_found = [], [] + for raw in body.lines: + ln = raw.strip() + if not ln: + continue + rid = title = None + if ln.isdigit(): + r = (await db.execute(text("SELECT id, title FROM disc_release WHERE id=:r"), + {"r": int(ln)})).first() + if r: + rid, title = r[0], r[1] + if not rid: # barcode / identifier + r = (await db.execute(text("""SELECT dr.id, dr.title FROM disc_release_identifier i + JOIN disc_release dr ON dr.id=i.release_id WHERE i.value=:v LIMIT 1"""), {"v": ln})).first() + if r: + rid, title = r[0], r[1] + if not rid: # catalogue number + r = (await db.execute(text("""SELECT dr.id, dr.title FROM disc_release_label l + JOIN disc_release dr ON dr.id=l.release_id WHERE l.catno=:v LIMIT 1"""), {"v": ln})).first() + if r: + rid, title = r[0], r[1] + if not rid: + not_found.append(ln) + continue + sku = "RG" + secrets.token_hex(4).upper() + await db.execute(text(""" + INSERT INTO inventory (sku, store_id, kind, release_id, title, price, qty, in_stock, condition, + crate_id, status, created_at, updated_at) + VALUES (:sku,:s,:kind,:rid,:title,:price,1,true,:cond,:crate,'publish',now(),now()) + ON CONFLICT (sku) DO NOTHING + """), {"sku": sku, "s": ident["store_id"], "kind": body.kind, "rid": rid, "title": title, + "price": body.price, "cond": body.condition, "crate": body.crate_id}) + added.append({"sku": sku, "release_id": rid, "title": title}) + await db.commit() + return {"ok": True, "added": len(added), "items": added, "not_found": not_found} + + @router.post("/inventory/bulk") async def inv_bulk(body: BulkIn, ident=Depends(require_token), db=Depends(get_db)): if not body.skus: diff --git a/site/admin.html b/site/admin.html index a252e93..2359f1a 100644 --- a/site/admin.html +++ b/site/admin.html @@ -134,7 +134,8 @@ async function vInv(){ + '
' + '' + '' - + '
' + + '' + + '' + '
' + '
loading…
'; $('#iq').addEventListener('keydown', e => { if(e.key==='Enter') invSearch(); }); @@ -239,6 +240,28 @@ async function invDoAdd(){ await fetch('/admin/inventory/add',{method:'POST',headers:hdr(),body:JSON.stringify(body)}); invModalClose(); loadInv(); } +function invImport(){ + invModalBox(`

Import list (bulk stock-in)

+
One per line — Release ID / barcode / catalogue no. Resolved against the catalog, each becomes an in-stock item.
+ + ${fld('Default price','impPrice','','type=number step=0.01')} + ${fld('Condition','impCond','')} + ${fld('Crate id','impCrate','','type=number')} + ${fld('Kind','impKind','vinyl')} +
+
`); +} +async function invDoImport(){ + const lines = $('#impLines').value.split('\n').map(s=>s.trim()).filter(Boolean); + if(!lines.length) return; + $('#impOut').textContent = 'resolving '+lines.length+'…'; + const body = { lines, price:numOrNull('impPrice'), condition:($('#impCond').value.trim()||null), + crate_id:numOrNull('impCrate'), kind:($('#impKind').value.trim()||'vinyl') }; + const d = await fetch('/admin/inventory/import',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(x=>x.json()); + $('#impOut').innerHTML = `✓ ${d.added} imported` + + (d.not_found.length?`
not found (${d.not_found.length}): ${d.not_found.map(escapeH).join(', ')}
`:''); + loadInv(); +} // ---------- Orders ---------- async function vOrders(){