[object Object]

← back to Designer Wallcoverings

Add Kravet roll-variant adder: idempotent, MAP-priced, audited (Shopify daily variant cap paces it)

4e4cff4ebeeb9cb6460965f67fae64bd8932760c · 2026-06-11 16:02:38 -0700 · SteveStudio2

Files touched

Diff

commit 4e4cff4ebeeb9cb6460965f67fae64bd8932760c
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu Jun 11 16:02:38 2026 -0700

    Add Kravet roll-variant adder: idempotent, MAP-priced, audited (Shopify daily variant cap paces it)
---
 .../scripts/cadence/add-kravet-roll-variants.js    | 171 +++++++++++++++++++++
 1 file changed, 171 insertions(+)

diff --git a/shopify/scripts/cadence/add-kravet-roll-variants.js b/shopify/scripts/cadence/add-kravet-roll-variants.js
new file mode 100644
index 00000000..8b21d1a0
--- /dev/null
+++ b/shopify/scripts/cadence/add-kravet-roll-variants.js
@@ -0,0 +1,171 @@
+#!/usr/bin/env node
+/**
+ * add-kravet-roll-variants.js — make sample-only Kravet-family WALLPAPER products sellable
+ * by the roll, by adding a "Sold Per Single Roll" variant priced at MAP.
+ *
+ * DW model (verified 2026-06-11 against the Acacia/Cole&Son template product): one product,
+ * option "Size"/"Title", TWO variants — "Sample" ($4.25) + "Sold Per Single Roll - {W}In Wide"
+ * at the real price. The Kravet-family wallpapers mostly have only the Sample variant; this adds
+ * the missing roll variant.
+ *
+ * PRICE = MAP, across the board for all Kravet lines (Steve, 2026-06-11). MAP source priority:
+ *   1) kravet_master_price.map_price   (explicit MAP from GDrive master)
+ *   2) kravet_catalog.dw_sell_price    (already MAP-floored)
+ *   3) kravet_master_price.whls_cost * 1.5  /  kravet_catalog.cost_price * 1.5  (MAP = cost×1.5)
+ * No MAP source  ->  skipped (never guessed).  See CLAUDE.md "Kravet-family lines price at MAP".
+ *
+ * SAFE / reversible:
+ *   - DRY-RUN default. --commit writes via productVariantsBulkCreate.
+ *   - IDEMPOTENT: fetches the live product first; if it already has a non-sample (roll) variant,
+ *     SKIPS it. Re-running never double-adds.
+ *   - every created variant recorded in kravet_roll_add_audit (rollback = delete that variant id).
+ *   - velocity cap --limit N (default 25); kill switch ~/.dw-fixer-stop; MAP sanity 5..50000.
+ *
+ * Usage:
+ *   node add-kravet-roll-variants.js                      # DRY-RUN, all candidates
+ *   node add-kravet-roll-variants.js --only <shopify_id>  # one product (numeric id ok)
+ *   node add-kravet-roll-variants.js --limit 10 --commit  # LIVE, capped at 10
+ */
+const https = require('https');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+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 ONLY = val('--only', null);
+const LIMIT = parseInt(val('--limit', '25'), 10);
+const SANE_MIN = 5, SANE_MAX = 50000, SAMPLE = 4.25;
+const KILL = path.join(os.homedir(), '.dw-fixer-stop');
+
+const psql = sql => execFileSync('psql', ['-d', 'dw_unified', '-At', '-F', '\t', '-c', sql],
+  { encoding: 'utf8', maxBuffer: 1 << 28 }).trim();
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+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';
+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(600); return r;
+  }
+  return { status: 429, raw: 'throttled' };
+}
+const Q_PRODUCT = `query($id: ID!){ product(id:$id){ id title tags
+  options{ name values }
+  variants(first:25){ nodes{ id title sku price selectedOptions{ name value } } } } }`;
+const MUT_CREATE = `mutation($pid: ID!, $variants: [ProductVariantsBulkInput!]!){
+  productVariantsBulkCreate(productId:$pid, variants:$variants){
+    productVariants{ id title sku price } userErrors{ field message } } }`;
+
+function ensureAudit() {
+  psql(`CREATE TABLE IF NOT EXISTS kravet_roll_add_audit (
+    id serial PRIMARY KEY, shopify_id text, mfr_sku text, roll_sku text, option_name text,
+    option_value text, map_price numeric, map_source text, created_variant_id text,
+    committed boolean, note text, ts timestamptz DEFAULT now());`);
+}
+
+// candidate products from the mirror: Kravet-family wallcovering, MAP-priceable, sample-bearing
+function candidates() {
+  const onlyClause = ONLY
+    ? `AND s.shopify_id = '${ONLY.startsWith('gid://') ? ONLY : 'gid://shopify/Product/' + ONLY}'`
+    : '';
+  const sql = `
+    WITH base AS (
+      SELECT DISTINCT s.shopify_id, s.mfr_sku, s.title, s.variant_sku,
+             COALESCE(km.map_price, kc.dw_sell_price, km.whls_cost*1.5, kc.cost_price*1.5) AS map,
+             CASE WHEN km.map_price>0 THEN 'master_map'
+                  WHEN kc.dw_sell_price>0 THEN 'catalog_sell'
+                  WHEN km.whls_cost>0 THEN 'master_cost*1.5'
+                  WHEN kc.cost_price>0 THEN 'catalog_cost*1.5' END AS map_src
+      FROM shopify_products s
+      LEFT JOIN kravet_catalog kc ON upper(trim(kc.mfr_sku))=upper(trim(s.mfr_sku))
+      LEFT JOIN kravet_master_price km ON upper(trim(km.mfr_sku))=upper(trim(s.mfr_sku))
+      WHERE s.vendor ~* 'kravet|lee ?jofa|groundworks|brunschwig|baker|mulberry|threads|clarke|cole|gp ?& ?j|colefax|nicolette'
+        AND s.product_type ~* 'wallcover'
+        AND s.mfr_sku IS NOT NULL AND s.mfr_sku <> ''
+        AND s.variant_sku ILIKE '%sample%'
+        ${onlyClause})
+    SELECT shopify_id, mfr_sku, title, variant_sku, map, map_src
+      FROM base
+     WHERE map > ${SANE_MIN} AND map < ${SANE_MAX}
+     ORDER BY shopify_id;`;
+  const out = psql(sql);
+  if (!out) return [];
+  return out.split('\n').map(l => {
+    const [shopify_id, mfr_sku, title, variant_sku, map, map_src] = l.split('\t');
+    return { shopify_id, mfr_sku, title, variant_sku, map: Math.round(parseFloat(map) * 100) / 100, map_src };
+  });
+}
+
+// roll option value, matching DW convention "Sold Per Single Roll - {W}In Wide"
+function rollLabel(tags) {
+  const m = (tags || '').match(/(\d+(?:\.\d+)?)\s*in/i);
+  return m ? `Sold Per Single Roll - ${m[1]}In Wide` : 'Sold Per Single Roll';
+}
+
+(async () => {
+  if (COMMIT && fs.existsSync(KILL)) { console.log(`KILL SWITCH present (${KILL}) — aborting.`); return; }
+  ensureAudit();
+  const cands = candidates();
+  console.log(`MAP-priceable sample-only candidates (from mirror): ${cands.length}`);
+  const bySrc = {}; cands.forEach(c => bySrc[c.map_src] = (bySrc[c.map_src] || 0) + 1);
+  console.log('  map source:', JSON.stringify(bySrc));
+
+  const todo = COMMIT ? cands.slice(0, LIMIT) : cands.slice(0, Math.min(cands.length, 40));
+  console.log(`\n${COMMIT ? 'LIVE' : 'DRY-RUN'}: examining ${todo.length}${COMMIT ? ` (cap --limit ${LIMIT})` : ' (sample for preview)'}...\n`);
+
+  let added = 0, already = 0, skip = 0, fail = 0;
+  for (const c of todo) {
+    if (COMMIT && fs.existsSync(KILL)) { console.log('KILL SWITCH — stopping.'); break; }
+    const pr = await gqlRetry(Q_PRODUCT, { id: c.shopify_id });
+    const p = pr.json && pr.json.data && pr.json.data.product;
+    if (!p) { fail++; console.log(`  ✗ ${c.mfr_sku} product fetch failed`); continue; }
+    const variants = p.variants.nodes;
+    const hasRoll = variants.some(v => !/sample/i.test(v.sku || '') && !/^sample$/i.test(v.title || ''));
+    if (hasRoll) { already++; continue; }   // idempotent: roll already exists
+    const optName = (p.options[0] && p.options[0].name) || 'Title';
+    const label = rollLabel((p.tags || []).join(' '));
+    const rollSku = (c.variant_sku || '').replace(/-sample$/i, '');
+    const price = c.map.toFixed(2);
+    const variantInput = [{
+      price,
+      inventoryPolicy: 'CONTINUE',
+      inventoryItem: { sku: rollSku, tracked: true },
+      optionValues: [{ optionName: optName, name: label }],
+    }];
+
+    if (!COMMIT) {
+      console.log(`  + [${c.map_src}] ${c.mfr_sku}  add "${label}" @ $${price}  opt:${optName}  sku:${rollSku}  ${(c.title||'').slice(0,32)}`);
+      added++; continue;
+    }
+    const res = await gqlRetry(MUT_CREATE, { pid: c.shopify_id, variants: variantInput });
+    const r = res.json && res.json.data && res.json.data.productVariantsBulkCreate;
+    const ue = r && r.userErrors;
+    const good = res.status === 200 && ue && ue.length === 0 && r.productVariants && r.productVariants.length;
+    const vid = good ? r.productVariants[0].id : null;
+    psql(`INSERT INTO kravet_roll_add_audit(shopify_id,mfr_sku,roll_sku,option_name,option_value,map_price,map_source,created_variant_id,committed,note)
+      VALUES ('${c.shopify_id}','${(c.mfr_sku||'').replace(/'/g,"''")}','${rollSku.replace(/'/g,"''")}','${optName.replace(/'/g,"''")}','${label.replace(/'/g,"''")}',${c.map},'${c.map_src}',${vid?`'${vid}'`:'NULL'},${good},${good?'NULL':`'${JSON.stringify(ue||res.raw||res.err).slice(0,150).replace(/'/g,"''")}'`});`);
+    if (good) { added++; console.log(`  ✓ ${c.mfr_sku}  +${label} @ $${price}`); }
+    else { fail++; console.log(`  ✗ ${c.mfr_sku} ${JSON.stringify(ue || res.raw || res.err).slice(0,150)}`); }
+  }
+  console.log(`\n${COMMIT ? 'DONE' : 'PREVIEW'}: ${added} ${COMMIT ? 'added' : 'would add'}, ${already} already-have-roll, ${fail} failed.`);
+  if (COMMIT) console.log('Audit + rollback ids in dw_unified.kravet_roll_add_audit.');
+  else console.log(`\nDRY-RUN — no writes. Re-run with --commit (--limit ${LIMIT}) to add roll variants.`);
+})();

← 5ba2c387 Kravet master price loader: WHLS cost + MAP from GDrive mast  ·  back to Designer Wallcoverings  ·  Durable launchd morning report for Kravet roll-variant runne c5d370e1 →