[object Object]

← back to Designer Wallcoverings

cadence: add Anna French config with per-row product type (AT→Wallcovering/roll, AF→Fabric/yard); HELD in DENY_VENDORS pending gated re-prefix/handle-flip repair tool

e4435faab3c3256cef19d3eec1ec7e4ee930ed64 · 2026-06-25 10:51:14 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e4435faab3c3256cef19d3eec1ec7e4ee930ed64
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 25 10:51:14 2026 -0700

    cadence: add Anna French config with per-row product type (AT→Wallcovering/roll, AF→Fabric/yard); HELD in DENY_VENDORS pending gated re-prefix/handle-flip repair tool
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 shopify/scripts/cadence/cadence-import.js | 46 +++++++++++++++++++++++++------
 shopify/scripts/cadence/vendors.js        | 26 +++++++++++++++++
 2 files changed, 63 insertions(+), 9 deletions(-)

diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
index 2e43172d..9ab97e18 100644
--- a/shopify/scripts/cadence/cadence-import.js
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -66,6 +66,13 @@ const TODAY = new Date().toISOString().slice(0, 10);
 // excludes them by omission; this hard-blocks a future accidental add. (compliance officer, 2026-06-17)
 const DENY_VENDORS = new Set(['Nicolette Mayer','Phillip Jeffries','Et Cie','Spoonflower','Colefax & Fowler','Carlisle','Paul Montgomery','Cowtan & Tout','Rebel Walls','Koroseal','Newmor','Wolf Gordon','Scalamandre','Desima',
   // Romo LIFTED 2026-06-23 (Steve: "WE WANT ROMO") — tariff-treatment hold cleared; cost = romo_catalog landed (trade+tariff) per vendors.js.
+  // Anna French (anna_french_catalog): HELD config-only (2026-06-25, Phase 2). The cadence is
+  // CREATE-only + dedups on the NEW dw_sku; 254 promotable Wallcovering rows have an ACTIVE live
+  // counterpart under the OLD prefix DWTA that findExistingProduct('DWAT-…') can't match → a
+  // blind --commit would create 254 active DUPLICATES, and the 143 Fabric rows link-and-skip with
+  // garbled handles unrepaired (redirects can't fire). Needs a gated in-place re-prefix/handle-flip
+  // repair tool before lifting. See vendors.js 'Anna French' block + Phase-2 report.
+  'Anna French',
   'York Wallcoverings']);   // York (york_catalog): HELD — Steve 2026-06-23 "york just needs to create skus in our private label program first"; yes in principle, but blocked until the private-label SKUs exist (+ ~395 dangling shopify_product_ids cleared). Re-enable after PL SKU creation.
   // Innovations LIFTED 2026-06-18 (Steve): cost on innovations_catalog.price_trade (10% off net, per-yd),
   // wired in vendors.js (soldBy:Yard). Imported the costed-not-on-Shopify rows as DRAFTs (no --activate).
@@ -508,10 +515,31 @@ function emitVendorFor(vendor, cfg, row) {
   return vendor;
 }
 
+// ---- resolve the per-row productType + soldBy ----
+// Most vendors are single-type: cfg.productType + cfg.soldBy are constants. A vendor whose ONE
+// staging table mixes types (e.g. Anna French — AT##### Wallcovering + AF##### Fabric) sets
+// cfg.typeByRow=true and a cfg.typeMap keyed by the staging product_type column value →
+// { productType, soldBy }. resolveType() returns the per-row pair (case-insensitive key match),
+// falling back to the cfg constants (and finally Wallcovering / Single Roll) so single-type vendors
+// behave EXACTLY as before — this is purely additive.
+function resolveType(cfg, row) {
+  if (cfg && cfg.typeByRow && cfg.typeMap) {
+    const raw = String((row && row.product_type) || '').trim();
+    let hit = cfg.typeMap[raw];
+    if (!hit && raw) {
+      const k = Object.keys(cfg.typeMap).find(x => x.toLowerCase() === raw.toLowerCase());
+      if (k) hit = cfg.typeMap[k];
+    }
+    if (hit && hit.productType && hit.soldBy) return { productType: hit.productType, soldBy: hit.soldBy };
+  }
+  return { productType: cfg.productType || 'Wallcovering', soldBy: cfg.soldBy || 'Single Roll' };
+}
+
 // ---- build the Shopify productSet input (generic across standard-schema vendors) ----
 function buildInput(vendor, cfg, row, retail, activate) {
   vendor = emitVendorFor(vendor, cfg, row);   // per-product house line (DWBR → Malibu Wallpaper / LA Walls); never the banned key
   if (cfg.sampleOnly) return buildSampleOnlyInput(vendor, cfg, row, activate);
+  const { productType, soldBy } = resolveType(cfg, row);   // per-row type (Anna French: AT→Wallcovering/roll, AF→Fabric/yard)
   const stripCodes = s => String(s||'').replace(/\(\s*[A-Za-z]?\d{3,}[^)]*\)/g, '').replace(/\s{2,}/g,' ').trim(); // kill (T457) etc
   const pattern = titleCase(stripCodes(row.pattern_name || ''));
   const rawColor = cleanColor(row.color_name, row.pattern_name, row.mfr_sku);
@@ -525,10 +553,10 @@ function buildInput(vendor, cfg, row, retail, activate) {
   if (row.width) specs.push(`<li><strong>Width:</strong> ${esc(row.width)}</li>`);
   if (row.roll_length) specs.push(`<li><strong>Roll Length:</strong> ${esc(row.roll_length)}</li>`);
   if (row.pattern_repeat) specs.push(`<li><strong>Repeat:</strong> ${esc(row.pattern_repeat)}</li>`);
-  specs.push(`<li><strong>Sold By:</strong> ${esc(cfg.soldBy)}</li>`);
+  specs.push(`<li><strong>Sold By:</strong> ${esc(soldBy)}</li>`);
   const safeVendor = scrubBanned(vendor);
   const safeCollection = scrubBanned(row.collection);
-  const intro = `${pattern}${color?' in '+color:''} is a designer wallcovering${safeCollection?' from the '+esc(safeCollection)+' collection':''} by ${safeVendor}. Priced per ${cfg.soldBy.toLowerCase()}.`;
+  const intro = `${pattern}${color?' in '+color:''} is a designer wallcovering${safeCollection?' from the '+esc(safeCollection)+' collection':''} by ${safeVendor}. Priced per ${soldBy.toLowerCase()}.`;
   const SL='single_line_text_field', ML='multi_line_text_field', ND='number_decimal';
   const mf=[]; const m=(ns,k,v,t)=>{ if(v!=null&&v!=='') mf.push({namespace:ns,key:k,type:t,value:String(v)}); };
   m('custom','manufacturer_sku',row.mfr_sku,SL); m('global','manufacturer_sku',row.mfr_sku,SL);
@@ -540,7 +568,7 @@ function buildInput(vendor, cfg, row, retail, activate) {
   if (row.collection) m('custom','collection_name',row.collection,SL);
   m('custom','cost',row.cost.toFixed(2),ND);                 // REAL cost
   m('custom','price_updated_at',TODAY,'date');                // 30-day refresh audit
-  m('global','unit_of_measure',`Priced Per ${cfg.soldBy}`,SL);
+  m('global','unit_of_measure',`Priced Per ${soldBy}`,SL);
   // Auto-activate-to-live readiness gate (Steve 2026-06-17 "auto-activate to live";
   // description gate 2026-06-19; SPECS+DESC+ALL-VENDOR-IMAGES gate 2026-06-20):
   // a product goes ACTIVE only when validateBeforeActivate() passes — real image
@@ -552,8 +580,8 @@ function buildInput(vendor, cfg, row, retail, activate) {
   const descriptionHtml = `<p>${esc(intro)}</p><ul>${specs.join('')}</ul>`;
   const gate = validateBeforeActivate({
     title, dwSku: row.dw_sku, descriptionHtml,
-    specs:       { width: row.width, length: row.roll_length, repeat: row.pattern_repeat, material: row.material, unitOfMeasure: `Priced Per ${cfg.soldBy}` },
-    vendorSpecs: { width: row.width, length: row.roll_length, repeat: row.pattern_repeat, material: row.material, unitOfMeasure: cfg.soldBy },
+    specs:       { width: row.width, length: row.roll_length, repeat: row.pattern_repeat, material: row.material, unitOfMeasure: `Priced Per ${soldBy}` },
+    vendorSpecs: { width: row.width, length: row.roll_length, repeat: row.pattern_repeat, material: row.material, unitOfMeasure: soldBy },
     images:       row.image_url ? [row.image_url] : [],
     vendorImages: row.all_images || (row.image_url ? [row.image_url] : []),
     variants: [{ sku: row.dw_sku }, { sku: `${row.dw_sku}-Sample` }],
@@ -561,7 +589,7 @@ function buildInput(vendor, cfg, row, retail, activate) {
   const ready = gate.ok;
   const willActivate = !!(activate && ready);
   const input = {
-    title, handle, vendor, productType: cfg.productType,
+    title, handle, vendor, productType,
     status: willActivate ? 'ACTIVE' : 'DRAFT',  // DRAFT unless --activate AND the full gate passes
     // 'New Arrival' drives the SMART collection id 167327760435 (rule: tag="New Arrival" AND
     // type!=Trim, sort CREATED_DESC). Apply it INLINE here so a freshly-ACTIVE product appears in
@@ -571,12 +599,12 @@ function buildInput(vendor, cfg, row, retail, activate) {
     // target set), and only non-Trim types (matches the smart-collection rule). The reconciler stays
     // the self-healing source of truth: it trims products that age out of newest-TOP_N, so this inline
     // add never causes drift — both paths use the identical literal tag 'New Arrival'. (Steve 2026-06-24)
-    tags: [vendor, cfg.productType, `Priced Per ${cfg.soldBy}`, 'Trending Wallcovering Collection 2026', 'display_variant', ...(row.collection?[`Collection: ${row.collection}`]:[]), ...(color?[color]:[]), ...(willActivate && cfg.productType !== 'Trim' ? ['New Arrival'] : []), ...gate.tags],
+    tags: [vendor, productType, `Priced Per ${soldBy}`, 'Trending Wallcovering Collection 2026', 'display_variant', ...(row.collection?[`Collection: ${row.collection}`]:[]), ...(color?[color]:[]), ...(willActivate && productType !== 'Trim' ? ['New Arrival'] : []), ...gate.tags],
     descriptionHtml,
     metafields: mf,
-    productOptions: [{ name:'Title', position:1, values:[{name:cfg.soldBy},{name:'Sample'}] }],
+    productOptions: [{ name:'Title', position:1, values:[{name:soldBy},{name:'Sample'}] }],
     variants: [
-      { optionValues:[{optionName:'Title',name:cfg.soldBy}], price:String(retail), sku:row.dw_sku, inventoryItem:{sku:row.dw_sku,tracked:true,cost:row.cost.toFixed(2)}, inventoryPolicy:'CONTINUE', taxable:true },
+      { optionValues:[{optionName:'Title',name:soldBy}], price:String(retail), sku:row.dw_sku, inventoryItem:{sku:row.dw_sku,tracked:true,cost:row.cost.toFixed(2)}, inventoryPolicy:'CONTINUE', taxable:true },
       { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:true}, inventoryPolicy:'CONTINUE', taxable:true },
     ],
   };
diff --git a/shopify/scripts/cadence/vendors.js b/shopify/scripts/cadence/vendors.js
index 077e67cf..284f5b4e 100644
--- a/shopify/scripts/cadence/vendors.js
+++ b/shopify/scripts/cadence/vendors.js
@@ -71,6 +71,32 @@ module.exports = {
   // Graham & Brown: cost loaded from authoritative GDrive list (DWGB-70xxxx GRAHAM BROWN DWGB.xlsx)
   // into cost_price = wholesale Cost; retail = cost/0.65/0.85 (no MAP in list). discount_confirmed.
   'Graham & Brown': { table: 'graham_brown_catalog', costExpr: 'cost_price',           soldBy: 'Single Roll', productType: 'Wallcovering', discountNote: 'cost from GDrive GB wholesale list' },
+  // ── Anna French (thibautdesign.com brand) — MIXED product types in ONE staging table ──────────
+  // mfr AT##### → Wallcovering (DWAT, per-roll);  mfr AF##### → Fabric (DWAF, per-yard).
+  // productType + soldBy CANNOT be a single constant, so this vendor sets typeByRow:true + a
+  // typeMap keyed by the staging product_type column value. The importer's resolveType(cfg,row)
+  // returns the per-row {productType,soldBy}; single-type vendors are unaffected (they have no
+  // typeByRow and fall back to the productType/soldBy constants below). cost = REAL net cost on
+  // anna_french_catalog.cost (143/143 Fabric + 588/591 Wallcovering costed); retail = cost/0.65/0.85
+  // (NOT Kravet/MAP). Sample variant = $4.25. dw_sku prefixes already assigned in Phase 0 staging.
+  //
+  // ⚠️ HELD in DENY_VENDORS (config-only wiring, mirrors the Innovations/York precedent) — DO NOT
+  // lift without Steve + a dedicated re-prefix/handle-flip repair tool. WHY: the cadence is a
+  // CREATE-only path whose dedup keys on the NEW dw_sku. 254 promotable Wallcovering rows have an
+  // ACTIVE live counterpart STILL under the OLD prefix DWTA (sku DWTA-#####), which
+  // findExistingProduct('DWAT-#####') cannot match → a blind --commit would CREATE 254 ACTIVE
+  // DUPLICATES (never-duplicate hard rule). All 143 Fabric rows already carry DWAF live, so they
+  // link-and-skip with their garbled mfr-code-leaking handles UNREPAIRED (the Phase-1 redirects
+  // never fire). The 622 re-prefix/handle-flip products need an IN-PLACE repair tool
+  // (find live by mfr_sku → productUpdate handle + productVariantsBulkUpdate inventoryItem.sku +
+  // archive the old ghost) — a GATED customer-facing live mutation. Only the 100 truly-new
+  // Wallcovering rows are clean net-new creates. discount_confirmed intentionally left UNSET while
+  // held (do not arm the create gate). See the Phase-2 report 2026-06-25.
+  'Anna French': { table: 'anna_french_catalog',    costExpr: 'cost',                 soldBy: 'Single Roll', productType: 'Wallcovering',
+                   typeByRow: true,
+                   typeMap: { 'Wallcovering': { productType: 'Wallcovering', soldBy: 'Single Roll' },
+                              'Fabric':       { productType: 'Fabric',       soldBy: 'Yard' } },
+                   discountNote: 'cost/0.65/0.85; per-row type (AT→Wallcovering/roll, AF→Fabric/yard); HELD in DENY_VENDORS pending gated re-prefix/handle-flip repair tool' },
   // Innovations: trade acct 58315, web login confirmed (Steve 2026-06-17). Discount = 10% off net
   // → cost = list × 0.90. Priced & sold PER YARD (vendor shows "$X Per Yd"; 54"/36" goods with a
   // per-pattern min order + cut fee) — NOT per roll, so soldBy:'Yard'. price_trade (cost, per-yd) +

← 115f2e5b cadence: DWBR per-product house-line emission (Malibu Wallpa  ·  back to Designer Wallcoverings  ·  Stage infinite-scroll hardening patch (DTD verdict B: live a c1902e87 →