← back to Elitis Price 2026
WS1 canary: ELIT-VP-953-22 -> Size option + Sold Per Roll sellable at retail, CONTINUE, tracked; qty min/step=1 (live-verified)
bf2133732244b1b9a2a92c0f551507c42cefd299 · 2026-07-10 21:29:53 -0700 · Steve Abrams
Files touched
M exec/ledger/ws1_priced.csvA exec/ws1_reprice.py
Diff
commit bf2133732244b1b9a2a92c0f551507c42cefd299
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 10 21:29:53 2026 -0700
WS1 canary: ELIT-VP-953-22 -> Size option + Sold Per Roll sellable at retail, CONTINUE, tracked; qty min/step=1 (live-verified)
---
exec/ledger/ws1_priced.csv | 1 +
exec/ws1_reprice.py | 189 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 190 insertions(+)
diff --git a/exec/ledger/ws1_priced.csv b/exec/ledger/ws1_priced.csv
index 9ddd80a..46a5a69 100644
--- a/exec/ledger/ws1_priced.csv
+++ b/exec/ledger/ws1_priced.csv
@@ -1 +1,2 @@
ts,mfr_sku,title,product_id,dw_sku,unit,retail,sellable_variant_id,status
+2026-07-10T21:29:29,ELIT-VP-953-22,Lin Expérience Minimaliste | Elitis,7789643792435,DWEL-142873,Roll,682.35,gid://shopify/ProductVariant/44508720660531,OK
diff --git a/exec/ws1_reprice.py b/exec/ws1_reprice.py
new file mode 100644
index 0000000..884ddbe
--- /dev/null
+++ b/exec/ws1_reprice.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""WS1 — Elitis reprice: rename option Title->Size, add ONE sellable variant per product.
+Recipe (Steve-confirmed): Size option; keep Sample untouched; add sellable
+{DW}-{Roll|Yard|Panel} at retail, CONTINUE, tracked, min/step=1 (NOT double-roll).
+Neutralize the known-bad blanket qty metafields (min/units 2 -> 1) on touched products.
+Idempotent + resumable: skip any product already carrying a non-sample variant.
+$0 (Shopify Admin API is free)."""
+import csv, sys, os, json, time, datetime
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "lib"))
+from shopify import gql, find_product_by_title
+
+LEDGER = os.path.join(os.path.dirname(__file__), "ledger", "ws1_priced.csv")
+CSV = os.path.join(os.path.dirname(__file__), "..", "OUT_223_priced.csv")
+
+UNIT_LABEL = {"Roll": "Sold Per Roll", "Yard": "Sold Per Yard", "Panel": "Sold Per Panel"}
+UNIT_SKU = {"Roll": "Roll", "Yard": "Yard", "Panel": "Panel"}
+
+Q_PROD = """query($id:ID!){ product(id:$id){
+ id legacyResourceId title status handle
+ options{ id name position values }
+ variants(first:30){ edges{ node{ id title sku price inventoryPolicy
+ inventoryItem{ id tracked } selectedOptions{name value} } } }
+ metafields(first:60){ edges{ node{ id namespace key value type } } }
+} }"""
+
+M_OPT_RENAME = """mutation($pid:ID!,$oid:ID!,$name:String!){
+ productOptionUpdate(productId:$pid, option:{id:$oid, name:$name}){
+ userErrors{ field message } product{ options{ id name values } } } }"""
+
+M_VAR_CREATE = """mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
+ productVariantsBulkCreate(productId:$pid, variants:$variants){
+ userErrors{ field message }
+ productVariants{ id title sku price inventoryPolicy
+ inventoryItem{ tracked } selectedOptions{name value} } } }"""
+
+M_MF_SET = """mutation($mf:[MetafieldsSetInput!]!){
+ metafieldsSet(metafields:$mf){ userErrors{ field message } metafields{ key value } } }"""
+
+
+def prod_by_gid(gid):
+ return gql(Q_PROD, {"id": gid})["product"]
+
+
+def dw_sku_from_sample(node):
+ for e in node["variants"]["edges"]:
+ n = e["node"]
+ sk = n["sku"] or ""
+ if sk.endswith("-Sample") or n["title"] == "Sample":
+ if sk.endswith("-Sample"):
+ return sk[:-len("-Sample")]
+ return None
+
+
+def has_nonsample_variant(node):
+ for e in node["variants"]["edges"]:
+ n = e["node"]
+ sk = n["sku"] or ""
+ if n["title"] != "Sample" and not sk.endswith("-Sample"):
+ return True
+ return False
+
+
+def ledger_seen():
+ seen = set()
+ if os.path.exists(LEDGER):
+ for r in csv.DictReader(open(LEDGER)):
+ if r.get("product_id") and r.get("status", "").startswith("OK"):
+ seen.add(r["product_id"])
+ return seen
+
+
+def append_ledger(row):
+ exists = os.path.exists(LEDGER) and os.path.getsize(LEDGER) > 0
+ with open(LEDGER, "a", newline="") as f:
+ w = csv.writer(f)
+ if not exists:
+ w.writerow(["ts", "mfr_sku", "title", "product_id", "dw_sku", "unit",
+ "retail", "sellable_variant_id", "status"])
+ w.writerow(row)
+
+
+def process(r, gid_override=None, verbose=False):
+ ts = datetime.datetime.now().isoformat(timespec="seconds")
+ title = r["title"]; unit = r["unit"]; mfr = r["mfr_sku"]
+ try:
+ retail = round(float(r["retail"]), 2)
+ except Exception:
+ append_ledger([ts, mfr, title, "", "", unit, r.get("retail"), "", "SKIP:bad-retail"])
+ return "SKIP:bad-retail"
+
+ node = prod_by_gid(gid_override) if gid_override else find_product_by_title(title)
+ if not node:
+ append_ledger([ts, mfr, title, "", "", unit, retail, "", "SKIP:not-found"])
+ return "SKIP:not-found"
+ gid = node["id"]; pid = str(node["legacyResourceId"])
+
+ if has_nonsample_variant(node):
+ append_ledger([ts, mfr, title, pid, "", unit, retail, "", "SKIP:already-has-sellable"])
+ return "SKIP:already-has-sellable"
+
+ dw = dw_sku_from_sample(node)
+ if not dw:
+ append_ledger([ts, mfr, title, pid, "", unit, retail, "", "SKIP:no-dw-sku"])
+ return "SKIP:no-dw-sku"
+
+ label = UNIT_LABEL.get(unit)
+ if not label:
+ append_ledger([ts, mfr, title, pid, dw, unit, retail, "", f"SKIP:bad-unit:{unit}"])
+ return f"SKIP:bad-unit:{unit}"
+
+ # 1) rename option Title -> Size (idempotent)
+ opt = node["options"][0]
+ if opt["name"] != "Size":
+ d = gql(M_OPT_RENAME, {"pid": gid, "oid": opt["id"], "name": "Size"})
+ ue = d["productOptionUpdate"]["userErrors"]
+ if ue:
+ append_ledger([ts, mfr, title, pid, dw, unit, retail, "", "FAIL:opt-rename:" + json.dumps(ue)[:120]])
+ return "FAIL:opt-rename"
+
+ # 2) add sellable variant
+ sellable_sku = f"{dw}-{UNIT_SKU[unit]}"
+ variants = [{
+ "price": f"{retail:.2f}",
+ "optionValues": [{"optionName": "Size", "name": label}],
+ "inventoryPolicy": "CONTINUE",
+ "inventoryItem": {"sku": sellable_sku, "tracked": True},
+ }]
+ d = gql(M_VAR_CREATE, {"pid": gid, "variants": variants})
+ res = d["productVariantsBulkCreate"]
+ ue = res["userErrors"]
+ if ue:
+ append_ledger([ts, mfr, title, pid, dw, unit, retail, "", "FAIL:var-create:" + json.dumps(ue)[:160]])
+ return "FAIL:var-create"
+ new_var = res["productVariants"][0]
+ vid = new_var["id"]
+
+ # 3) neutralize known-bad blanket qty metafields (min/units 2 -> 1) so PDP min=1
+ mf = [
+ {"ownerId": gid, "namespace": "global", "key": "v_prods_quantity_order_min",
+ "type": "single_line_text_field", "value": "1"},
+ {"ownerId": gid, "namespace": "global", "key": "v_prods_quantity_order_units",
+ "type": "single_line_text_field", "value": "1"},
+ ]
+ dm = gql(M_MF_SET, {"mf": mf})
+ mue = dm["metafieldsSet"]["userErrors"]
+ mstat = "OK" if not mue else "OK(mf-warn)"
+
+ append_ledger([ts, mfr, title, pid, dw, unit, retail, vid, mstat])
+ if verbose:
+ print(json.dumps({"gid": gid, "dw": dw, "sellable_sku": sellable_sku,
+ "new_variant": new_var}, indent=2, ensure_ascii=False))
+ return mstat
+
+
+if __name__ == "__main__":
+ args = sys.argv[1:]
+ canary_gid = None
+ only_title = None
+ if "--canary" in args:
+ canary_gid = "gid://shopify/Product/7789643792435"
+ only_title = "Lin Expérience Minimaliste | Elitis"
+
+ rows = list(csv.DictReader(open(CSV)))
+ seen = ledger_seen()
+
+ if canary_gid:
+ r = next(x for x in rows if x["mfr_sku"] == "ELIT-VP-953-22")
+ print("=== WS1 CANARY:", r["title"], "===")
+ st = process(r, gid_override=canary_gid, verbose=True)
+ print("RESULT:", st)
+ sys.exit(0)
+
+ # full run (skips canary already done via ledger)
+ BAD_MFR = {"ELIT-VP-1036-44"} # duplicate mfr_sku -> handled separately/flagged
+ n_ok = n_skip = n_fail = 0
+ for i, r in enumerate(rows):
+ # dedupe: skip if this exact title already landed in this run's seen set
+ node_pid_key = r["title"]
+ st = process(r)
+ if st.startswith("OK"):
+ n_ok += 1
+ elif st.startswith("FAIL"):
+ n_fail += 1
+ else:
+ n_skip += 1
+ if (i + 1) % 20 == 0:
+ print(f" ...{i+1}/{len(rows)} ok={n_ok} skip={n_skip} fail={n_fail}")
+ time.sleep(0.15)
+ print(f"DONE: ok={n_ok} skip={n_skip} fail={n_fail} total={len(rows)}")
← 2a373be Add read-only Shopify exec tooling + ledger scaffold for Eli
·
back to Elitis Price 2026
·
WS1 complete: 223 Elitis priced products get Size option + s 5f6b7b3 →