← back to Elitis Price 2026
Add read-only Shopify exec tooling + ledger scaffold for Elitis reprice (no live writes yet; convention probe)
2a373be8c5f96da4a71a6d8fd1bc3f4b69c1987f · 2026-07-10 21:25:09 -0700 · Steve Abrams
Files touched
A exec/find_ref.pyA exec/ledger/ws1_priced.csvA exec/ledger/ws2_archived.csvA exec/ledger/ws3_drafts.csvA exec/lib/__pycache__/shopify.cpython-314.pycA exec/lib/shopify.pyA exec/probe_qty_by_unit.pyA exec/probe_units.pyA exec/probe_ws2_ws3.py
Diff
commit 2a373be8c5f96da4a71a6d8fd1bc3f4b69c1987f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 10 21:25:09 2026 -0700
Add read-only Shopify exec tooling + ledger scaffold for Elitis reprice (no live writes yet; convention probe)
---
exec/find_ref.py | 26 +++++++++
exec/ledger/ws1_priced.csv | 1 +
exec/ledger/ws2_archived.csv | 1 +
exec/ledger/ws3_drafts.csv | 1 +
exec/lib/__pycache__/shopify.cpython-314.pyc | Bin 0 -> 6038 bytes
exec/lib/shopify.py | 84 +++++++++++++++++++++++++++
exec/probe_qty_by_unit.py | 23 ++++++++
exec/probe_units.py | 27 +++++++++
exec/probe_ws2_ws3.py | 28 +++++++++
9 files changed, 191 insertions(+)
diff --git a/exec/find_ref.py b/exec/find_ref.py
new file mode 100644
index 0000000..2bf53f2
--- /dev/null
+++ b/exec/find_ref.py
@@ -0,0 +1,26 @@
+import json, sys
+sys.path.insert(0,'exec/lib')
+from shopify import gql
+
+# Find ACTIVE products that have a Sample variant AND a non-sample sellable variant,
+# to learn the canonical sample+sellable modeling. Try a few known priced vendors.
+Q="""query($q:String!){ products(first:15, query:$q){ edges{ node{
+ title vendor status
+ options{name values}
+ variants(first:15){ edges{ node{ title sku price inventoryPolicy inventoryItem{tracked}
+ selectedOptions{name value} } } }
+} } } }"""
+
+for vendor in ["Thibaut","Novasuede","Phillipe Romano","Maya Romanoff"]:
+ d=gql(Q,{"q":f'vendor:"{vendor}" status:active'})
+ for e in d["products"]["edges"]:
+ n=e["node"]
+ vs=[v["node"] for v in n["variants"]["edges"]]
+ has_sample=any((x["sku"] or "").endswith("-Sample") or x["title"]=="Sample" for x in vs)
+ has_sell=any(not((x["sku"] or "").endswith("-Sample")) and x["title"]!="Sample" for x in vs)
+ if has_sample and has_sell and len(vs)>=2:
+ print(f"\n### {vendor}: {n['title']}")
+ print(" options:", n["options"])
+ for x in vs:
+ print(f" - sku={x['sku']!r} title={x['title']!r} price={x['price']} policy={x['inventoryPolicy']} tracked={x['inventoryItem']['tracked']} opts={x['selectedOptions']}")
+ break
diff --git a/exec/ledger/ws1_priced.csv b/exec/ledger/ws1_priced.csv
new file mode 100644
index 0000000..9ddd80a
--- /dev/null
+++ b/exec/ledger/ws1_priced.csv
@@ -0,0 +1 @@
+ts,mfr_sku,title,product_id,dw_sku,unit,retail,sellable_variant_id,status
diff --git a/exec/ledger/ws2_archived.csv b/exec/ledger/ws2_archived.csv
new file mode 100644
index 0000000..6e00744
--- /dev/null
+++ b/exec/ledger/ws2_archived.csv
@@ -0,0 +1 @@
+ts,title,product_id,prev_status,new_status
diff --git a/exec/ledger/ws3_drafts.csv b/exec/ledger/ws3_drafts.csv
new file mode 100644
index 0000000..701d7fd
--- /dev/null
+++ b/exec/ledger/ws3_drafts.csv
@@ -0,0 +1 @@
+ts,list_sku,pattern,colorway,unit,retail,product_id,dw_sku,status
diff --git a/exec/lib/__pycache__/shopify.cpython-314.pyc b/exec/lib/__pycache__/shopify.cpython-314.pyc
new file mode 100644
index 0000000..fd2ddb8
Binary files /dev/null and b/exec/lib/__pycache__/shopify.cpython-314.pyc differ
diff --git a/exec/lib/shopify.py b/exec/lib/shopify.py
new file mode 100644
index 0000000..c99a597
--- /dev/null
+++ b/exec/lib/shopify.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+"""Shared Shopify Admin API (2024-10) client for the Elitis reprice job.
+Reads token from the DW repo .env. All calls are $0 (Shopify Admin API is free).
+"""
+import os, json, time, urllib.request, urllib.error, sys
+
+API_VERSION = "2024-10"
+
+def _load_env():
+ dom = os.environ.get("SHOPIFY_STORE_DOMAIN")
+ tok = os.environ.get("SHOPIFY_ADMIN_ACCESS_TOKEN")
+ if dom and tok:
+ return dom, tok
+ envp = os.path.expanduser("~/Projects/Designer-Wallcoverings/.env")
+ with open(envp) as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ k, v = line.split("=", 1)
+ v = v.strip().strip('"').strip("'")
+ if k == "SHOPIFY_STORE_DOMAIN":
+ dom = v
+ elif k == "SHOPIFY_ADMIN_ACCESS_TOKEN":
+ tok = v
+ if not dom or not tok:
+ sys.exit("Missing SHOPIFY_STORE_DOMAIN / SHOPIFY_ADMIN_ACCESS_TOKEN")
+ return dom, tok
+
+DOMAIN, TOKEN = _load_env()
+GQL_URL = f"https://{DOMAIN}/admin/api/{API_VERSION}/graphql.json"
+
+def gql(query, variables=None, retries=5):
+ body = json.dumps({"query": query, "variables": variables or {}}).encode()
+ req = urllib.request.Request(GQL_URL, data=body, method="POST", headers={
+ "X-Shopify-Access-Token": TOKEN,
+ "Content-Type": "application/json",
+ })
+ last = None
+ for attempt in range(retries):
+ try:
+ with urllib.request.urlopen(req, timeout=60) as r:
+ data = json.loads(r.read())
+ except urllib.error.HTTPError as e:
+ last = f"HTTP {e.code}: {e.read().decode()[:300]}"
+ if e.code in (429, 500, 502, 503):
+ time.sleep(2 * (attempt + 1)); continue
+ raise RuntimeError(last)
+ except urllib.error.URLError as e:
+ last = str(e); time.sleep(2 * (attempt + 1)); continue
+ if "errors" in data and data["errors"]:
+ raise RuntimeError("GQL errors: " + json.dumps(data["errors"])[:500])
+ # Handle throttle inside userErrors-free responses
+ ext = data.get("extensions", {}).get("cost", {}).get("throttleStatus", {})
+ avail = ext.get("currentlyAvailable")
+ if avail is not None and avail < 200:
+ time.sleep(1.5)
+ return data["data"]
+ raise RuntimeError(f"gql failed after {retries}: {last}")
+
+def find_product_by_title(title):
+ """Exact title match (title is unique per Elitis product). Returns node or None."""
+ q = """query($q:String!){ products(first:10, query:$q){ edges{ node{
+ id legacyResourceId title status handle vendor productType tags
+ options{name values}
+ variants(first:30){ edges{ node{
+ id title sku price inventoryPolicy inventoryItem{ id tracked }
+ selectedOptions{name value} } } }
+ metafields(first:50){ edges{ node{ namespace key value type } } }
+ } } } }"""
+ # Escape quotes/backslashes for the Shopify search-query string literal
+ safe = title.replace("\\", "\\\\").replace('"', '\\"')
+ data = gql(q, {"q": f'title:"{safe}"'})
+ edges = data["products"]["edges"]
+ for e in edges:
+ if e["node"]["title"] == title:
+ return e["node"]
+ # fall back: single result even if not exact
+ return edges[0]["node"] if len(edges) == 1 else None
+
+if __name__ == "__main__":
+ # smoke test
+ n = find_product_by_title(sys.argv[1] if len(sys.argv) > 1 else "Lin Expérience Minimaliste | Elitis")
+ print(json.dumps(n, indent=2, ensure_ascii=False) if n else "NOT FOUND")
diff --git a/exec/probe_qty_by_unit.py b/exec/probe_qty_by_unit.py
new file mode 100644
index 0000000..7223598
--- /dev/null
+++ b/exec/probe_qty_by_unit.py
@@ -0,0 +1,23 @@
+import csv, sys, json
+sys.path.insert(0,'exec/lib')
+from shopify import find_product_by_title, gql
+
+# Sample one product per unit from OUT_223 and read its qty/packaging metafields
+rows=list(csv.DictReader(open("OUT_223_priced.csv")))
+by_unit={}
+for r in rows:
+ by_unit.setdefault(r["unit"],[]).append(r)
+
+def mfmap(node):
+ return { f"{e['node']['namespace']}.{e['node']['key']}": e['node']['value'] for e in node["metafields"]["edges"] }
+
+for unit,rs in by_unit.items():
+ r=rs[0]
+ n=find_product_by_title(r["title"])
+ if not n:
+ print(f"{unit}: {r['title']!r} -> NOT FOUND"); continue
+ m=mfmap(n)
+ keys=["global.packaged","global.v_prods_quantity_order_min","global.v_prods_quantity_order_units",
+ "global.width","global.length","specs.width","specs.roll_length","global.Contents"]
+ print(f"\n=== UNIT={unit} ({len(rs)} products) ex: {r['title']}")
+ for k in keys: print(f" {k} = {m.get(k)!r}")
diff --git a/exec/probe_units.py b/exec/probe_units.py
new file mode 100644
index 0000000..e822989
--- /dev/null
+++ b/exec/probe_units.py
@@ -0,0 +1,27 @@
+import json, sys
+sys.path.insert(0,'exec/lib')
+from shopify import gql
+Q="""query($q:String!){ products(first:50, query:$q){ edges{ node{
+ title vendor status
+ variants(first:15){ edges{ node{ title sku price inventoryPolicy
+ selectedOptions{name value} } } }
+} } } }"""
+
+print("===== ELITIS with >1 variant (any already priced?) =====")
+d=gql(Q,{"q":'vendor:"Elitis" status:active'})
+cnt=0; multi=0
+for e in d["products"]["edges"]:
+ n=e["node"]; vs=[v["node"] for v in n["variants"]["edges"]]; cnt+=1
+ if len(vs)>1:
+ multi+=1
+ print(n["title"])
+ for x in vs:
+ print(" ", x["sku"], "|", x["title"], "|", x["price"], "|", x["selectedOptions"])
+print(f"(elitis active: {cnt} shown, {multi} with >1 variant)")
+
+print("\n===== label examples for units =====")
+for term in ['Sold Per Panel','Double Roll','Sold Per Roll','Sold Per Double Roll','Sold Per Bolt']:
+ d=gql("""query($q:String!){ productVariants(first:6, query:$q){ edges{ node{ title sku price selectedOptions{name value} product{title vendor} } } } }""",
+ {"q":f'title:"{term}"'})
+ for e in d["productVariants"]["edges"]:
+ n=e["node"]; print(f" [{term}] {n['product']['vendor']} :: {n['title']!r} :: sku={n['sku']} :: {n['price']}")
diff --git a/exec/probe_ws2_ws3.py b/exec/probe_ws2_ws3.py
new file mode 100644
index 0000000..6b3b4dd
--- /dev/null
+++ b/exec/probe_ws2_ws3.py
@@ -0,0 +1,28 @@
+import csv, sys, json
+sys.path.insert(0,'exec/lib')
+from shopify import find_product_by_title
+
+# WS2: the 20 disco products - confirm they resolve + current status
+plan=list(csv.DictReader(open("PLAN_price_update.csv")))
+disco=[r for r in plan if r["action"].startswith("DISCONTINUE")]
+print(f"WS2 DISCONTINUE rows in plan: {len(disco)}")
+found=0; already=0; missing=[]
+for r in disco:
+ n=find_product_by_title(r["title"])
+ if not n: missing.append(r["title"]); continue
+ found+=1
+ if n["status"]=="ARCHIVED": already+=1
+pats=set()
+for r in disco:
+ t=r["title"]
+ if "VP-1048" in t or "1048" in t: pats.add("VP-1048")
+ elif "VP-1050" in t or "1050" in t: pats.add("VP-1050")
+print(f" resolved on live: {found}/{len(disco)} already ARCHIVED: {already} patterns: {sorted(pats)}")
+if missing: print(" MISSING:", missing)
+
+# WS3 counts
+sj=list(csv.DictReader(open("OUT_577_staging_join.csv")))
+nd=list(csv.DictReader(open("OUT_577_no_catalog_data.csv")))
+print(f"\nWS3 staging_join rows: {len(sj)} no_catalog_data rows: {len(nd)}")
+print(" staging_join header:", sj[0].keys() if sj else None)
+print(" no_catalog header:", nd[0].keys() if nd else None)
← 897b15b Compute retail (US1/0.65/0.85), staging join, gated approval
·
back to Elitis Price 2026
·
WS1 canary: ELIT-VP-953-22 -> Size option + Sold Per Roll se bf21337 →