← 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.`);