← 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));
})();