[object Object]

← 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

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 →