[object Object]

← back to Designerwallcoverings

TK-10039: 4 push-*-live.js (zoffany/tres-tintas/fallingstar/artmura) write specs to metafields at create + prose-only body; artmura now writes metafields (text-safe mapper removes the 422 fear)

4357a081b31a757edea81de13f270ca85f60d827 · 2026-07-29 07:46:35 -0700 · Steve Abrams

Files touched

Diff

commit 4357a081b31a757edea81de13f270ca85f60d827
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 29 07:46:35 2026 -0700

    TK-10039: 4 push-*-live.js (zoffany/tres-tintas/fallingstar/artmura) write specs to metafields at create + prose-only body; artmura now writes metafields (text-safe mapper removes the 422 fear)
---
 scripts/artmura-onboard/push-artmura-live.js       | 119 +++++++++++++++++++--
 .../fallingstar-onboard/push-fallingstar-live.js   |  78 ++++++++++++--
 .../tres-tintas-onboard/push-tres-tintas-live.js   |  69 +++++++++++-
 scripts/zoffany-onboard/push-zoffany-live.js       |  97 +++++++++++++++--
 4 files changed, 333 insertions(+), 30 deletions(-)

diff --git a/scripts/artmura-onboard/push-artmura-live.js b/scripts/artmura-onboard/push-artmura-live.js
index 71fe3b7..2894a8d 100644
--- a/scripts/artmura-onboard/push-artmura-live.js
+++ b/scripts/artmura-onboard/push-artmura-live.js
@@ -49,18 +49,111 @@ function shopify(method, p, body) {
 
 const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
 
-function specTable(r) {
-  const rows = [
+function specBody(r) {
+  // Body is PROSE ONLY — specs now go to metafields at create (text-safe), not a body table.
+  return `<div class="artmura-product">\n${r.body_html || ('<p>' + esc(r.title) + ' by Artmura.</p>')}\n</div>`;
+}
+
+// CANONICAL text-safe spec→metafield mapping. Every target below is a TEXT-typed
+// metafield (verified safe — no 422). custom.material is multi_line_text_field.
+// product_reference keys are deliberately AVOIDED by routing to global.*/specs.* text twins.
+function buildMetafields(r) {
+  const mf = [];
+  const seen = new Set(); // dedupe by namespace.key — Shopify rejects duplicate keys on create
+  const single = { type: 'single_line_text_field', namespace: 'custom' };
+  const multi  = { type: 'multi_line_text_field',  namespace: 'custom' };
+  const add = (namespace, key, type, value) => {
+    if (value == null || String(value).trim() === '') return;
+    const id = `${namespace}.${key}`;
+    if (seen.has(id)) return; // first-write-wins; avoids 422 on duplicate metafield key
+    seen.add(id);
+    mf.push({ namespace, key, type, value: String(value) });
+  };
+
+  // Source rows: [label, value] — same fields the old spec table fed, plus canonical labels.
+  const labelVal = [
     ['Material', r.substrate || 'Non-Woven'],
     ['Dimensions', r.dimensions],
     ['Wall coverage', r.wall_coverage],
     ['Grade', r.grade],
     ['Lead-time', r.lead_time],
     ['Origin', r.origin],
-  ].filter(([, v]) => v);
-  const trs = rows.map(([k, v]) =>
-    `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n');
-  return `<div class="artmura-product">\n${r.body_html || ('<p>' + esc(r.title) + ' by Artmura.</p>')}\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${trs}\n</table>\n</div>`;
+    // additional canonical labels (emit only when present on the record)
+    ['Pattern', r.pattern_name],
+    ['Color', r.color],
+    ['Content', r.content],
+    ['Composition', r.composition],
+    ['Width', r.width],
+    ['Collection', r.collection_name || r.collection_book],
+    ['Repeat', r.repeat || r.pattern_repeat],
+    ['Match', r.match],
+    ['Finish', r.finish],
+    ['Fire Rating', r.fire_rating],
+    ['Backing', r.backing || r.substrate],
+    ['Weight', r.weight],
+    ['Length', r.length || r.roll_length],
+    ['Manufacturer SKU', r.manufacturer_sku || r.mfr_sku],
+    ['Brand', r.brand],
+    ['Designer', r.designer],
+    ['Country', r.country],
+    ['Style', r.style],
+    ['Sold By', r.sold_by || r.unit_of_measure],
+    ['Coverage', r.coverage],
+    ['Lead Time', r.lead_time],
+  ];
+
+  // Presence set (for the Composition-both-present rule)
+  const present = {};
+  for (const [label, value] of labelVal) {
+    if (value != null && String(value).trim() !== '') present[label] = String(value);
+  }
+
+  for (const [label, valueRaw] of labelVal) {
+    if (valueRaw == null || String(valueRaw).trim() === '') continue;
+    const value = String(valueRaw);
+    switch (label) {
+      case 'Pattern':          add('custom', 'pattern_name',    single.type, value); break;
+      case 'Color':            add('custom', 'color',           single.type, value); break;
+      case 'Material':
+      case 'Content':          add('custom', 'material',        multi.type,  value); break;
+      case 'Composition':
+        add('custom', 'material', multi.type, value);
+        // specs.composition ONLY if BOTH Material/Content AND Composition present
+        if (present['Composition'] && (present['Material'] || present['Content']))
+          add('specs', 'composition', multi.type, value);
+        break;
+      case 'Width':
+        add('custom',  'width',  single.type, value);
+        add('global',  'width',  single.type, value);
+        break;
+      case 'Collection':       add('custom', 'collection_name', single.type, value); break;
+      case 'Repeat':           add('custom', 'pattern_repeat',  single.type, value); break;
+      case 'Match':            add('global', 'Match',           single.type, value); break;
+      case 'Finish':           add('global', 'Finish',          single.type, value); break;
+      case 'Fire Rating':      add('custom', 'fire_rating',     single.type, value); break;
+      case 'Backing':          add('global', 'Substrate',       single.type, value); break;
+      case 'Weight':           add('global', 'Weight',          single.type, value); break;
+      case 'Length':           add('custom', 'length',          single.type, value); break;
+      case 'Manufacturer SKU': add('custom', 'manufacturer_sku',single.type, value); break;
+      case 'Brand':            add('custom', 'brand',           single.type, value); break;
+      case 'Designer':         add('custom', 'designer',        single.type, value); break;
+      case 'Country':          add('global', 'Country',         single.type, value); break;
+      case 'Style':            add('global', 'Style',           single.type, value); break;
+      case 'Sold By':          add('global', 'unit_of_measure', single.type, value); break;
+      case 'Dimensions':       add('custom', 'dimensions',      single.type, value); break;
+      case 'Grade':            add('custom', 'grade',           single.type, value); break;
+      case 'Lead-time':
+      case 'Lead Time':        add('custom', 'leadtime',        single.type, value); break;
+      case 'Coverage':         add('custom', 'coverage',        single.type, value); break;
+      case 'Wall coverage':    add('custom', 'wall_coverage',   single.type, value); break;
+      case 'Origin':
+        // "Origin" maps to global.Country (customer-facing country of origin)
+        add('global', 'Country', single.type, value);
+        break;
+      default: /* unmapped labels are skipped */ break;
+    }
+  }
+  return mf;
 }
 
 function buildPayload(r, dwSku) {
@@ -68,13 +161,15 @@ function buildPayload(r, dwSku) {
   const tags = [r.pattern_series, r.collection_book, 'Wallcovering', 'Non-Woven', 'Made in Italy', 'Artmura', dwSku]
     .filter(Boolean).join(', ');
   const images = (r.images || []).map(src => ({ alt: esc(r.title), src }));
-  // NOTE: no metafields on create — the store has existing custom.* definitions with
-  // fixed types (material=multi_line_text_field, some keys=product_reference) and REST
-  // create rejects (422) on any type mismatch. Specs live in body_html; metafields are
-  // backfilled separately (backfill_metafields.js) with per-key type detection.
+  // NOTE (2026-07-29, TK-10039): specs now go to METAFIELDS at create (text-safe), NOT a body table.
+  // The canonical mapping (buildMetafields) targets ONLY text-typed metafields — product_reference
+  // custom.* keys are deliberately avoided by routing to global.*/specs.* text twins — so REST
+  // create is safe (no 422 type-mismatch). Body carries the prose description only.
+  const metafields = buildMetafields(r);
   return { product: {
     title, vendor: 'Artmura', product_type: r.product_type || 'Wallcoverings',
-    status: STATUS, tags, images, body_html: specTable(r),
+    status: STATUS, tags, images, body_html: specBody(r),
+    ...(metafields.length ? { metafields } : {}),
     options: [{ name: 'Size' }],
     variants: [
       { sku: dwSku, price: String(r.price_newwall_retail), option1: 'Yard', taxable: true, requires_shipping: true },
@@ -101,6 +196,8 @@ function buildPayload(r, dwSku) {
       const pl = buildPayload(r, d.dw_sku);
       console.log(`\n[create] ${d.dw_sku}  "${pl.product.title}"`);
       console.log(`   variants: Yard $${r.price_newwall_retail} / Sample $${r.sample_price || 5} · imgs:${pl.product.images.length} · tags: ${pl.product.tags}`);
+      const mfs = pl.product.metafields || [];
+      console.log(`   metafields (${mfs.length}): ${mfs.map(m => `${m.namespace}.${m.key}`).join(', ') || '(none)'}`);
     }
     console.log(`\nDRY-RUN only. Re-run with --apply (optionally --limit=N) to create drafts.`);
     await pool.end(); return;
diff --git a/scripts/fallingstar-onboard/push-fallingstar-live.js b/scripts/fallingstar-onboard/push-fallingstar-live.js
index ae55cf0..1ee8c5b 100644
--- a/scripts/fallingstar-onboard/push-fallingstar-live.js
+++ b/scripts/fallingstar-onboard/push-fallingstar-live.js
@@ -46,26 +46,92 @@ function shopify(method, p, body) {
   });
 }
 
-function specTable(r) {
+// Body is PROSE ONLY — the story/description. Specs live in metafields (TK-10039).
+function specBody(r) {
+  return `<div class="fallingstar-product">\n${r.body_html || ('<p>' + esc(r.dw_title) + '</p>')}\n</div>`;
+}
+
+// Canonical text-safe label→metafield mapping. Emits Shopify REST metafield defs (all TEXT-typed).
+// Skips blanks; no duplicate namespace.key; custom.material is multi_line_text_field.
+function specMetafields(r) {
   const rows = [
     ['Designer', r.designer], ['Collection', r.collection], ['Pattern', r.pattern],
     ['Material', 'Hand-Painted Wallpaper'], ['Sample size', '8.5" x 11"'], ['Brand', 'FallingStar Studio'],
-  ].filter(([, v]) => v);
-  const trs = rows.map(([k, v]) =>
-    `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n');
-  return `<div class="fallingstar-product">\n${r.body_html || ('<p>' + esc(r.dw_title) + '</p>')}\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${trs}\n</table>\n</div>`;
+  ].filter(([, v]) => v != null && String(v).trim() !== '');
+
+  const has = label => rows.some(([k]) => k === label);
+  const out = [];
+  const seen = new Set();
+  const push = (namespace, key, type, value) => {
+    const v = value == null ? '' : String(value).trim();
+    if (!v) return;
+    const dedupe = `${namespace}.${key}`;
+    if (seen.has(dedupe)) return;
+    seen.add(dedupe);
+    out.push({ namespace, key, type, value: v });
+  };
+
+  for (const [label, value] of rows) {
+    switch (label) {
+      case 'Pattern':               push('custom', 'pattern_name', 'single_line_text_field', value); break;
+      case 'Color':                 push('custom', 'color', 'single_line_text_field', value); break;
+      case 'Material':
+      case 'Content':
+      case 'Composition':
+        push('custom', 'material', 'multi_line_text_field', value);
+        // Composition → specs.composition ONLY when BOTH Material and Composition are present.
+        if (label === 'Composition' && has('Material')) push('specs', 'composition', 'multi_line_text_field', value);
+        break;
+      case 'Width':
+        push('custom', 'width', 'single_line_text_field', value);
+        push('global', 'width', 'single_line_text_field', value);
+        break;
+      case 'Collection':            push('custom', 'collection_name', 'single_line_text_field', value); break;
+      case 'Repeat':                push('custom', 'pattern_repeat', 'single_line_text_field', value); break;
+      case 'Match':                 push('global', 'Match', 'single_line_text_field', value); break;
+      case 'Finish':                push('global', 'Finish', 'single_line_text_field', value); break;
+      case 'Fire Rating':           push('custom', 'fire_rating', 'single_line_text_field', value); break;
+      case 'Backing':
+      case 'Substrate':             push('global', 'Substrate', 'single_line_text_field', value); break;
+      case 'Weight':                push('global', 'Weight', 'single_line_text_field', value); break;
+      case 'Length':
+      case 'Roll Length':           push('custom', 'length', 'single_line_text_field', value); break;
+      case 'Manufacturer SKU':
+      case 'SKU':                   push('custom', 'manufacturer_sku', 'single_line_text_field', value); break;
+      case 'Brand':                 push('custom', 'brand', 'single_line_text_field', value); break;
+      case 'Designer':              push('custom', 'designer', 'single_line_text_field', value); break;
+      case 'Country':
+      case 'Origin':                push('global', 'Country', 'single_line_text_field', value); break;
+      case 'Style':                 push('global', 'Style', 'single_line_text_field', value); break;
+      case 'Sold By':
+      case 'Unit of Measure':       push('global', 'unit_of_measure', 'single_line_text_field', value); break;
+      default: /* Unmapped labels → skip */ break;
+    }
+  }
+  return out;
 }
 
 function buildPayload(r) {
   const images = (r.images || []).map(src => ({ alt: esc(r.dw_title), src }));
+  // Preserve any pre-existing metafields on the record; merge spec metafields with no duplicate namespace.key.
+  const existing = Array.isArray(r.metafields) ? r.metafields : [];
+  const seen = new Set(existing.map(m => `${m.namespace}.${m.key}`));
+  const metafields = existing.slice();
+  for (const m of specMetafields(r)) {
+    const dedupe = `${m.namespace}.${m.key}`;
+    if (seen.has(dedupe)) continue;
+    seen.add(dedupe);
+    metafields.push(m);
+  }
   return { product: {
     title: r.dw_title, vendor: 'FallingStar Studio', product_type: 'Wallcoverings',
-    status: STATUS, tags: r.tags.join(', '), images, body_html: specTable(r),
+    status: STATUS, tags: r.tags.join(', '), images, body_html: specBody(r),
     options: [{ name: 'Size' }],
     variants: [
       { sku: r.dw_sku, price: String(r.price_retail), option1: 'Roll', taxable: true, requires_shipping: true },
       { sku: `${r.dw_sku}-Sample`, price: String(r.sample_price || 5), option1: 'Sample', taxable: true, requires_shipping: true },
     ],
+    metafields,
   } };
 }
 
diff --git a/scripts/tres-tintas-onboard/push-tres-tintas-live.js b/scripts/tres-tintas-onboard/push-tres-tintas-live.js
index 369b417..81af3e1 100644
--- a/scripts/tres-tintas-onboard/push-tres-tintas-live.js
+++ b/scripts/tres-tintas-onboard/push-tres-tintas-live.js
@@ -48,18 +48,79 @@ function shopify(method, p, body) {
 }
 const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
 
+// Spec rows: [label, value] pairs. These now feed METAFIELDS (specMetafields),
+// NOT a spec <table> baked into body_html (TK-10039).
+function specRows(r) {
+  return [['Material', 'Non-Woven'], ['Type', r.collection], ['Pattern ref.', r.vendor_sku], ['Origin', 'Spain']]
+    .filter(([, v]) => v);
+}
+
 function bodyHtml(r) {
-  const rows = [['Material', 'Non-Woven'], ['Type', r.collection], ['Pattern ref.', r.vendor_sku], ['Origin', 'Spain']]
-    .filter(([, v]) => v)
-    .map(([k, v]) => `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n');
-  return `<div class="tres-tintas-product"><p>${esc(r.name)} by Tres Tintas Barcelona.</p>\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${rows}\n</table></div>`;
+  return `<div class="tres-tintas-product"><p>${esc(r.name)} by Tres Tintas Barcelona.</p></div>`;
+}
+
+// CANONICAL text-safe spec→metafield mapping (INLINED — CommonJS, do NOT import the .mjs).
+// All targets are TEXT-typed. Skips blanks; dedups namespace.key; emits global.width alongside
+// custom.width. If BOTH Material and Composition exist, Composition → specs.composition.
+function specMetafields(rows) {
+  const has = label => rows.some(([k]) => k === label);
+  const hasMaterialAndComposition = (has('Material') || has('Content')) && has('Composition');
+  const out = [];
+  const seen = new Set();
+  const push = (namespace, key, value, type) => {
+    if (value == null || String(value).trim() === '') return;
+    const id = `${namespace}.${key}`;
+    if (seen.has(id)) return;
+    seen.add(id);
+    out.push({ namespace, key, value: String(value), type });
+  };
+  for (const [label, value] of rows) {
+    switch (label) {
+      case 'Pattern': push('custom', 'pattern_name', value, 'single_line_text_field'); break;
+      case 'Color': push('custom', 'color', value, 'single_line_text_field'); break;
+      case 'Material':
+      case 'Content':
+        push('custom', 'material', value, 'multi_line_text_field'); break;
+      case 'Composition':
+        if (hasMaterialAndComposition) push('specs', 'composition', value, 'multi_line_text_field');
+        else push('custom', 'material', value, 'multi_line_text_field');
+        break;
+      case 'Width':
+        push('custom', 'width', value, 'single_line_text_field');
+        push('global', 'width', value, 'single_line_text_field'); break;
+      case 'Collection': push('custom', 'collection_name', value, 'single_line_text_field'); break;
+      case 'Repeat':
+      case 'Pattern Repeat': push('custom', 'pattern_repeat', value, 'single_line_text_field'); break;
+      case 'Match': push('global', 'Match', value, 'single_line_text_field'); break;
+      case 'Finish': push('global', 'Finish', value, 'single_line_text_field'); break;
+      case 'Fire Rating': push('custom', 'fire_rating', value, 'single_line_text_field'); break;
+      case 'Backing':
+      case 'Substrate': push('global', 'Substrate', value, 'single_line_text_field'); break;
+      case 'Weight': push('global', 'Weight', value, 'single_line_text_field'); break;
+      case 'Length':
+      case 'Roll Length': push('custom', 'length', value, 'single_line_text_field'); break;
+      case 'Manufacturer SKU':
+      case 'SKU': push('custom', 'manufacturer_sku', value, 'single_line_text_field'); break;
+      case 'Brand': push('custom', 'brand', value, 'single_line_text_field'); break;
+      case 'Designer': push('custom', 'designer', value, 'single_line_text_field'); break;
+      case 'Country':
+      case 'Origin': push('global', 'Country', value, 'single_line_text_field'); break;
+      case 'Style': push('global', 'Style', value, 'single_line_text_field'); break;
+      case 'Sold By':
+      case 'Unit of Measure': push('global', 'unit_of_measure', value, 'single_line_text_field'); break;
+      default: break; // not in map → skip
+    }
+  }
+  return out;
 }
 function buildPayload(r) {
   const title = `${r.name} Wallcovering | Tres Tintas`;
   const tags = [r.collection, 'Wallcovering', 'Non-Woven', 'Made in Spain', 'Tres Tintas', r.dw_sku].filter(Boolean).join(', ');
   const images = r.image ? [{ alt: esc(r.name), src: r.image }] : [];
+  const metafields = specMetafields(specRows(r));
   return { product: {
     title, vendor: 'Tres Tintas', product_type: 'Wallcoverings', status: STATUS, tags, images, body_html: bodyHtml(r),
+    ...(metafields.length ? { metafields } : {}),
     options: [{ name: 'Size' }],
     variants: [
       { sku: r.dw_sku, price: String(r.dw_retail), option1: 'Roll', taxable: true, requires_shipping: true,
diff --git a/scripts/zoffany-onboard/push-zoffany-live.js b/scripts/zoffany-onboard/push-zoffany-live.js
index 183a0c9..ff673a9 100644
--- a/scripts/zoffany-onboard/push-zoffany-live.js
+++ b/scripts/zoffany-onboard/push-zoffany-live.js
@@ -69,15 +69,86 @@ function buildTags(r) {
   return [...new Set(t)].join(', ');
 }
 
-function specTable(r) {
-  const rows = [
+// Spec rows [label, value] — feed BOTH the (now prose-only) body context and the metafields.
+function specRows(r) {
+  return [
     ['Pattern', r.pattern_name], ['Colour', r.color_name], ['Collection', r.collection],
     ['Width', r.width], ['Pattern Repeat', r.pattern_repeat], ['Composition', r.composition],
-  ].filter(([, v]) => v);
-  const trs = rows.map(([k, v]) =>
-    `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n');
-  const intro = (r.description && !/^DISCONTINUED/.test(r.description)) ? `<p>${esc(r.description)}</p>\n` : '';
-  return `<div class="zoffany-product">\n${intro}<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${trs}\n</table>\n</div>`;
+  ].filter(([, v]) => v != null && String(v).trim() !== '');
+}
+
+// Body is PROSE ONLY now — the story/description. Specs move to metafields (TK-10039).
+function specTable(r) {
+  const intro = (r.description && !/^DISCONTINUED/.test(r.description)) ? `<p>${esc(r.description)}</p>` : '';
+  return `<div class="zoffany-product">\n${intro}\n</div>`;
+}
+
+// Canonical text-safe label → metafield mapping (CommonJS-inlined; do NOT import the .mjs).
+// All targets are verified TEXT-typed (no product_reference 422). custom.material MUST be multi_line.
+function buildMetafields(r) {
+  const rows = specRows(r);
+  const byLabel = {};
+  for (const [k, v] of rows) byLabel[k] = String(v).trim();
+  const has = k => byLabel[k] != null && byLabel[k] !== '';
+  const get = (...ks) => { for (const k of ks) if (has(k)) return byLabel[k]; return null; };
+
+  const S = 'single_line_text_field';
+  const M = 'multi_line_text_field';
+  const out = [];
+  const seen = new Set();
+  const push = (namespace, key, value, type) => {
+    if (value == null || String(value).trim() === '') return;
+    const id = `${namespace}.${key}`;
+    if (seen.has(id)) return;               // no double-write of a namespace.key
+    seen.add(id);
+    out.push({ namespace, key, type, value: String(value) });
+  };
+
+  // Pattern
+  push('custom', 'pattern_name', get('Pattern'), S);
+  // Color
+  push('custom', 'color', get('Color', 'Colour'), S);
+  // Material / Content / Composition
+  const material = get('Material', 'Content', 'Composition');
+  push('custom', 'material', material, M);
+  //   Composition specifically → also specs.composition (multi_line) if both Material AND Composition exist
+  if (has('Composition') && (has('Material') || has('Content'))) {
+    push('specs', 'composition', byLabel['Composition'], M);
+  }
+  // Width → custom.width AND global.width (theme reads global.width)
+  const width = get('Width');
+  push('custom', 'width', width, S);
+  push('global', 'width', width, S);
+  // Collection
+  push('custom', 'collection_name', get('Collection'), S);
+  // Repeat / Pattern Repeat
+  push('custom', 'pattern_repeat', get('Repeat', 'Pattern Repeat'), S);
+  // Match / Pattern Match
+  push('global', 'Match', get('Match', 'Pattern Match'), S);
+  // Finish
+  push('global', 'Finish', get('Finish'), S);
+  // Fire Rating / Flammability
+  push('custom', 'fire_rating', get('Fire Rating', 'Flammability'), S);
+  // Backing / Substrate
+  push('global', 'Substrate', get('Backing', 'Substrate'), S);
+  // Weight
+  push('global', 'Weight', get('Weight'), S);
+  // Length / Roll Length
+  push('custom', 'length', get('Length', 'Roll Length'), S);
+  // Manufacturer SKU / SKU
+  push('custom', 'manufacturer_sku', get('Manufacturer SKU', 'SKU'), S);
+  // Brand
+  push('custom', 'brand', get('Brand'), S);
+  // Designer
+  push('custom', 'designer', get('Designer'), S);
+  // Country / Origin
+  push('global', 'Country', get('Country', 'Origin'), S);
+  // Style
+  push('global', 'Style', get('Style'), S);
+  // Sold By / Unit of Measure
+  push('global', 'unit_of_measure', get('Sold By', 'Unit of Measure'), S);
+
+  return { list: out, seen };
 }
 
 function buildPayload(r) {
@@ -86,7 +157,7 @@ function buildPayload(r) {
   const imgs = arr(r.all_images);
   // primary first, dedup by URL, cap at 12 (avoid Schumacher-style dup-gallery bloat)
   const srcs = [...new Set([r.image_url, ...imgs].filter(Boolean))].slice(0, 12);
-  return { product: {
+  const product = {
     title, vendor: 'Zoffany', product_type: r.is_fabric ? 'Fabric' : 'Wallcovering',
     status: STATUS, tags: buildTags(r), body_html: specTable(r),
     images: srcs.map(src => ({ alt: esc(`${r.pattern_name} - ${r.color_name}`), src })),
@@ -96,7 +167,13 @@ function buildPayload(r) {
       taxable: true, requires_shipping: true,
       inventory_management: null, inventory_policy: 'continue',
     }],
-  } };
+  };
+  // Spec metafields (TK-10039): preserve any already on the payload, no duplicate namespace.key.
+  const existing = Array.isArray(product.metafields) ? product.metafields : [];
+  const already = new Set(existing.map(m => `${m.namespace}.${m.key}`));
+  const { list } = buildMetafields(r);
+  product.metafields = [...existing, ...list.filter(m => !already.has(`${m.namespace}.${m.key}`))];
+  return { product };
 }
 
 (async () => {
@@ -119,6 +196,8 @@ function buildPayload(r) {
       console.log(`\n[create] ${r.dw_sku}  "${pl.title}"  [${pl.status}]`);
       console.log(`   variant: ${pl.variants[0].option1} $${pl.variants[0].price} · imgs:${pl.images.length}`);
       console.log(`   tags: ${pl.tags}`);
+      console.log(`   body_html: ${pl.body_html.replace(/\n/g, ' ')}`);
+      console.log(`   metafields (${pl.metafields.length}): ${pl.metafields.map(m => `${m.namespace}.${m.key}[${m.type}]=${JSON.stringify(m.value)}`).join(' · ')}`);
     }
     console.log(`\nDRY-RUN only. Re-run with --apply (optionally --limit=N) to create.`);
     await pool.end(); return;

← 695d29e auto-save: 2026-07-29T07:37:00 (8 files) — data/orphan-clean  ·  back to Designerwallcoverings  ·  auto-save: 2026-07-29T08:07:14 (3 files) — scripts/price-she 079c0ee →