[object Object]

← back to Designer Wallcoverings

Add gated daily cadence importer (dry-run first)

6bec4bde7df2da43de22d9d48c640d11527db89b · 2026-06-11 08:58:04 -0700 · SteveStudio2

cadence-import.js + vendors.js: imports up to N vendors × M net-new costed SKUs
per slot (07:30 am / 12:00 pm). HARD GATE per Steve's rule — vendor must be in
vendors.js with a verified costExpr, discount_confirmed=true, and have net-new
costed SKUs, else skipped. Per SKU: cost→retail=cost/0.65/0.85, log our_price+
price_updated_at to dw_unified FIRST, then productSet create (DRAFT) with main
variant=retail, Sample variant=$4.25, custom.cost + custom.price_updated_at,
title-cased + mfr-code/(Tcode) stripped from titles. Rotation cursor.
Dry-run default; --commit creates live. READY today: Romo(262), Thibaut(1512).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 6bec4bde7df2da43de22d9d48c640d11527db89b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu Jun 11 08:58:04 2026 -0700

    Add gated daily cadence importer (dry-run first)
    
    cadence-import.js + vendors.js: imports up to N vendors × M net-new costed SKUs
    per slot (07:30 am / 12:00 pm). HARD GATE per Steve's rule — vendor must be in
    vendors.js with a verified costExpr, discount_confirmed=true, and have net-new
    costed SKUs, else skipped. Per SKU: cost→retail=cost/0.65/0.85, log our_price+
    price_updated_at to dw_unified FIRST, then productSet create (DRAFT) with main
    variant=retail, Sample variant=$4.25, custom.cost + custom.price_updated_at,
    title-cased + mfr-code/(Tcode) stripped from titles. Rotation cursor.
    Dry-run default; --commit creates live. READY today: Romo(262), Thibaut(1512).
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 shopify/scripts/cadence/cadence-import.js          | 253 ++++++++++++++++
 shopify/scripts/cadence/data/cadence-cursor.json   |   5 +
 .../cadence/data/cadence-plan-am-2026-06-11.json   | 322 +++++++++++++++++++++
 shopify/scripts/cadence/vendors.js                 |  24 ++
 4 files changed, 604 insertions(+)

diff --git a/shopify/scripts/cadence/cadence-import.js b/shopify/scripts/cadence/cadence-import.js
new file mode 100644
index 00000000..15c2cf0f
--- /dev/null
+++ b/shopify/scripts/cadence/cadence-import.js
@@ -0,0 +1,253 @@
+#!/usr/bin/env node
+/**
+ * Daily controlled-import cadence for DW Shopify.
+ *
+ * Replaces single-vendor BULK dumps (e.g. the disabled rebel-walls-daily-push)
+ * with a controlled cadence: each slot imports up to N vendors × M net-new SKUs.
+ *   07:30 slot ("am") and 12:00 slot ("pm"), daily.
+ *
+ * HARD GATE (Steve's standing rule — see memory scraper-must-confirm-cost-and-discount):
+ *   A vendor is imported ONLY if it is in vendors.js with a verified costExpr,
+ *   vendor_registry.discount_confirmed=true, AND has net-new costed SKUs.
+ *   No cost  ->  vendor skipped (logged). NEVER import a placeholder price.
+ *
+ * Per SKU:  cost (from catalog costExpr)  ->  retail = round(cost/0.65/0.85, 2)
+ *   - log computed retail to dw_unified catalog FIRST (our_price col), then
+ *   - create Shopify product (productSet): main variant price=retail,
+ *     Sample variant price=$4.25 (correct sample price), custom.cost + custom.price_updated_at,
+ *     title-cased "Pattern, Color Wallcoverings | Vendor".
+ *
+ * Usage:
+ *   node cadence-import.js --slot am                 # DRY-RUN (default), 10 vendors × 20 skus
+ *   node cadence-import.js --slot pm --vendors 10 --skus 20
+ *   node cadence-import.js --slot am --commit         # LIVE create on Shopify
+ *   node cadence-import.js --gate                     # just print vendor readiness
+ */
+const https = require('https');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+const VENDORS = require('./vendors.js');
+
+// ---- args ----
+const args = process.argv.slice(2);
+const flag = n => args.includes(n);
+const val = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
+const COMMIT = flag('--commit');
+const GATE_ONLY = flag('--gate');
+const SLOT = val('--slot', 'am');
+const VENDORS_PER_SLOT = parseInt(val('--vendors', '10'), 10);
+const SKUS_PER_VENDOR = parseInt(val('--skus', '20'), 10);
+const TODAY = new Date().toISOString().slice(0, 10);
+const RETAIL = c => Math.round((c / 0.65 / 0.85) * 100) / 100;
+const SAMPLE_PRICE = '4.25';   // correct price for the Sample variant (Steve 2026-06-11)
+
+// ---- creds ----
+const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const DATADIR = path.join(__dirname, 'data');
+const CURSOR = path.join(DATADIR, 'cadence-cursor.json');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+// ---- title-case (same corrected logic as fix-24h-new-skus.js) ----
+const SMALL = new Set(['a','an','and','as','at','but','by','de','del','for','if','in','la','le','nor','of','on','or','per','the','to','und','via','vs','with']);
+const KEEP_UPPER = new Set(['usa','uk','ny','nyc','sf','clj','led','pvc','uv','3d','dna']);
+const decode = s => String(s||'').replace(/&amp;/gi,'&').replace(/&quot;/gi,'"').replace(/&#39;|&apos;/gi,"'").replace(/&nbsp;/gi,' ');
+const capRuns = w => w.replace(/[A-Za-z]+/g, (m, off, str) => {
+  const prev = str[off - 1];
+  if ((prev === "'" || prev === '’') && m.length <= 2) return m.toLowerCase(); // possessive 's / contractions
+  return m[0].toUpperCase() + m.slice(1).toLowerCase();
+});
+// strip junk color_name (vendor echoes the pattern + "(Tcode)" into the color field)
+function cleanColor(color, pattern, mfrSku) {
+  if (!color) return '';
+  if (/unknown/i.test(color)) return '';
+  if (/\([A-Za-z]?\d{3,}\)/.test(color)) return '';                 // contains an (Tnnnn) sku code
+  if (mfrSku && color.toLowerCase().includes(String(mfrSku).toLowerCase())) return '';
+  if (pattern && color.toLowerCase().includes(pattern.toLowerCase()) && color.length > pattern.length + 4) return '';
+  return color;
+}
+function tcWord(w, first, last) {
+  if (!w) return w;
+  if (/\d/.test(w)) return w;
+  const low = w.replace(/[^A-Za-z]/g,'').toLowerCase();
+  if (KEEP_UPPER.has(low)) return w.toUpperCase();
+  if (SMALL.has(low) && !first) return w.toLowerCase();
+  if (last && low.length === 1) return w.toLowerCase();
+  return capRuns(w);
+}
+function titleCase(s) {
+  const ws = decode(s).trim().split(/\s+/);
+  return ws.map((w,i) => tcWord(w, i===0, i===ws.length-1)).join(' ');
+}
+const esc = s => String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
+
+// ---- pg ----
+function psql(sql) {
+  return execFileSync('psql', ['-At', '-F', '\t', '-d', 'dw_unified', '-c', sql], { encoding: 'utf8', maxBuffer: 1 << 28 }).trim();
+}
+function confirmedDiscountVendors() {
+  const out = psql(`SELECT vendor_name FROM vendor_registry WHERE discount_confirmed IS TRUE;`);
+  return new Set(out ? out.split('\n') : []);
+}
+
+// ---- shopify ----
+function gql(query, variables) {
+  return new Promise(res => {
+    const data = JSON.stringify({ query, variables });
+    const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } },
+      r => { let d=''; r.on('data',c=>d+=c); r.on('end',()=>{ try{res({status:r.statusCode,json:JSON.parse(d)})}catch{res({status:r.statusCode,raw:d.slice(0,400)})} }); });
+    req.on('error', e => res({ status: 0, err: e.message }));
+    req.write(data); req.end();
+  });
+}
+async function gqlRetry(q, v) {
+  for (let a=0;a<5;a++){ const r=await gql(q,v); const t=r.json&&r.json.errors&&JSON.stringify(r.json.errors).includes('THROTTLED');
+    if (r.status===429||t){await sleep(2000*(a+1));continue;} await sleep(550); return r; }
+  return { status: 429, raw: 'throttled' };
+}
+
+// ---- ensure dw_unified logs our computed price FIRST (Steve's hard rule) ----
+function ensureOurPriceCol(table) {
+  // additive, idempotent — our computed retail + when it was set (30-day audit)
+  psql(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS our_price numeric(10,2);`);
+  psql(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS price_updated_at date;`);
+}
+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 ----
+function gate() {
+  const confirmed = confirmedDiscountVendors();
+  const rows = [];
+  for (const [vendor, cfg] of Object.entries(VENDORS)) {
+    if (!confirmed.has(vendor)) { rows.push({ vendor, cfg, ready: false, why: 'discount-not-confirmed', netnew: 0 }); continue; }
+    let netnew = 0, why = 'READY';
+    try {
+      const c = psql(`SELECT count(*) FROM ${cfg.table} WHERE coalesce(shopify_product_id::text,'')='' AND (${cfg.costExpr})::numeric > 0;`);
+      netnew = parseInt(c || '0', 10);
+      if (!netnew) why = 'no-net-new-costed-skus';
+    } catch (e) { why = 'gate-query-error: ' + String(e.message).slice(0,60); }
+    rows.push({ vendor, cfg, ready: netnew > 0, why, netnew });
+  }
+  return rows;
+}
+
+// ---- rotation cursor so successive slots/days pick different vendors ----
+function loadCursor() { try { return JSON.parse(fs.readFileSync(CURSOR,'utf8')); } catch { return { idx: 0 }; } }
+function saveCursor(c) { fs.mkdirSync(DATADIR,{recursive:true}); fs.writeFileSync(CURSOR, JSON.stringify(c,null,1)); }
+
+// ---- select next M net-new costed SKUs for a vendor ----
+function selectSkus(cfg, limit) {
+  const cols = 'dw_sku, mfr_sku, pattern_name, color_name, collection, width, image_url, product_type, material, roll_length, pattern_repeat';
+  const sql = `SELECT ${cols}, (${cfg.costExpr})::numeric AS cost
+    FROM ${cfg.table}
+    WHERE coalesce(shopify_product_id::text,'')='' AND (${cfg.costExpr})::numeric > 0
+    ORDER BY dw_sku LIMIT ${limit};`;
+  const out = psql(sql);
+  if (!out) return [];
+  const keys = [...cols.split(', '), 'cost'];
+  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); return o; });
+}
+
+// ---- build the Shopify productSet input (generic across standard-schema vendors) ----
+function buildInput(vendor, cfg, row, retail) {
+  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);
+  const color = rawColor ? titleCase(stripCodes(rawColor)) : '';
+  let core = color ? `${pattern}, ${color}` : pattern;
+  if (!core) core = titleCase(row.pattern_name || '') || row.dw_sku;   // never fall back to mfr_sku in title
+  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);
+  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>`);
+  specs.push(`<li><strong>Sold By:</strong> ${esc(cfg.soldBy)}</li>`);
+  const intro = `${pattern}${color?' in '+color:''} is a designer wallcovering${row.collection?' from the '+esc(row.collection)+' collection':''} by ${vendor}. Priced per ${cfg.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);
+  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.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);
+  const input = {
+    title, handle, vendor, productType: cfg.productType, status: 'DRAFT',  // never ACTIVE on create (Steve rule)
+    tags: [vendor, cfg.productType, `Priced Per ${cfg.soldBy}`, ...(row.collection?[`Collection: ${row.collection}`]:[]), ...(color?[color]:[])],
+    descriptionHtml: `<p>${esc(intro)}</p><ul>${specs.join('')}</ul>`,
+    metafields: mf,
+    productOptions: [{ name:'Title', position:1, values:[{name:cfg.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:'Sample'}], price:SAMPLE_PRICE, sku:`${row.dw_sku}-Sample`, inventoryItem:{sku:`${row.dw_sku}-Sample`,tracked:true}, inventoryPolicy:'CONTINUE', taxable:true },
+    ],
+  };
+  if (row.image_url) input.files = [{ originalSource: row.image_url, contentType:'IMAGE', alt: core }];
+  return { input, title, retail };
+}
+
+const PRODUCTSET = `mutation push($input: ProductSetInput!){productSet(synchronous:true,input:$input){product{id handle status} userErrors{field message}}}`;
+
+async function createProduct(table, row, payload) {
+  // dw_unified FIRST
+  logPriceToDb(table, row.dw_sku, payload.retail);
+  const r = await gqlRetry(PRODUCTSET, { input: payload.input });
+  const ue = r.json?.data?.productSet?.userErrors;
+  if (ue && ue.length) return { ok:false, err: ue };
+  const pid = r.json?.data?.productSet?.product?.id;
+  if (pid) psql(`UPDATE ${table} SET shopify_product_id='${String(pid).replace(/.*\//,'')}' WHERE dw_sku='${row.dw_sku.replace(/'/g,"''")}';`);
+  return { ok:true, pid };
+}
+
+// ---- main ----
+(async () => {
+  if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+  const all = gate();
+  console.log(`\n=== VENDOR GATE (slot=${SLOT}) ===`);
+  for (const r of all) console.log(`  ${r.ready?'✅':'⛔'} ${r.vendor.padEnd(18)} net-new costed=${String(r.netnew).padStart(5)}  ${r.why}`);
+  if (GATE_ONLY) return;
+
+  const ready = all.filter(r => r.ready);
+  if (!ready.length) { console.log('\nNo READY vendors with net-new costed SKUs. Nothing to import.'); return; }
+
+  // rotate
+  const cur = loadCursor();
+  const start = cur.idx % ready.length;
+  const picked = [];
+  for (let i=0;i<Math.min(VENDORS_PER_SLOT, ready.length);i++) picked.push(ready[(start+i)%ready.length]);
+  console.log(`\n=== ${COMMIT?'LIVE COMMIT':'DRY-RUN'} — slot ${SLOT}: ${picked.length} vendor(s) × up to ${SKUS_PER_VENDOR} SKUs ===`);
+
+  const plan = []; let created=0, failed=0;
+  for (const r of picked) {
+    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}):`);
+    for (const row of skus) {
+      const retail = RETAIL(row.cost);
+      const payload = buildInput(r.vendor, r.cfg, row, retail);
+      plan.push({ vendor:r.vendor, dw_sku:row.dw_sku, cost:row.cost, retail, sample:SAMPLE_PRICE, title:payload.title });
+      if (!COMMIT) { console.log(`  · ${row.dw_sku}  cost $${row.cost.toFixed(2)} → roll $${retail}  sample $${SAMPLE_PRICE}  | ${payload.title.slice(0,52)}`); continue; }
+      const res = await createProduct(r.cfg.table, row, payload);
+      if (res.ok) { created++; if(created%10===0) process.stdout.write(`\r  created ${created}…`); }
+      else { failed++; console.log(`  ✗ ${row.dw_sku} ${JSON.stringify(res.err).slice(0,140)}`); }
+    }
+  }
+  // advance rotation
+  saveCursor({ idx: (start + picked.length) % ready.length, lastSlot: SLOT, lastRun: TODAY });
+  const planFile = path.join(DATADIR, `cadence-plan-${SLOT}-${TODAY}.json`);
+  fs.mkdirSync(DATADIR,{recursive:true}); fs.writeFileSync(planFile, JSON.stringify(plan,null,1));
+  console.log(`\n\n${COMMIT?`CREATED ${created}, failed ${failed}`:`DRY-RUN planned ${plan.length} products`} → ${planFile}`);
+  if (!COMMIT) console.log('Re-run with --commit to create these on Shopify (DRAFT status, dw_unified logged first).');
+})();
diff --git a/shopify/scripts/cadence/data/cadence-cursor.json b/shopify/scripts/cadence/data/cadence-cursor.json
new file mode 100644
index 00000000..cce0f09a
--- /dev/null
+++ b/shopify/scripts/cadence/data/cadence-cursor.json
@@ -0,0 +1,5 @@
+{
+ "idx": 0,
+ "lastSlot": "am",
+ "lastRun": "2026-06-11"
+}
\ No newline at end of file
diff --git a/shopify/scripts/cadence/data/cadence-plan-am-2026-06-11.json b/shopify/scripts/cadence/data/cadence-plan-am-2026-06-11.json
new file mode 100644
index 00000000..caaf607b
--- /dev/null
+++ b/shopify/scripts/cadence/data/cadence-plan-am-2026-06-11.json
@@ -0,0 +1,322 @@
+[
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240007",
+  "cost": 425,
+  "retail": 769.23,
+  "sample": "4.25",
+  "title": "Abaca, Pewter Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240013",
+  "cost": 493,
+  "retail": 892.31,
+  "sample": "4.25",
+  "title": "Tipi, Indigo Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240016",
+  "cost": 555,
+  "retail": 1004.52,
+  "sample": "4.25",
+  "title": "Savanna, Bark Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240020",
+  "cost": 432,
+  "retail": 781.9,
+  "sample": "4.25",
+  "title": "Raffia, Gunmetal Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240022",
+  "cost": 493,
+  "retail": 892.31,
+  "sample": "4.25",
+  "title": "Akata, Plaster Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240033",
+  "cost": 345,
+  "retail": 624.43,
+  "sample": "4.25",
+  "title": "Sisal, Chestnut Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240037",
+  "cost": 390,
+  "retail": 705.88,
+  "sample": "4.25",
+  "title": "Seagrass, Metal Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240046",
+  "cost": 458,
+  "retail": 828.96,
+  "sample": "4.25",
+  "title": "Duo Sisal, Anthracite Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240048",
+  "cost": 514,
+  "retail": 930.32,
+  "sample": "4.25",
+  "title": "Kuima, Canvas Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240050",
+  "cost": 514,
+  "retail": 930.32,
+  "sample": "4.25",
+  "title": "Kuima, Salt Marsh Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240058",
+  "cost": 371,
+  "retail": 671.49,
+  "sample": "4.25",
+  "title": "Kami, Anthracite Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240064",
+  "cost": 358,
+  "retail": 647.96,
+  "sample": "4.25",
+  "title": "Papier, Metal Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240081",
+  "cost": 343,
+  "retail": 620.81,
+  "sample": "4.25",
+  "title": "Shifu, Metal Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240085",
+  "cost": 1066,
+  "retail": 1929.41,
+  "sample": "4.25",
+  "title": "Square Cut, Silver Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240093",
+  "cost": 1282,
+  "retail": 2320.36,
+  "sample": "4.25",
+  "title": "Anagram, Chestnut Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240097",
+  "cost": 1110,
+  "retail": 2009.05,
+  "sample": "4.25",
+  "title": "Panorama, Atlantic Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240100",
+  "cost": 1250,
+  "retail": 2262.44,
+  "sample": "4.25",
+  "title": "Grid, Chestnut Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240103",
+  "cost": 904,
+  "retail": 1636.2,
+  "sample": "4.25",
+  "title": "Suna, Stoneware Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240108",
+  "cost": 904,
+  "retail": 1636.2,
+  "sample": "4.25",
+  "title": "Sidestep, Indigo Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Romo",
+  "dw_sku": "DWRM-240113",
+  "cost": 904,
+  "retail": 1636.2,
+  "sample": "4.25",
+  "title": "Washi, Indigo Wallcoverings | Romo"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-70420",
+  "cost": 59.4,
+  "retail": 107.51,
+  "sample": "4.25",
+  "title": "Belgium Linen Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-70680",
+  "cost": 80.1,
+  "retail": 144.98,
+  "sample": "4.25",
+  "title": "Cornwall, Blush Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-70800",
+  "cost": 129.6,
+  "retail": 234.57,
+  "sample": "4.25",
+  "title": "Sachon Basket, Navy Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-70810",
+  "cost": 96.3,
+  "retail": 174.3,
+  "sample": "4.25",
+  "title": "Portage, Light Blue Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-70820",
+  "cost": 118.8,
+  "retail": 215.02,
+  "sample": "4.25",
+  "title": "Sutton, Metallic Gold Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-70880",
+  "cost": 35.1,
+  "retail": 63.53,
+  "sample": "4.25",
+  "title": "Cabo Raffia Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71120",
+  "cost": 69.3,
+  "retail": 125.43,
+  "sample": "4.25",
+  "title": "Taluk Sisal Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71320",
+  "cost": 69.3,
+  "retail": 125.43,
+  "sample": "4.25",
+  "title": "Woolston, Flax Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71340",
+  "cost": 170.1,
+  "retail": 307.87,
+  "sample": "4.25",
+  "title": "Tabacon Abaca, Cream Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71370",
+  "cost": 69.3,
+  "retail": 125.43,
+  "sample": "4.25",
+  "title": "Kendari Grass, Light Grey Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71380",
+  "cost": 69.3,
+  "retail": 125.43,
+  "sample": "4.25",
+  "title": "Taluk Sisal Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71410",
+  "cost": 69.3,
+  "retail": 125.43,
+  "sample": "4.25",
+  "title": "Luta Sisal, Ice Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71490",
+  "cost": 170.1,
+  "retail": 307.87,
+  "sample": "4.25",
+  "title": "Tabacon Abaca, Robin's Egg Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71600",
+  "cost": 69.3,
+  "retail": 125.43,
+  "sample": "4.25",
+  "title": "Painted Desert Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71670",
+  "cost": 129.6,
+  "retail": 234.57,
+  "sample": "4.25",
+  "title": "Morada Bay, Turquoise Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71810",
+  "cost": 49.5,
+  "retail": 89.59,
+  "sample": "4.25",
+  "title": "Baldwin Herringbone Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-71880",
+  "cost": 69.3,
+  "retail": 125.43,
+  "sample": "4.25",
+  "title": "Woolston, Ivory Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-72310",
+  "cost": 44.1,
+  "retail": 79.82,
+  "sample": "4.25",
+  "title": "Colored Blocks Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-72340",
+  "cost": 41.4,
+  "retail": 74.93,
+  "sample": "4.25",
+  "title": "Sunburst, Grey Wallcoverings | Thibaut"
+ },
+ {
+  "vendor": "Thibaut",
+  "dw_sku": "DWTT-72350",
+  "cost": 46.8,
+  "retail": 84.71,
+  "sample": "4.25",
+  "title": "Colored Blocks Wallcoverings | Thibaut"
+ }
+]
\ No newline at end of file
diff --git a/shopify/scripts/cadence/vendors.js b/shopify/scripts/cadence/vendors.js
new file mode 100644
index 00000000..8635ab5a
--- /dev/null
+++ b/shopify/scripts/cadence/vendors.js
@@ -0,0 +1,24 @@
+// Verified READY-vendor config for the daily cadence importer.
+// A vendor is import-eligible ONLY if: it appears here with a verified costExpr,
+// vendor_registry.discount_confirmed=true, AND it has net-new costed SKUs.
+//
+// costExpr = a SQL expression (against the catalog table) that yields the REAL COST.
+//   Our per-roll retail is then computed as cost / 0.65 / 0.85 (fixed, all vendors).
+// Standard field columns assumed unless overridden: dw_sku, mfr_sku, pattern_name,
+//   color_name, collection, width, image_url, product_type, material, roll_length, pattern_repeat.
+//
+// To add a vendor: verify its cost column by inspection, confirm its discount in
+// vendor_registry, add an entry here. NEVER guess a cost column (money).
+
+module.exports = {
+  // direct cost column
+  'Romo':       { table: 'romo_catalog',           costExpr: 'cost',                 soldBy: 'Single Roll', productType: 'Wallcovering' },
+  'Thibaut':    { table: 'thibaut_catalog',         costExpr: 'your_cost',            soldBy: 'Single Roll', productType: 'Wallcovering' },
+  'Osborne & Little': { table: 'osborne_catalog',   costExpr: 'price_trade',          soldBy: 'Single Roll', productType: 'Wallcovering' },
+  'Designtex':  { table: 'designtex_catalog',        costExpr: 'price_trade',          soldBy: 'Single Roll', productType: 'Wallcovering' },
+  'Ralph Lauren': { table: 'rl_catalog',             costExpr: 'cost',                 soldBy: 'Single Roll', productType: 'Wallcovering' },
+  // cost derived from vendor retail × (1 - confirmed trade discount)
+  'York Contract': { table: 'york_contract_catalog', costExpr: 'retail_price * 0.50',  soldBy: 'Single Roll', productType: 'Wallcovering', discountNote: '50% off retail (confirmed)' },
+  // WallQuest: price_retail column actually holds the COST (price_dw = our retail, verified 2026-06-11)
+  'WallQuest':  { table: 'wallquest_catalog',        costExpr: 'price_retail::numeric', soldBy: 'Single Roll', productType: 'Wallcovering' },
+};

← 0f0ded51 Add fix-24h-new-skus.js: title-case + cost/price + price_upd  ·  back to Designer Wallcoverings  ·  cadence: Romo cost basis = total_price_per_roll (DTD-B, incl 950348b5 →