← 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
A scripts/dwpw-grs-migrate.py
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 →