feat(distro): Inertia xlsx ingest + cost/new-used/margin in inventory editor

(1) Inventory edit modal gains Cost + New/Used + a live Margin readout (price−cost, %); inv_edit accepts
cost_price + condition_type; inventory list returns them.
(2) Inertia/Warner SOH list = an xlsx that doubles as the order form (fill ORDER QTY, upload). New
/admin/intake/distro-sheet[/preview]: openpyxl parses header-mapped columns (ORDER QTY/UPC/ARTIST/TITLE/
FORMAT/PPD), takes rows with QTY>0 → shared _ingest_distro core (refactored out of the RareWaves path) →
staged NEW stock with cost_price=PPD, kind from FORMAT (cd/vinyl), catno kept. Upload+preview UI in New stock.
DistroItem gains kind+catno; RareWaves path now passes kind too. Verified on the real 3,368-row Warner sheet:
3 ordered titles → 4 copies, all resolved, kind=cd, cost from PPD. Added openpyxl to requirements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-27 11:39:39 +10:00
parent 926468a8b5
commit ef37063b2e
6 changed files with 192 additions and 21 deletions

Binary file not shown.

View File

@ -269,7 +269,8 @@ async def inventory(q: str = Query(""), kind: str = Query(""), crate_id: int | N
w = " AND ".join(where)
items = [dict(r) for r in (await db.execute(text(f"""
SELECT i.sku, i.kind, i.release_id, coalesce(i.title, dc.title) AS title, dc.artist,
i.price::float AS price, i.condition, i.in_stock, i.crate_id,
i.price::float AS price, i.cost_price::float AS cost_price, i.condition_type,
i.condition, i.sleeve_cond, i.slot_number, i.notes, i.in_stock, i.crate_id,
c.label_text AS crate, dc.thumb, i.target_price::float AS target
FROM inventory i
LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
@ -305,7 +306,9 @@ class InvItemIn(BaseModel):
class InvEditIn(BaseModel):
price: float | None = None
cost_price: float | None = None
condition: str | None = None
condition_type: str | None = None # 'new' | 'used'
sleeve_cond: str | None = None
title: str | None = None
crate_id: int | None = None

View File

@ -732,6 +732,8 @@ class DistroItem(BaseModel):
year: int | None = None
qty: int = 1
unit_cost: float | None = None
kind: str | None = None # cd | vinyl | … (Inertia gives FORMAT; RareWaves = vinyl)
catno: str | None = None
slug: str | None = None
variant_id: str | None = None
image: str | None = None
@ -743,12 +745,10 @@ class DistroIn(BaseModel):
items: list[DistroItem]
@router.post("/distro")
async def intake_distro(body: DistroIn, ident=Depends(require_token), db=Depends(get_db)):
"""Ingest a distributor order → staged NEW stock with per-copy cost. Idempotent per order
(re-scraping the same order is a no-op). Unresolved barcodes still stage (staff pick the release)."""
sid = ident["store_id"]
cost_source = f"{body.source} #{body.order_ref}"
async def _ingest_distro(db, sid, source, order_ref, items):
"""Shared core for every distro source (web-scrape or spreadsheet). `items` = list of dicts.
Idempotent per order (cost_source). Stages one NEW copy per qty; unresolved barcodes still stage."""
cost_source = f"{source} #{order_ref}"
existing = (await db.execute(text(
"SELECT count(*) FROM inventory WHERE cost_source=:cs AND store_id=:sid"),
{"cs": cost_source, "sid": sid})).scalar()
@ -756,35 +756,131 @@ async def intake_distro(body: DistroIn, ident=Depends(require_token), db=Depends
return {"ok": True, "already_ingested": True, "existing": existing, "cost_source": cost_source}
staged, errors = [], []
for it in body.items:
for it in items:
try:
rid = it.release_id or await _resolve_barcode(db, it.barcode)
rid = it.get("release_id") or await _resolve_barcode(db, it.get("barcode"))
meta = await _enrich(db, rid) if rid else None
title = it.title or (meta or {}).get("title")
title = it.get("title") or (meta or {}).get("title")
weight = (meta or {}).get("weight")
for _ in range(max(1, it.qty)):
kind = it.get("kind") or "vinyl"
for _ in range(max(1, int(it.get("qty") or 1))):
sku = _new_sku()
attrs = {"source": body.source, "order_ref": body.order_ref, "slug": it.slug,
"variant_id": it.variant_id, "year": it.year,
"resolved": rid is not None, "scrape_image": it.image}
attrs = {"source": source, "order_ref": order_ref, "slug": it.get("slug"),
"variant_id": it.get("variant_id"), "year": it.get("year"),
"catno": it.get("catno"), "artist": it.get("artist"),
"resolved": rid is not None, "scrape_image": it.get("image")}
await db.execute(text("""
INSERT INTO inventory (sku, store_id, kind, release_id, identifier, title,
cost_price, cost_source, condition_type, condition, weight_g, attributes,
staged, in_stock, status)
VALUES (:sku,:sid,'vinyl',:rid,:bc,:title,:cost,:cs,'new','M',:wt,
VALUES (:sku,:sid,:kind,:rid,:bc,:title,:cost,:cs,'new','M',:wt,
CAST(:attrs AS jsonb), true, false, 'staged')
ON CONFLICT (sku) DO NOTHING"""),
{"sku": sku, "sid": sid, "rid": rid, "bc": it.barcode, "title": title,
"cost": it.unit_cost, "cs": cost_source, "wt": weight, "attrs": json.dumps(attrs)})
{"sku": sku, "sid": sid, "kind": kind, "rid": rid, "bc": it.get("barcode"),
"title": title, "cost": it.get("unit_cost"), "cs": cost_source,
"wt": weight, "attrs": json.dumps(attrs)})
staged.append({"sku": sku, "release_id": rid, "title": title, "resolved": rid is not None})
except Exception as e:
errors.append({"barcode": it.barcode, "error": str(e)})
errors.append({"barcode": it.get("barcode"), "error": str(e)})
await db.commit()
resolved = sum(1 for s in staged if s["resolved"])
return {"ok": True, "source": body.source, "order_ref": body.order_ref, "cost_source": cost_source,
return {"ok": True, "source": source, "order_ref": order_ref, "cost_source": cost_source,
"staged": len(staged), "resolved": resolved, "unresolved": len(staged) - resolved, "errors": errors}
@router.post("/distro")
async def intake_distro(body: DistroIn, ident=Depends(require_token), db=Depends(get_db)):
"""Web-scraped distributor order (RareWaves) → staged NEW stock with per-copy cost."""
return await _ingest_distro(db, ident["store_id"], body.source, body.order_ref,
[it.model_dump() for it in body.items])
# --- Spreadsheet distro ingest (Inertia/Warner SOH list — barcode/title/format/cost in columns) ---
_DISTRO_COLS = { # our field -> header keywords (first match wins)
"qty": ["order qty", "qty", "quantity"],
"barcode": ["upc", "barcode", "ean", "bar code"],
"catno": ["catalogue", "catalog", "cat#", "cat no", "catno"],
"artist": ["artist"],
"title": ["title"],
"format": ["format"],
"unit_cost": ["ppd", "wsp", "cost", "price", "dealer"],
}
def _parse_distro_xlsx(data: bytes):
"""Inertia-style stock-list/order sheet → distro items for the rows with ORDER QTY > 0.
Header-mapped (works across distro sheets); FORMAT kind (cd/vinyl)."""
import io
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
items = []
for ws in wb.worksheets:
col = None
for row in ws.iter_rows(values_only=True):
cells = [(str(c).strip() if c is not None else "") for c in row]
if col is None:
up = [c.lower() for c in cells]
if any("title" in c for c in up) and any(("upc" in c or "barcode" in c or "ean" in c) for c in up):
col = {}
for i, h in enumerate(up):
for field, keys in _DISTRO_COLS.items():
if field not in col and any(k in h for k in keys):
col[field] = i
continue
def g(k):
i = col.get(k)
return cells[i] if i is not None and i < len(cells) else ""
try:
qty = int(float(g("qty"))) if g("qty") else 0
except ValueError:
qty = 0
bc = re.sub(r"\s", "", g("barcode"))
if qty <= 0 or not bc.isdigit():
continue
try:
cost = float(re.sub(r"[^0-9.]", "", g("unit_cost"))) if g("unit_cost") else None
except ValueError:
cost = None
fmt = g("format").lower()
kind = "cd" if "cd" in fmt else ("vinyl" if ("lp" in fmt or "vinyl" in fmt) else (fmt or "vinyl"))
items.append({"barcode": bc, "title": g("title") or None, "artist": g("artist") or None,
"catno": g("catno") or None, "qty": qty, "unit_cost": cost, "kind": kind})
return items
class DistroSheetIn(BaseModel):
source: str = "inertia"
order_ref: str | None = None
filename: str | None = None
xlsx_b64: str
@router.post("/distro-sheet")
async def intake_distro_sheet(body: DistroSheetIn, ident=Depends(require_token), db=Depends(get_db)):
"""Upload a distro stock-list/order sheet (xlsx) with ORDER QTY filled → stage those rows."""
try:
raw = base64.b64decode(body.xlsx_b64.split(",", 1)[-1])
items = _parse_distro_xlsx(raw)
except Exception as e:
return {"ok": False, "error": f"could not read the sheet: {e}"}
if not items:
return {"ok": False, "error": "no rows with ORDER QTY > 0 — fill the order-qty column and re-upload"}
order_ref = (body.order_ref or body.filename or "sheet").replace("#", "").strip()[:60]
return await _ingest_distro(db, ident["store_id"], body.source, order_ref, items)
@router.post("/distro-sheet/preview")
async def intake_distro_sheet_preview(body: DistroSheetIn, ident=Depends(require_token), db=Depends(get_db)):
"""Dry-run: how many order rows + a sample, before committing."""
try:
raw = base64.b64decode(body.xlsx_b64.split(",", 1)[-1])
items = _parse_distro_xlsx(raw)
except Exception as e:
return {"ok": False, "error": f"could not read the sheet: {e}"}
total = sum(int(i.get("qty") or 1) for i in items)
return {"ok": True, "rows": len(items), "copies": total, "sample": items[:10]}
def _selfcheck():
# SKU from a Google-Form timestamp (both ISO and en-AU slash formats) → YYYYMMDDHHMMSS
assert _sku_from_ts("2025-01-30T14:01:02.821Z") == "20250130140102"

30
inertia.txt Normal file
View File

@ -0,0 +1,30 @@
Inertia Warner Stock List - 26/06/2026
Inbox
Yvonne Cho <yvonne@inertiamusic.com>
Attachments
Fri, Jun 26, 12:39PM (22 hours ago)
to Yvonne
Hi all,
Please find attached todays Inertia/Warner SOH list. Please feel free to place orders on this document add your QTYs to the first column (highlighted in blue) if you wish to place an order. This stock list reflects the current stock situation, and new SKUs will continue to be added to the stock lists as they become available.
Those titles highlighted in blue have been re stocked in the last 48 business hours.
Thanks,
Yvonne
One attachment
• Scanned by Gmail

View File

@ -4,3 +4,4 @@ sqlalchemy[asyncio]
asyncpg
httpx
cryptography
openpyxl

View File

@ -220,7 +220,10 @@ function invEdit(sku){
const r = invItems[sku] || {};
invModalBox(`<h3 style="margin:0 0 12px">${escapeH(r.title||sku)}</h3>
${fld('Title','eTitle',r.title)}
${fld('Price','ePrice',r.price,'type=number step=0.01')}
${fld('Price','ePrice',r.price,'type=number step=0.01 oninput=invMargin()')}
${fld('Cost','eCost',r.cost_price,'type=number step=0.01 oninput=invMargin()')}
<div class="bar" style="margin:-2px 0 8px"><span class=muted style="width:90px">Margin</span><span id="eMargin" class="muted"></span></div>
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">New / used</span><select id="eCondType"><option value="used"${r.condition_type!=='new'?' selected':''}>Used</option><option value="new"${r.condition_type==='new'?' selected':''}>New / sealed</option></select></div>
${fld('Target','eTarget',r.target,'type=number step=0.01')}
${fld('Condition','eCond',r.condition)}
${fld('Sleeve','eSleeve',r.sleeve_cond)}
@ -233,10 +236,19 @@ function invEdit(sku){
<button class="ghost" onclick="invLabel('${sku}')">🏷 Label</button>
<button class="ghost" onclick="invDelete('${sku}')" style="color:#e66">Delete</button>
<button class="ghost" onclick="invModalClose()">Cancel</button></div>`);
invMargin();
}
function invMargin(){
const p=parseFloat($('#ePrice').value), c=parseFloat($('#eCost').value), m=$('#eMargin');
if(!m) return;
if(isNaN(p)||isNaN(c)){ m.textContent='—'; return; }
const d=p-c, pct=p>0?Math.round(d/p*100):0;
m.innerHTML=`<b style="color:${d>=0?'var(--ok)':'var(--warn)'}">${money(d)}</b> (${pct}%)`;
}
async function invSave(sku){
const v = id => { const x = $('#'+id).value.trim(); return x===''?null:x; };
const body = { title:v('eTitle'), price:numOrNull('ePrice'), target_price:numOrNull('eTarget'),
const body = { title:v('eTitle'), price:numOrNull('ePrice'), cost_price:numOrNull('eCost'),
target_price:numOrNull('eTarget'), condition_type:$('#eCondType').value,
condition:v('eCond'), sleeve_cond:v('eSleeve'), crate_id:numOrNull('eCrate'), slot_number:numOrNull('eSlot'),
kind:v('eKind'), notes:v('eNotes'), in_stock:$('#eStock').checked };
await fetch('/admin/inventory/'+encodeURIComponent(sku)+'/edit',{method:'POST',headers:hdr(),body:JSON.stringify(body)});
@ -740,9 +752,38 @@ async function healRun(){
// ---------- New stock (distro purchases → approve → publish) ----------
async function vNewstock(){
$('#content').innerHTML = '<h2>New stock <span class="muted" style="font-size:13px">· approve distro purchases, then publish</span></h2>'
+ '<div class="card"><h3>Import a distro order sheet <span class="muted" style="font-weight:400">· Inertia / Warner SOH (fill ORDER QTY, then upload)</span></h3>'
+ '<div class="bar"><select id="ns_sheet_src"><option value="inertia">Inertia</option><option value="bertus">Bertus</option><option value="rocket">Rocket</option><option value="other">Other</option></select>'
+ '<input id="ns_sheet_file" type="file" accept=".xlsx" onchange="nsPickSheet(this)"></div>'
+ '<div id="ns_sheet_out" class="muted" style="margin-top:8px"></div></div>'
+ '<div id="ns_bar" class="muted" style="margin:8px 0">loading…</div><div id="ns_body"></div>';
nsLoad();
}
function nsPickSheet(input){
const f=input.files[0]; if(!f) return;
const rd=new FileReader();
rd.onload=()=>{ window._nsSheet={b64:rd.result, filename:f.name}; nsSheetPreview(); };
rd.readAsDataURL(f);
}
async function nsSheetPreview(){
const s=window._nsSheet; if(!s) return;
$('#ns_sheet_out').innerHTML='reading sheet…';
const src=$('#ns_sheet_src').value;
const d=await fetch('/admin/intake/distro-sheet/preview',{method:'POST',headers:hdr(),body:JSON.stringify({source:src,filename:s.filename,xlsx_b64:s.b64})}).then(r=>r.json());
if(!d.ok){ $('#ns_sheet_out').innerHTML='<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>'; return; }
$('#ns_sheet_out').innerHTML=`<div style="margin-bottom:6px"><b>${d.rows}</b> ordered titles · <b>${d.copies}</b> copies in <em>${escapeH(s.filename)}</em></div>`
+'<table style="font-size:12px"><tr style="color:var(--mut);text-align:left"><th>barcode</th><th>title</th><th>fmt</th><th>qty</th><th>cost</th></tr>'
+(d.sample||[]).map(r=>`<tr><td>${escapeH(r.barcode||'')}</td><td>${escapeH((r.artist?r.artist+' ':'')+(r.title||''))}</td><td>${escapeH(r.kind||'')}</td><td>${r.qty}</td><td>${r.unit_cost!=null?money(r.unit_cost):''}</td></tr>`).join('')
+'</table><div style="margin-top:10px"><button onclick="nsSheetImport()">📥 Import '+d.copies+' copies</button> <span id="ns_sheet_msg" class="muted"></span></div>';
}
async function nsSheetImport(){
const s=window._nsSheet, src=$('#ns_sheet_src').value, m=$('#ns_sheet_msg');
m.textContent='importing… (resolving barcodes, please wait)';
const d=await fetch('/admin/intake/distro-sheet',{method:'POST',headers:hdr(),body:JSON.stringify({source:src,filename:s.filename,xlsx_b64:s.b64})}).then(r=>r.json());
if(d.ok&&d.already_ingested) m.innerHTML='<span class="muted">already imported ('+d.existing+' copies)</span>';
else if(d.ok){ m.innerHTML=`<span style="color:var(--ok)">✓ staged ${d.staged} · ${d.resolved} matched</span>`; nsLoad(); }
else m.innerHTML='<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
}
async function nsLoad(){
const d = await api('/newstock');
if(!d.items.length){ $('#ns_bar').innerHTML='<div class="card" style="color:var(--ok)">✓ Nothing pending — scrape a distro order with the PRICEGOD extension and it lands here.</div>'; $('#ns_body').innerHTML=''; return; }