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:
parent
926468a8b5
commit
ef37063b2e
BIN
Inertia Warner Stock List - 26062026.xlsx
Normal file
BIN
Inertia Warner Stock List - 26062026.xlsx
Normal file
Binary file not shown.
@ -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
|
||||
|
||||
@ -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
30
inertia.txt
Normal file
@ -0,0 +1,30 @@
|
||||
Inertia Warner Stock List - 26/06/2026
|
||||
Inbox
|
||||
|
||||
Yvonne Cho <yvonne@inertiamusic.com>
|
||||
Attachments
|
||||
Fri, Jun 26, 12:39 PM (22 hours ago)
|
||||
to Yvonne
|
||||
|
||||
Hi all,
|
||||
|
||||
|
||||
|
||||
Please find attached today’s 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
|
||||
@ -4,3 +4,4 @@ sqlalchemy[asyncio]
|
||||
asyncpg
|
||||
httpx
|
||||
cryptography
|
||||
openpyxl
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user