diff --git a/Inertia Warner Stock List - 26062026.xlsx b/Inertia Warner Stock List - 26062026.xlsx new file mode 100644 index 0000000..c98bc5d Binary files /dev/null and b/Inertia Warner Stock List - 26062026.xlsx differ diff --git a/app/admin_routes.py b/app/admin_routes.py index 2796a76..fee8ce2 100644 --- a/app/admin_routes.py +++ b/app/admin_routes.py @@ -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 diff --git a/app/intake_routes.py b/app/intake_routes.py index b9625ab..31bcf89 100644 --- a/app/intake_routes.py +++ b/app/intake_routes.py @@ -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" diff --git a/inertia.txt b/inertia.txt new file mode 100644 index 0000000..25dab89 --- /dev/null +++ b/inertia.txt @@ -0,0 +1,30 @@ +Inertia Warner Stock List - 26/06/2026 +Inbox + +Yvonne Cho +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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 20a610f..e1a9389 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ sqlalchemy[asyncio] asyncpg httpx cryptography +openpyxl diff --git a/site/admin.html b/site/admin.html index 0aa7cce..6c788ef 100644 --- a/site/admin.html +++ b/site/admin.html @@ -220,7 +220,10 @@ function invEdit(sku){ const r = invItems[sku] || {}; invModalBox(`

${escapeH(r.title||sku)}

${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()')} +
Margin
+
New / used
${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){ `); + 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=`${money(d)} (${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 = '

New stock · approve distro purchases, then publish

' + + '

Import a distro order sheet · Inertia / Warner SOH (fill ORDER QTY, then upload)

' + + '
' + + '
' + + '
' + '
loading…
'; 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=''+escapeH(d.error||'failed')+''; return; } + $('#ns_sheet_out').innerHTML=`
${d.rows} ordered titles · ${d.copies} copies in ${escapeH(s.filename)}
` + +'' + +(d.sample||[]).map(r=>``).join('') + +'
barcodetitlefmtqtycost
${escapeH(r.barcode||'')}${escapeH((r.artist?r.artist+' – ':'')+(r.title||''))}${escapeH(r.kind||'')}${r.qty}${r.unit_cost!=null?money(r.unit_cost):''}
'; +} +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='already imported ('+d.existing+' copies)'; + else if(d.ok){ m.innerHTML=`✓ staged ${d.staged} · ${d.resolved} matched`; nsLoad(); } + else m.innerHTML=''+escapeH(d.error||'failed')+''; +} async function nsLoad(){ const d = await api('/newstock'); if(!d.items.length){ $('#ns_bar').innerHTML='
✓ Nothing pending — scrape a distro order with the PRICEGOD extension and it lands here.
'; $('#ns_body').innerHTML=''; return; }