← back to Dw Yolo Loop
Kravet MAP push tool: roll-variant-only price=MAP, dry-run gate + anomaly guard
6f4cc868e91c439073353c5655abc59d853e1b7a · 2026-06-15 09:10:17 -0700 · Steve Abrams
Dry-run over 6,341 resolved: 2,665 already at MAP, 998 to change (632up/366down),
267 anomalies held (e.g. colorway-collapse), 2,411 sample-only skipped. Sample variant
never touched. LIVE requires --apply --i-am-steve.
Files touched
A scripts/kravet-cost/push-map-prices.mjs
Diff
commit 6f4cc868e91c439073353c5655abc59d853e1b7a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 15 09:10:17 2026 -0700
Kravet MAP push tool: roll-variant-only price=MAP, dry-run gate + anomaly guard
Dry-run over 6,341 resolved: 2,665 already at MAP, 998 to change (632up/366down),
267 anomalies held (e.g. colorway-collapse), 2,411 sample-only skipped. Sample variant
never touched. LIVE requires --apply --i-am-steve.
---
scripts/kravet-cost/push-map-prices.mjs | 115 ++++++++++++++++++++++++++++++++
1 file changed, 115 insertions(+)
diff --git a/scripts/kravet-cost/push-map-prices.mjs b/scripts/kravet-cost/push-map-prices.mjs
new file mode 100644
index 0000000..e52be4e
--- /dev/null
+++ b/scripts/kravet-cost/push-map-prices.mjs
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+/**
+ * push-map-prices.mjs — set the ROLL variant price = MAP for resolved Kravet-family
+ * products on Shopify. DRY-RUN by default; LIVE requires --apply --i-am-steve.
+ *
+ * SAFETY RAILS:
+ * - Only ever updates the ROLL (non -sample) variant. The $4.25 memo SAMPLE is never touched.
+ * - Skips products already at MAP (idempotent).
+ * - Anomaly guard: if the CURRENT roll price is a normal price (>$10) but MAP differs by
+ * >2.5x either way, that smells like a SKU-mismatch -> FLAG + SKIP (never auto-push).
+ * - If current roll price is garbage (<=$10, i.e. $0 or the $4.25 sample-trap), MAP is a FIX -> allowed.
+ * - No deletes, no archive, no sample edits, no other field. Reversible (re-set price).
+ *
+ * USAGE:
+ * node push-map-prices.mjs # dry-run (default)
+ * node push-map-prices.mjs --apply --i-am-steve # LIVE
+ * [--limit=N] [--vendor="Kravet"]
+ */
+import fs from 'node:fs';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
+const TOKEN = (fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1]?.trim();
+if (!TOKEN) { console.error('no token'); process.exit(1); }
+const URL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
+const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v === undefined ? true : v]; }));
+const APPLY = args.apply === true && args['i-am-steve'] === true;
+const LIMIT = args.limit ? parseInt(args.limit, 10) : Infinity;
+const VENDOR = args.vendor || null;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function gql(q, v) {
+ for (let a = 0; a < 8; a++) {
+ let j; try { const r = await fetch(URL, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) }); j = await r.json(); }
+ catch (e) { await sleep(1500 * (a + 1)); continue; }
+ if (j.errors) { if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; } throw new Error(JSON.stringify(j.errors)); }
+ const t = j.extensions?.cost?.throttleStatus; if (t && t.currentlyAvailable < 400) await sleep(1200);
+ return j.data;
+ }
+ throw new Error('retries');
+}
+const norm = s => String(s == null ? '' : s).toUpperCase().trim();
+const isSample = v => /(sample|memo|swatch)/i.test([v.title, v.sku].join(' ')) || /-sample$/i.test(v.sku || '');
+
+// 1) MAP by mfr_sku
+const mapBy = new Map();
+for (const line of fs.readFileSync('data/kravet-cost/MAP-resolved-consolidated.csv', 'utf8').trim().split('\n').slice(1)) {
+ const [vendor, mfr, whls, map] = line.split(',');
+ const m = parseFloat(map); if (m > 0) mapBy.set(norm(mfr), { vendor, map: m });
+}
+// 2) gid -> mfr (active Kravet-family)
+let targets = [];
+for (const line of fs.readFileSync('/tmp/kf_gid_mfr.csv', 'utf8').trim().split('\n')) {
+ const [gidNum, mfr] = line.split(',');
+ const hit = mapBy.get(norm(mfr));
+ if (hit && (!VENDOR || hit.vendor === VENDOR)) targets.push({ gid: `gid://shopify/Product/${gidNum}`, mfr, map: hit.map });
+}
+// de-dup by gid (one product = one roll)
+const seen = new Set(); targets = targets.filter(t => seen.has(t.gid) ? false : seen.add(t.gid));
+if (Number.isFinite(LIMIT)) targets = targets.slice(0, LIMIT);
+
+console.log(`push-map-prices — mode: ${APPLY ? '⚠️ LIVE APPLY' : 'DRY-RUN'}${VENDOR ? ` | vendor=${VENDOR}` : ''}`);
+console.log(`resolved MAP targets: ${targets.length}\n`);
+
+const Q = `query($ids:[ID!]!){ nodes(ids:$ids){ ... on Product { id variants(first:30){ nodes { id title sku price } } } } }`;
+const M = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){ productVariantsBulkUpdate(productId:$pid, variants:$variants){ userErrors{ field message } } }`;
+
+const buckets = { update: [], already_ok: 0, fix_bad_price: [], anomaly: [], no_roll: [] };
+for (let i = 0; i < targets.length; i += 50) {
+ const batch = targets.slice(i, i + 50);
+ const data = await gql(Q, { ids: batch.map(b => b.gid) });
+ const byId = new Map(batch.map(b => [b.gid, b]));
+ for (const p of data.nodes) {
+ if (!p) continue;
+ const tgt = byId.get(p.id);
+ const rolls = p.variants.nodes.filter(v => !isSample(v));
+ if (!rolls.length) { buckets.no_roll.push(tgt); continue; }
+ const roll = rolls.reduce((a, b) => parseFloat(b.price) > parseFloat(a.price) ? b : a);
+ const cur = parseFloat(roll.price);
+ const map = tgt.map;
+ const rec = { pid: p.id, vid: roll.id, mfr: tgt.mfr, cur, map };
+ if (Math.abs(cur - map) < 0.01) { buckets.already_ok++; continue; }
+ if (cur <= 10) { buckets.fix_bad_price.push(rec); } // $0/$4.25 garbage -> MAP is a fix
+ else if (map / cur > 2.5 || map / cur < 0.4) { buckets.anomaly.push(rec); } // suspicious -> skip
+ else { buckets.update.push(rec); }
+ }
+ if (i % 500 === 0 && i) process.stderr.write(` scanned ${i}/${targets.length}\n`);
+}
+
+const toWrite = [...buckets.update, ...buckets.fix_bad_price];
+const up = toWrite.filter(r => r.map > r.cur).length, down = toWrite.filter(r => r.map < r.cur).length;
+console.log('DRY-RUN RESULT:');
+console.log(` already at MAP (skip) : ${buckets.already_ok}`);
+console.log(` normal price change : ${buckets.update.length} (↑${buckets.update.filter(r=>r.map>r.cur).length} ↓${buckets.update.filter(r=>r.map<r.cur).length})`);
+console.log(` fix garbage roll ($0/$4.25): ${buckets.fix_bad_price.length}`);
+console.log(` ⚠ anomaly (skipped) : ${buckets.anomaly.length}`);
+console.log(` ⚠ no roll variant (skipped): ${buckets.no_roll.length}`);
+console.log(` => WOULD WRITE : ${toWrite.length} (↑${up} ↓${down})`);
+console.log('\n sample changes:');
+for (const r of buckets.update.slice(0, 6)) console.log(` ${r.mfr}: $${r.cur} -> $${r.map}`);
+if (buckets.anomaly.length) { console.log('\n ⚠ anomalies (NOT pushed — review):'); for (const r of buckets.anomaly.slice(0, 10)) console.log(` ${r.mfr}: cur $${r.cur} vs MAP $${r.map}`); }
+fs.writeFileSync('data/kravet-cost/price-push-plan.json', JSON.stringify({ generated: 'dry-run', toWrite, anomaly: buckets.anomaly, no_roll: buckets.no_roll }, null, 2));
+
+if (!APPLY) { console.log('\nDRY-RUN only. To execute: node push-map-prices.mjs --apply --i-am-steve'); process.exit(0); }
+
+console.log('\n⚠️ LIVE APPLY — updating ROLL variant prices…');
+let done = 0, err = 0;
+for (const r of toWrite) {
+ try {
+ const d = await gql(M, { pid: r.pid, variants: [{ id: r.vid, price: r.map.toFixed(2) }] });
+ const ue = d.productVariantsBulkUpdate?.userErrors || [];
+ if (ue.length) { err++; if (err <= 10) console.log(' err', r.mfr, JSON.stringify(ue)); } else done++;
+ } catch (e) { err++; if (err <= 10) console.log(' EX', r.mfr, e.message); }
+ if ((done + err) % 200 === 0) console.log(` progress ${done} ok / ${err} err`);
+}
+console.log(`\nDONE — ${done} roll prices set to MAP, ${err} errors.`);
← 2dae150 Kravet cost campaign: 6,373/8,317 (77%) MAP-resolved from fr
·
back to Dw Yolo Loop
·
Kravet MAP revert tool (DTD verdict B): restore the 448 writ 182a368 →