← 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
A shopify/scripts/cadence/add-kravet-roll-variants.js
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 →