← 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
A DW-Programming/fabricut-fix-pricing.js
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 →