← back to Tk10895 GroupB
reprice-volpi.mjs
101 lines
#!/usr/bin/env node
// TK-10895 — Volpi reprice: 3 LIVE wallpapers priced off the FABRIC token of a fabric/wallpaper pair.
//
// The mill sheet cell reads "$178/$198". Which token is which is PINNED by the sheet itself, not
// inferred: 15 standalone Volpi FABRIC rows carry a CLEAN single "$178.00" (Material "100% linen
// fabric"), while all 14 WALLPAPER rows carry the pair. So token 1 = fabric cost, token 2 = WALLPAPER
// cost. Our three SKUs are Wallpaper (F/W=Wallpaper, 27in) -> correct cost $198, not $178.
//
// This is the MONEY-LOSING direction: live at $322.17 (from $178) vs correct $358.37 (from $198),
// i.e. under by $36.20/roll — 1.63x cost against our 1.81x target.
//
// Dry-run by default. --apply to write. Rollback map written BEFORE any write.
import fs from 'node:fs';
const TOK = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
.split('\n').find(l => l.startsWith('SHOPIFY_ADMIN_TOKEN='))
?.split('=').slice(1).join('=').trim();
if (!TOK) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
const APPLY = process.argv.includes('--apply');
const EV = process.env.HOME + '/.claude/yolo-queue/evidence/TK-10895';
const WALLPAPER_COST = 198; // token 2 — pinned by the 15 clean $178.00 FABRIC rows
const WHOLESALE_MARGIN = 0.65, RETAIL_MARGIN = 0.85;
const WANT = (WALLPAPER_COST / WHOLESALE_MARGIN / RETAIL_MARGIN).toFixed(2); // 358.37
const EXPECT_BEFORE = '322.17'; // what token-1 ($178) produced — refuse anything else
const SELLABLE_FLOOR = 10, PACE_MS = 600;
const TARGETS = ['784821', '784921', '785021'];
async function api(path, opt = {}) {
const r = await fetch(`https://${SHOP}/admin/api/${API}/${path}`, {
...opt,
headers: { 'X-Shopify-Access-Token': TOK, 'Content-Type': 'application/json', ...(opt.headers || {}) },
});
if (!r.ok) throw new Error(`${r.status} ${path} :: ${(await r.text()).slice(0, 200)}`);
return r.json();
}
// resolve pids from the per-SKU evidence CSV rather than hardcoding them.
// NOTE: a naive split(',') is WRONG here — pull_reason is a quoted field containing commas, which
// shifts every column after it. Use a real RFC4180-ish parse.
function parseCsv(txt) {
const rows = []; let row = [], f = '', q = false;
for (let i = 0; i < txt.length; i++) {
const c = txt[i];
if (q) { if (c === '"' && txt[i + 1] === '"') { f += '"'; i++; } else if (c === '"') q = false; else f += c; }
else if (c === '"') q = true;
else if (c === ',') { row.push(f); f = ''; }
else if (c === '\n') { row.push(f); rows.push(row); row = []; f = ''; }
else if (c !== '\r') f += c;
}
if (f || row.length) { row.push(f); rows.push(row); }
const h = rows.shift();
return rows.filter(r => r.length === h.length).map(r => Object.fromEntries(h.map((k, i) => [k, r[i]])));
}
const pidOf = {};
for (const r of parseCsv(fs.readFileSync(EV + '/TrancheB-per-sku-evidence-20260910.csv', 'utf8'))) {
pidOf[r.mfr_sku] = { pid: String(r.shopify_id).split('/').pop(), dw: r.dw_sku };
}
const map = [];
for (const mfr of TARGETS) {
const rec = pidOf[mfr];
if (!rec?.pid) { console.log(` ${mfr} no pid in evidence CSV — skip`); continue; }
const p = (await api(`products/${rec.pid}.json?fields=id,handle,variants`)).product;
const v = p.variants.find(v => parseFloat(v.price) > SELLABLE_FLOOR);
if (!v) { console.log(` ${mfr} ${p.handle} no sellable variant (still sample-only) — skip`); continue; }
if (v.price === WANT) { console.log(` ${mfr} ${p.handle} already $${WANT} — skip (idempotent)`); continue; }
// FAIL CLOSED: only correct the exact known-bad price. Anything else means someone changed it.
if (v.price !== EXPECT_BEFORE) {
console.error(` REFUSING ${mfr} ${p.handle}: live $${v.price}, expected $${EXPECT_BEFORE} — drifted, not touching it`);
continue;
}
map.push({ mfr, dw_sku: rec.dw, pid: rec.pid, handle: p.handle, variant_id: v.id,
title: v.title, price_before: v.price, price_after: WANT, cost_before: 178, cost_after: WALLPAPER_COST });
console.log(` ${mfr} ${p.handle.padEnd(14)} $${v.price} -> $${WANT} (cost $178 fabric-token -> $198 wallpaper)`);
}
if (!map.length) { console.log('\nnothing to do.'); process.exit(0); }
fs.writeFileSync(EV + '/volpi-reprice-rollback-20260910.json', JSON.stringify(map, null, 1));
console.log(`\nrollback map -> ${EV}/volpi-reprice-rollback-20260910.json (${map.length})`);
if (!APPLY) { console.log('\nDRY RUN — nothing written. Re-run with --apply.'); process.exit(0); }
let ok = 0, fail = 0;
for (const m of map) {
try {
const r = await api(`variants/${m.variant_id}.json`, {
method: 'PUT', body: JSON.stringify({ variant: { id: m.variant_id, price: m.price_after } }),
});
if (r.variant.price !== m.price_after) throw new Error(`read-back ${r.variant.price} != ${m.price_after}`);
console.log(` OK ${m.mfr} ${m.handle} now $${r.variant.price}`); ok++;
} catch (e) { console.log(` FAIL ${m.mfr} ${m.handle} :: ${e.message}`); fail++; }
await new Promise(r => setTimeout(r, PACE_MS));
}
if (fail) process.exitCode = 1;
console.log(`\napplied=${ok} failed=${fail}`);
console.log('Verify from the STOREFRONT after ~30s, never the Admin API (its variant order is stale).');