← back to Japan Enrich
TK-10820: gate Carnegie importers on non-blank body_html before ACTIVE-publish
169db8c773a4b56a039916c4f19eba40e218e981 · 2026-08-24 19:13:02 -0700 · Steve Abrams
terrain-arctic-onboard.mjs previously gated activation on mfr_sku only, so it
could still ship a product ACTIVE with an empty body_html — the exact class that
shipped 57 blank-desc Carnegie products live under TK-10686. Now activation
requires okMfr AND bodyHtmlValid(desc); a blank body keeps the product DRAFT and
tags it Needs-Description (reuses the shared carnegie-mfr-gate.mjs guard).
pilot-exec.cjs / pilot-dryrun.cjs (the TK-10686 Siltech pilot path) posted every
product status:active with NO description at all — a root cause of the 57. The
dry-run now pulls a real body_html (preferring a non-blank body over a tag/nbsp
shell, else description_text) and pilot-exec gates status on body_html, creating
blank-body products as DRAFT + Needs-Description instead of ACTIVE.
Verified: 13/13 shared-guard unit assertions + 7/7 inline-mirror + SQL
body-preference proof. Re-activation scripts (acapella-golive / carnegie-fanout-fix)
are out of scope — they flip existing clean drafts, not create born-blank products.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M carnegie-split/acapella-fix/terrain-arctic-onboard.mjsA carnegie-split/pilot-dryrun.cjsA carnegie-split/pilot-exec.cjs
Diff
commit 169db8c773a4b56a039916c4f19eba40e218e981
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 24 19:13:02 2026 -0700
TK-10820: gate Carnegie importers on non-blank body_html before ACTIVE-publish
terrain-arctic-onboard.mjs previously gated activation on mfr_sku only, so it
could still ship a product ACTIVE with an empty body_html — the exact class that
shipped 57 blank-desc Carnegie products live under TK-10686. Now activation
requires okMfr AND bodyHtmlValid(desc); a blank body keeps the product DRAFT and
tags it Needs-Description (reuses the shared carnegie-mfr-gate.mjs guard).
pilot-exec.cjs / pilot-dryrun.cjs (the TK-10686 Siltech pilot path) posted every
product status:active with NO description at all — a root cause of the 57. The
dry-run now pulls a real body_html (preferring a non-blank body over a tag/nbsp
shell, else description_text) and pilot-exec gates status on body_html, creating
blank-body products as DRAFT + Needs-Description instead of ACTIVE.
Verified: 13/13 shared-guard unit assertions + 7/7 inline-mirror + SQL
body-preference proof. Re-activation scripts (acapella-golive / carnegie-fanout-fix)
are out of scope — they flip existing clean drafts, not create born-blank products.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.../acapella-fix/terrain-arctic-onboard.mjs | 13 +++++--
carnegie-split/pilot-dryrun.cjs | 41 ++++++++++++++++++++++
carnegie-split/pilot-exec.cjs | 40 +++++++++++++++++++++
3 files changed, 91 insertions(+), 3 deletions(-)
diff --git a/carnegie-split/acapella-fix/terrain-arctic-onboard.mjs b/carnegie-split/acapella-fix/terrain-arctic-onboard.mjs
index d2b0ff3..e51bed4 100644
--- a/carnegie-split/acapella-fix/terrain-arctic-onboard.mjs
+++ b/carnegie-split/acapella-fix/terrain-arctic-onboard.mjs
@@ -15,7 +15,7 @@ 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';
+import { mfrSkuValid, bodyHtmlValid, addSkipTag, SKIP_TAG as NEEDS_MFR_TAG, NEEDS_DESC_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];
@@ -59,8 +59,14 @@ for(const line of raw){
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-10820 (a) gate activation on a REAL, non-blank description too. TK-10798 only gated on
+ // mfr, so this onboarder could still ship ACTIVE with an empty body_html (the exact class that
+ // shipped 57 blank-desc Carnegie products live under TK-10686). body = the catalog `desc` field.
+ const okBody = bodyHtmlValid(desc);
+ const okActivate = okMfr && okBody;
+ const productStatus = okActivate ? 'active' : 'draft';
+ const publishedFlag = okActivate; // never publish an un-gated product
+ if(!okBody) console.warn(` [DESC-GATE] SKIP-ACTIVE ${sku} (${pat} ${num}) blank body_html → DRAFT+${NEEDS_DESC_TAG}`);
// 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
@@ -68,6 +74,7 @@ for(const line of raw){
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
+ if(!okBody && !tags.includes(NEEDS_DESC_TAG)) tags.push(NEEDS_DESC_TAG); // TK-10820 Needs-Description
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',
diff --git a/carnegie-split/pilot-dryrun.cjs b/carnegie-split/pilot-dryrun.cjs
new file mode 100644
index 0000000..45e2841
--- /dev/null
+++ b/carnegie-split/pilot-dryrun.cjs
@@ -0,0 +1,41 @@
+// DRY-RUN — Siltech Grain: each colorway = its OWN standalone product (NO "Color N" variant option).
+// Product variant set = [the fabric itself (sku=DWAG-######, retail), Memo Sample ($4.25)]. NO Color option.
+const { execSync } = require("child_process"); const fs = require("fs");
+const PARENT = "6300-upholstery";
+// DW retail from the LIVE product ($136), not the $75 catalog cost (per carnegie-reprice fix).
+const RETAIL = "136.00";
+// TK-10820: pull body_html/description_text too so the created products carry a real description.
+// The original pilot produced payloads with NO description at all — one of the root causes of the
+// 57 blank-desc Carnegie products shipped live under TK-10686. pilot-exec.cjs now gates status on it.
+// Prefer a body_html that has real text once HTML tags/ /whitespace are stripped; otherwise
+// fall back to description_text. NULLIF('') alone would keep a tag/nbsp-only body over a good
+// description_text and needlessly draft a fixable product (Codex TK-10820 review).
+const bodyExpr = `case when length(trim(regexp_replace(regexp_replace(coalesce(body_html,''),'<[^>]*>',' ','g'),' ',' ','gi'))) > 0 then body_html else coalesce(description_text,'') end`;
+const q = `select dw_sku, mfr_sku, pattern_name, color_number, color_name, coalesce(image_url,''), ${bodyExpr} from carnegie_catalog where parent_sku='${PARENT}' order by color_number::int`;
+const rows = execSync(`psql "host=/tmp dbname=dw_unified" -tA -F '\t' -c "${q}"`,{encoding:"utf8"}).trim().split("\n").map(l=>l.split("\t"));
+const slug = s => String(s||"").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");
+const payloads = rows.map(([dw_sku,mfr_sku,pattern,cnum,cname,image,body])=>({
+ title: `Carnegie ${pattern} ${cname}`.trim(), // colorway in TITLE, not a variant
+ handle: slug(`carnegie ${pattern} ${dw_sku}`),
+ vendor: "Carnegie", product_type: "Upholstery",
+ // NOTE: no `status` here — pilot-exec.cjs decides active-vs-draft per the TK-10820 body_html gate.
+ body_html: body || "",
+ // NO "Color" option. One sellable fabric variant + a Memo Sample variant. Standard DW shape.
+ options: ["Title"],
+ variants: [
+ { option1: "Fabric", sku: dw_sku, price: RETAIL },
+ { option1: "Memo Sample", sku: `${dw_sku}-sample`, price: "4.25" }
+ ],
+ image: image, // this colorway's own _puf texture
+ metafields: [
+ {namespace:"custom",key:"manufacturer_sku",value:mfr_sku},
+ {namespace:"dwc",key:"manufacturer_sku",value:mfr_sku},
+ {namespace:"global",key:"Brand",value:"Carnegie"}
+ ],
+ tags: ["Carnegie", pattern]
+}));
+fs.writeFileSync("pilot-siltech-grain-payloads.json", JSON.stringify(payloads,null,1));
+console.log("PILOT (corrected): each colorway = its OWN product, NO Color variant.");
+console.log("colorways -> standalone products:", payloads.length, "| all with own _puf image:", payloads.every(p=>p.image));
+console.log("\nsample product (note: no 'Color' option; variants = Fabric + Memo Sample only):");
+console.log(JSON.stringify(payloads[0],null,1));
diff --git a/carnegie-split/pilot-exec.cjs b/carnegie-split/pilot-exec.cjs
new file mode 100644
index 0000000..cb7a60d
--- /dev/null
+++ b/carnegie-split/pilot-exec.cjs
@@ -0,0 +1,40 @@
+// LIVE PILOT — create 26 Siltech Grain standalone per-colorway products. Records full map for reversibility.
+// TK-10820: NEVER set a product ACTIVE without a real, non-blank body_html. The original pilot posted
+// every product status:"active" with NO description at all — a root cause of the 57 blank-desc Carnegie
+// products shipped live under TK-10686. Blank-body products are now created as DRAFT + Needs-Description.
+const fs=require("fs"),os=require("os"),path=require("path"),https=require("https");
+// Inline mirror of carnegie-mfr-gate.mjs bodyHtmlValid/NEEDS_DESC_TAG (this file is CJS; the canonical
+// guard is ESM). Same logic: strip tags/ /whitespace, require non-empty. Keep in sync with the .mjs.
+const NEEDS_DESC_TAG="Needs-Description";
+const bodyHtmlValid=b=>{if(b===null||b===undefined)return false;return String(b).replace(/<[^>]*>/g,"").replace(/ /gi," ").trim().length>0;};
+const env=fs.readFileSync(path.join(os.homedir(),"Projects/secrets-manager/.env"),"utf8");
+const g=k=>{const m=env.match(new RegExp("^"+k+"=(.*)$","m"));return m?m[1].trim().replace(/^["']|["']$/g,""):null};
+const shop="designer-laboratory-sandbox.myshopify.com",tok=g("SHOPIFY_ADMIN_TOKEN")||g("SHOPIFY_ADMIN_API_TOKEN");
+const payloads=JSON.parse(fs.readFileSync("pilot-siltech-grain-payloads.json","utf8"));
+function req(method,p,body){return new Promise((res,rej)=>{const b=body?JSON.stringify(body):null;const o={host:shop,path:p,method,headers:{"X-Shopify-Access-Token":tok,"Content-Type":"application/json"}};if(b)o.headers["Content-Length"]=Buffer.byteLength(b);const r=https.request(o,x=>{let d="";x.on("data",c=>d+=c);x.on("end",()=>res({status:x.statusCode,body:d}))});r.on("error",rej);if(b)r.write(b);r.end()})}
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+(async()=>{
+ const map=[]; let ok=0,fail=0;
+ let descDraftSkipped=0;
+ for(const pl of payloads){
+ // TK-10820 body_html gate: no real description → create DRAFT + Needs-Description, never ACTIVE.
+ const okBody=bodyHtmlValid(pl.body_html);
+ const status=okBody?"active":"draft";
+ if(!okBody){descDraftSkipped++;console.error(`\n[DESC-GATE] SKIP-ACTIVE ${pl.variants[0].sku} (${pl.title}) blank body_html → DRAFT+${NEEDS_DESC_TAG}`);}
+ const tags=(pl.tags||[]).concat(["split-batch:TK-10686"]).concat(okBody?[]:[NEEDS_DESC_TAG]);
+ const product={title:pl.title,vendor:pl.vendor,product_type:pl.product_type,status,
+ body_html:pl.body_html||"",
+ tags:tags.join(", "),
+ options:[{name:"Type"}],
+ images:[{src:pl.image}],
+ variants:pl.variants.map(v=>({option1:v.option1,sku:v.sku,price:v.price})),
+ metafields:pl.metafields.map(m=>({namespace:m.namespace,key:m.key,value:m.value,type:"single_line_text_field"}))};
+ const r=await req("POST","/admin/api/2024-10/products.json",{product});
+ if(r.status===201){const j=JSON.parse(r.body);ok++;map.push({sku:pl.variants[0].sku,new_id:j.product.id,handle:j.product.handle,title:j.product.title});process.stdout.write(".");}
+ else{fail++;map.push({sku:pl.variants[0].sku,error:r.status,body:r.body.slice(0,150)});process.stdout.write("x");console.error("\nFAIL",pl.variants[0].sku,r.status,r.body.slice(0,200));}
+ await sleep(600);
+ }
+ fs.writeFileSync("split-run-map.json",JSON.stringify({ts:new Date().toISOString(),pattern:"Siltech Grain",created:map},null,1));
+ console.log(`\nPILOT created: ${ok} ok, ${fail} fail, ${descDraftSkipped} drafted (blank body_html, DESC-GATE). Map -> split-run-map.json`);
+ if(map[0]&&map[0].handle) console.log("sample new product:", `https://${shop}/products/${map[0].handle}`);
+})();
← 959c778 Carnegie importer: gate ACTIVE-publish on non-blank body_htm
·
back to Japan Enrich
·
Carnegie: widen blank-body_html ACTIVE gate to acapella-goli 76f479c →