← back to Japan Enrich
fix(carnegie): harden terrain-arctic onboarder — stamp mfr+dw_sku metafields, gate activation on real mfr, dedupe via substrate-in-title, verify image landed (TK-10798)
ff6075c86f72f3630c21355eb2de3873a5db4814 · 2026-08-24 09:24:15 -0700 · Steve Abrams
Files touched
A carnegie-split/acapella-fix/terrain-arctic-onboard.mjs
Diff
commit ff6075c86f72f3630c21355eb2de3873a5db4814
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 24 09:24:15 2026 -0700
fix(carnegie): harden terrain-arctic onboarder — stamp mfr+dw_sku metafields, gate activation on real mfr, dedupe via substrate-in-title, verify image landed (TK-10798)
---
.../acapella-fix/terrain-arctic-onboard.mjs | 155 +++++++++++++++++++++
1 file changed, 155 insertions(+)
diff --git a/carnegie-split/acapella-fix/terrain-arctic-onboard.mjs b/carnegie-split/acapella-fix/terrain-arctic-onboard.mjs
new file mode 100644
index 0000000..d2b0ff3
--- /dev/null
+++ b/carnegie-split/acapella-fix/terrain-arctic-onboard.mjs
@@ -0,0 +1,155 @@
+#!/usr/bin/env node
+// Onboard the 2 skipped Carnegie patterns (Terrain + Arctic) into born-correct records,
+// sourced straight from carnegie_catalog (they had no pre-built drafts, which is why the
+// fan-out skipped them). Matches the reference format exactly:
+// title = "Carnegie <pattern> <color_number>" (real color NUMBER, not a made-up name)
+// retail = cost/0.65/0.85 ; option "Type" = [<substrate>, Memo Sample]
+// variants: sellable @retail + <sku>-sample @4.25 (inventory_policy deny)
+// tags = [mfr, pattern, Carnegie, bucket, colorName, style] ; NO hex tag
+// metafields: custom/global.Color=colorName, custom.Color Hex+swatch_hex=hex,
+// custom/global.Style=style, custom.Type=substrate
+// published to Online Store. Then ARCHIVES the 7 old shells.
+// DRY-RUN by default (prints payloads). Pass --apply to create + archive.
+import { execSync } from 'node:child_process';
+import { readFileSync, writeFileSync } from 'node:fs';
+// TK-10798: wire in the canonical mfr gate (same guard rollout.mjs/rebuild-line.mjs use).
+// Without this, this one-off onboarder shipped Arctic+Terrain ACTIVE with NO mfr metafield,
+// no dw_sku metafield, and 2 identical-titled products per colorway (panels vs wallcoverings).
+import { mfrSkuValid, addSkipTag, SKIP_TAG as NEEDS_MFR_TAG } from '../../carnegie-reprice/carnegie-mfr-gate.mjs';
+const HOME=process.env.HOME, DIR=`${HOME}/Projects/carnegie-split/acapella-fix`;
+const APPLY=process.argv.includes('--apply');
+const TOK=readFileSync(`${HOME}/Projects/secrets-manager/.env`,'utf8').match(/SHOPIFY_ADMIN_TOKEN=([A-Za-z0-9_]+)/)[1];
+const base='https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10';
+const H={'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'};
+const sleep=ms=>new Promise(s=>setTimeout(s,ms));
+
+const P2S={}; for(const l of readFileSync(`${DIR}/carnegie-styles.psv`,'utf8').trim().split('\n')){const [p,s]=l.split('|'); if(p)P2S[p]=s;}
+const mfrCode=m=>(m||'').replace(/-(upholstery|panels|wallcoverings|windows|wallcovering|panel)$/i,'');
+const retail=cost=>Math.round((cost/0.65/0.85)*100)/100;
+function bucket(hex){ // luminance -> Light/Dark + warm/cool nudge
+ const m=/#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(hex||''); if(!m) return 'Neutral';
+ const [r,g,b]=[1,2,3].map(i=>parseInt(m[i],16)); const L=0.2126*r+0.7152*g+0.0722*b;
+ if(L>170) return 'Light'; if(L<70) return 'Dark';
+ return (r>b+12)?'Warm':(b>r+12)?'Cool':'Neutral';
+}
+function substrate(mfr,ptype,dwclass){
+ const s=`${mfr} ${ptype} ${dwclass}`.toLowerCase();
+ if(s.includes('wallcover')) return 'Wallcovering';
+ if(s.includes('panel')) return 'Panel';
+ return 'Fabric';
+}
+
+// pull Terrain + Arctic rows
+const raw=execSync(`psql "postgresql:///dw_unified?host=/tmp" -A -F'\x1f' -t -c "`+
+ `SELECT pattern_name,color_number,mfr_sku,dw_sku,price,`+
+ `regexp_replace(COALESCE(description_text,''),'[\\r\\n\\t]+',' ','g'),`+
+ `COALESCE(content,''),product_type,dw_class,`+
+ `COALESCE(palette->0->>'hex',''),COALESCE(array_to_string(color_tags,','),''),image_url `+
+ `FROM carnegie_catalog WHERE pattern_name IN ('Terrain','Arctic') AND dw_sku IS NOT NULL ORDER BY pattern_name,color_number,mfr_sku"`,
+ {encoding:'utf8',maxBuffer:1e8}).trim().split('\n');
+
+const products=[];
+for(const line of raw){
+ const [pat,num,mfr,sku,price,desc,content,ptype,dwclass,hex,ctags,img]=line.split('\x1f');
+ if(!sku) continue;
+ const sub=substrate(mfr,ptype,dwclass);
+ const colorName=(ctags||'').split(',')[0]||`Color ${num}`;
+ const style=P2S[pat]||'Solid';
+ const bkt=bucket(hex);
+ const cost=parseFloat(price)||0; const sell=retail(cost);
+ // TK-10798 (a) gate activation on a REAL vendor mfr — mirrors rollout.mjs/rebuild-line.mjs.
+ const okMfr = mfrSkuValid(mfr);
+ const productStatus = okMfr ? 'active' : 'draft';
+ const publishedFlag = okMfr; // never publish an un-gated product
+ // TK-10798 (c) dedupe: the SQL pulls BOTH -panels AND -wallcoverings per colorway.
+ // Titling both `Carnegie <pat> <num>` made two identical products. Append the substrate
+ // so each colorway's two products are distinct (Panel vs Wallcovering) — the discriminator
+ // now lives in the TITLE, not just a buried variant option, so they can be told apart later.
+ const title = `Carnegie ${pat} ${num} ${sub}`;
+ let tags=[...new Set([mfrCode(mfr),pat,'Carnegie',bkt,colorName,style,'split-batch:TK-10686'].filter(Boolean))];
+ if(!okMfr) tags = addSkipTag(tags.join(', ')).split(',').map(t=>t.trim()).filter(Boolean); // Needs-Mfr-SKU
+ products.push({pat,num,sku,mfr,sub,colorName,style,hex,bkt,cost,sell,ptype,content,okMfr,productStatus,
+ title, body:desc, img,
+ payload:{product:{title,body_html:desc,vendor:'Carnegie',
+ product_type:ptype||dwclass||'Wallcovering',status:productStatus,published:publishedFlag,published_scope:'web',
+ tags:tags.join(', '),
+ options:[{name:'Type'}],
+ variants:[
+ {sku:sku,price:String(sell),option1:sub,inventory_policy:'deny',inventory_management:null,taxable:true},
+ {sku:`${sku}-sample`,price:'4.25',option1:'Memo Sample',inventory_policy:'deny',inventory_management:null,taxable:true}],
+ images: img?[{src:img}]:[] }}});
+}
+
+console.log(`Terrain+Arctic records to onboard: ${products.length} (${APPLY?'APPLY':'DRY-RUN'})`);
+console.log('sample (first 3):');
+for(const p of products.slice(0,3)){
+ console.log(` ${p.title} [${p.sub}] $${p.sell} (cost $${p.cost}) color=${p.colorName} hex=${p.hex} bucket=${p.bkt} style=${p.style}`);
+ console.log(` sku=${p.sku} +sample tags=[${p.payload.product.tags}]`);
+}
+if(!APPLY){ console.log('\nDRY-RUN only. Re-run with --apply to create these + archive the 7 old shells.'); process.exit(0); }
+
+// CREATE
+async function gql(q,v){const r=await fetch(`${base}/graphql.json`,{method:'POST',headers:H,body:JSON.stringify({query:q,variables:v})});return r.json();}
+const created=[]; const err=[];
+for(const p of products){
+ try{
+ const r=await fetch(`${base}/products.json`,{method:'POST',headers:H,body:JSON.stringify(p.payload)});
+ if(!r.ok) throw new Error('HTTP '+r.status+' '+(await r.text()).slice(0,120));
+ const np=(await r.json()).product; const gid=`gid://shopify/Product/${np.id}`;
+ // metafields: color, hex, style, type
+ const mf=[{ownerId:gid,namespace:'custom',key:'Color',type:'single_line_text_field',value:p.colorName},
+ {ownerId:gid,namespace:'global',key:'Color',type:'single_line_text_field',value:p.colorName},
+ {ownerId:gid,namespace:'custom',key:'Style',type:'single_line_text_field',value:p.style},
+ {ownerId:gid,namespace:'global',key:'Style',type:'single_line_text_field',value:p.style},
+ {ownerId:gid,namespace:'custom',key:'Type',type:'single_line_text_field',value:p.sub}];
+ // TK-10798 (b) STAMP the go-live-gate identity metafields the original onboarder omitted:
+ // the manufacturer_sku (×namespaces the go-live canary reads) + the dw_sku. This is the
+ // root cause of the 34 no-mfr live products — data was in scope, never written.
+ if(p.okMfr){
+ for(const ns of ['custom','dwc','global']) mf.push({ownerId:gid,namespace:ns,key:'manufacturer_sku',type:'single_line_text_field',value:String(p.mfr)});
+ }
+ if(p.sku){ for(const ns of ['global','custom','dwc']) mf.push({ownerId:gid,namespace:ns,key:'dw_sku',type:'single_line_text_field',value:String(p.sku)}); }
+ if(p.hex){mf.push({ownerId:gid,namespace:'custom',key:'Color Hex',type:'single_line_text_field',value:p.hex},
+ {ownerId:gid,namespace:'custom',key:'swatch_hex',type:'single_line_text_field',value:p.hex});}
+ if(p.content){mf.push({ownerId:gid,namespace:'custom',key:'Content',type:'single_line_text_field',value:p.content});}
+ await gql(`mutation($m:[MetafieldsSetInput!]!){metafieldsSet(metafields:$m){userErrors{message}}}`,{m:mf});
+ // TK-10798 (d) verify the image actually landed (Shopify ingests images async; the create
+ // payload's images[] can silently fail). Re-fetch and flag if a product that was supposed
+ // to have an image ended up with none.
+ let imgOk = !p.img; // if we never intended an image, that's fine
+ if(p.img){
+ await sleep(800);
+ try{ const chk=await(await fetch(`${base}/products/${np.id}.json?fields=images`,{headers:H})).json();
+ imgOk=Array.isArray(chk.product?.images)&&chk.product.images.length>0; }catch{ imgOk=false; }
+ if(!imgOk){ console.log(` ⚠ image did NOT land for ${np.handle} — tagging Needs-Image`);
+ try{ const pj=await(await fetch(`${base}/products/${np.id}.json?fields=tags`,{headers:H})).json();
+ const nt=[...new Set((pj.product?.tags||'').split(',').map(t=>t.trim()).filter(Boolean).concat('Needs-Image'))].join(', ');
+ await fetch(`${base}/products/${np.id}.json`,{method:'PUT',headers:H,body:JSON.stringify({product:{id:np.id,tags:nt}})});
+ }catch{}
+ }
+ }
+ created.push({id:np.id,handle:np.handle,title:np.title,sub:p.sub,status:p.productStatus,gated:!p.okMfr,imgOk});
+ console.log(` ${p.okMfr?'✓':'▲'} ${np.title} [${p.sub}] status=${p.productStatus}${p.okMfr?'':' (NO MFR → DRAFT+Needs-Mfr-SKU)'} img=${imgOk?'ok':'MISSING'} -> ${np.handle}`);
+ }catch(e){ err.push({title:p.title,sub:p.sub,err:String(e).slice(0,140)}); console.log(` ✗ ${p.title} [${p.sub}]: ${String(e).slice(0,90)}`); }
+ await sleep(400);
+}
+console.log(`\nCREATED ${created.length}/${products.length}. errors: ${err.length}`);
+
+// ARCHIVE the 7 old shells (only after creates succeeded)
+const OLD=['carnegie-terrain','carnegie-terrain-1','arctic-fabric-color-18','arctic-fabric-color-19','arctic-fabric-color-21','arctic-wallcovering-color-19','arctic-wallcovering-color-21'];
+const archived=[];
+if(created.length>=products.length*0.9){
+ for(const h of OLD){
+ try{
+ const d=await(await fetch(`${base}/products.json?handle=${h}&fields=id`,{headers:H})).json();
+ const id=d.products?.[0]?.id; if(!id){continue;}
+ await fetch(`${base}/products/${id}.json`,{method:'PUT',headers:H,body:JSON.stringify({product:{id,status:'archived'}})});
+ archived.push({id,handle:h}); console.log(` archived old shell: ${h}`);
+ }catch(e){ console.log(` archive ${h} failed: ${String(e).slice(0,60)}`); }
+ await sleep(350);
+ }
+} else { console.log('SKIPPED archiving old shells — creates <90%, review errors first.'); }
+
+writeFileSync(`${DIR}/terrain-arctic-onboard-DONE.json`,JSON.stringify(
+ {created,archived,errors:err,undo:'delete/archive created ids; un-archive the old shells',ts:new Date().toISOString()},null,1));
+console.log(`\nrecord -> terrain-arctic-onboard-DONE.json (created ${created.length}, archived ${archived.length} old shells)`);
← 1609532 feat: Carnegie split rollout gate — import canonical mfr_sku
·
back to Japan Enrich
·
Carnegie importer: gate ACTIVE-publish on non-blank body_htm 959c778 →