← back to Dw Yolo Loop
scripts/kravet-cost/push-map-prices.mjs
137 lines
#!/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 || '');
// UNIT GATE (root cause 2026-06-15): the Kravet MAP table mixes YARD (fabric) and ROLL
// (wallcovering) units; MAP is per-unit. Only ROLL items may have MAP pushed to a roll
// price. YARD/EACH/etc are excluded (per-yard fabric MAP on a roll = ~3x underpricing).
const UNIT = new Map();
try {
for (const line of fs.readFileSync('data/kravet-cost/unit-map.csv', 'utf8').trim().split('\n')) {
const [item, u] = line.split(','); if (item) UNIT.set(item.trim(), (u || '').trim());
}
} catch (e) { console.error('FATAL: unit-map.csv missing — run the unit export first'); process.exit(1); }
const unitOf = mfr => UNIT.get(norm(mfr)) || '(unknown)';
// 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));
// UNIT GATE: keep only ROLL items; tally what we drop and why.
const unitDrop = {};
const before = targets.length;
targets = targets.filter(t => {
const u = unitOf(t.mfr);
if (u === 'ROLL') return true;
unitDrop[u] = (unitDrop[u] || 0) + 1; return false;
});
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(`unit gate: ${before} resolved -> ${targets.length} ROLL (excluded non-ROLL: ${JSON.stringify(unitDrop)})`);
console.log(`resolved ROLL 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.`);