[object Object]

← 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

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/&nbsp;/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'),'&nbsp;',' ','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/&nbsp;/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(/&nbsp;/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 →