← back to Japan Enrich
feat: Carnegie split rollout gate — import canonical mfr_sku guard (TK-10792)
160953251aaa78bf529807282c11a57958a4b0e0 · 2026-08-23 04:13:18 -0700 · steve@designerwallcoverings.com
Replace inline mfrSkuValid copies with import from carnegie-mfr-gate.mjs
(canonical, single source of truth). Both rollout.mjs and rollout2.mjs now
block ACTIVE on null/empty/DWAG-* mfr_sku -> DRAFT + Needs-Mfr-SKU tag.
Skip count tracked per run; reported in final summary.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Files touched
A carnegie-split/rollout.mjsA carnegie-split/rollout2.mjs
Diff
commit 160953251aaa78bf529807282c11a57958a4b0e0
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date: Sun Aug 23 04:13:18 2026 -0700
feat: Carnegie split rollout gate — import canonical mfr_sku guard (TK-10792)
Replace inline mfrSkuValid copies with import from carnegie-mfr-gate.mjs
(canonical, single source of truth). Both rollout.mjs and rollout2.mjs now
block ACTIVE on null/empty/DWAG-* mfr_sku -> DRAFT + Needs-Mfr-SKU tag.
Skip count tracked per run; reported in final summary.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
carnegie-split/rollout.mjs | 104 ++++++++++++++++++++++++++++++++++++++++++++
carnegie-split/rollout2.mjs | 91 ++++++++++++++++++++++++++++++++++++++
2 files changed, 195 insertions(+)
diff --git a/carnegie-split/rollout.mjs b/carnegie-split/rollout.mjs
new file mode 100644
index 0000000..c501054
--- /dev/null
+++ b/carnegie-split/rollout.mjs
@@ -0,0 +1,104 @@
+// Carnegie full split rollout — resumable, per-pattern, rate-limit-aware.
+// Steve-approved model (2026-08-18): per pattern -> CREATE standalone per-colorway products, then ARCHIVE the old
+// Color-variant product + REDIRECT its handle to the first standalone. Correct model:
+// - standalone product, 2 variants (Fabric + Memo Sample), NO Color option
+// - TITLE = vendor "Carnegie <pattern> - Color <n>" (no derived name, no hex)
+// - color metafield = vendor "Color <n>"; derived color name + hex# in TAGS
+// - full spec metafields + description from carnegie_catalog; Shopify-hosted image (src)
+// - tag split-batch:carnegie-v2 (dedup/rollback marker)
+// Usage: node rollout.mjs [--pattern "Ponte"] [--limit N] [--no-archive] [--dry]
+//
+// TK-10792 stop-the-bleed: products with null/empty or DWAG-* mfr_sku are created
+// as DRAFT with tag Needs-Mfr-SKU — NEVER activated until Steve provides real code.
+import { execSync } from 'node:child_process';
+import fs from 'node:fs';
+// TK-10792: canonical mfr_sku gate — one source of truth, no inline copy
+import { mfrSkuValid, SKIP_TAG as NEEDS_MFR_TAG } from '../carnegie-reprice/carnegie-mfr-gate.mjs';
+const HOME=process.env.HOME, SHOP='designer-laboratory-sandbox.myshopify.com', API='2024-10';
+const TOK=execSync(`grep -m1 '^SHOPIFY_ADMIN_TOKEN=' ${HOME}/Projects/secrets-manager/.env|cut -d= -f2-|tr -d '"'\\'' '`).toString().trim();
+const args=process.argv.slice(2), aV=k=>{const i=args.indexOf(k);return i>=0?args[i+1]:null;};
+const ONLY_PAT=aV('--pattern'), LIMIT=Number(aV('--limit')||0), DRY=args.includes('--dry'), NO_ARCH=args.includes('--no-archive');
+const LEDGER=`${HOME}/Projects/carnegie-split/rollout-ledger.jsonl`;
+// TK-10792: mfrSkuValid + NEEDS_MFR_TAG imported from canonical gate above
+let mfrSkipCount=0;
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+const ANCHORS={White:[245,244,240],Alabaster:[237,234,224],Ivory:[240,234,214],Cream:[238,228,200],Bone:[226,220,205],Oatmeal:[214,205,185],Sand:[206,190,160],Fawn:[190,170,140],Taupe:[160,146,128],Greige:[176,168,152],Pewter:[140,138,132],Silver:[190,190,190],"Dove Gray":[168,166,162],Gray:[128,128,128],Slate:[96,102,108],Charcoal:[64,64,66],Graphite:[48,48,50],Black:[24,22,22],Camel:[176,140,98],Tan:[190,155,110],Wheat:[198,178,130],Honey:[196,150,80],Caramel:[168,118,66],Cognac:[140,84,48],Saddle:[120,78,48],Chestnut:[108,68,44],Chocolate:[78,54,40],Espresso:[56,42,34],Oxblood:[96,40,38],Brick:[150,66,50],Rust:[168,86,52],Terracotta:[186,102,72],Clay:[170,120,96],Blush:[214,176,168],Rose:[196,128,128],Wine:[96,44,56],Burgundy:[110,40,52],Navy:[40,48,84],Denim:[72,96,132],"Steel Blue":[96,120,146],Teal:[48,110,112],Sage:[140,150,120],Olive:[110,110,64],Moss:[84,96,58],Forest:[52,80,56],Mustard:[190,150,54],Gold:[196,160,80],Bronze:[140,116,70],Putty:[196,186,168],Stone:[180,172,158],Mushroom:[168,156,140],Flannel:[120,120,124]};
+const hex2rgb=h=>{h=String(h).replace('#','');if(h.length!==6)return null;const n=parseInt(h,16);return[(n>>16)&255,(n>>8)&255,n&255];};
+const nearest=rgb=>{let b=null,bd=1e18;for(const[n,[ar,ag,ab]]of Object.entries(ANCHORS)){const d=(rgb[0]-ar)**2*.3+(rgb[1]-ag)**2*.59+(rgb[2]-ab)**2*.11;if(d<bd){bd=d;b=n;}}return b;};
+const slug=s=>s.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
+const cleanMfr=s=>String(s).replace(/-(upholstery|windows|panels[a-z-]*|wall[a-z-]*|drapery|cubicle|health[a-z-]*|acoustic[a-z-]*)$/i,'');
+async function rest(p,o={},t=10){for(let i=0;i<t;i++){const r=await fetch(`https://${SHOP}/admin/api/${API}/${p}`,{...o,headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json',...(o.headers||{})}});if(r.status===429){await sleep(Number(r.headers.get('retry-after')||2)*1000+400);continue;}return r;}throw new Error('429 '+p);}
+async function gql(q,v,t=10){for(let i=0;i<t;i++){const r=await fetch(`https://${SHOP}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});if(r.status===429){await sleep(2000);continue;}const j=await r.json();if(j.errors&&/throttl/i.test(JSON.stringify(j.errors))){await sleep(2500);continue;}return j;}throw new Error('gql');}
+const psql=s=>execSync(`psql -h /tmp -d dw_unified -tA -F'§' -c "${s.replace(/"/g,'\\"')}"`).toString();
+const ledger=o=>fs.appendFileSync(LEDGER, JSON.stringify(o)+'\n');
+const T='single_line_text_field',M='multi_line_text_field';
+const SET=`mutation($m:[MetafieldsSetInput!]!){metafieldsSet(metafields:$m){userErrors{message}}}`;
+
+// standalone dw_skus already built (resume): products tagged split-batch:carnegie-v2 OR the done Siltech (split-batch:TK-10686 active)
+async function builtSkus(){
+ const set=new Set(); for(const tag of ['carnegie-v2','TK-10686']){ let after=null;
+ do{ const q=`{products(first:100,query:"tag:split-batch\\\\:${tag} status:active"${after?`,after:"${after}"`:''}){pageInfo{hasNextPage endCursor} edges{node{variants(first:3){edges{node{sku}}}}}}}`;
+ const j=await gql(q); const d=j.data?.products; if(!d)break;
+ for(const e of d.edges)for(const v of e.node.variants.edges){const s=v.node.sku||'';if(s&&!/sample/i.test(s))set.add(s);}
+ after=d.pageInfo.hasNextPage?d.pageInfo.endCursor:null; await sleep(450);
+ }while(after); }
+ return set;
+}
+// old Color-variant pattern-product (to archive): exact title "Carnegie <pattern>", multi-variant / Color option
+async function oldPatternProduct(pattern){
+ const q=`{products(first:5,query:${JSON.stringify(`vendor:Carnegie title:'${pattern}'`)}){edges{node{id title handle status options{name} variants(first:3){edges{node{id}}}}}}`;
+ const j=await gql(q); const es=j.data?.products?.edges||[];
+ return es.map(e=>e.node).find(n=>n.title===`Carnegie ${pattern}` && n.status==='ACTIVE' && (n.options||[]).some(o=>/color/i.test(o.name)));
+}
+async function makeRedirect(from,to){ // needs write_content; returns true/false
+ const j=await gql(`mutation($i:UrlRedirectInput!){urlRedirectCreate(urlRedirect:$i){userErrors{message}}}`,{i:{path:`/products/${from}`,target:`/products/${to}`}});
+ return !(j.errors)&&!(j.data?.urlRedirectCreate?.userErrors||[]).length;
+}
+
+(async()=>{
+ const cols=['dw_sku','pattern_name','color_number','mfr_sku','price','image_url','body_html','description_text','width','content','durability_wyzenbeek','cleaning_code','flammability','finish','backing','origin','primary_hex','product_type'];
+ let where=`WHERE image_url IS NOT NULL AND image_url<>'' AND pattern_name<>'Siltech Grain'`;
+ if(ONLY_PAT) where=`WHERE image_url IS NOT NULL AND image_url<>'' AND pattern_name='${ONLY_PAT.replace(/'/g,"''")}'`;
+ const rows=psql(`SELECT ${cols.map(c=>`coalesce(${c}::text,'')`).join("||'§'||")} FROM carnegie_catalog ${where} ORDER BY pattern_name, color_number::int NULLS LAST`).trim().split('\n').filter(Boolean).map(l=>{const p=l.split('§');return Object.fromEntries(cols.map((c,i)=>[c,p[i]]));});
+ const patterns=[...new Set(rows.map(r=>r.pattern_name))];
+ console.log(`patterns: ${patterns.length} | colorways: ${rows.length}${DRY?' (DRY)':''}`);
+ console.log('building already-built set...'); const built=await builtSkus(); console.log('already built standalones:', built.size);
+ let created=0,archived=0,fail=0,pi=0;
+ for(const pat of patterns){
+ pi++; const pr=rows.filter(r=>r.pattern_name===pat);
+ const perName={}; for(const r of pr){const rgb=hex2rgb(r.primary_hex);r._d=rgb?nearest(rgb):'';perName[r._d]=(perName[r._d]||0)+1;}
+ const todo=pr.filter(r=>!built.has(r.dw_sku)); if(LIMIT&&created>=LIMIT)break;
+ console.log(`[${pi}/${patterns.length}] ${pat}: ${pr.length} colorways, ${todo.length} to create`);
+ for(const r of todo){ if(LIMIT&&created>=LIMIT)break;
+ const title=`Carnegie ${pat} - Color ${r.color_number}`, mfr=cleanMfr(r.mfr_sku);
+ // TK-10792: gate — DWAG-* or missing mfr_sku → DRAFT+Needs-Mfr-SKU
+ const hasMfr=mfrSkuValid(r.mfr_sku);
+ const productStatus=hasMfr?'active':'draft';
+ if(!hasMfr){mfrSkipCount++;console.warn(` [MFR-GATE] SKIP-ACTIVE ${r.dw_sku} mfr=${JSON.stringify(r.mfr_sku)} → DRAFT+${NEEDS_MFR_TAG}`);}
+ const tagArr=[...new Set(['Carnegie',pat,'split-batch:carnegie-v2',r._d,r.primary_hex,...(!hasMfr?[NEEDS_MFR_TAG]:[])].filter(Boolean))];
+ const tags=tagArr.join(', ');
+ const body=r.body_html||(r.description_text?`<p>${r.description_text}</p>`:'');
+ if(DRY){console.log(` WOULD ${productStatus.toUpperCase()} ${r.dw_sku} ${title} | mfr=${r.mfr_sku||'null'} | +tags ${r._d} ${r.primary_hex}${!hasMfr?' [MFR-GATE: DRAFT]':''}`);created++;continue;}
+ const cr=await rest('products.json',{method:'POST',body:JSON.stringify({product:{title,handle:slug(title),vendor:'Carnegie',product_type:r.product_type||'Upholstery',status:productStatus,body_html:body,tags,options:[{name:'Title'}],variants:[{option1:'Fabric',sku:r.dw_sku,price:r.price||'0.00'},{option1:'Memo Sample',sku:`${r.dw_sku}-sample`,price:'4.25'}],images:[{src:r.image_url}]}})});
+ if(!cr.ok){const t=await cr.text();console.log(' CREATE FAIL',r.dw_sku,cr.status,t.slice(0,80));ledger({t:Date.now(),dw:r.dw_sku,pat,ok:false,err:t.slice(0,120)});fail++;await sleep(700);continue;}
+ const np=(await cr.json()).product,oid=`gid://shopify/Product/${np.id}`; await sleep(450);
+ const mf=[['custom','width',r.width,T],['dwc','width',r.width,T],['custom','material',r.content,M],['dwc','contents',r.content,T],['dwc','durability',r.durability_wyzenbeek,T],['dwc','cleaning_code',r.cleaning_code,T],['dwc','finish',r.finish,T],['custom','fire_rating',r.flammability,T],['custom','backing',r.backing,T],['custom','origin',r.origin,T],['custom','pattern_name',pat,T],['dwc','pattern_name',pat,T],['custom','color',`Color ${r.color_number}`,T],['dwc','color',`Color ${r.color_number}`,T],['custom','color_hex',r.primary_hex,T],['custom','brand','Carnegie',T],['dwc','brand','Carnegie',T],['custom','manufacturer_sku',mfr,T],['dwc','manufacturer_sku',mfr,T],['dwc','order_unit','Yard',T],['custom','product_class','Fabric',T],['dwc','ai_generated_description',(r.description_text||body).replace(/<[^>]+>/g,' ').trim(),M],['global','title_tag',`${title} | Carnegie`,T]].filter(([,,v])=>v&&String(v).trim()).map(([ns,key,value,type])=>({ownerId:oid,namespace:ns,key,type,value:String(value)}));
+ const jm=await gql(SET,{m:mf}); ledger({t:Date.now(),dw:r.dw_sku,pat,id:np.id,ok:true,specs:mf.length});
+ created++; if(created%25===0)console.log(` ...created ${created}`); await sleep(500);
+ }
+ // archive old Color-variant product + redirect
+ if(!DRY && !NO_ARCH && (!LIMIT||created<LIMIT)){
+ const old=await oldPatternProduct(pat);
+ if(old){ const oldId=old.id.split('/').pop();
+ // redirect old handle -> first new standalone
+ const firstNew=slug(`Carnegie ${pat} - Color ${pr[0].color_number}`);
+ const rok=await makeRedirect(old.handle, firstNew); await sleep(300);
+ const a=await rest(`products/${oldId}.json`,{method:'PUT',body:JSON.stringify({product:{id:Number(oldId),status:'archived'}})});
+ ledger({t:Date.now(),pat,archived_old:oldId,handle:old.handle,redirect:rok});
+ if(a.ok){archived++;console.log(` archived old "${old.title}" (redirect:${rok?'ok':'NO write_content'})`);}
+ await sleep(500);
+ } else console.log(` (no old Color-variant product found for ${pat})`);
+ }
+ }
+ console.log(JSON.stringify({created,archived,fail,mfrDraftSkipped:mfrSkipCount,ledger:LEDGER}));
+})();
diff --git a/carnegie-split/rollout2.mjs b/carnegie-split/rollout2.mjs
new file mode 100644
index 0000000..f9d328a
--- /dev/null
+++ b/carnegie-split/rollout2.mjs
@@ -0,0 +1,91 @@
+// Carnegie split rollout v2 — grouped model (Steve-approved 2026-08-18).
+// One product per (pattern, color, DW-class); CONSTRUCTIONS are variants; a Memo Sample variant.
+// class = Wallcovering (construction ~ wallcover) else Fabric
+// TITLE = "<Pattern> <Class> - Color <N> | Carnegie" (NO 'Carnegie' prefix; brand as suffix)
+// color metafield = vendor "Color <N>"; derived color name + hex# in TAGS; full spec metafields + description
+// Shopify-hosted image (src); tag split-batch:carnegie-v2. Then archive old Color-variant product + redirect.
+// Usage: node rollout2.mjs [--pattern "Ponte"] [--limit-groups N] [--no-archive] [--dry]
+//
+// TK-10792 stop-the-bleed: products with null/empty or DWAG-* mfr_sku are created
+// as DRAFT with tag Needs-Mfr-SKU — NEVER activated until Steve provides real code.
+import { execSync } from 'node:child_process';
+import fs from 'node:fs';
+// TK-10792: canonical mfr_sku gate — one source of truth, no inline copy
+import { mfrSkuValid, SKIP_TAG as NEEDS_MFR_TAG } from '../carnegie-reprice/carnegie-mfr-gate.mjs';
+const HOME=process.env.HOME, SHOP='designer-laboratory-sandbox.myshopify.com', API='2024-10';
+const TOK=execSync(`grep -m1 '^SHOPIFY_ADMIN_TOKEN=' ${HOME}/Projects/secrets-manager/.env|cut -d= -f2-|tr -d '"'\\'' '`).toString().trim();
+const args=process.argv.slice(2), aV=k=>{const i=args.indexOf(k);return i>=0?args[i+1]:null;};
+const ONLY_PAT=aV('--pattern'), LG=Number(aV('--limit-groups')||0), DRY=args.includes('--dry'), NO_ARCH=args.includes('--no-archive');
+const LEDGER=`${HOME}/Projects/carnegie-split/rollout-ledger.jsonl`;
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+// TK-10792: mfrSkuValid + NEEDS_MFR_TAG imported from canonical gate above
+let mfrSkipCount=0;
+const ANCHORS={White:[245,244,240],Alabaster:[237,234,224],Ivory:[240,234,214],Cream:[238,228,200],Bone:[226,220,205],Oatmeal:[214,205,185],Sand:[206,190,160],Fawn:[190,170,140],Taupe:[160,146,128],Greige:[176,168,152],Pewter:[140,138,132],Silver:[190,190,190],"Dove Gray":[168,166,162],Gray:[128,128,128],Slate:[96,102,108],Charcoal:[64,64,66],Graphite:[48,48,50],Black:[24,22,22],Camel:[176,140,98],Tan:[190,155,110],Wheat:[198,178,130],Honey:[196,150,80],Caramel:[168,118,66],Cognac:[140,84,48],Saddle:[120,78,48],Chestnut:[108,68,44],Chocolate:[78,54,40],Espresso:[56,42,34],Oxblood:[96,40,38],Brick:[150,66,50],Rust:[168,86,52],Terracotta:[186,102,72],Clay:[170,120,96],Blush:[214,176,168],Rose:[196,128,128],Wine:[96,44,56],Burgundy:[110,40,52],Navy:[40,48,84],Denim:[72,96,132],"Steel Blue":[96,120,146],Teal:[48,110,112],Sage:[140,150,120],Olive:[110,110,64],Moss:[84,96,58],Forest:[52,80,56],Mustard:[190,150,54],Gold:[196,160,80],Bronze:[140,116,70],Putty:[196,186,168],Stone:[180,172,158],Mushroom:[168,156,140],Flannel:[120,120,124]};
+const hex2rgb=h=>{h=String(h).replace('#','');if(h.length!==6)return null;const n=parseInt(h,16);return[(n>>16)&255,(n>>8)&255,n&255];};
+const nearest=rgb=>{let b=null,bd=1e18;for(const[n,[ar,ag,ab]]of Object.entries(ANCHORS)){const d=(rgb[0]-ar)**2*.3+(rgb[1]-ag)**2*.59+(rgb[2]-ab)**2*.11;if(d<bd){bd=d;b=n;}}return b;};
+const slug=s=>s.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
+const cleanMfr=s=>String(s).replace(/-(upholstery|windows|panels[a-z-]*|wall[a-z-]*|drapery|cubicle|health[a-z-]*|acoustic[a-z-]*)$/i,'');
+const classOf=pt=>/wallcover/i.test(pt||'')?'Wallcovering':'Fabric';
+const consLabel=pt=>{pt=String(pt||'').trim();const m={ 'Wallcoverings':'Wallcovering','Upholstered Walls/Panels':'Panels','Upholstery':'Upholstery','Windows':'Windows','Drapery':'Drapery','Panels':'Panels','Cubicle':'Cubicle'};return m[pt]||pt||'Standard';};
+async function rest(p,o={},t=10){for(let i=0;i<t;i++){const r=await fetch(`https://${SHOP}/admin/api/${API}/${p}`,{...o,headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json',...(o.headers||{})}});if(r.status===429){await sleep(Number(r.headers.get('retry-after')||2)*1000+400);continue;}return r;}throw new Error('429 '+p);}
+async function gql(q,v,t=10){for(let i=0;i<t;i++){const r=await fetch(`https://${SHOP}/admin/api/${API}/graphql.json`,{method:'POST',headers:{'X-Shopify-Access-Token':TOK,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});if(r.status===429){await sleep(2000);continue;}const j=await r.json();if(j.errors&&/throttl/i.test(JSON.stringify(j.errors))){await sleep(2500);continue;}return j;}throw new Error('gql');}
+const psql=s=>execSync(`psql -h /tmp -d dw_unified -tA -F'§' -c "${s.replace(/"/g,'\\"')}"`,{maxBuffer:1<<28}).toString();
+const ledger=o=>fs.appendFileSync(LEDGER, JSON.stringify(o)+'\n');
+const T='single_line_text_field',M='multi_line_text_field';
+const SET=`mutation($m:[MetafieldsSetInput!]!){metafieldsSet(metafields:$m){userErrors{message}}}`;
+async function builtSkus(){const set=new Set();let after=null;do{const q=`{products(first:100,query:"tag:split-batch\\\\:carnegie-v2 status:active"${after?`,after:"${after}"`:''}){pageInfo{hasNextPage endCursor} edges{node{variants(first:20){edges{node{sku}}}}}}}`;const j=await gql(q);const d=j.data?.products;if(!d)break;for(const e of d.edges)for(const v of e.node.variants.edges){const s=v.node.sku||'';if(s&&!/sample/i.test(s))set.add(s);}after=d.pageInfo.hasNextPage?d.pageInfo.endCursor:null;await sleep(450);}while(after);return set;}
+async function oldPatternProduct(pattern){const q=`{products(first:8,query:${JSON.stringify(`vendor:Carnegie title:'${pattern}'`)}){edges{node{id title handle status options{name}}}}}`;const j=await gql(q);return (j.data?.products?.edges||[]).map(e=>e.node).find(n=>n.title===`Carnegie ${pattern}`&&n.status==='ACTIVE'&&(n.options||[]).some(o=>/color/i.test(o.name)));}
+async function makeRedirect(from,to){const j=await gql(`mutation($i:UrlRedirectInput!){urlRedirectCreate(urlRedirect:$i){userErrors{message}}}`,{i:{path:`/products/${from}`,target:`/products/${to}`}});return !(j.errors)&&!(j.data?.urlRedirectCreate?.userErrors||[]).length;}
+
+(async()=>{
+ const cols=['dw_sku','pattern_name','color_number','mfr_sku','price','image_url','body_html','description_text','width','content','durability_wyzenbeek','cleaning_code','flammability','finish','backing','origin','primary_hex','product_type'];
+ let where=`WHERE image_url IS NOT NULL AND image_url<>'' AND pattern_name<>'Siltech Grain'`;
+ if(ONLY_PAT) where=`WHERE image_url IS NOT NULL AND image_url<>'' AND pattern_name='${ONLY_PAT.replace(/'/g,"''")}'`;
+ const rows=psql(`SELECT ${cols.map(c=>`regexp_replace(coalesce(${c}::text,''),'['||chr(10)||chr(13)||']+',' ','g')`).join("||'§'||")} FROM carnegie_catalog ${where} ORDER BY pattern_name, color_number`).trim().split('\n').filter(Boolean).map(l=>{const p=l.split('§');const o=Object.fromEntries(cols.map((c,i)=>[c,p[i]]));const rgb=hex2rgb(o.primary_hex);o._d=rgb?nearest(rgb):'';o._cls=classOf(o.product_type);o._lab=consLabel(o.product_type);return o;});
+ // group by pattern|color|class
+ const groups={}; for(const r of rows){const k=`${r.pattern_name}§${r.color_number}§${r._cls}`;(groups[k]=groups[k]||[]).push(r);}
+ const gkeys=Object.keys(groups);
+ console.log(`rows:${rows.length} -> products(groups):${gkeys.length}${DRY?' (DRY)':''}`);
+ console.log('building already-built set...'); const built=await builtSkus(); console.log('already built:', built.size);
+ let created=0,archived=0,fail=0; const patternsSeen=new Set();
+ for(const k of gkeys){
+ if(LG&&created>=LG)break;
+ const g=groups[k]; const rep=g.slice().sort((a,b)=>(b.body_html||'').length-(a.body_html||'').length)[0];
+ const [pat,cnum,cls]=k.split('§');
+ if(g.some(r=>built.has(r.dw_sku))) continue; // resume: this color/class already built
+ const title=`${pat} ${cls} - Color ${cnum} | Carnegie`;
+ // TK-10792: gate — any row in the group has DWAG-* or missing mfr_sku → DRAFT
+ const allMfrsValid=g.every(r=>mfrSkuValid(r.mfr_sku));
+ const productStatus=allMfrsValid?'active':'draft';
+ if(!allMfrsValid){mfrSkipCount++;console.warn(` [MFR-GATE] SKIP-ACTIVE ${k} mfrs=${g.map(r=>r.mfr_sku||'null').join(',')} → DRAFT+${NEEDS_MFR_TAG}`);}
+ const tagSet=[...new Set(['Carnegie',pat,cls,'split-batch:carnegie-v2',rep._d,rep.primary_hex,...(!allMfrsValid?[NEEDS_MFR_TAG]:[])].filter(Boolean))];
+ const tags=tagSet.join(', ');
+ const body=rep.body_html||(rep.description_text?`<p>${rep.description_text}</p>`:'');
+ // variants: one per construction (unique label) + Memo Sample
+ const seen=new Set(); const vars=[];
+ for(const r of g){ let lab=r._lab; while(seen.has(lab)) lab+=' '; seen.add(lab); vars.push({option1:lab,sku:r.dw_sku,price:r.price||'0.00'}); }
+ vars.push({option1:'Memo Sample',sku:`${rep.dw_sku}-sample`,price:'4.25'});
+ if(DRY){ console.log(` WOULD ${productStatus.toUpperCase()} ${title} | variants: ${vars.map(v=>v.option1+'@'+v.price).join(', ')} | +tags ${rep._d} ${rep.primary_hex}${!allMfrsValid?' [MFR-GATE: DRAFT]':''}`); created++; continue; }
+ const payload={product:{title,handle:slug(`${pat} ${cls} color ${cnum}`),vendor:'Carnegie',product_type:cls,status:productStatus,body_html:body,tags,options:[{name:'Type'}],variants:vars,images:[{src:rep.image_url}]}};
+ const cr=await rest('products.json',{method:'POST',body:JSON.stringify(payload)});
+ if(!cr.ok){const t=await cr.text();console.log(' CREATE FAIL',k,cr.status,t.slice(0,90));ledger({t:Date.now(),k,ok:false,err:t.slice(0,120)});fail++;await sleep(700);continue;}
+ const np=(await cr.json()).product,oid=`gid://shopify/Product/${np.id}`; await sleep(450);
+ const mfrList=[...new Set(g.map(r=>cleanMfr(r.mfr_sku)))].join(', ');
+ const mf=[['custom','width',rep.width,T],['dwc','width',rep.width,T],['custom','material',rep.content,M],['dwc','contents',rep.content,T],['dwc','durability',rep.durability_wyzenbeek,T],['dwc','cleaning_code',rep.cleaning_code,T],['dwc','finish',rep.finish,T],['custom','fire_rating',rep.flammability,T],['custom','backing',rep.backing,T],['custom','origin',rep.origin,T],['custom','pattern_name',pat,T],['dwc','pattern_name',pat,T],['custom','color',`Color ${cnum}`,T],['dwc','color',`Color ${cnum}`,T],['custom','color_hex',rep.primary_hex,T],['custom','brand','Carnegie',T],['dwc','brand','Carnegie',T],['custom','manufacturer_sku',mfrList,T],['dwc','manufacturer_sku',mfrList,T],['dwc','order_unit','Yard',T],['custom','product_class',cls,T],['dwc','ai_generated_description',(rep.description_text||body).replace(/<[^>]+>/g,' ').trim(),M],['global','title_tag',title,T]].filter(([,,v])=>v&&String(v).trim()).map(([ns,key,value,type])=>({ownerId:oid,namespace:ns,key,type,value:String(value)}));
+ await gql(SET,{m:mf}); ledger({t:Date.now(),k,id:np.id,ok:true,vars:vars.length,specs:mf.length});
+ created++; if(created%20===0)console.log(` ...created ${created}`); await sleep(500);
+ patternsSeen.add(pat);
+ }
+ // archive old pattern products + redirect (once per pattern seen)
+ if(!DRY && !NO_ARCH){ for(const pat of patternsSeen){
+ const old=await oldPatternProduct(pat);
+ if(old){const oldId=old.id.split('/').pop();const firstNew=slug(`${pat} ${classOf(groups[Object.keys(groups).find(k=>k.startsWith(pat+'§'))].slice(-1)[0].product_type)} color ${old.title}`);
+ // redirect old -> a Carnegie search is safest; here redirect to first built group handle
+ const anyKey=gkeys.find(k=>k.startsWith(pat+'§')); const [p2,c2,cl2]=anyKey.split('§'); const target=slug(`${p2} ${cl2} color ${c2}`);
+ const rok=await makeRedirect(old.handle,target); await sleep(300);
+ const a=await rest(`products/${oldId}.json`,{method:'PUT',body:JSON.stringify({product:{id:Number(oldId),status:'archived'}})});
+ ledger({t:Date.now(),pat,archived_old:oldId,handle:old.handle,redirect:rok}); if(a.ok){archived++;console.log(` archived old "${old.title}" redirect:${rok?'ok':'no-write_content'}`);} await sleep(500);
+ }
+ }}
+ console.log(JSON.stringify({created,archived,fail,mfrDraftSkipped:mfrSkipCount}));
+})();
← bf1295d TK-10649 remediation executors: M2 Novasuede images, M4-B Fr
·
back to Japan Enrich
·
fix(carnegie): harden terrain-arctic onboarder — stamp mfr+d ff6075c →