[object Object]

← 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

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 →