← 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
M shopify/scripts/cadence/cadence-import.jsM shopify/scripts/cadence/vendors.js
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 →