← back to Kravet Wrongjoin Reprice 2026 07

apply-followup-reprice.js

150 lines

#!/usr/bin/env node
/**
 * apply-followup-reprice.js — TK-00065, Steve APPROVED 2026-07-26
 * Reprices the 63 followup drift rows (DWKK-101532 ABOVE_MAP already STRIPPED
 * from FOLLOWUP-reprice-authoritative.csv) to true MAP.
 *
 * Design fixes for the DTD REVISE(B) findings on the old FOLLOWUP CSV:
 *  - CSV now carries gid + main_variant_id + global_Item + resolved authoritative MAP.
 *  - The over-MAP row is stripped (never lowered).
 *  - Guard does NOT re-baseline to a stale "wrong price"; it RE-DERIVES MAP live from
 *    kravet_authoritative_pricing (priority-1) keyed by the LIVE global.Item, then writes
 *    ONLY when the live price is still strictly below that MAP. A row that someone fixed
 *    since the audit (now at/above MAP) is skipped, not clobbered.
 *
 * Guards:
 *  - product must be ACTIVE and its LIVE global.Item must equal the CSV global_Item
 *  - MAP is re-resolved live from the authoritative table on that global.Item; if it no
 *    longer resolves (NO_RESOLVABLE_MAP) the row is SKIPPED + logged (never guessed)
 *  - write only if live < resolved_map (strictly below); at/above -> skip
 *  - Sample variants ($4.25) and product status never touched
 *  - refresh global.MAP metafield to resolved_map
 *  - ≥90s gap every 50 products, throttle-aware retry
 *
 * DRY_RUN=1 for a no-write pass. Log: run-log-followup-<ts>.jsonl
 */
const https = require('https');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execSync } = require('child_process');

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';
// SAFE-BY-DEFAULT (2026-07-26, post gate-bypass audit): dry unless caller sets APPLY_CONFIRM=YES.
// Prevents any automated/accidental invocation from writing live prices (the 07-15 + 07-26 bypass vector).
const DRY = process.env.APPLY_CONFIRM !== 'YES';
const sleep = ms => new Promise(r => setTimeout(r, ms));

function gql(query, variables = {}) {
  return new Promise((resolve, reject) => {
    const body = JSON.stringify({ query, variables });
    const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) } },
      r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(new Error('bad json: ' + d.slice(0, 200))); } }); });
    req.on('error', reject); req.write(body); req.end();
  });
}
async function gqlR(q, v, tries = 5) {
  for (let i = 0; i < tries; i++) {
    let r; try { r = await gql(q, v); } catch (e) { r = { errors: [{ message: String(e).slice(0, 200) }] }; }
    if (r && !r.errors) return r;
    const throttled = JSON.stringify(r.errors || []).match(/throttl/i);
    if (!throttled && i >= 1) return r;
    await sleep(2000 * (i + 1));
  }
  return gql(q, v);
}
function splitCsvLine(line) {
  const out = []; let cur = ''; let inQ = false;
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if (inQ) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; } else cur += ch; }
    else if (ch === '"') inQ = true;
    else if (ch === ',') { out.push(cur); cur = ''; }
    else cur += ch;
  }
  out.push(cur); return out;
}
function authMap(item) {
  if (!item) return null;
  const norm = item.replace(/'/g, "''");
  const r = execSync(`psql -h /tmp -d dw_unified -tAc ${JSON.stringify(`select new_map from kravet_authoritative_pricing where mfr_sku='${norm}' limit 1`)}`, { encoding: 'utf8' }).trim();
  return r ? parseFloat(r) : null;
}

const Q_PRODUCT = `query($id:ID!){product(id:$id){id status handle
  variants(first:30){nodes{id title sku price}}
  metafield(namespace:"global",key:"Item"){value}}}`;
const M_PRICE = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
  productVariantsBulkUpdate(productId:$pid,variants:$variants){
    productVariants{id price} userErrors{field message}}}`;
const M_MAP = `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{field message}}}`;

(async () => {
  const csv = fs.readFileSync(path.join(__dirname, 'FOLLOWUP-reprice-authoritative.csv'), 'utf8').trim().split('\n');
  const rows = csv.slice(1).map(splitCsvLine).map(v => ({
    dw_sku: v[0], gid: v[1], handle: v[2], global_Item: v[3],
    audit_live: parseFloat(v[4]), main_variant_id: v[5], resolved_map: parseFloat(v[6]),
  }));
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
  const logPath = path.join(__dirname, `run-log-followup-${ts}.jsonl`);
  const log = o => fs.appendFileSync(logPath, JSON.stringify(o) + '\n');
  const near = (a, b) => Math.abs(a - b) <= 0.011;

  let updated = 0, skipAlready = 0, skipAbove = 0, skipGuard = 0, skipNoMap = 0, errors = 0, mfOk = 0, mfErr = 0;
  console.log(`${DRY ? 'DRY RUN' : 'LIVE'} — ${rows.length} followup rows -> ${logPath}`);
  log({ action: 'START', dry: DRY, n: rows.length, ts });

  for (let i = 0; i < rows.length; i++) {
    const r = rows[i];
    if (!DRY && i > 0 && i % 50 === 0) { console.log(`batch gap after ${i} (updated ${updated})…`); await sleep(90000); }

    const res = await gqlR(Q_PRODUCT, { id: r.gid });
    const p = res && res.data && res.data.product;
    if (!p) { errors++; log({ dw_sku: r.dw_sku, action: 'error', note: 'fetch failed', raw: JSON.stringify(res && res.errors).slice(0, 200) }); continue; }
    if (p.status !== 'ACTIVE') { skipGuard++; log({ dw_sku: r.dw_sku, action: 'skip_guard', note: `status ${p.status}` }); continue; }
    const item = p.metafield && p.metafield.value;
    if (item !== r.global_Item) { skipGuard++; log({ dw_sku: r.dw_sku, action: 'skip_guard', note: `live global.Item "${item}" != CSV "${r.global_Item}"` }); continue; }

    const main = p.variants.nodes.find(v => !/sample/i.test(v.title || '') && !/sample/i.test(v.sku || ''));
    if (!main) { skipGuard++; log({ dw_sku: r.dw_sku, action: 'skip_guard', note: 'no main variant' }); continue; }
    const live = parseFloat(main.price);

    // RE-DERIVE MAP live from authoritative table on the confirmed global.Item (do not trust stale CSV)
    const map = authMap(item);
    if (map == null) { skipNoMap++; log({ dw_sku: r.dw_sku, action: 'skip_no_map', item, note: 'no authoritative MAP' }); continue; }
    if (near(live, map)) { skipAlready++; log({ dw_sku: r.dw_sku, action: 'skip_already', live, map }); continue; }
    if (live > map) { skipAbove++; log({ dw_sku: r.dw_sku, action: 'skip_above_map', live, map }); continue; }
    // live < map -> reprice up to MAP

    if (DRY) { updated++; log({ dw_sku: r.dw_sku, action: 'would_update', from: live, to: map, item }); await sleep(120); continue; }

    const w = await gqlR(M_PRICE, { pid: p.id, variants: [{ id: main.id, price: map.toFixed(2) }] });
    const bup = w && w.data && w.data.productVariantsBulkUpdate;
    const ue = bup && bup.userErrors;
    const got = bup && bup.productVariants;
    if ((ue && ue.length) || !got || !got.length) {
      errors++; log({ dw_sku: r.dw_sku, action: 'error', note: 'price write failed', ue: JSON.stringify(ue).slice(0, 200) }); continue;
    }
    updated++;
    log({ dw_sku: r.dw_sku, handle: p.handle, action: 'updated', from: live, to: parseFloat(got[0].price), variant: main.id, item });

    let m = await gqlR(M_MAP, { mf: [{ ownerId: p.id, namespace: 'global', key: 'MAP', value: String(map) }] });
    let mue = m && m.data && m.data.metafieldsSet && m.data.metafieldsSet.userErrors;
    if (mue && mue.length) {
      m = await gqlR(M_MAP, { mf: [{ ownerId: p.id, namespace: 'global', key: 'MAP', value: String(map), type: 'single_line_text_field' }] });
      mue = m && m.data && m.data.metafieldsSet && m.data.metafieldsSet.userErrors;
    }
    if (mue && mue.length) { mfErr++; log({ dw_sku: r.dw_sku, action: 'map_metafield_error', ue: JSON.stringify(mue).slice(0, 200) }); }
    else mfOk++;

    await sleep(350);
  }

  const summary = { total: rows.length, updated, skipAlready, skipAbove, skipGuard, skipNoMap, errors, mapMetafieldOk: mfOk, mapMetafieldErr: mfErr, dry: DRY, log: logPath };
  log({ action: 'SUMMARY', ...summary });
  console.log('SUMMARY ' + JSON.stringify(summary));
})();