[object Object]

← back to Dw Validator Debug TK11314

TK-10796: Fabricut 'Yards yards' double-unit remediation (audit + gated fix + rollback scripts)

cebd45808d17a523c28762b38a25e7c9e2088458 · 2026-08-24 19:33:17 -0700 · Steve Abrams

Files touched

Diff

commit cebd45808d17a523c28762b38a25e7c9e2088458
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 24 19:33:17 2026 -0700

    TK-10796: Fabricut 'Yards yards' double-unit remediation (audit + gated fix + rollback scripts)
---
 .../fabricut-minorder-unit-fix-rollback.mjs        | 26 ++++++++
 DW-Programming/fabricut-minorder-unit-fix.mjs      | 71 ++++++++++++++++++++++
 DW-Programming/fabricut_minorder_full_audit.mjs    | 40 ++++++++++++
 3 files changed, 137 insertions(+)

diff --git a/DW-Programming/fabricut-minorder-unit-fix-rollback.mjs b/DW-Programming/fabricut-minorder-unit-fix-rollback.mjs
new file mode 100644
index 00000000..5d2eb631
--- /dev/null
+++ b/DW-Programming/fabricut-minorder-unit-fix-rollback.mjs
@@ -0,0 +1,26 @@
+#!/usr/bin/env node
+/**
+ * TK-10796 ROLLBACK — restore the original "N Yards" metafield values from the
+ * execution ledger written by fabricut-minorder-unit-fix.mjs --apply.
+ * Reads executed-reversible/fabricut-minorder-unit-fix-ledger.jsonl and PUTs each
+ * old value back. Only rolls back fields THIS fix actually wrote (the ledger), so it
+ * is exact. Usage: node fabricut-minorder-unit-fix-rollback.mjs [--apply]
+ */
+import fs from 'fs';
+const env=fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8');
+const TOKEN=(env.match(/SHOPIFY_ADMIN_TOKEN=(.+)/)||[])[1].trim();
+const DOMAIN='designer-laboratory-sandbox.myshopify.com',VER='2024-10';
+const LEDGER='/Users/macstudio3/.claude/yolo-queue/executed-reversible/fabricut-minorder-unit-fix-ledger.jsonl';
+const APPLY=process.argv.includes('--apply');
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+async function REST(m,p,b){const r=await fetch(`https://${DOMAIN}/admin/api/${VER}${p}`,{method:m,headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:b?JSON.stringify(b):undefined});if(r.status===429){await sleep(1500);return REST(m,p,b);}if(!r.ok)throw new Error(`${r.status} ${(await r.text()).slice(0,120)}`);return r.json();}
+if(!fs.existsSync(LEDGER)){console.error('no ledger — nothing was applied');process.exit(1);}
+const rows=fs.readFileSync(LEDGER,'utf8').trim().split('\n').filter(Boolean).map(l=>JSON.parse(l));
+console.log(`=== ROLLBACK ${APPLY?'[APPLY]':'[DRY]'} === ${rows.length} fields`);
+let n=0,fail=0;
+for(const r of rows){
+  if(!APPLY){console.log(`[DRY] ${r.dw_sku} ${r.key}: ${JSON.stringify(r.new)} -> ${JSON.stringify(r.old)}`);n++;continue;}
+  try{await REST('PUT',`/products/${r.pid}/metafields/${r.metafield_id}.json`,{metafield:{id:r.metafield_id,value:r.old,type:'single_line_text_field'}});n++;await sleep(320);}
+  catch(e){console.error(`✗ ${r.dw_sku} ${r.key}: ${e.message}`);fail++;}
+}
+console.log(`=== DONE === restored=${n} fail=${fail}`);
diff --git a/DW-Programming/fabricut-minorder-unit-fix.mjs b/DW-Programming/fabricut-minorder-unit-fix.mjs
new file mode 100644
index 00000000..ea179022
--- /dev/null
+++ b/DW-Programming/fabricut-minorder-unit-fix.mjs
@@ -0,0 +1,71 @@
+#!/usr/bin/env node
+/**
+ * TK-10796 — Fabricut "Minimum Order: 9 Yards yards" double-unit remediation.
+ *
+ * ROOT CAUSE: DATA. Live Shopify metafields global.v_prods_quantity_order_min and
+ * global.v_prods_quantity_order_units hold the unit baked into the VALUE ("9 Yards").
+ * The DW product Liquid theme appends the unit label ("yards") on render, so the page
+ * shows "Minimum Order: 9 Yards yards". Go-forward scrapers/activators already store
+ * BARE numbers (fabricut-fix-pricing.js:80, fabricut-activate.js:72) — this only
+ * remediates the LEGACY rows that predate that fix.
+ *
+ * FIX: overwrite each affected metafield VALUE with its leading integer only
+ * ("9 Yards" -> "9"). Reversible via the restore-map (old value per metafield_id).
+ *
+ * SAFETY: dry-run by default. Reads the pre-built restore-map so the target set is the
+ * exact, verified, enumerated 979-product / 1,841-field set from the audit.
+ * Re-verifies each metafield's CURRENT value before writing (skips if already fixed or
+ * changed since audit). Records executed changes to an execution ledger.
+ *
+ * GATED: this is a live customer-facing Shopify write across ~979 products. DO NOT run
+ * with --apply without Steve's approval.
+ *
+ * Usage: node fabricut-minorder-unit-fix.mjs [--apply] [--limit N]
+ */
+import fs from 'fs';
+const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8');
+const TOKEN = (env.match(/SHOPIFY_ADMIN_TOKEN=(.+)/)||[])[1].trim();
+const DOMAIN='designer-laboratory-sandbox.myshopify.com', VER='2024-10';
+const MAP='/Users/macstudio3/.claude/yolo-queue/pending-approval/fabricut-minorder-restore-map.jsonl';
+const LEDGER='/Users/macstudio3/.claude/yolo-queue/executed-reversible/fabricut-minorder-unit-fix-ledger.jsonl';
+const args=process.argv.slice(2), APPLY=args.includes('--apply');
+let LIMIT=null; const li=args.indexOf('--limit'); if(li>=0) LIMIT=parseInt(args[li+1]);
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+const fixVal = v => { const m=String(v).match(/-?\d+/); return m?m[0]:''; };
+async function REST(method,path,body){
+  const r=await fetch(`https://${DOMAIN}/admin/api/${VER}${path}`,{method,
+    headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},
+    body: body?JSON.stringify(body):undefined});
+  if(r.status===429){ await sleep(1500); return REST(method,path,body); }
+  if(!r.ok) throw new Error(`${r.status} ${path} ${(await r.text()).slice(0,160)}`);
+  return r.json();
+}
+let rows = fs.readFileSync(MAP,'utf8').trim().split('\n').filter(Boolean).map(l=>JSON.parse(l));
+if(LIMIT) rows = rows.slice(0,LIMIT);
+const totalFields = rows.reduce((n,r)=>n+r.fields.length,0);
+console.log(`=== Fabricut minorder unit-fix ${APPLY?'[APPLY - LIVE]':'[DRY-RUN]'} ===`);
+console.log(`products=${rows.length} fields=${totalFields}`);
+let wrote=0, skipped=0, fail=0, prod=0;
+for(const r of rows){
+  prod++;
+  for(const f of r.fields){
+    const want = f.new || fixVal(f.old);
+    if(!/^\-?\d+$/.test(want)){ console.error(`✗ ${r.dw_sku} ${f.key}: unsafe target ${JSON.stringify(want)} — SKIP`); skipped++; continue; }
+    if(!APPLY){ console.log(`[DRY] ${r.dw_sku} global.${f.key}: ${JSON.stringify(f.old)} -> ${JSON.stringify(want)}`); wrote++; continue; }
+    try{
+      // re-verify current value before writing (guard against drift since audit)
+      const cur=(await REST('GET',`/products/${r.pid}/metafields/${f.metafield_id}.json`)).metafield;
+      if(!cur){ skipped++; continue; }
+      if(String(cur.value)===want){ skipped++; continue; }               // already fixed
+      if(!/[a-z]/i.test(String(cur.value))){ skipped++; continue; }        // no unit text now — leave it
+      await REST('PUT',`/products/${r.pid}/metafields/${f.metafield_id}.json`,
+        {metafield:{id:f.metafield_id,value:want,type:'single_line_text_field'}});
+      fs.appendFileSync(LEDGER, JSON.stringify({ts:new Date().toISOString(),dw_sku:r.dw_sku,pid:r.pid,
+        metafield_id:f.metafield_id,key:f.key,old:cur.value,new:want})+'\n');
+      wrote++;
+      await sleep(320);
+    }catch(e){ console.error(`✗ ${r.dw_sku} ${f.key}: ${e.message}`); fail++; }
+  }
+  if(prod%50===0) console.log(`[${prod}/${rows.length}] wrote=${wrote} skipped=${skipped} fail=${fail}`);
+}
+console.log(`=== DONE === wrote=${wrote} skipped=${skipped} fail=${fail}`);
diff --git a/DW-Programming/fabricut_minorder_full_audit.mjs b/DW-Programming/fabricut_minorder_full_audit.mjs
new file mode 100644
index 00000000..90da6567
--- /dev/null
+++ b/DW-Programming/fabricut_minorder_full_audit.mjs
@@ -0,0 +1,40 @@
+import pg from 'pg';
+import fs from 'fs';
+const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8');
+const TOKEN = (env.match(/SHOPIFY_ADMIN_TOKEN=(.+)/)||[])[1].trim();
+const DOMAIN='designer-laboratory-sandbox.myshopify.com', VER='2024-10';
+const pool = new pg.Pool({host:'/tmp',database:'dw_unified'});
+const OUT='/Users/macstudio3/.claude/yolo-queue/pending-approval/fabricut-minorder-restore-map.jsonl';
+async function R(path){
+  const r = await fetch(`https://${DOMAIN}/admin/api/${VER}${path}`,{headers:{'X-Shopify-Access-Token':TOKEN}});
+  if(!r.ok) throw new Error(`${r.status}`);
+  return r.json();
+}
+// bad if value contains a non-digit (i.e. has "Yards"/"yards"/text). Fix = leading integer only.
+const KEYS=['v_prods_quantity_order_min','v_prods_quantity_order_units'];
+const isBad = v => v!=null && /[a-z]/i.test(String(v));
+const fixVal = v => { const m=String(v).match(/-?\d+/); return m?m[0]:''; };
+const {rows} = await pool.query(`SELECT dw_sku, shopify_product_id FROM fabricut_catalog WHERE on_shopify=true AND shopify_product_id IS NOT NULL ORDER BY dw_sku`);
+if(fs.existsSync(OUT)) fs.unlinkSync(OUT);
+let checked=0, prodsBad=0, fieldsBad=0;
+for(const row of rows){
+  try{
+    const {metafields} = await R(`/products/${row.shopify_product_id}/metafields.json`);
+    checked++;
+    const hits=[];
+    for(const m of (metafields||[])){
+      if(m.namespace==='global' && KEYS.includes(m.key) && isBad(m.value)){
+        hits.push({metafield_id:m.id, namespace:m.namespace, key:m.key, old:m.value, new:fixVal(m.value)});
+      }
+    }
+    if(hits.length){
+      prodsBad++; fieldsBad+=hits.length;
+      fs.appendFileSync(OUT, JSON.stringify({dw_sku:row.dw_sku, pid:row.shopify_product_id, fields:hits})+'\n');
+    }
+  }catch(e){}
+  await new Promise(r=>setTimeout(r,310));
+  if(checked%100===0) console.log(`checked ${checked}/${rows.length} prodsBad=${prodsBad} fieldsBad=${fieldsBad}`);
+}
+console.log(`=== DONE === total=${rows.length} checked=${checked} prodsBad=${prodsBad} fieldsBad=${fieldsBad}`);
+console.log(`restore-map: ${OUT}`);
+await pool.end();

← 27789b80 fabricut daily-post: localize S3 image hotlinks to DW URLs b  ·  back to Dw Validator Debug TK11314  ·  auto-data-snapshot: 2026-08-24T21:21:09 (6 data files) — DW- 5da4a13c →