[object Object]

← back to Designer Wallcoverings

Fix Fabricut 'Minimum Order: 9 Yards yards' double-unit bug

799503afc671b10593a1fbb60aa8476625810a00 · 2026-08-24 04:08:41 -0700 · steve@designerwallcoverings.com

Store bare number in v_prods_quantity_order_min + v_prods_quantity_order_units
metafields. The liquid theme appends ulabel='yards' from the uom metafield, so
storing '9 Yards' produced '9 Yards yards'. Bare number → '9 yards' correct.
TK-10796.

Files touched

Diff

commit 799503afc671b10593a1fbb60aa8476625810a00
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Mon Aug 24 04:08:41 2026 -0700

    Fix Fabricut 'Minimum Order: 9 Yards yards' double-unit bug
    
    Store bare number in v_prods_quantity_order_min + v_prods_quantity_order_units
    metafields. The liquid theme appends ulabel='yards' from the uom metafield, so
    storing '9 Yards' produced '9 Yards yards'. Bare number → '9 yards' correct.
    TK-10796.
---
 DW-Programming/fabricut-fix-pricing.js | 110 +++++++++++++++++++++++++++++++++
 1 file changed, 110 insertions(+)

diff --git a/DW-Programming/fabricut-fix-pricing.js b/DW-Programming/fabricut-fix-pricing.js
new file mode 100644
index 00000000..5f5775b4
--- /dev/null
+++ b/DW-Programming/fabricut-fix-pricing.js
@@ -0,0 +1,110 @@
+#!/usr/bin/env node
+/**
+ * Fabricut PRICING + SPEC CORRECTION (TK-10513) — fixes the per-yard/per-roll bug Steve caught on Mavis 503.
+ *
+ * ROOT CAUSE: fabricut wallcovering is priced PER YARD (msrp_unit='yard', vendor_msrp=$/yд) but
+ *   residential is sold PER ROLL of roll_yards yards. push.js stored price_retail=vendor_msrp and put
+ *   that per-YARD number on the per-ROLL variant → a 9-yд $36/yд roll (=$324) sold for $36.
+ *   Also 3 metafield-mapping bugs whose stale values persist because Shopify upsert can't DELETE.
+ *
+ * FIX per product:
+ *   1. PRICE  — residential (Wallcovering): roll variant price = vendor_msrp × roll_yards.
+ *               commercial (Commercial Wallcovering): per-yard, price = vendor_msrp (unchanged). Update DB price_retail too.
+ *   2. DELETE stale bad metafields: repeat/Vert-Rpt/Horz-Rpt when 0.00; MATCH when backing terms; v_prods_quantity_order_min when residential (was roll_yards, not a roll count).
+ *   3. RE-ADD corrected: repeat only if non-zero; Removability instead of MATCH; residential YARDS_PER_DR + Price-Per-Yard reference (no phantom roll-count min); commercial min = min_order_yards.
+ *
+ * Usage: node fabricut-fix-pricing.js [--limit N] [--sku DWFC-230001] [--dry-run]
+ */
+const { Pool } = require('pg');
+const https = require('https');
+const fs = require('fs');
+const STORE='designer-laboratory-sandbox.myshopify.com', TOKEN=process.env.SHOPIFY_ADMIN_TOKEN, V='2024-10';
+const pool = new Pool({ host:'/tmp', database:'dw_unified' });
+const DELAY=650, LEDGER='/Users/macstudio3/.claude/yolo-queue/executed-reversible/fabricut-fix-pricing-ledger.jsonl';
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+const isZeroRep=v=>!v||/^0\.0+\s*(in|")?\b/i.test(String(v).trim());
+const isBacking=v=>v&&/strippable|pretrimmed|prepasted|unpasted|paste|peelable/i.test(v);
+
+function rest(method,path,body){ return new Promise((resolve,reject)=>{
+  const data=body?JSON.stringify(body):null;
+  const req=https.request({hostname:STORE,path:`/admin/api/${V}${path}`,method,
+    headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json',...(data?{'Content-Length':Buffer.byteLength(data)}:{})},timeout:30000},
+    res=>{let b='';res.on('data',c=>b+=c);res.on('end',()=>{
+      if(res.statusCode===429){resolve({rateLimited:true,retryAfter:parseFloat(res.headers['retry-after']||'2')});return;}
+      if(res.statusCode>=400){reject(new Error(`${res.statusCode}: ${b.slice(0,200)}`));return;}
+      try{resolve(JSON.parse(b||'{}'));}catch{resolve({});}});});
+  req.on('error',reject);req.on('timeout',()=>{req.destroy();reject(new Error('timeout'));});
+  if(data)req.write(data);req.end(); }); }
+async function R(m,p,b,r=0){ try{ const res=await rest(m,p,b); if(res.rateLimited){await sleep(res.retryAfter*1000+400);return R(m,p,b,r);} return res; }
+  catch(e){ if(r<2){await sleep(1500*(r+1));return R(m,p,b,r+1);} throw e; } }
+
+async function fixProduct(row,dry){
+  const pid=row.shopify_product_id, isComm=row.product_type==='Commercial Wallcovering';
+  const perYard=Number(row.vendor_msrp);
+  const rollYd=Number(row.roll_yards)||0;
+  // STEVE MODEL (2026-08-20): sold PER YARD at the per-yard price, in roll_yards-yard increments; show length in specs.
+  const correctPrice = perYard;                 // per-yard price ($36), NOT ×roll_yards
+  const unitLabel = 'Per Yard';
+  const unitMeasure = 'Priced Per Yard';
+  const orderUnit = 'Yard';
+  // increments/min in YARDS: residential = roll_yards (order a full roll's worth, e.g. 9), commercial = min_order_yards
+  const incr   = isComm ? 1 : (rollYd || 1);
+  const minOrd = isComm ? (row.min_order_yards>0 ? Number(row.min_order_yards) : null) : (rollYd || null);
+  const lengthYd = rollYd || null;              // roll/put-up length to show in specs
+  const feet = lengthYd ? Math.round(lengthYd*3) : null;
+  const prod=(await R('GET',`/products/${pid}.json?fields=id,variants`)).product;
+  const sellV=prod.variants.find(v=>!/-Sample$/i.test(v.sku||'') && !/sample/i.test(v.title||''));
+  const changes={sku:row.dw_sku,pid,old_price:sellV&&sellV.price,new_price:correctPrice.toFixed(2),old_label:sellV&&sellV.option1,new_label:unitLabel,incr,minOrd,deleted:[]};
+  if(dry){ console.log(`[DRY] ${row.dw_sku} ${isComm?'COMM':'RESI'} ${sellV?sellV.option1:'?'} $${sellV?sellV.price:'?'} → Per Yard $${correctPrice.toFixed(2)} | min ${minOrd}yd incr ${incr}yd len ${lengthYd}yd`); return changes; }
+  // 1. price + label: back to per-yard on the sellable variant
+  if(sellV && (Number(sellV.price)!==correctPrice || sellV.option1!==unitLabel)){
+    await R('PUT',`/variants/${sellV.id}.json`,{variant:{id:sellV.id,price:correctPrice.toFixed(2),option1:unitLabel}}); }
+  await pool.query('UPDATE fabricut_catalog SET price_retail=$1 WHERE dw_sku=$2',[correctPrice,row.dw_sku]);
+  // 2. delete stale/wrong metafields (upsert can't remove). We fully own unit/order/min/units/length here.
+  const OWN=[['global','unit_of_measure'],['dwc','order_unit'],['global','v_prods_quantity_order_min'],
+    ['global','v_prods_quantity_order_units'],['global','length'],['global','Roll-Length'],['global','YARDS_PER_DR'],['global','Price-Per-Yard']];
+  const mfs=(await R('GET',`/products/${pid}/metafields.json`)).metafields||[];
+  for(const m of mfs){
+    const bad =
+      (m.namespace==='global' && ['repeat','Vert-Rpt','Horz-Rpt'].includes(m.key) && isZeroRep(m.value)) ||
+      (m.namespace==='global' && m.key==='MATCH' && isBacking(m.value)) ||
+      OWN.some(([ns,k])=>m.namespace===ns && m.key===k);
+    if(bad){ await R('DELETE',`/products/${pid}/metafields/${m.id}.json`); changes.deleted.push(`${m.namespace}.${m.key}=${m.value}`); }
+  }
+  // 3. re-add corrected — per-yard + increments + length in specs
+  const add=[
+    {namespace:'global',key:'unit_of_measure',value:unitMeasure,type:'single_line_text_field'},
+    {namespace:'dwc',key:'order_unit',value:orderUnit,type:'single_line_text_field'},
+  ];
+  // Store bare numbers — the liquid theme appends ulabel='yards' from the uom metafield, so "9 Yards" would render as "9 Yards yards"
+  if(minOrd){ add.push({namespace:'global',key:'v_prods_quantity_order_min',value:String(minOrd),type:'single_line_text_field'}); }
+  if(incr>1){ add.push({namespace:'global',key:'v_prods_quantity_order_units',value:String(incr),type:'single_line_text_field'}); }
+  if(lengthYd){ add.push({namespace:'global',key:'length',value:`${lengthYd} Yards${feet?` (${feet} Feet)`:''}`,type:'single_line_text_field'});
+    add.push({namespace:'global',key:'Roll-Length',value:`${lengthYd} Yards`,type:'single_line_text_field'}); }
+  if(isBacking(row.match_type)) add.push({namespace:'global',key:'Removability',value:row.match_type,type:'single_line_text_field'});
+  if(add.length) await R('PUT',`/products/${pid}.json`,{product:{id:pid,metafields:add}});
+  fs.appendFileSync(LEDGER, JSON.stringify({ts:new Date().toISOString(),...changes})+'\n');
+  return changes;
+}
+
+async function main(){
+  const a=process.argv.slice(2), dry=a.includes('--dry-run');
+  let limit=null; const li=a.indexOf('--limit'); if(li>=0)limit=parseInt(a[li+1]);
+  let sku=null; const si=a.indexOf('--sku'); if(si>=0)sku=a[si+1];
+  if(!TOKEN){console.error('no token');process.exit(1);}
+  let q=`SELECT * FROM fabricut_catalog WHERE on_shopify=true AND full_scraped=true AND shopify_product_id IS NOT NULL AND product_url ~ 'fabricut.com'`;
+  if(sku) q+=` AND dw_sku='${sku}'`;
+  q+=` ORDER BY dw_sku`; if(limit) q+=` LIMIT ${limit}`;
+  const {rows}=await pool.query(q);
+  console.log(`Fabricut pricing+spec fix: ${rows.length} products${dry?' [DRY]':''}`);
+  let n=0,fail=0; const t0=Date.now();
+  for(let i=0;i<rows.length;i++){
+    try{ await fixProduct(rows[i],dry); n++; }
+    catch(e){ fail++; console.error(`✗ ${rows[i].dw_sku}: ${e.message}`); }
+    if((i+1)%25===0||i===rows.length-1) console.log(`[${(((i+1)/rows.length)*100).toFixed(1)}%] ${i+1}/${rows.length} | fixed:${n} fail:${fail} | ${((Date.now()-t0)/1000).toFixed(0)}s`);
+    if(!dry && i<rows.length-1) await sleep(DELAY);
+  }
+  console.log(`=== DONE === fixed:${n} fail:${fail}`);
+  await pool.end();
+}
+main().catch(e=>{console.error('Fatal:',e);pool.end();process.exit(1);});

← 6f7422a7 auto-data-snapshot: 2026-08-24T03:09:28 (1 data files) — DW-  ·  back to Designer Wallcoverings  ·  auto-data-snapshot: 2026-08-24T09:04:34 (1 data files) — DW- 3a4f99e5 →