feat(import): bulk stock-in — resolve Release ID/barcode/catno against disc_release → create inventory (UI + endpoint)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-22 22:48:43 +10:00
parent c5ce84de1c
commit a817b9d163
2 changed files with 73 additions and 1 deletions

View File

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

View File

@ -134,7 +134,8 @@ async function vInv(){
+ '<div class="bar"><input id="iq" placeholder="search title / artist / sku…" value="'+invState.q+'">'
+ '<select id="ikind"><option value="">all kinds</option><option>vinyl</option><option>book</option><option>magazine</option><option>cd</option></select>'
+ '<button onclick="invSearch()">Search</button>'
+ '<button class="ghost" onclick="invAdd()"> Add item</button></div>'
+ '<button class="ghost" onclick="invAdd()"> Add item</button>'
+ '<button class="ghost" onclick="invImport()">⇪ Import list</button></div>'
+ '<div id="invbulk"></div>'
+ '<div id="invrows" class="muted">loading…</div>';
$('#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(`<h3 style="margin:0 0 6px">Import list (bulk stock-in)</h3>
<div class="muted" style="margin-bottom:8px">One per line — Release ID / barcode / catalogue no. Resolved against the catalog, each becomes an in-stock item.</div>
<textarea id="impLines" rows="8" style="width:100%;margin-bottom:6px" placeholder="12345&#10;0123456789012&#10;ABC-001"></textarea>
${fld('Default price','impPrice','','type=number step=0.01')}
${fld('Condition','impCond','')}
${fld('Crate id','impCrate','','type=number')}
${fld('Kind','impKind','vinyl')}
<div class="bar" style="margin-top:8px"><button onclick="invDoImport()" style="flex:1">Resolve &amp; import</button><button class="ghost" onclick="invModalClose()">Cancel</button></div>
<div id="impOut" class="muted" style="margin-top:8px"></div>`);
}
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 = `<span style="color:#6ee7b7">✓ ${d.added} imported</span>`
+ (d.not_found.length?`<div style="color:#e6a046">not found (${d.not_found.length}): ${d.not_found.map(escapeH).join(', ')}</div>`:'');
loadInv();
}
// ---------- Orders ----------
async function vOrders(){