[object Object]

← back to Designer Wallcoverings

cadence: add sampleOnly vendor mode for Quadrille-house $4.25 sample-only lines (no cost, additive)

6a487a9d11c22c2b96d889c2e3d2afa997c5ade3 · 2026-06-23 17:14:44 -0700 · Steve

Files touched

Diff

commit 6a487a9d11c22c2b96d889c2e3d2afa997c5ade3
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 23 17:14:44 2026 -0700

    cadence: add sampleOnly vendor mode for Quadrille-house $4.25 sample-only lines (no cost, additive)
---
 shopify/scripts/cadence/cadence-import.js | 157 +++++++++++++++++++++++++++---
 shopify/scripts/cadence/vendors.js        |  25 +++++
 2 files changed, 171 insertions(+), 11 deletions(-)

diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
index 048b59e0..877e50e3 100644
--- a/shopify/scripts/cadence/cadence-import.js
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -174,12 +174,35 @@ function logPriceToDb(table, dwSku, retail) {
   psql(`UPDATE ${table} SET our_price=${retail}, price_updated_at='${TODAY}' WHERE dw_sku='${dwSku.replace(/'/g,"''")}';`);
 }
 
-// ---- gate: which configured vendors are READY with net-new costed fuel ----
+// ---- brand-filter clause for shared-table vendors (Quadrille-house sample-only) ----
+// Several sample-only vendors share ONE staging table (quadrille_house_catalog) keyed by a
+// `brand` column. brandFilter scopes a vendor entry to its rows. SQL-escaped, single-quoted.
+function brandFilterClause(cfg) {
+  if (!cfg.brandFilter) return '';
+  return ` AND brand = '${String(cfg.brandFilter).replace(/'/g, "''")}'`;
+}
+
+// ---- gate: which configured vendors are READY with net-new fuel ----
+// cost-based vendors: net-new = shopify_product_id empty AND costExpr>0 AND discount_confirmed.
+// sampleOnly vendors (Quadrille-house, no cost): net-new = shopify_product_id empty, scoped by
+// brand. They BYPASS both the costExpr>0 filter and the discount_confirmed gate (there is no
+// cost to confirm). Steve AUTHORIZED 2026-06-23 as $4.25 sample-only products.
 function gate() {
   const confirmed = confirmedDiscountVendors();
   const rows = [];
   for (const [vendor, cfg] of Object.entries(VENDORS)) {
     if (DENY_VENDORS.has(vendor)) { rows.push({ vendor, cfg, ready: false, why: 'DENYLISTED', netnew: 0 }); continue; }
+    if (cfg.sampleOnly) {
+      // no cost, no discount gate — just net-new rows for this brand
+      let netnew = 0, why = 'READY (sample-only)';
+      try {
+        const c = psql(`SELECT count(*) FROM ${cfg.table} WHERE coalesce(shopify_product_id::text,'')=''${brandFilterClause(cfg)}${dedupSkipClause(cfg.table)};`);
+        netnew = parseInt(c || '0', 10);
+        if (!netnew) why = 'no-net-new-skus';
+      } catch (e) { why = 'gate-query-error: ' + String(e.message).slice(0,60); }
+      rows.push({ vendor, cfg, ready: netnew > 0, why, netnew });
+      continue;
+    }
     if (!confirmed.has(vendor)) { rows.push({ vendor, cfg, ready: false, why: 'discount-not-confirmed', netnew: 0 }); continue; }
     let netnew = 0, why = 'READY';
     try {
@@ -231,6 +254,19 @@ function selectSkus(cfg, limit) {
     hasAllImg = (psql(`SELECT 1 FROM information_schema.columns WHERE table_name='${t}' AND column_name='${c}' LIMIT 1;`) || '').trim() === '1';
   } catch { hasAllImg = false; }
   const allImgSel = hasAllImg ? `, (${ALLIMG})::text AS all_images` : '';
+  // sampleOnly (Quadrille-house, no cost): select net-new rows scoped by brand, NO cost column,
+  // NO cost>0 filter. description pulled from ai_description / description for the activation gate.
+  if (cfg.sampleOnly) {
+    const descCol = (cfg.colOverrides && cfg.colOverrides.description) || 'COALESCE(ai_description, description)';
+    const sql = `SELECT ${cols}, (${descCol})::text AS vendor_desc${allImgSel}
+      FROM ${cfg.table}
+      WHERE coalesce(shopify_product_id::text,'')=''${brandFilterClause(cfg)}${dedupSkipClause(cfg.table)}
+      ORDER BY dw_sku LIMIT ${limit};`;
+    const out = psql(sql);
+    if (!out) return [];
+    const keys = [...STD, 'vendor_desc', ...(hasAllImg ? ['all_images'] : [])];
+    return out.split('\n').map(line => { const v=line.split('\t'); const o={}; keys.forEach((k,i)=>o[k]=v[i]); o.cost=0; o.sampleOnly=true; return o; });
+  }
   const sql = `SELECT ${cols}, (${cfg.costExpr})::numeric AS cost${sellSel}${allImgSel}
     FROM ${cfg.table}
     WHERE coalesce(shopify_product_id::text,'')='' AND (${cfg.costExpr})::numeric > 0${dedupSkipClause(cfg.table)}
@@ -241,8 +277,94 @@ function selectSkus(cfg, limit) {
   return out.split('\n').map(line => { const v=line.split('\t'); const o={}; keys.forEach((k,i)=>o[k]=v[i]); o.cost=parseFloat(o.cost); if(cfg.sellExpr) o.sell=parseFloat(o.sell); return o; });
 }
 
+// ---- SAMPLE-ONLY product input (Quadrille-house, no cost — Steve AUTHORIZED 2026-06-23) ----
+// Builds a product with ONE Sample variant @ $4.25 (no roll variant, no cost). Used for the
+// Quadrille-house lines whose wholesale cost is unobtainable from the public site, so they can
+// be onboarded + sampled but not sold as priced rolls until a trade relationship lands a cost.
+// The China Seas sample-only model. Activation gate still applies: image + real title +
+// ≥2 tags + the Sample variant + width-where-the-vendor-has-it. Title NEVER "Unknown"/banned.
+function buildSampleOnlyInput(vendor, cfg, row, activate) {
+  const stripCodes = s => String(s||'').replace(/\(\s*[A-Za-z]?\d{3,}[^)]*\)/g, '').replace(/\s{2,}/g,' ').trim();
+  const pattern = titleCase(stripCodes(row.pattern_name || ''));
+  const rawColor = cleanColor(row.color_name, row.pattern_name, row.mfr_sku);
+  const color = rawColor ? titleCase(stripCodes(rawColor)) : '';
+  // Title core: "Pattern, Color" → "Color" (when no pattern) → pattern → dw_sku. NEVER mfr_sku, NEVER Unknown.
+  let core = (pattern && color) ? `${pattern}, ${color}` : (pattern || color);
+  if (!core) core = row.dw_sku;
+  const title = `${core} Wallcoverings | ${vendor}`.replace(/wallpaper/gi,'Wallcovering');
+  const handle = (`${core}-${row.dw_sku}`).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,100);
+
+  // Description: prefer the real AI/vendor description (already enriched); fall back to a clean
+  // generated sentence. Strip the banned word. Never a placeholder.
+  const SL='single_line_text_field', ML='multi_line_text_field';
+  let vendorDesc = String(row.vendor_desc || '').trim();
+  if (/^(\\N|null|undefined)$/i.test(vendorDesc)) vendorDesc = '';
+  const specs = [];
+  if (row.material) specs.push(`<li><strong>Material:</strong> ${esc(row.material)}</li>`);
+  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>`);
+  if (row.collection) specs.push(`<li><strong>Collection:</strong> ${esc(row.collection)}</li>`);
+  const intro = vendorDesc
+    ? esc(vendorDesc).replace(/wallpaper/gi,'Wallcovering')
+    : `${pattern||core}${color?' in '+color:''} is a designer wallcovering${row.collection?' from the '+esc(row.collection)+' collection':''} by ${vendor}.`;
+  const descriptionHtml = `<p>${intro}</p>${specs.length?`<ul>${specs.join('')}</ul>`:''}`;
+
+  // Metafields — NO cost (there is none). Width/specs where present, brand, sku, pattern, color.
+  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);
+  m('global','Brand',vendor,SL); m('global','dw_sku',row.dw_sku,SL);
+  if (row.width){ m('global','width',row.width,SL); m('custom','width',row.width,SL); }
+  if (pattern){ m('global','pattern_name',pattern,SL); m('custom','pattern_name',pattern,SL); }
+  if (color){ m('custom','color',color,SL); m('global','color',color,SL); m('dwc','color',color,SL); }
+  if (row.material){ m('global','material',row.material,SL); m('custom','material',row.material,ML); }
+  if (row.roll_length){ m('global','length',row.roll_length,SL); }
+  if (row.pattern_repeat){ m('global','repeat',row.pattern_repeat,SL); }
+  if (row.collection) m('custom','collection_name',row.collection,SL);
+  m('global','unit_of_measure','Sample Only',SL);   // no priced roll yet — sample-only listing
+
+  // Activation gate — sample-only has a SINGLE Sample variant (no roll). The gate treats
+  // unitOfMeasure as not-vendor-provided (sample-only has no roll UOM) so it isn't required.
+  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: 'Sample Only' },
+    vendorSpecs: { width: row.width, length: row.roll_length, repeat: row.pattern_repeat, material: row.material },
+    images:       row.image_url ? [row.image_url] : [],
+    vendorImages: row.all_images || (row.image_url ? [row.image_url] : []),
+    variants: [{ sku: `${row.dw_sku}-Sample` }],
+  });
+  const ready = gate.ok;
+  const willActivate = !!(activate && ready);
+
+  // Tags: vendor + productType give the required ≥2; add collection, color, AI-derived tags, and a
+  // Sample-Only marker. de-duped. gate.tags carry any Needs-* flags for DRAFT products.
+  const tagSet = [vendor, cfg.productType, 'Sample Only', 'display_variant', 'Quadrille House',
+    ...(row.collection?[`Collection: ${row.collection}`]:[]), ...(color?[color]:[]), ...gate.tags];
+
+  const input = {
+    title, handle, vendor, productType: cfg.productType,
+    status: willActivate ? 'ACTIVE' : 'DRAFT',
+    tags: [...new Set(tagSet.filter(Boolean))],
+    descriptionHtml,
+    metafields: mf,
+    productOptions: [{ name:'Title', position:1, values:[{name:'Sample'}] }],
+    variants: [
+      { optionValues:[{optionName:'Title',name:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:false}, inventoryPolicy:'CONTINUE', taxable:true },
+    ],
+  };
+  // Attach ALL vendor images (full-page-scrape rule), primary first, de-duped.
+  const { toImageList } = require('../lib/validate-before-activate.js');
+  const imgUrls = [...new Set([
+    ...(row.image_url ? [row.image_url] : []),
+    ...toImageList(row.all_images),
+  ].map(u => String(u||'').trim()).filter(u => /^https?:\/\//i.test(u)))];
+  if (imgUrls.length) input.files = imgUrls.map(u => ({ originalSource: webImage(u), contentType:'IMAGE', alt: core }));
+  return { input, title, retail: 0, willActivate, ready, sampleOnly: true };
+}
+
 // ---- build the Shopify productSet input (generic across standard-schema vendors) ----
 function buildInput(vendor, cfg, row, retail, activate) {
+  if (cfg.sampleOnly) return buildSampleOnlyInput(vendor, cfg, row, activate);
   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);
@@ -438,13 +560,14 @@ async function createProduct(table, row, payload) {
   if (existing && !existing.archivedOnly) {
     const num = String(existing.id).replace(/.*\//, '');
     // log our computed price to the DB (the hard rule) but DO NOT overwrite the live Shopify product.
-    logPriceToDb(table, row.dw_sku, payload.retail);
+    // sample-only has no priced roll → nothing to log.
+    if (!payload.sampleOnly) logPriceToDb(table, row.dw_sku, payload.retail);
     psql(`UPDATE ${table} SET shopify_product_id='${num}' WHERE dw_sku='${row.dw_sku.replace(/'/g,"''")}' AND coalesce(shopify_product_id::text,'')='';`);
     appendRestore({ table, mfr_sku: row.mfr_sku, dw_sku: row.dw_sku, shopify_product_id: num, action: 'linked-existing', activated: false });
     return { ok: true, pid: existing.id, action: 'linked-existing', status: existing.status };
   }
-  // dw_unified FIRST
-  logPriceToDb(table, row.dw_sku, payload.retail);
+  // dw_unified FIRST (sample-only has no priced roll → nothing to log)
+  if (!payload.sampleOnly) logPriceToDb(table, row.dw_sku, payload.retail);
   const r = await gqlRetry(PRODUCTSET, { input: payload.input });
   // Shopify enforces a DAILY variant-creation cap separate from the GraphQL cost bucket.
   // When hit, every create fails with VARIANT_THROTTLE_EXCEEDED until the 24h window rolls.
@@ -467,12 +590,19 @@ async function createProduct(table, row, payload) {
       if (!published) console.log(`  ⚠ ${row.dw_sku} ACTIVE but publish failed: ${JSON.stringify(pub.errors||pub.why).slice(0,120)}`);
     } catch (e) { console.log(`  ⚠ ${row.dw_sku} publish error: ${String(e.message).slice(0,120)}`); }
   }
-  appendRestore({ table, mfr_sku: row.mfr_sku, dw_sku: row.dw_sku, shopify_product_id: num, action: 'created', activated, published, status: activated ? 'ACTIVE' : 'DRAFT' });
-  return { ok:true, pid, activated, published, skus: activated ? [row.dw_sku, `${row.dw_sku}-Sample`] : [] };
+  appendRestore({ table, mfr_sku: row.mfr_sku, dw_sku: row.dw_sku, shopify_product_id: num, action: 'created', activated, published, sampleOnly: !!payload.sampleOnly, status: activated ? 'ACTIVE' : 'DRAFT' });
+  // sample-only variants are inventory-untracked ($4.25 sample, no stock) → do NOT feed them to
+  // the inventory=2026 setter (it only applies to tracked roll/sample variants of priced products).
+  const invSkus = activated && !payload.sampleOnly ? [row.dw_sku, `${row.dw_sku}-Sample`] : [];
+  return { ok:true, pid, activated, published, skus: invSkus };
 }
 
+// Export internals for unit/inspection harnesses (no behavior change when run as a script;
+// the main IIFE below only runs on direct execution via the require.main guard).
+module.exports = { gate, selectSkus, buildInput, buildSampleOnlyInput, brandFilterClause };
+
 // ---- main ----
-(async () => {
+if (require.main === module) (async () => {
   if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
   const all = gate();
   console.log(`\n=== VENDOR GATE (slot=${SLOT}) ===`);
@@ -517,13 +647,18 @@ async function createProduct(table, row, payload) {
     if (hitMax || hitCap) break;
     if (COMMIT) ensureOurPriceCol(r.cfg.table);
     const skus = selectSkus(r.cfg, SKUS_PER_VENDOR);
-    console.log(`\n${r.vendor} — ${skus.length} SKU(s) (cost ${r.cfg.costExpr}):`);
+    console.log(`\n${r.vendor} — ${skus.length} SKU(s) (${r.cfg.sampleOnly ? 'SAMPLE-ONLY $'+SAMPLE_PRICE+', no cost' : 'cost '+r.cfg.costExpr}):`);
     for (const row of skus) {
+      // sample-only (Quadrille-house): no roll, single $4.25 sample → retail is N/A (0).
       // MAP-priced vendors (sellExpr) sell at MAP; everyone else at cost/0.65/0.85.
-      const retail = (r.cfg.sellExpr && row.sell > 0) ? Math.round(row.sell * 100) / 100 : RETAIL(row.cost);
+      const retail = r.cfg.sampleOnly ? 0 : ((r.cfg.sellExpr && row.sell > 0) ? Math.round(row.sell * 100) / 100 : RETAIL(row.cost));
       const payload = buildInput(r.vendor, r.cfg, row, retail, ACTIVATE);
-      plan.push({ vendor:r.vendor, dw_sku:row.dw_sku, cost:row.cost, retail, sample:SAMPLE_PRICE, title:payload.title, willActivate:payload.willActivate });
-      if (!COMMIT) { console.log(`  · ${row.dw_sku}  cost $${row.cost.toFixed(2)} → roll $${retail}  sample $${SAMPLE_PRICE}  ${ACTIVATE?(payload.willActivate?'[→ACTIVE]':'[draft:not-ready]'):''}  | ${payload.title.slice(0,48)}`); continue; }
+      plan.push({ vendor:r.vendor, dw_sku:row.dw_sku, cost:row.cost, retail, sample:SAMPLE_PRICE, sampleOnly: !!r.cfg.sampleOnly, title:payload.title, willActivate:payload.willActivate });
+      if (!COMMIT) {
+        if (r.cfg.sampleOnly) console.log(`  · ${row.dw_sku}  SAMPLE-ONLY  sample $${SAMPLE_PRICE}  ${ACTIVATE?(payload.willActivate?'[→ACTIVE]':'[draft:not-ready]'):''}  | ${payload.title.slice(0,52)}`);
+        else console.log(`  · ${row.dw_sku}  cost $${row.cost.toFixed(2)} → roll $${retail}  sample $${SAMPLE_PRICE}  ${ACTIVATE?(payload.willActivate?'[→ACTIVE]':'[draft:not-ready]'):''}  | ${payload.title.slice(0,48)}`);
+        continue;
+      }
       const res = await createProduct(r.cfg.table, row, payload);
       if (res.ok && res.action === 'linked-existing') { linked++; console.log(`  ↪ ${row.dw_sku} already on Shopify (${res.status}) → backfilled link ${String(res.pid).replace(/.*\//,'')}, skipped create (dedup guard)`); }
       else if (res.ok) { created++; if (res.activated && res.skus) activatedSkus.push(...res.skus); if (res.published && res.pid) publishedProductIds.push(res.pid); if(created%10===0) process.stdout.write(`\r  created ${created}…`); }
diff --git a/shopify/scripts/cadence/vendors.js b/shopify/scripts/cadence/vendors.js
index fc229553..6cc78f1e 100644
--- a/shopify/scripts/cadence/vendors.js
+++ b/shopify/scripts/cadence/vendors.js
@@ -75,4 +75,29 @@ module.exports = {
   // ⚠️ HELD in cadence-import.js DENY_VENDORS until Steve lifts it — config-only wiring (Innovations
   // pattern); it imports NOTHING while denylisted. Re-import universe ≈ 4,613 net-new + ~395 dangling.
   'York Wallcoverings': { table: 'york_catalog',     costExpr: 'cost',                 soldBy: 'Single Roll', productType: 'Wallcovering', discountNote: 'cost=wholesale from York/Brewster price list; us_map MAP floor; retail=cost/0.65/0.85 above MAP; HELD in DENY_VENDORS until Steve lifts' },
+
+  // ── Quadrille-house SAMPLE-ONLY lines (Steve AUTHORIZED 2026-06-23) ──────────────
+  // Source = public quadrillefabrics.com only → NO wholesale cost obtainable, so these 8
+  // net-new lines CANNOT be priced/activated as sellable rolls (the no-cost HELD rule).
+  // Steve's decision: onboard them as $4.25 SAMPLE-ONLY products (the China Seas sample
+  // model) — ONE Sample variant @ $4.25, NO roll variant, no cost. They become full
+  // sellable rolls only when DW secures wholesale cost via a trade relationship.
+  //
+  // sampleOnly: true        → cadence builds a single $4.25 Sample variant (no roll, no cost),
+  //                           BYPASSES the costExpr>0 net-new filter AND the discount_confirmed
+  //                           gate (there is no cost to confirm). Activation gate STILL applies
+  //                           (image + ≥2 tags + real title + sample variant + width-where-present).
+  // brandFilter: '<Brand>'  → all 8 lines share ONE staging table (quadrille_house_catalog) keyed
+  //                           by the `brand` column; brandFilter scopes each vendor entry to its rows.
+  // dedup: net-new selected as shopify_product_id IS NULL; the create-time dedup guard
+  //                           (findExistingProduct, base-sku match) skips any SKU already live.
+  // soldBy 'Sample' is cosmetic for sample-only (no roll), kept for spec/tag text consistency.
+  'Quadrille':       { table: 'quadrille_house_catalog', sampleOnly: true, brandFilter: 'Quadrille',       soldBy: 'Sample', productType: 'Wallcovering', note: 'Quadrille-house sample-only (no cost); public-site source' },
+  'Alan Campbell':   { table: 'quadrille_house_catalog', sampleOnly: true, brandFilter: 'Alan Campbell',   soldBy: 'Sample', productType: 'Wallcovering', note: 'Quadrille-house sample-only (no cost); public-site source' },
+  'Home Couture':    { table: 'quadrille_house_catalog', sampleOnly: true, brandFilter: 'Home Couture',    soldBy: 'Sample', productType: 'Wallcovering', note: 'Quadrille-house sample-only (no cost); public-site source' },
+  'Charles Burger':  { table: 'quadrille_house_catalog', sampleOnly: true, brandFilter: 'Charles Burger',  soldBy: 'Sample', productType: 'Wallcovering', note: 'Quadrille-house sample-only (no cost); public-site source' },
+  'Cloth and Paper': { table: 'quadrille_house_catalog', sampleOnly: true, brandFilter: 'Cloth and Paper', soldBy: 'Sample', productType: 'Wallcovering', note: 'Quadrille-house sample-only (no cost); public-site source' },
+  'Suncloth':        { table: 'quadrille_house_catalog', sampleOnly: true, brandFilter: 'Suncloth',        soldBy: 'Sample', productType: 'Wallcovering', note: 'Quadrille-house sample-only (no cost); public-site source' },
+  'Plains':          { table: 'quadrille_house_catalog', sampleOnly: true, brandFilter: 'Plains',          soldBy: 'Sample', productType: 'Wallcovering', note: 'Quadrille-house sample-only (no cost); public-site source' },
+  'Grasscloth':      { table: 'quadrille_house_catalog', sampleOnly: true, brandFilter: 'Grasscloth',      soldBy: 'Sample', productType: 'Wallcovering', note: 'Quadrille-house sample-only (no cost); public-site source' },
 };

← 0070a06b Backfill dw_unified mirror metafields from live Shopify for  ·  back to Designer Wallcoverings  ·  cadence: fix ACTIVE/DRAFT summary count for sample-only prod eb9c513f →