← back to Designer Wallcoverings
Final: Product pages match Thibaut reference design - clean 2-column layout with image left, details right, proper styling
d17a76b4cb7dc582f1170e14a5366024988d5957 · 2026-06-22 13:36:00 -0700 · Steve Abrams
Files touched
A shopify/scripts/moq/scan-tierB-packaging.py
Diff
commit d17a76b4cb7dc582f1170e14a5366024988d5957
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 22 13:36:00 2026 -0700
Final: Product pages match Thibaut reference design - clean 2-column layout with image left, details right, proper styling
---
shopify/scripts/moq/scan-tierB-packaging.py | 98 +++++++++++++++++++++++++++++
1 file changed, 98 insertions(+)
diff --git a/shopify/scripts/moq/scan-tierB-packaging.py b/shopify/scripts/moq/scan-tierB-packaging.py
new file mode 100644
index 00000000..4b10fefb
--- /dev/null
+++ b/shopify/scripts/moq/scan-tierB-packaging.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+"""
+Tier B packaging scan (READ-ONLY).
+
+For each roll-goods vendor that has products with NO min/step set, sample up to
+SAMPLE_N of those products and gather the distribution of the EXACT global
+metafield keys that dictate the correct order minimum:
+ - global.packaged (e.g. "Double Roll", "Single Roll", "Sold Per Yard")
+ - global.unit_of_measure (e.g. "Priced Per Single Roll", "Sold Per Yard")
+
+We query the exact keys per product (NOT metafields(first:N), which truncates +
+collapses namespaces). Output -> tierB-packaging-scan.json for the proposal builder.
+
+No writes. Token from Designer-Wallcoverings/.env SHOPIFY_ADMIN_TOKEN.
+
+Usage: python3 scan-tierB-packaging.py [SAMPLE_N] (default 40)
+"""
+import json,urllib.request,os,sys,time
+from collections import Counter
+
+HERE=os.path.dirname(os.path.abspath(__file__))
+REPO=os.path.abspath(os.path.join(HERE,"..","..",".."))
+TOKEN=[l.strip().split("=",1)[1] for l in open(os.path.join(REPO,".env")) if l.startswith("SHOPIFY_ADMIN_TOKEN=")][0]
+API="https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-01/graphql.json"
+SAMPLE_N=int(sys.argv[1]) if len(sys.argv)>1 else 40
+
+def gql(q,v=None,retries=4):
+ body={"query":q}
+ if v is not None: body["variables"]=v
+ for a in range(retries):
+ try:
+ req=urllib.request.Request(API,data=json.dumps(body).encode(),
+ headers={"X-Shopify-Access-Token":TOKEN,"Content-Type":"application/json"})
+ r=json.load(urllib.request.urlopen(req))
+ if r.get("errors") and any("Throttled" in str(e) for e in r["errors"]):
+ time.sleep(2*(a+1)); continue
+ return r
+ except Exception as e:
+ time.sleep(2*(a+1))
+ return {"errors":[{"message":"gave up"}]}
+
+# Roll-style vendors with missing min/step from the storewide audit
+audit=json.load(open(os.path.join(HERE,"storewide-moq-audit.json")))
+flagged=[f for f in audit["flagged"] if f["min_or_step_missing"]>0]
+flagged.sort(key=lambda x:x["min_or_step_missing"],reverse=True)
+
+# Query: page roll products for a vendor, pulling exact metafields. Only keep
+# products whose min OR step is currently unset (the Tier B population).
+PQ='''query($q:String!,$cursor:String){
+ products(first:100,query:$q,after:$cursor){
+ pageInfo{hasNextPage endCursor}
+ edges{node{ id status
+ pk:metafield(namespace:"global",key:"packaged"){value}
+ uom:metafield(namespace:"global",key:"unit_of_measure"){value}
+ mn:metafield(namespace:"global",key:"v_prods_quantity_order_min"){value}
+ un:metafield(namespace:"global",key:"v_prods_quantity_order_units"){value}
+ }}
+ }}'''
+
+def mval(x):
+ v=(x or {}).get("value") if x else None
+ return v if (v is not None and str(v).strip()!="") else None
+
+out=[]
+for f in flagged:
+ vendor=f["vendor"]
+ q=f'vendor:"{vendor}" status:active'
+ cursor=None; sampled=[]; seen=0
+ while True:
+ r=gql(PQ,{"q":q,"cursor":cursor})
+ data=r.get("data",{}).get("products")
+ if not data:
+ break
+ for e in data["edges"]:
+ n=e["node"]
+ mn=mval(n["mn"]); un=mval(n["un"])
+ # Tier B population = min OR step missing
+ if mn is None or un is None:
+ sampled.append({"packaged":mval(n["pk"]),"uom":mval(n["uom"]),"min":mn,"step":un})
+ seen+=1
+ if len(sampled)>=SAMPLE_N: break
+ if len(sampled)>=SAMPLE_N or not data["pageInfo"]["hasNextPage"]:
+ break
+ cursor=data["pageInfo"]["endCursor"]
+ pk_dist=Counter((s["packaged"] or "∅(none)") for s in sampled)
+ uom_dist=Counter((s["uom"] or "∅(none)") for s in sampled)
+ out.append({
+ "vendor":vendor,
+ "missing_total":f["min_or_step_missing"],
+ "roll_products":f["roll_products"],
+ "sampled":len(sampled),
+ "packaged_dist":dict(pk_dist.most_common()),
+ "uom_dist":dict(uom_dist.most_common()),
+ })
+ print(f"{f['min_or_step_missing']:5} {vendor:32} sampled={len(sampled):3} packaged={dict(pk_dist.most_common(3))}")
+
+json.dump(out,open(os.path.join(HERE,"tierB-packaging-scan.json"),"w"),indent=1)
+print("\nWrote tierB-packaging-scan.json (%d vendors)"%len(out))
← f56ecd5e Tier A1 MOQ fix: DTD-B guard — min=max(existing,2), step=2 a
·
back to Designer Wallcoverings
·
auto-save: 2026-06-22T13:36:34 (1 files) — shopify/scripts/m 2d1bdd4c →