[object Object]

← back to Designer Wallcoverings

Double-roll S/R+D/R lengths: new sqft-classified script (single vs bolt per product) + theme renders explicit single/double_roll_length rows (fixes Thibaut halving mismatch); Thibaut live 2198 mf

7f189ae9b78da12799906142e556d45014e1184c · 2026-07-08 10:47:13 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit 7f189ae9b78da12799906142e556d45014e1184c
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Wed Jul 8 10:47:13 2026 -0700

    Double-roll S/R+D/R lengths: new sqft-classified script (single vs bolt per product) + theme renders explicit single/double_roll_length rows (fixes Thibaut halving mismatch); Thibaut live 2198 mf
---
 shopify/scripts/set-doubleroll-lengths.py          | 273 +++++++++++++++++++++
 .../snippets/product-description-meta.liquid       |  12 +-
 2 files changed, 284 insertions(+), 1 deletion(-)

diff --git a/shopify/scripts/set-doubleroll-lengths.py b/shopify/scripts/set-doubleroll-lengths.py
new file mode 100644
index 00000000..c7054d44
--- /dev/null
+++ b/shopify/scripts/set-doubleroll-lengths.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+"""
+Double-roll S/R + D/R LENGTH standardization for the 4 double-roll PL families
+(Steve 2026-07-08: "all needs to be 2 and 2 — most brewster, york, wallquest, thibaut").
+
+RULE ("2 and 2"): every roll product shows BOTH a single-roll length (S/R) and a
+double-roll length (D/R) where D/R = 2 x S/R, and is ordered min 2 / step 2 (2 single
+rolls = 1 double roll). This writes EXPLICIT metafields so the PDP renders S/R and D/R
+directly instead of the theme guessing by halving global.length (the guess is right for
+American bolts, wrong for Thibaut singles — that mismatch is exactly what this fixes).
+
+Per-vendor the stored catalog length means DIFFERENT things, so we DETECT it per product
+via a width x length sq-ft check instead of hardcoding:
+  ~22-42 sq ft  -> the value IS the single roll      -> single = value,   double = 2 x value
+  ~48-78 sq ft  -> the value IS the double/bolt       -> double = value,   single = value / 2
+  else          -> ODD (mural/panel/short-cut/unknown)-> min/step only, length UNTOUCHED, flagged
+
+Metafields written (global namespace, single_line_text_field):
+  single_roll_length   = S/R length (units preserved)
+  double_roll_length   = D/R length = 2 x S/R (units preserved)
+  v_prods_quantity_order_min/units = "2"  (product) + custom.v_prod_quantity_order_min/units on Roll variants
+  unit_of_measure      = composite copy: "...Single roll: W x S/R. Double roll: W x D/R."
+NOTE: global.length is NOT mutated here (other consumers read it). The theme change reads
+the explicit single_roll_length/double_roll_length when present.
+
+Length source per product: the vendor catalog (PostgreSQL, source of truth) by dw_sku,
+falling back to the live global.length. Vendor is selected by EMITTED Shopify vendor name.
+
+Vendors (--vendor):
+  thibaut    -> Shopify vendor 'Thibaut'          catalog thibaut_catalog.roll_length   (mostly SINGLE)
+  wallquest  -> Shopify vendor 'Malibu Wallpaper' catalog wallquest_catalog.length      (mostly BOLT)
+  brewster   -> Shopify vendor 'Malibu Walls'     catalog brewster_catalog.roll_length  (mostly BOLT)
+  york       -> Shopify vendor 'York'             catalog york_catalog.roll_length      (mostly BOLT)
+
+Default: --dry (print only + classification tally + sample). LIVE writes require --live.
+Idempotent (writes only metafields whose value differs). Before-state -> audits/ for rollback.
+Run: SHOPIFY_ADMIN_TOKEN=... python3 set-doubleroll-lengths.py --vendor thibaut [--live]
+"""
+import json, os, re, sys, time, subprocess, urllib.request, urllib.error
+
+TOKEN = os.environ["SHOPIFY_ADMIN_TOKEN"]
+SHOP  = os.environ.get("SHOPIFY_SHOP", "designer-laboratory-sandbox.myshopify.com")
+GQL   = f"https://{SHOP}/admin/api/2024-10/graphql.json"
+PGURI = "postgresql:///dw_unified?host=/tmp"
+LIVE  = "--live" in sys.argv
+HERE  = os.path.dirname(os.path.abspath(__file__))
+
+def arg(flag, default=None):
+    return sys.argv[sys.argv.index(flag)+1] if flag in sys.argv and sys.argv.index(flag)+1 < len(sys.argv) else default
+
+VENDORS = {
+    "thibaut":   {"shopify_vendor": "Thibaut",          "table": "thibaut_catalog",   "len_col": "roll_length"},
+    "wallquest": {"shopify_vendor": "Malibu Wallpaper", "table": "wallquest_catalog", "len_col": "length"},
+    "brewster":  {"shopify_vendor": "Malibu Walls",     "table": "brewster_catalog",  "len_col": "roll_length"},
+    "york":      {"shopify_vendor": "York",             "table": "york_catalog",      "len_col": "roll_length"},
+}
+VKEY = arg("--vendor")
+if VKEY not in VENDORS:
+    raise SystemExit(f"--vendor must be one of {list(VENDORS)}")
+CFG = VENDORS[VKEY]
+BEFORE = os.path.join(HERE, "audits", f"doubleroll-lengths-{VKEY}-before.jsonl")
+os.makedirs(os.path.dirname(BEFORE), exist_ok=True)
+
+# single ~28-32 sqft, double ~56-64; windows chosen wide enough for real variance,
+# narrow enough that a mural (14 sqft) or a jumbo panel falls in the ODD gap.
+SINGLE_LO, SINGLE_HI = 22, 42
+DOUBLE_LO, DOUBLE_HI = 48, 78
+
+EXCLUDE_RE = re.compile(r"sold per\s*\(|per yard|yard|sample|memo|mural|panel", re.I)
+ROLL_RE    = re.compile(r"\broll\b", re.I)
+
+def gql(q, v=None):
+    body = json.dumps({"query": q, "variables": v or {}}).encode()
+    req = urllib.request.Request(GQL, data=body, headers={
+        "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"})
+    for _ in range(8):
+        try:
+            with urllib.request.urlopen(req) as r:
+                d = json.load(r)
+            if d.get("errors"):
+                s = json.dumps(d["errors"])
+                if "THROTTLED" in s: time.sleep(2.5); continue
+                raise SystemExit("GQL ERR: " + s)
+            return d["data"]
+        except urllib.error.HTTPError as e:
+            if e.code == 429: time.sleep(2.5); continue
+            raise
+    raise SystemExit("retries exhausted")
+
+def psql(sql):
+    return subprocess.run(["psql", PGURI, "-tA", "-F", "\t", "-c", sql],
+                          capture_output=True, text=True, check=True).stdout
+
+def load_catalog():
+    """dw_sku -> (width, length) from the vendor catalog (PG source of truth)."""
+    lc = CFG["len_col"]
+    out = psql(f"SELECT dw_sku, COALESCE(width,''), COALESCE({lc},'') FROM {CFG['table']} "
+               f"WHERE COALESCE(dw_sku,'')<>''")
+    m = {}
+    for line in out.splitlines():
+        p = line.split("\t")
+        if len(p) == 3 and p[0]:
+            m[p[0].strip().upper()] = (p[1].strip(), p[2].strip())
+    return m
+
+def width_in(s):
+    if not s: return None
+    m = re.search(r"(\d+(?:\.\d+)?)", str(s))
+    return float(m.group(1)) if m else None
+
+def length_ft(s):
+    """primary numeric of a length string -> feet (for the sqft classifier)."""
+    if not s: return None
+    m = re.search(r"(\d+(?:\.\d+)?)\s*(yd|yard|yards|ft|feet|foot|in|inch|inches|m|cm|meter|metre)?",
+                  str(s), re.I)
+    if not m: return None
+    val = float(m.group(1)); u = (m.group(2) or "").lower()
+    if u in ("yd","yard","yards"): return val*3
+    if u in ("m","meter","metre"): return val*3.28084
+    if u == "cm":                  return val*0.0328084
+    if u in ("in","inch","inches"):return val/12
+    return val  # bare / ft / feet
+
+def scale_tokens(s, factor):
+    """multiply every numeric token so units + parentheticals scale together.
+       '4.5 yd (4.11 m)' *2 -> '9 yd (8.22 m)';  '9 yd | 8.2 m' *0.5 -> '4.5 yd | 4.1 m'."""
+    def fx(m):
+        x = float(m.group()) * factor
+        return str(int(x)) if abs(x-round(x)) < 1e-9 else f"{round(x,3):g}"
+    return re.sub(r"\d+(?:\.\d+)?", fx, str(s))
+
+PAGE = """
+query($vendor:String!,$cursor:String){
+  products(first:60, after:$cursor, query:$vendor){
+    pageInfo{ hasNextPage endCursor }
+    nodes{
+      id title handle status
+      w:   metafield(namespace:"global", key:"width"){ value }
+      len: metafield(namespace:"global", key:"length"){ value }
+      sr:  metafield(namespace:"global", key:"single_roll_length"){ value }
+      dr:  metafield(namespace:"global", key:"double_roll_length"){ value }
+      uom: metafield(namespace:"global", key:"unit_of_measure"){ value }
+      pmin:metafield(namespace:"global", key:"v_prods_quantity_order_min"){ value }
+      pun: metafield(namespace:"global", key:"v_prods_quantity_order_units"){ value }
+      variants(first:25){ nodes{ id title sku
+        vmin: metafield(namespace:"custom", key:"v_prod_quantity_order_min"){ value }
+        vun:  metafield(namespace:"custom", key:"v_prods_quantity_order_units"){ value } } }
+    }
+  }
+}"""
+SET = """
+mutation($mfs:[MetafieldsSetInput!]!){
+  metafieldsSet(metafields:$mfs){ metafields{ id } userErrors{ field message } }
+}"""
+
+def mf(owner, key, val):
+    return {"ownerId": owner, "namespace": "global", "key": key,
+            "type": "single_line_text_field", "value": val}
+
+def dwsku_of(variant_sku):
+    return re.sub(r"-(Roll|Sample)$", "", (variant_sku or ""), flags=re.I).strip().upper()
+
+def main():
+    catalog = load_catalog()
+    print(f">>> vendor={VKEY} (Shopify '{CFG['shopify_vendor']}')  catalog {CFG['table']}.{CFG['len_col']}: {len(catalog)} rows")
+
+    prods, cursor = [], None
+    vq = f"vendor:'{CFG['shopify_vendor']}' status:active,draft"
+    while True:
+        d = gql(PAGE, {"vendor": vq, "cursor": cursor})["products"]
+        prods.extend(d["nodes"])
+        sys.stderr.write(f"\rscanned {len(prods)}")
+        if not d["pageInfo"]["hasNextPage"]: break
+        cursor = d["pageInfo"]["endCursor"]; time.sleep(0.25)
+    sys.stderr.write("\n")
+
+    inputs, var_inputs = [], []   # global mfs ; variant custom mfs (namespace custom)
+    scanned = roll = n_single = n_double = n_odd = corrections = 0
+    ms = lenw = copyw = 0
+    odd_samples, cls_samples = [], []
+    bf = open(BEFORE, "w")
+
+    for p in prods:
+        scanned += 1
+        pid = p["id"]
+        roll_vars = [v for v in p["variants"]["nodes"]
+                     if ROLL_RE.search(v["title"] or "") and not EXCLUDE_RE.search(v["title"] or "")]
+        cur_sr = (p["sr"] or {}).get("value"); cur_dr = (p["dr"] or {}).get("value")
+        bf.write(json.dumps({"id": pid, "handle": p["handle"], "status": p["status"],
+                             "width": (p["w"] or {}).get("value"), "length": (p["len"] or {}).get("value"),
+                             "single_roll_length": cur_sr, "double_roll_length": cur_dr,
+                             "uom": (p["uom"] or {}).get("value"),
+                             "pmin": (p["pmin"] or {}).get("value"),
+                             "roll_variants": [v["sku"] for v in roll_vars]}) + "\n")
+        if not roll_vars:
+            continue
+        roll += 1
+
+        # ---- min/step = 2 (idempotent), always for roll products ----
+        if (p["pmin"] or {}).get("value") != "2": inputs.append(mf(pid,"v_prods_quantity_order_min","2")); ms+=1
+        if (p["pun"]  or {}).get("value") != "2": inputs.append(mf(pid,"v_prods_quantity_order_units","2")); ms+=1
+        for v in roll_vars:
+            if (v["vmin"] or {}).get("value") != "2":
+                var_inputs.append({"ownerId":v["id"],"namespace":"custom","key":"v_prod_quantity_order_min","type":"single_line_text_field","value":"2"}); ms+=1
+            if (v["vun"] or {}).get("value") != "2":
+                var_inputs.append({"ownerId":v["id"],"namespace":"custom","key":"v_prods_quantity_order_units","type":"single_line_text_field","value":"2"}); ms+=1
+
+        # ---- resolve width + candidate length: catalog first, else live global.length ----
+        dwsku = dwsku_of(roll_vars[0]["sku"])
+        cat_w, cat_L = catalog.get(dwsku, ("", ""))
+        W = cat_w or (p["w"] or {}).get("value") or ""
+        L = cat_L or (p["len"] or {}).get("value") or ""
+        wi = width_in(W); lf = length_ft(L)
+        if not (wi and lf and lf > 0):
+            n_odd += 1
+            if len(odd_samples) < 6: odd_samples.append((dwsku, W, L, "unparseable"))
+            continue
+        sqft = wi/12 * lf
+
+        if SINGLE_LO <= sqft <= SINGLE_HI:
+            n_single += 1; single = L; double = scale_tokens(L, 2.0); kind = "single"
+        elif DOUBLE_LO <= sqft <= DOUBLE_HI:
+            n_double += 1; single = scale_tokens(L, 0.5); double = L; kind = "bolt"
+        else:
+            n_odd += 1
+            if len(odd_samples) < 6: odd_samples.append((dwsku, W, L, f"{sqft:.0f}sqft"))
+            continue
+
+        if len(cls_samples) < 6:
+            cls_samples.append(f"{dwsku:16} {W:16} L={L!r:20} {sqft:.0f}sqft {kind}: S/R={single!r} D/R={double!r}")
+        # a "correction" = we're changing an existing double_roll_length to a different value
+        if cur_dr and cur_dr != double: corrections += 1
+
+        Wdisp = (p["w"] or {}).get("value") or W
+        if cur_sr != single: inputs.append(mf(pid,"single_roll_length",single)); lenw+=1
+        if cur_dr != double: inputs.append(mf(pid,"double_roll_length",double)); lenw+=1
+        copy = (f"Priced per single roll — packaged in double rolls only. "
+                f"Single roll: {Wdisp} × {single}. Double roll: {Wdisp} × {double}.")
+        if (p["uom"] or {}).get("value") != copy:
+            inputs.append(mf(pid,"unit_of_measure",copy)); copyw+=1
+    bf.close()
+
+    allmf = inputs + var_inputs
+    print(f"scanned {scanned}  | in-scope ROLL {roll}")
+    print(f"  classified SINGLE-sized (double=2x) : {n_single}")
+    print(f"  classified BOLT-sized  (single=/2)  : {n_double}")
+    print(f"  ODD / skipped (min/step only)       : {n_odd}")
+    print(f"  existing D/R values CORRECTED        : {corrections}")
+    print(f"metafield writes queued: {len(allmf)}  (min/step {ms} · S/R+D/R {lenw} · copy {copyw})")
+    print(f"before-state: {BEFORE}")
+    if cls_samples:
+        print("sample classifications:")
+        for s in cls_samples: print("   ", s)
+    if odd_samples:
+        print("sample ODD/skipped:")
+        for s in odd_samples: print("   ", s)
+
+    if not LIVE:
+        print("\nDRY RUN — no writes. Re-run with --live to apply.")
+        return
+    done = errs = 0
+    for i in range(0, len(allmf), 25):
+        batch = allmf[i:i+25]
+        d = gql(SET, {"mfs": batch})
+        ue = d["metafieldsSet"]["userErrors"]
+        if ue: errs += len(ue); print("userErrors:", ue[:3])
+        done += len(batch)
+        if done % 500 < 25: print(f"  ...{done}/{len(allmf)}")
+        time.sleep(0.3)
+    print(f"DONE. wrote {done} metafields, userErrors={errs}")
+
+if __name__ == "__main__":
+    main()
diff --git a/shopify/theme-LIVE-591/snippets/product-description-meta.liquid b/shopify/theme-LIVE-591/snippets/product-description-meta.liquid
index dcfc2759..99214960 100644
--- a/shopify/theme-LIVE-591/snippets/product-description-meta.liquid
+++ b/shopify/theme-LIVE-591/snippets/product-description-meta.liquid
@@ -12,6 +12,8 @@
 {% assign has_specs = false %}
 {% assign w = product.metafields.custom.width.value | default: product.metafields.specs.width.value | default: product.metafields.global.Width.value | default: product.metafields.global.width.value %}
 {% assign mat = product.metafields.custom.material.value | default: product.metafields.specs.composition.value | default: product.metafields.global.Content.value | default: product.metafields.global.Contents.value | default: product.metafields.global.Construction.value %}
+{% comment %} envelope-guard: unwrap a leaked raw metafield JSON wrapper e.g. {"type":"single_line_text_field","value":"Paper"} if one ever slips through, and prefer the canonical global.Material when the value is still an envelope {% endcomment %}
+{% if mat contains '"single_line_text_field"' and mat contains '"value"' %}{% assign mat = mat | split: '"value": "' | last | split: '"}' | first | default: product.metafields.global.Material.value %}{% endif %}
 {% assign col = product.metafields.custom.collection_name.value | default: product.metafields.specs.collection.value | default: product.metafields.global.Collection.value %}
 {% assign rep = product.metafields.custom.pattern_repeat.value | default: product.metafields.global.repeat.value | default: product.metafields.global['Vert-Rpt'].value %}
 {% assign fin = product.metafields.custom.finish.value | default: product.metafields.specs.finish.value | default: product.metafields.global.Finish.value | default: product.metafields.global.FINISH.value %}
@@ -36,7 +38,15 @@
 <div class="dw-specs">
   <div class="dw-specs-title">Specifications</div>
   {% if w != blank %}<div class="dw-spec-row"><span class="dw-spec-label">Width</span><span class="dw-spec-value">{{ w }}</span></div>{% endif %}
-  {% if len != blank %}<div class="dw-spec-row"><span class="dw-spec-label">{% if product.vendor == 'Designtex' %}Bolt size{% else %}Length{% endif %}</span><span class="dw-spec-value">{{ len }}</span></div>{% endif %}
+  {% comment %} Double-roll lines (Thibaut/Malibu Wallpaper/Malibu Walls/York): explicit S/R + D/R
+     length metafields (D/R = 2 x S/R) render directly so the theme never has to guess by halving
+     global.length. Falls back to the plain Length row for everything else. (Steve 2026-07-08 "2 and 2") {% endcomment %}
+  {% assign srlen = product.metafields.global.single_roll_length.value %}
+  {% assign drlen = product.metafields.global.double_roll_length.value %}
+  {% if srlen != blank and drlen != blank %}
+    <div class="dw-spec-row"><span class="dw-spec-label">Length S/R</span><span class="dw-spec-value">{{ srlen }}</span></div>
+    <div class="dw-spec-row"><span class="dw-spec-label">Length D/R</span><span class="dw-spec-value">{{ drlen }}</span></div>
+  {% elsif len != blank %}<div class="dw-spec-row"><span class="dw-spec-label">{% if product.vendor == 'Designtex' %}Bolt size{% else %}Length{% endif %}</span><span class="dw-spec-value">{{ len }}</span></div>{% endif %}
   {% if rep != blank %}<div class="dw-spec-row"><span class="dw-spec-label">Pattern Repeat</span><span class="dw-spec-value">{{ rep }}</span></div>{% endif %}
   {% if match != blank %}<div class="dw-spec-row"><span class="dw-spec-label">Match</span><span class="dw-spec-value">{{ match }}</span></div>{% endif %}
   {% if mat != blank %}<div class="dw-spec-row"><span class="dw-spec-label">Material</span><span class="dw-spec-value">{{ mat }}</span></div>{% endif %}

← 14e4dd9e auto-save: 2026-07-08T10:35:15 (3 files) — pending-approval/  ·  back to Designer Wallcoverings  ·  auto-save: 2026-07-08T11:05:21 (13 files) — pending-approval 2983280e →