[object Object]

← back to Designerwallcoverings

fix(dwpw-grs-migrate): verify draft by product ID with retry (fixes false VERIFY_DRAFT_FAILED on fresh products)

9357ca26968b3d479889875b2dae1a64ba20aabc · 2026-09-08 13:22:32 -0700 · Steve Abrams

Step-2 verify re-read via find_grs() (SKU search index) returned None on a
just-created product because that index is eventually-consistent — yielding
{'variants': None, 'price_ok': True} and blocking publish for GRS-26050/26200/
26210/26220. Added find_grs_by_id() (read-your-writes consistent) + verify_read()
with a propagation retry, and pointed step 2 at it. v_variants/v_image are now
booleans, never None.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6KGNY395sd4PKXbEzXbgV

Files touched

Diff

commit 9357ca26968b3d479889875b2dae1a64ba20aabc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 8 13:22:32 2026 -0700

    fix(dwpw-grs-migrate): verify draft by product ID with retry (fixes false VERIFY_DRAFT_FAILED on fresh products)
    
    Step-2 verify re-read via find_grs() (SKU search index) returned None on a
    just-created product because that index is eventually-consistent — yielding
    {'variants': None, 'price_ok': True} and blocking publish for GRS-26050/26200/
    26210/26220. Added find_grs_by_id() (read-your-writes consistent) + verify_read()
    with a propagation retry, and pointed step 2 at it. v_variants/v_image are now
    booleans, never None.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01C6KGNY395sd4PKXbEzXbgV
---
 scripts/dwpw-grs-migrate.py | 584 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 584 insertions(+)

diff --git a/scripts/dwpw-grs-migrate.py b/scripts/dwpw-grs-migrate.py
new file mode 100644
index 0000000..505765f
--- /dev/null
+++ b/scripts/dwpw-grs-migrate.py
@@ -0,0 +1,584 @@
+#!/usr/bin/env python3
+"""
+dwpw-grs-migrate.py — DWPW (Phillipe Romano) -> GRS (Fentucci) grasscloth migration.
+
+Continues the TK-11306 pilot (create_new.py / update_existing.py / verify.py).
+
+PER PRODUCT, in this EXACT order (the ordering IS the safety property):
+  1. Ensure the GRS-xxxxx product exists as DRAFT with the sheet data
+     (create if missing, update if present). Vendor/brand "Fentucci", type
+     Wallcovering, one-paragraph description, image = sheet URL, price = DW Price.
+     Variant convention "Per Yard": option "Size",
+       pos1 = sellable "Per Yard"  (SKU = bare GRS)
+       pos2 = "{GRS}-Sample" $4.25 (untracked)
+     sellable ALWAYS pos1 (productVariantsBulkReorder in one op).
+  2. VERIFY draft: image HTTP 200, price == 3*cost_yd (+/-0.02), variants present.
+     Fail -> SKIP product, leave DWPW untouched.
+  3. PUBLISH the GRS (status ACTIVE) + add to Online Store + Google & YouTube channel.
+  4. VERIFY GRS live on the public storefront (/products/<handle> -> 200).
+  5. HARD INTERLOCK: ONLY if step 4 passed, find the ACTIVE DWPW twin by mfr
+     (custom/dwc.manufacturer_sku == row.mfr, status ACTIVE), archive it
+     (ACTIVE->ARCHIVED) and add a 301 redirect DWPW-handle -> GRS-handle.
+     If GRS did not publish/serve, DO NOT archive — leave DWPW live.
+
+SAFETY:
+  * Absent --apply  => DRY-RUN. PRINTS the planned action per product. ZERO writes.
+  * --apply         => performs the writes, appends every real action to the
+                       executed-reversible ledger with a restore-map + undo path.
+  * NEVER writes dw_unified.
+  * Idempotent + resumable (safe to re-run).
+  * gql retried on transient 502/503/429.
+
+Usage:
+  python3 dwpw-grs-migrate.py                 # DRY-RUN over all 52 rows
+  python3 dwpw-grs-migrate.py --apply         # LIVE (Steve-authorized only)
+  python3 dwpw-grs-migrate.py --limit 5       # first N rows
+  python3 dwpw-grs-migrate.py --grs GRS-26050 # single row
+"""
+import json, os, sys, time, argparse, urllib.request, urllib.error, datetime
+
+# ---------------------------------------------------------------- config
+DOMAIN = "designer-laboratory-sandbox.myshopify.com"
+API = "2024-10"
+STOREFRONT = "https://designerwallcoverings.com"
+SECRETS = "/Users/macstudio3/Projects/secrets-manager/.env"
+LEDGER = "/Users/macstudio3/.claude/yolo-queue/executed-reversible/ledger.jsonl"
+BATCH_DEFAULT = ("/private/tmp/claude-501/-Users-macstudio3/"
+                 "e81bfc6f-0706-4788-9de7-7577c96b6619/scratchpad/batch53.json")
+
+PUB_ONLINE_STORE = "gid://shopify/Publication/22208643184"
+PUB_GOOGLE_YT    = "gid://shopify/Publication/29646651457"
+
+VENDOR = "Fentucci"
+PRODUCT_TYPE = "Wallcovering"
+COLLECTION = "TWIL Naturals"
+WIDTH_METAFIELD = '36" Wide (trim to 34")'
+DESC_TPL = ("{name} is a natural woven grasscloth wallcovering by Fentucci, with a "
+            "handcrafted natural-fiber texture that brings organic warmth and understated "
+            "luxury to any interior. It is well suited to both residential and commercial "
+            "spaces, from feature walls to refined hospitality settings.")
+
+# ---------------------------------------------------------------- token / http
+def load_token():
+    tk = os.environ.get("SHOPIFY_FULL_ACCESS_TOKEN")
+    if tk:
+        return tk.strip()
+    for line in open(SECRETS):
+        if line.startswith("SHOPIFY_FULL_ACCESS_TOKEN="):
+            return line.split("=", 1)[1].strip()
+    raise RuntimeError("SHOPIFY_FULL_ACCESS_TOKEN not found (env or secrets .env)")
+
+TOKEN = load_token()
+
+def gql(query, variables=None, _tries=0):
+    body = json.dumps({"query": query, "variables": variables or {}}).encode()
+    req = urllib.request.Request(
+        f"https://{DOMAIN}/admin/api/{API}/graphql.json",
+        data=body,
+        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
+        method="POST")
+    try:
+        with urllib.request.urlopen(req, timeout=60) as r:
+            out = json.loads(r.read())
+    except urllib.error.HTTPError as e:
+        if e.code in (429, 502, 503) and _tries < 5:
+            time.sleep(2 * (_tries + 1))
+            return gql(query, variables, _tries + 1)
+        raise
+    except urllib.error.URLError:
+        if _tries < 5:
+            time.sleep(2 * (_tries + 1))
+            return gql(query, variables, _tries + 1)
+        raise
+    # GraphQL-level throttle
+    if out.get("errors"):
+        codes = [ (e.get("extensions") or {}).get("code") for e in out["errors"] ]
+        if "THROTTLED" in codes and _tries < 5:
+            time.sleep(2 * (_tries + 1))
+            return gql(query, variables, _tries + 1)
+    return out
+
+def http_status(url, method="GET"):
+    try:
+        req = urllib.request.Request(url, method=method, headers={"User-Agent": "Mozilla/5.0"})
+        with urllib.request.urlopen(req, timeout=25) as r:
+            return r.status
+    except urllib.error.HTTPError as e:
+        return e.code
+    except Exception as e:
+        return "ERR:" + str(e)[:40]
+
+# ---------------------------------------------------------------- ledger
+def ledger_append(entry):
+    os.makedirs(os.path.dirname(LEDGER), exist_ok=True)
+    entry = dict(entry)
+    entry.setdefault("ts", datetime.datetime.utcnow().isoformat() + "Z")
+    entry.setdefault("agent", "vp-dw-commerce")
+    entry.setdefault("ticket", "dwpw-grs-migrate")
+    with open(LEDGER, "a") as f:
+        f.write(json.dumps(entry) + "\n")
+
+# ---------------------------------------------------------------- helpers
+def norm_len(v):
+    v = (v or "").strip()
+    if not v:
+        return ""
+    # "8 yds" -> "8 Yards", "12 yds" -> "12 Yards"
+    v = v.replace("yds", "Yards").replace("yd", "Yard")
+    return " ".join(w.capitalize() if w.isalpha() else w for w in v.split())
+
+def desc_for(row):
+    name = f"{row['pattern']} in {row['color']}" if row.get("color") else row["pattern"]
+    return DESC_TPL.format(name=name)
+
+def tags_for(row, image_ok):
+    tags = ["display_variant", VENDOR, "Grasscloth", "Natural Wallcovering", COLLECTION]
+    if row.get("color"):
+        tags.append(f"color:{row['color']}")
+    if not image_ok:
+        tags.append("Needs-Image")
+    return tags
+
+def metafields_for(row):
+    length = norm_len(row.get("length"))
+    mfs = [
+      {"namespace": "global", "key": "width", "type": "single_line_text_field", "value": WIDTH_METAFIELD},
+      {"namespace": "global", "key": "unit_of_measure", "type": "single_line_text_field", "value": "Priced Per Yard"},
+      {"namespace": "global", "key": "Content", "type": "single_line_text_field", "value": "Natural Grasscloth"},
+      {"namespace": "global", "key": "Brand", "type": "single_line_text_field", "value": VENDOR},
+      {"namespace": "global", "key": "Collection", "type": "single_line_text_field", "value": COLLECTION},
+      {"namespace": "custom", "key": "manufacturer_sku", "type": "single_line_text_field", "value": row["mfr"]},
+      {"namespace": "dwc", "key": "manufacturer_sku", "type": "single_line_text_field", "value": row["mfr"]},
+      {"namespace": "custom", "key": "pattern_name", "type": "single_line_text_field", "value": row["pattern"]},
+      {"namespace": "dwc", "key": "pattern_name", "type": "single_line_text_field", "value": row["pattern"]},
+      {"namespace": "dwc", "key": "order_unit", "type": "single_line_text_field", "value": "Yard"},
+      {"namespace": "dwc", "key": "width", "type": "single_line_text_field", "value": WIDTH_METAFIELD},
+      {"namespace": "custom", "key": "width", "type": "single_line_text_field", "value": WIDTH_METAFIELD},
+    ]
+    if length:
+        mfs.append({"namespace": "global", "key": "length", "type": "single_line_text_field", "value": length})
+    return mfs
+
+# ---------------------------------------------------------------- reads
+def find_grs(grs):
+    """Return {id, handle, status, variants:[{id,sku,title,price,position,tracked}], has_image} or None."""
+    q = '''query($qy:String!){ products(first:5, query:$qy){ edges{ node{
+      id handle status featuredImage{ url }
+      variants(first:10){ edges{ node{ id sku title price position inventoryItem{ tracked } } } } } } } }'''
+    d = gql(q, {"qy": f"sku:{grs}"})
+    for e in d.get("data", {}).get("products", {}).get("edges", []):
+        n = e["node"]
+        vs = [{"id": x["node"]["id"], "sku": x["node"]["sku"], "title": x["node"]["title"],
+               "price": x["node"]["price"], "position": x["node"]["position"],
+               "tracked": (x["node"]["inventoryItem"] or {}).get("tracked")} for x in n["variants"]["edges"]]
+        if any(v["sku"] == grs or v["sku"] == grs + "-Sample" for v in vs):
+            return {"id": n["id"], "handle": n["handle"], "status": n["status"],
+                    "variants": vs, "has_image": bool(n["featuredImage"])}
+    return None
+
+def find_grs_by_id(pid):
+    """Re-read a product by its GID (read-your-writes consistent, unlike the SKU
+    search index which lags a fresh productCreate). Same shape as find_grs()."""
+    q = '''query($id:ID!){ product(id:$id){
+      id handle status featuredImage{ url }
+      variants(first:10){ edges{ node{ id sku title price position inventoryItem{ tracked } } } } } }'''
+    d = gql(q, {"id": pid})
+    n = (d.get("data") or {}).get("product")
+    if not n:
+        return None
+    vs = [{"id": x["node"]["id"], "sku": x["node"]["sku"], "title": x["node"]["title"],
+           "price": x["node"]["price"], "position": x["node"]["position"],
+           "tracked": (x["node"]["inventoryItem"] or {}).get("tracked")} for x in n["variants"]["edges"]]
+    return {"id": n["id"], "handle": n["handle"], "status": n["status"],
+            "variants": vs, "has_image": bool(n["featuredImage"])}
+
+def verify_read(pid, grs, tries=6, delay=2):
+    """Authoritative post-write re-read. Prefer by-ID (immediately consistent);
+    fall back to the SKU search index. Retry to absorb index/propagation lag so a
+    freshly-created product with both variants isn't falsely flagged empty."""
+    chk = None
+    for i in range(tries):
+        chk = find_grs_by_id(pid) if pid else None
+        if not chk:
+            chk = find_grs(grs)          # fallback: SKU search index
+        if chk and len(chk["variants"]) >= 2:
+            return chk
+        if i < tries - 1:
+            time.sleep(delay)
+    return chk
+
+def find_active_dwpw_twin(mfr):
+    """Find ACTIVE DWPW product whose manufacturer_sku metafield == mfr."""
+    q = '''query($qy:String!,$after:String){ products(first:50, query:$qy, after:$after){
+      pageInfo{ hasNextPage endCursor }
+      edges{ node{ id handle status
+        m1:metafield(namespace:"custom",key:"manufacturer_sku"){ value }
+        m2:metafield(namespace:"dwc",key:"manufacturer_sku"){ value }
+        variants(first:3){ edges{ node{ sku } } } } } } }'''
+    target = mfr.strip().upper()
+    after = None
+    matches = []
+    while True:
+        d = gql(q, {"qy": "sku:DWPW* AND status:active", "after": after})
+        pr = d["data"]["products"]
+        for e in pr["edges"]:
+            n = e["node"]
+            mf = (n["m1"] or {}).get("value") if n["m1"] else None
+            if not mf:
+                mf = (n["m2"] or {}).get("value") if n["m2"] else None
+            if mf and mf.strip().upper() == target:
+                matches.append({"id": n["id"], "handle": n["handle"], "status": n["status"]})
+        if pr["pageInfo"]["hasNextPage"]:
+            after = pr["pageInfo"]["endCursor"]; time.sleep(0.2)
+        else:
+            break
+    return matches
+
+def redirect_exists(path):
+    q = '''query($qy:String!){ urlRedirects(first:5, query:$qy){ edges{ node{ id path target } } } }'''
+    d = gql(q, {"qy": f"path:{path}"})
+    for e in d.get("data", {}).get("urlRedirects", {}).get("edges", []):
+        if e["node"]["path"] == path:
+            return e["node"]
+    return None
+
+# ---------------------------------------------------------------- writes (only under --apply)
+def create_grs(row, image_ok, res):
+    tags = tags_for(row, image_ok)
+    inp = {"title": row["title"], "vendor": VENDOR, "productType": PRODUCT_TYPE,
+           "status": "DRAFT", "descriptionHtml": desc_for(row), "tags": tags,
+           "productOptions": [{"name": "Size", "values": [{"name": "Per Yard"}, {"name": "Sample"}]}]}
+    q = '''mutation($input:ProductInput!){ productCreate(input:$input){
+      product{ id handle status variants(first:5){ edges{ node{ id sku title } } } }
+      userErrors{ field message } } }'''
+    d = gql(q, {"input": inp})
+    pc = d["data"]["productCreate"]
+    if pc["userErrors"]:
+        raise RuntimeError(f"productCreate {row['grs']}: {pc['userErrors']}")
+    prod = pc["product"]
+    pid = prod["id"]; handle = prod["handle"]
+    res["created_product_id"] = pid; res["handle"] = handle
+    default_variant = prod["variants"]["edges"][0]["node"]["id"]
+    # Per Yard variant: sku + price + tracked
+    q = '''mutation($pid:ID!,$vars:[ProductVariantsBulkInput!]!){
+      productVariantsBulkUpdate(productId:$pid, variants:$vars){ userErrors{ field message } } }'''
+    d = gql(q, {"pid": pid, "vars": [{"id": default_variant, "price": row["dw_price"],
+              "inventoryItem": {"sku": row["grs"], "tracked": True}}]})
+    ue = d["data"]["productVariantsBulkUpdate"]["userErrors"]
+    if ue: raise RuntimeError(f"variant update {row['grs']}: {ue}")
+    # Sample variant
+    q = '''mutation($pid:ID!,$vars:[ProductVariantsBulkInput!]!){
+      productVariantsBulkCreate(productId:$pid, variants:$vars){ userErrors{ field message } } }'''
+    d = gql(q, {"pid": pid, "vars": [{"optionValues": [{"optionName": "Size", "name": "Sample"}],
+              "price": "4.25", "inventoryItem": {"sku": row["grs"] + "-Sample", "tracked": False}}]})
+    ue = d["data"]["productVariantsBulkCreate"]["userErrors"]
+    if ue: raise RuntimeError(f"sample create {row['grs']}: {ue}")
+    _set_metafields(pid, row)
+    if image_ok:
+        _attach_image(pid, row["image"])
+        res["image_attached"] = True
+    else:
+        res["image_attached"] = False
+    _reorder_sellable_first(pid)
+    ledger_append({"action": "create_grs_draft", "grs": row["grs"], "blast_radius": 1,
+                   "restore_map": {"product_id": pid, "old_status": None, "new_status": "DRAFT"},
+                   "created_ids": [pid],
+                   "undo_cmd": f"productDelete id={pid}",
+                   "verify": f"find_grs({row['grs']}) is None"})
+    return pid, handle
+
+def update_grs(row, existing, image_ok, res):
+    pid = existing["id"]; handle = existing["handle"]
+    res["handle"] = handle
+    # title / vendor / type / desc / tags (keep DRAFT here; publish is a later step)
+    tags = tags_for(row, image_ok or existing["has_image"])
+    q = '''mutation($input:ProductInput!){ productUpdate(input:$input){
+      product{ id } userErrors{ field message } } }'''
+    d = gql(q, {"input": {"id": pid, "title": row["title"], "vendor": VENDOR,
+              "productType": PRODUCT_TYPE, "descriptionHtml": desc_for(row), "tags": tags}})
+    ue = d["data"]["productUpdate"]["userErrors"]
+    if ue: raise RuntimeError(f"productUpdate {row['grs']}: {ue}")
+    # ensure Per Yard variant price + sku + tracked
+    sell = next((v for v in existing["variants"] if v["sku"] == row["grs"]), None)
+    if sell:
+        q = '''mutation($pid:ID!,$vars:[ProductVariantsBulkInput!]!){
+          productVariantsBulkUpdate(productId:$pid, variants:$vars){ userErrors{ field message } } }'''
+        d = gql(q, {"pid": pid, "vars": [{"id": sell["id"], "price": row["dw_price"],
+                  "inventoryItem": {"sku": row["grs"], "tracked": True}}]})
+        ue = d["data"]["productVariantsBulkUpdate"]["userErrors"]
+        if ue: raise RuntimeError(f"variant update {row['grs']}: {ue}")
+    # ensure Sample variant exists
+    if not any(v["sku"] == row["grs"] + "-Sample" for v in existing["variants"]):
+        q = '''mutation($pid:ID!,$vars:[ProductVariantsBulkInput!]!){
+          productVariantsBulkCreate(productId:$pid, variants:$vars){ userErrors{ field message } } }'''
+        d = gql(q, {"pid": pid, "vars": [{"optionValues": [{"optionName": "Size", "name": "Sample"}],
+                  "price": "4.25", "inventoryItem": {"sku": row["grs"] + "-Sample", "tracked": False}}]})
+        ue = d["data"]["productVariantsBulkCreate"]["userErrors"]
+        if ue: raise RuntimeError(f"sample create {row['grs']}: {ue}")
+    _set_metafields(pid, row)
+    if image_ok and not existing["has_image"]:
+        _attach_image(pid, row["image"])
+        res["image_attached"] = True
+    else:
+        res["image_attached"] = existing["has_image"]
+    _reorder_sellable_first(pid)
+    ledger_append({"action": "update_grs_draft", "grs": row["grs"], "blast_radius": 1,
+                   "restore_map": {"product_id": pid, "old_status": existing["status"], "new_status": existing["status"]},
+                   "created_ids": [],
+                   "undo_cmd": "no-op (pre-existing product content updated; see git of sheet)",
+                   "verify": f"find_grs({row['grs']}) not None"})
+    return pid, handle
+
+def _set_metafields(pid, row):
+    mfs = metafields_for(row)
+    for m in mfs:
+        m["ownerId"] = pid
+    q = '''mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){
+      userErrors{ field message } } }'''
+    d = gql(q, {"mf": mfs})
+    ue = d["data"]["metafieldsSet"]["userErrors"]
+    if ue: raise RuntimeError(f"metafieldsSet {row['grs']}: {ue}")
+
+def _attach_image(pid, url):
+    q = '''mutation($pid:ID!,$media:[CreateMediaInput!]!){ productCreateMedia(productId:$pid,media:$media){
+      media{ ... on MediaImage { id } status } mediaUserErrors{ field message } } }'''
+    d = gql(q, {"pid": pid, "media": [{"originalSource": url, "mediaContentType": "IMAGE"}]})
+    ue = d["data"]["productCreateMedia"]["mediaUserErrors"]
+    if ue: raise RuntimeError(f"productCreateMedia {url}: {ue}")
+
+def _reorder_sellable_first(pid):
+    q = '''query($id:ID!){ product(id:$id){ variants(first:10){ edges{ node{ id title } } } } }'''
+    d = gql(q, {"id": pid})
+    vlist = [e["node"] for e in d["data"]["product"]["variants"]["edges"]]
+    sell = next((v["id"] for v in vlist if v["title"] != "Sample"), None)
+    samp = next((v["id"] for v in vlist if v["title"] == "Sample"), None)
+    if not (sell and samp):
+        return
+    q = '''mutation($pid:ID!,$moves:[ProductVariantPositionInput!]!){
+      productVariantsBulkReorder(productId:$pid, positions:$moves){ userErrors{ field message } } }'''
+    d = gql(q, {"pid": pid, "moves": [{"id": sell, "position": 1}, {"id": samp, "position": 2}]})
+    ue = d["data"]["productVariantsBulkReorder"]["userErrors"]
+    if ue: raise RuntimeError(f"reorder: {ue}")
+
+def publish_active(pid, grs, prev_status):
+    # status ACTIVE
+    q = '''mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id status } userErrors{ field message } } }'''
+    d = gql(q, {"input": {"id": pid, "status": "ACTIVE"}})
+    ue = d["data"]["productUpdate"]["userErrors"]
+    if ue: raise RuntimeError(f"activate {grs}: {ue}")
+    # publish to Online Store + Google & YouTube
+    q = '''mutation($id:ID!,$pubs:[PublicationInput!]!){ publishablePublish(id:$id, input:$pubs){
+      userErrors{ field message } } }'''
+    d = gql(q, {"id": pid, "pubs": [{"publicationId": PUB_ONLINE_STORE}, {"publicationId": PUB_GOOGLE_YT}]})
+    ue = d["data"]["publishablePublish"]["userErrors"]
+    if ue: raise RuntimeError(f"publish {grs}: {ue}")
+    ledger_append({"action": "publish_grs_active", "grs": grs, "blast_radius": 1,
+                   "restore_map": {"product_id": pid, "old_status": prev_status, "new_status": "ACTIVE"},
+                   "created_ids": [],
+                   "undo_cmd": f"productUpdate id={pid} status={prev_status or 'DRAFT'} + publishableUnpublish(online_store,google_yt)",
+                   "verify": f"storefront /products/<handle> == 200"})
+
+def archive_dwpw(twin, grs):
+    pid = twin["id"]
+    q = '''mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id status } userErrors{ field message } } }'''
+    d = gql(q, {"input": {"id": pid, "status": "ARCHIVED"}})
+    ue = d["data"]["productUpdate"]["userErrors"]
+    if ue: raise RuntimeError(f"archive DWPW {twin['handle']}: {ue}")
+    ledger_append({"action": "archive_dwpw_twin", "grs": grs, "dwpw_handle": twin["handle"], "blast_radius": 1,
+                   "restore_map": {"product_id": pid, "old_status": "ACTIVE", "new_status": "ARCHIVED"},
+                   "created_ids": [],
+                   "undo_cmd": f"productUpdate id={pid} status=ACTIVE",
+                   "verify": f"DWPW {twin['handle']} status == ARCHIVED"})
+
+def create_redirect(dwpw_handle, grs_handle, grs):
+    path = f"/products/{dwpw_handle}"
+    target = f"/products/{grs_handle}"
+    ex = redirect_exists(path)
+    if ex:
+        return ex["id"], "existing"
+    q = '''mutation($redirect:UrlRedirectInput!){ urlRedirectCreate(urlRedirect:$redirect){
+      urlRedirect{ id path target } userErrors{ field message } } }'''
+    d = gql(q, {"redirect": {"path": path, "target": target}})
+    rc = d["data"]["urlRedirectCreate"]
+    if rc["userErrors"]:
+        raise RuntimeError(f"redirect {path}: {rc['userErrors']}")
+    rid = rc["urlRedirect"]["id"]
+    ledger_append({"action": "create_301_redirect", "grs": grs, "blast_radius": 1,
+                   "restore_map": {"redirect_id": rid, "path": path, "target": target},
+                   "created_ids": [rid],
+                   "undo_cmd": f"urlRedirectDelete id={rid}",
+                   "verify": f"GET {path} -> 301 -> {target}"})
+    return rid, "created"
+
+# ---------------------------------------------------------------- per-product
+def process(row, apply):
+    grs = row["grs"]
+    plan = {"grs": grs, "mfr": row["mfr"], "title": row["title"]}
+    # ---- pre-flight reads (safe in dry-run) ----
+    existing = find_grs(grs)
+    plan["ensure"] = "update" if existing else "create"
+    img_status = http_status(row["image"])
+    image_ok = (img_status == 200)
+    plan["image_status"] = img_status
+    try:
+        price = float(row["dw_price"]); cost = float(row["cost_yd"])
+        price_ok = abs(price - 3 * cost) <= 0.02
+    except Exception:
+        price = cost = None; price_ok = False
+    plan["price_ok"] = price_ok
+    plan["price"] = row["dw_price"]; plan["cost_yd"] = row["cost_yd"]
+
+    # ---- step 2 pre-flag: verify gate (image 200 + price==3x) ----
+    # image failing does NOT skip the whole product (draft stays with Needs-Image);
+    # a price mismatch DOES skip (can't safely publish a wrong price).
+    skip = None
+    if not price_ok:
+        skip = f"price {row['dw_price']} != 3x cost {row['cost_yd']} (={round(3*cost,2) if cost else '?'})"
+    plan["would_create_or_update"] = plan["ensure"]
+
+    # ---- twin lookup (read) ----
+    twins = find_active_dwpw_twin(row["mfr"])
+    plan["dwpw_twin_found"] = bool(twins)
+    plan["dwpw_twins"] = [t["handle"] for t in twins]
+    if len(twins) > 1:
+        plan["twin_note"] = f"MULTIPLE active twins ({len(twins)}) — archive step would need review"
+
+    # publish gate: image + width metafield required to go ACTIVE.
+    # width metafield is ALWAYS set by create/update. image gate = image_ok OR
+    # (update path where product already had an image).
+    will_have_image = image_ok or (existing and existing["has_image"])
+    can_publish = (skip is None) and bool(will_have_image)
+    plan["would_publish"] = can_publish
+    if skip:
+        plan["publish_block"] = skip
+    elif not will_have_image:
+        plan["publish_block"] = "no image -> stays DRAFT + Needs-Image (rule: never ACTIVE without image)"
+
+    # archive/redirect only happen if publish + storefront verify succeed
+    plan["would_archive_dwpw"] = bool(can_publish and twins)
+    plan["would_redirect"] = bool(can_publish and twins)
+    plan["skip"] = skip
+
+    if not apply:
+        return plan  # DRY-RUN: zero writes
+
+    # ------------------------------------------------ APPLY ------------------
+    res = dict(plan)
+    if skip:
+        res["result"] = "SKIPPED_PREFLIGHT"
+        return res
+    prev_status = existing["status"] if existing else None
+    # STEP 1
+    if existing:
+        pid, handle = update_grs(row, existing, image_ok, res)
+    else:
+        pid, handle = create_grs(row, image_ok, res)
+    res["product_id"] = pid; res["handle"] = handle
+    # STEP 2 verify draft (re-read authoritative BY PRODUCT ID — read-your-writes
+    # consistent, with a propagation retry; the SKU search index lags a fresh
+    # productCreate and previously returned None here -> false VERIFY_DRAFT_FAILED).
+    chk = verify_read(pid, grs)
+    v_variants = bool(chk and len(chk["variants"]) >= 2)
+    v_image = bool(chk and chk["has_image"])
+    v_price_ok = price_ok
+    v_img_http = (http_status(row["image"]) == 200)
+    if not (v_variants and v_price_ok):
+        res["result"] = "VERIFY_DRAFT_FAILED"; res["detail"] = {"variants": v_variants, "price_ok": v_price_ok}
+        return res  # leave DWPW untouched
+    if not v_image:
+        # no image -> cannot go ACTIVE; leave DRAFT + Needs-Image, do NOT archive DWPW
+        res["result"] = "LEFT_DRAFT_NEEDS_IMAGE"; return res
+    # STEP 3 publish
+    publish_active(pid, grs, prev_status)
+    # STEP 4 verify storefront
+    time.sleep(3)
+    st = http_status(f"{STOREFRONT}/products/{handle}")
+    res["storefront_status"] = st
+    if st != 200:
+        res["result"] = "PUBLISHED_BUT_STOREFRONT_NOT_200_NO_ARCHIVE"
+        return res  # HARD INTERLOCK: do not archive
+    # STEP 5 hard interlock
+    if twins:
+        if len(twins) > 1:
+            res["result"] = "PUBLISHED_MULTI_TWIN_ARCHIVE_SKIPPED"; return res
+        twin = twins[0]
+        archive_dwpw(twin, grs)
+        rid, how = create_redirect(twin["handle"], handle, grs)
+        res["archived_dwpw"] = twin["handle"]; res["redirect"] = f"{how}:{rid}"
+        res["result"] = "MIGRATED"
+    else:
+        res["result"] = "PUBLISHED_NO_TWIN"
+    return res
+
+# ---------------------------------------------------------------- main
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument("--apply", action="store_true", help="perform writes (default: DRY-RUN print-only)")
+    ap.add_argument("--batch", default=BATCH_DEFAULT)
+    ap.add_argument("--limit", type=int, default=0)
+    ap.add_argument("--grs", default=None, help="process a single GRS row")
+    args = ap.parse_args()
+
+    rows = json.load(open(args.batch))
+    if args.grs:
+        rows = [r for r in rows if r["grs"] == args.grs]
+    if args.limit:
+        rows = rows[:args.limit]
+
+    mode = "APPLY (LIVE WRITES)" if args.apply else "DRY-RUN (zero writes)"
+    print(f"# dwpw-grs-migrate — {mode} — {len(rows)} rows — {datetime.datetime.now().isoformat()}")
+    print(f"# store={DOMAIN} api={API}")
+    if not args.apply:
+        print("#")
+        print("# {:<10} {:<8} {:<7} {:<6} {:<8} {:<7} {:<8} {}".format(
+            "GRS", "ACTION", "IMG", "PRICE", "PUBLISH", "TWIN", "ARCHIVE", "NOTE/SKIP"))
+        print("# " + "-" * 100)
+
+    results = []
+    tot = {"create": 0, "update": 0, "publish": 0, "twin": 0, "archive": 0, "redirect": 0,
+           "skip": 0, "no_image_draft": 0}
+    for row in rows:
+        r = process(row, args.apply)
+        results.append(r)
+        if r["ensure"] == "create": tot["create"] += 1
+        else: tot["update"] += 1
+        if r.get("would_publish"): tot["publish"] += 1
+        else: tot["no_image_draft"] += 1
+        if r.get("dwpw_twin_found"): tot["twin"] += 1
+        if r.get("would_archive_dwpw"): tot["archive"] += 1
+        if r.get("would_redirect"): tot["redirect"] += 1
+        if r.get("skip"): tot["skip"] += 1
+        if not args.apply:
+            note = r.get("skip") or r.get("publish_block") or r.get("twin_note") or ""
+            twin_disp = (r["dwpw_twins"][0][:22] if r["dwpw_twins"] else "-")
+            print("  {:<10} {:<8} {:<7} {:<6} {:<8} {:<7} {:<8} {}".format(
+                r["grs"], r["ensure"],
+                str(r["image_status"]), "ok" if r["price_ok"] else "BAD",
+                "yes" if r["would_publish"] else "DRAFT",
+                twin_disp if r["dwpw_twin_found"] else "-",
+                "yes" if r["would_archive_dwpw"] else "-",
+                note))
+        else:
+            print("  {:<10} {}".format(r["grs"], r.get("result", "?")))
+
+    print()
+    print("# TOTALS")
+    print(f"#   rows                : {len(rows)}")
+    print(f"#   would CREATE        : {tot['create']}")
+    print(f"#   would UPDATE        : {tot['update']}")
+    print(f"#   would PUBLISH ACTIVE: {tot['publish']}")
+    print(f"#   would stay DRAFT    : {tot['no_image_draft']} (no image / pre-flag skip)")
+    print(f"#   active DWPW twin    : {tot['twin']}")
+    print(f"#   would ARCHIVE DWPW  : {tot['archive']}")
+    print(f"#   would 301 REDIRECT  : {tot['redirect']}")
+    print(f"#   pre-flagged SKIP    : {tot['skip']}")
+    # machine-readable dump alongside the human table
+    outp = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dwpw-grs-migrate.lastrun.json")
+    try:
+        json.dump({"mode": mode, "totals": tot, "results": results}, open(outp, "w"), indent=2)
+        print(f"# json -> {outp}")
+    except Exception:
+        pass
+
+if __name__ == "__main__":
+    main()

← 3e1ff00 auto-data-snapshot: 2026-09-08T12:16:02 (1 data files) — dat  ·  back to Designerwallcoverings  ·  auto-data-snapshot: 2026-09-08T13:39:29 (1 data files) — scr 9e97f6a →