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:
parent
c5ce84de1c
commit
a817b9d163
@ -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:
|
||||
|
||||
@ -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 0123456789012 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 & 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(){
|
||||
|
||||
Loading…
Reference in New Issue
Block a user