← back to Japan Enrich
TK-11434: apply DW markup in the three Carnegie create paths that wrote vendor cost as the sell price
ced1de9b6cb82dec332eca2759d41d157c2f5484 · 2026-09-10 14:53:44 -0700 · Steve Abrams
rollout.mjs, rollout2.mjs and build-siltech-grain-v2-archived.mjs all did
price: r.price||'0.00' — the scraped vendor cost written straight into the
customer-facing sell price, so every product they created sold at zero margin.
The Sep-10 cutover published a set built this way and archived the marked-up
one, leaving 5,914 live variants (99.76% of the active Carnegie line) at cost.
The Aug TK-10686 reprice did not regress and could not have caught this:
reprice.mjs paginates LIVE variants only, and this set was archived at the time.
Each file now derives retail with the DW standard, mirroring the one create path
that always had it right (carnegie-reprice/rebuild-line.mjs:94):
RETAIL(cost) = round(cost / 0.65 / 0.85), falling back to '0.00' when cost is
absent, which preserves existing behaviour for costless rows.
Without this, repricing the 5,914 live variants is temporary — the next cutover
re-lands the line at cost and clobbers it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Files touched
A carnegie-split/build-siltech-grain-v2-archived.mjsM carnegie-split/rollout.mjsM carnegie-split/rollout2.mjs
Diff
commit ced1de9b6cb82dec332eca2759d41d157c2f5484
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 10 14:53:44 2026 -0700
TK-11434: apply DW markup in the three Carnegie create paths that wrote vendor cost as the sell price
rollout.mjs, rollout2.mjs and build-siltech-grain-v2-archived.mjs all did
price: r.price||'0.00' — the scraped vendor cost written straight into the
customer-facing sell price, so every product they created sold at zero margin.
The Sep-10 cutover published a set built this way and archived the marked-up
one, leaving 5,914 live variants (99.76% of the active Carnegie line) at cost.
The Aug TK-10686 reprice did not regress and could not have caught this:
reprice.mjs paginates LIVE variants only, and this set was archived at the time.
Each file now derives retail with the DW standard, mirroring the one create path
that always had it right (carnegie-reprice/rebuild-line.mjs:94):
RETAIL(cost) = round(cost / 0.65 / 0.85), falling back to '0.00' when cost is
absent, which preserves existing behaviour for costless rows.
Without this, repricing the 5,914 live variants is temporary — the next cutover
re-lands the line at cost and clobbers it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---
carnegie-split/build-siltech-grain-v2-archived.mjs | 173 +++++++++++++++++++++
carnegie-split/rollout.mjs | 11 +-
carnegie-split/rollout2.mjs | 11 +-
3 files changed, 193 insertions(+), 2 deletions(-)
diff --git a/carnegie-split/build-siltech-grain-v2-archived.mjs b/carnegie-split/build-siltech-grain-v2-archived.mjs
new file mode 100644
index 0000000..761dba7
--- /dev/null
+++ b/carnegie-split/build-siltech-grain-v2-archived.mjs
@@ -0,0 +1,173 @@
+// TK-10686 STEP 1 — build the 26 missing "Carnegie Siltech Grain" v2 products.
+// Root cause: rollout2.mjs default WHERE hard-excludes pattern_name<>'Siltech Grain'
+// (NOT a null color_number — verified: 26 rows, color 1..26, real mfr_sku 63001..630026).
+//
+// TK-11434 markup fix (2026-09-10): this create path wrote `price: r.price` — the SCRAPED
+// VENDOR COST — straight into the customer-facing sell price, so everything it created sold
+// at zero margin. The Sep-10 cutover published a set built this way and archived the
+// marked-up one, putting 5,914 live variants (99.76% of the active line) at cost.
+// The Aug TK-10686 reprice could not have caught it: reprice.mjs walks LIVE variants only and
+// this set was archived at the time. DW standard markup, mirroring carnegie-reprice/
+// rebuild-line.mjs:94 (the one create path that always had it right):
+const RETAIL = c => { const n = +c; return n > 0 ? String(Math.round(n / 0.65 / 0.85)) : '0.00'; };
+//
+// This is the Siltech-Grain-scoped v2 builder, with rollout2's EXACT product-build logic
+// (title / tags / variants / metafields / image), but hard-differing on safety points:
+// 1) status FORCED 'archived' AND published:false in the payload, AND each create is
+// RE-QUERIED (publishedAt + publication count) — any product that comes back published
+// is immediately DELETED and the run ABORTS. "Verified unpublished", not "assumed".
+// 2) it NEVER archives old products and NEVER creates redirects (STEP 3, gated).
+// 3) reversibility is durable: every created id is appended to an append-only .jsonl
+// IMMEDIATELY after create (before metafields), so a crash mid-run cannot orphan a
+// product; --rollback unions the append-log + json file + ledger.
+// 4) the TK-10792 mfr/body gate is HONORED — a failing row gets Needs-Mfr-SKU /
+// Needs-Description tags (not a decorative console.warn).
+// Tag split-batch:carnegie-v2 so STEP 2's publish query picks these up with the rest.
+// Resume-safe: skips any color whose v2 handle already exists.
+// DRY-RUN by default. Pass --apply to write. Reversible: node ... --rollback [--apply]
+process.env.PATH = `/opt/homebrew/bin:/opt/homebrew/opt/postgresql@14/bin:/usr/local/bin:${process.env.PATH || ''}`;
+import { execSync } from 'node:child_process';
+import fs from 'node:fs';
+import { mfrSkuValid, bodyHtmlValid, SKIP_TAG as NEEDS_MFR_TAG, NEEDS_DESC_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);
+const APPLY = args.includes('--apply');
+const ROLLBACK = args.includes('--rollback');
+const PATTERN = 'Siltech Grain';
+const CREATED_IDS = `${HOME}/Projects/carnegie-split/siltech-grain-v2-created-ids.json`;
+const CREATED_LOG = `${HOME}/Projects/carnegie-split/siltech-grain-v2-created-ids.jsonl`; // durable, append-only
+const LEDGER = `${HOME}/Projects/carnegie-split/build-siltech-grain-v2-ledger.jsonl`;
+const EXEC_LEDGER = `${HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
+const CAP = 30; // sanity cap: expected exactly 26 groups; abort --apply if grouping explodes
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// ---- rollout2 helper block (verbatim) ----
+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 T='single_line_text_field',M='multi_line_text_field';
+const SET=`mutation($m:[MetafieldsSetInput!]!){metafieldsSet(metafields:$m){userErrors{message}}}`;
+
+function loadIds(){ try { return JSON.parse(fs.readFileSync(CREATED_IDS,'utf8')); } catch { return []; } }
+function saveIds(a){ fs.writeFileSync(CREATED_IDS, JSON.stringify(a,null,2)); }
+const ledger = o => fs.appendFileSync(LEDGER, JSON.stringify(o)+'\n');
+// union every id-bearing durable record so a truncated json rewrite can't orphan a product
+function allCreatedIds(){
+ const map = new Map();
+ const add = (id, handle) => { if (id!=null && /^\d+$/.test(String(id))) map.set(String(id), { id:String(id), handle: handle || map.get(String(id))?.handle || '' }); };
+ for (const e of loadIds()) add(e.id, e.handle);
+ for (const src of [CREATED_LOG, LEDGER]) {
+ try { for (const line of fs.readFileSync(src,'utf8').split('\n')) { if(!line.trim()) continue; try { const o=JSON.parse(line); if (o && o.id && (o.ok!==false)) add(o.id, o.handle); } catch {} } } catch {}
+ }
+ return [...map.values()];
+}
+
+// ---- ROLLBACK path: delete every created product id (union of all durable records) ----
+if (ROLLBACK) {
+ const ids = allCreatedIds();
+ if (!ids.length) { console.log('rollback: no created ids on file — nothing to delete.'); process.exit(0); }
+ console.log(`rollback: ${ids.length} distinct created products${APPLY?'':' (DRY-RUN, pass --rollback --apply to execute)'}`);
+ let del=0;
+ for (const e of ids) {
+ const pid = String(e.id);
+ if (!/^\d+$/.test(pid)) { console.log(' skip malformed id', pid); continue; }
+ if (!APPLY) { console.log(` would DELETE product ${pid} (${e.handle})`); continue; }
+ const r = await rest(`products/${pid}.json`, { method:'DELETE' });
+ console.log(` DELETE ${pid} (${e.handle}) -> ${r.status}`); if (r.ok||r.status===404) del++;
+ ledger({ ts:new Date().toISOString(), action:'rollback-delete', id:pid, handle:e.handle, http:r.status });
+ await sleep(400);
+ }
+ if (APPLY && del===ids.length) { saveIds([]); console.log('rollback complete; created-ids json cleared (append-logs retained for audit).'); }
+ else if (APPLY) { console.log(`rollback partial: ${del}/${ids.length} deleted — re-run --rollback --apply to finish.`); }
+ process.exit(0);
+}
+
+// ---- resume guard: which v2 handles already exist ----
+async function existingV2Handles() {
+ const set = new Set();
+ const j = await gql(`{products(first:100,query:"handle:siltech-grain-fabric-color*"){nodes{handle}}}`);
+ for (const n of (j.data?.products?.nodes||[])) set.add(n.handle);
+ return set;
+}
+// ---- post-create publication verify (hole #1): archived+unpublished, or delete + abort ----
+async function assertUnpublishedOrDie(np, handle) {
+ const oid = `gid://shopify/Product/${np.id}`;
+ const j = await gql(`{product(id:"${oid}"){status publishedAt resourcePublicationsCount{count} onlineStoreUrl}}`);
+ const p = j.data?.product;
+ const bad = !p || p.status !== 'ARCHIVED' || p.publishedAt !== null || p.onlineStoreUrl !== null || (p.resourcePublicationsCount?.count||0) !== 0;
+ if (bad) {
+ console.error(` !! ${handle} came back PUBLISHED/active (status=${p?.status} publishedAt=${p?.publishedAt} pubs=${p?.resourcePublicationsCount?.count} url=${p?.onlineStoreUrl}). Deleting ${np.id} and ABORTING.`);
+ await rest(`products/${np.id}.json`, { method:'DELETE' });
+ ledger({ ts:new Date().toISOString(), action:'abort-delete-published', id:np.id, handle, seen:p });
+ process.exit(3);
+ }
+}
+
+(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'];
+ const where = `WHERE image_url IS NOT NULL AND image_url<>'' AND pattern_name='${PATTERN.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;});
+ 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(`[siltech-grain-v2] source rows:${rows.length} -> product groups:${gkeys.length} ${APPLY?'APPLY (create ARCHIVED+unpublished, verified)':'DRY-RUN'}`);
+ if (APPLY && gkeys.length > CAP) { console.error(`ABORT: ${gkeys.length} groups > sanity cap ${CAP}. Re-verify scope.`); process.exit(2); }
+
+ const existing = await existingV2Handles();
+ const createdIds = loadIds();
+ let created=0, skipped=0, fail=0;
+
+ for (const k of gkeys) {
+ 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('§');
+ const handle = slug(`${pat} ${cls} color ${cnum}`);
+ if (existing.has(handle)) { skipped++; console.log(` SKIP exists ${handle}`); continue; }
+ const title = `${pat} ${cls} - Color ${cnum} | Carnegie`;
+ const body = rep.body_html || (rep.description_text ? `<p>${rep.description_text}</p>` : '');
+ // HONOR the TK-10792 gate: failing rows are tagged so downstream/step-2 can find them
+ const allMfrsValid = g.every(r=>mfrSkuValid(r.mfr_sku));
+ const bodyValid = bodyHtmlValid(body);
+ if (!allMfrsValid) console.warn(` [MFR-GATE] ${k} mfrs=${g.map(r=>r.mfr_sku||'null').join(',')} -> +${NEEDS_MFR_TAG}`);
+ if (!bodyValid) console.warn(` [DESC-GATE] ${k} blank body -> +${NEEDS_DESC_TAG}`);
+ const tags = [...new Set(['Carnegie',pat,cls,'split-batch:carnegie-v2',rep._d,rep.primary_hex,
+ ...(!allMfrsValid?[NEEDS_MFR_TAG]:[]),...(!bodyValid?[NEEDS_DESC_TAG]:[])].filter(Boolean))].join(', ');
+ 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:RETAIL(r.price)}); }
+ vars.push({ option1:'Memo Sample', sku:`${rep.dw_sku}-sample`, price:'4.25' });
+
+ if (!APPLY) { console.log(` WOULD CREATE(archived,unpublished) ${title} | handle ${handle} | variants ${vars.map(v=>v.option1+'@'+v.price).join(', ')} | +tags ${rep._d} ${rep.primary_hex}${!allMfrsValid?' +'+NEEDS_MFR_TAG:''}${!bodyValid?' +'+NEEDS_DESC_TAG:''}`); created++; continue; }
+
+ // status archived + published:false (belt-and-suspenders); post-create verify below
+ const payload = { product:{ title, handle, vendor:'Carnegie', product_type:cls, status:'archived', published:false, 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,120)); ledger({ts:Date.now(),k,ok:false,err:t.slice(0,160)}); fail++; await sleep(700); continue; }
+ const np = (await cr.json()).product;
+ // durable append-log FIRST (before metafields) so a crash can't orphan this id
+ fs.appendFileSync(CREATED_LOG, JSON.stringify({ ts:new Date().toISOString(), id:np.id, handle, title })+'\n');
+ createdIds.push({ id:np.id, handle, title, status:np.status, color:cnum }); saveIds(createdIds);
+ await sleep(300);
+ // hole #1: prove it is not customer-facing, else delete this one + abort
+ await assertUnpublishedOrDie(np, handle);
+ const oid = `gid://shopify/Product/${np.id}`;
+ 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({ ts:new Date().toISOString(), k, id:np.id, handle, status:np.status, vars:vars.length, specs:mf.length, ok:true });
+ created++; console.log(` CREATED(${np.status},unpublished) ${np.id} ${handle}`); await sleep(500);
+ }
+
+ console.log(JSON.stringify({ created, skipped, fail, distinct_on_file:allCreatedIds().length }));
+ if (APPLY && created>0) {
+ fs.appendFileSync(EXEC_LEDGER, JSON.stringify({ ts:new Date().toISOString(), agent:'claude-opus (main)', ticket:'TK-10686', action:`build ${created} Carnegie Siltech Grain v2 products STAGED/ARCHIVED+unpublished (step 1)`, blast_radius:created, undo_cmd:`node ${HOME}/Projects/carnegie-split/build-siltech-grain-v2-archived.mjs --rollback --apply`, verify:`shopify query handle:siltech-grain-fabric-color* -> ${created} archived, publishedAt null` })+'\n');
+ console.log(`\nROLLBACK: node build-siltech-grain-v2-archived.mjs --rollback --apply`);
+ }
+})();
diff --git a/carnegie-split/rollout.mjs b/carnegie-split/rollout.mjs
index e7efbaa..a8ee382 100644
--- a/carnegie-split/rollout.mjs
+++ b/carnegie-split/rollout.mjs
@@ -10,6 +10,15 @@
//
// 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.
+//
+// TK-11434 markup fix (2026-09-10): this create path wrote `price: r.price` — the SCRAPED
+// VENDOR COST — straight into the customer-facing sell price, so everything it created sold
+// at zero margin. The Sep-10 cutover published a set built this way and archived the
+// marked-up one, putting 5,914 live variants (99.76% of the active line) at cost.
+// The Aug TK-10686 reprice could not have caught it: reprice.mjs walks LIVE variants only and
+// this set was archived at the time. DW standard markup, mirroring carnegie-reprice/
+// rebuild-line.mjs:94 (the one create path that always had it right):
+const RETAIL = c => { const n = +c; return n > 0 ? String(Math.round(n / 0.65 / 0.85)) : '0.00'; };
import { execSync } from 'node:child_process';
import fs from 'node:fs';
// TK-10792: canonical mfr_sku gate — one source of truth, no inline copy
@@ -82,7 +91,7 @@ async function makeRedirect(from,to){ // needs write_content; returns true/false
const tagArr=[...new Set(['Carnegie',pat,'split-batch:carnegie-v2',r._d,r.primary_hex,...(!hasMfr?[NEEDS_MFR_TAG]:[]),...(!bodyValid?[NEEDS_DESC_TAG]:[])].filter(Boolean))];
const tags=tagArr.join(', ');
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]':''}${!bodyValid?' [DESC-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}]}})});
+ 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:RETAIL(r.price)},{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)}));
diff --git a/carnegie-split/rollout2.mjs b/carnegie-split/rollout2.mjs
index 555fc16..0b4368f 100644
--- a/carnegie-split/rollout2.mjs
+++ b/carnegie-split/rollout2.mjs
@@ -6,6 +6,15 @@
// 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-11434 markup fix (2026-09-10): this create path wrote `price: r.price` — the SCRAPED
+// VENDOR COST — straight into the customer-facing sell price, so everything it created sold
+// at zero margin. The Sep-10 cutover published a set built this way and archived the
+// marked-up one, putting 5,914 live variants (99.76% of the active line) at cost.
+// The Aug TK-10686 reprice could not have caught it: reprice.mjs walks LIVE variants only and
+// this set was archived at the time. DW standard markup, mirroring carnegie-reprice/
+// rebuild-line.mjs:94 (the one create path that always had it right):
+const RETAIL = c => { const n = +c; return n > 0 ? String(Math.round(n / 0.65 / 0.85)) : '0.00'; };
+//
// 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';
@@ -66,7 +75,7 @@ async function makeRedirect(from,to){const j=await gql(`mutation($i:UrlRedirectI
const tags=tagSet.join(', ');
// 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'}); }
+ 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:RETAIL(r.price)}); }
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]':''}${!bodyValid?' [DESC-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}]}};
← 537e072 Snapshot: commit 3,534 previously-staged files across the Pr
·
back to Japan Enrich
·
governance(TK-11370): make live-by-default Shopify writers d a44e343 →