← back to Harlequin Pilot Publish
10-rollout-add-single-roll.mjs
108 lines
// PART B: add a real sellable "Single Roll" variant to each LIVE-ACTIVE sample-only Harlequin product.
// Worklist = data/rollout-worklist.csv (dw_sku, mfr_sku, trade, retail) — 403 rows, verified price path
// shopify_products.mfr_sku -> _hq_bridge.mertex_short -> trade -> retail=round(trade/0.65/0.85,2).
// Mirrors the pilot exactly: Single Roll = tracked (inventory_management=shopify) + inventory_policy=continue
// (always sellable). Samples are handled separately (Part A / creation path) and are UNTRACKED.
// IDEMPOTENT: skip if a Single Roll already exists at the correct price. EXCLUDES the 23 pilot DW SKUs.
// REVERSIBLE: records every added variant id into a full restore-map; batched (BATCH=50) with progress.
import fs from 'node:fs';
import path from 'node:path';
import { rest, getProductVariants, findProductByVariantSku } from './lib.mjs';
const APPLY = process.argv.includes('--apply');
const BATCH = Number((process.argv.find(a => a.startsWith('--batch=')) || '').split('=')[1] || 50);
const ONLYARG = (process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1]; // e.g. --only=0-49 (row range)
const PILOT = new Set(JSON.parse(fs.readFileSync(new URL('./data/pilot-23.json', import.meta.url))).map(p => p.dw_sku.toUpperCase()));
let work = fs.readFileSync(new URL('./data/rollout-worklist.csv', import.meta.url), 'utf8')
.trim().split('\n').map(l => { const [dw_sku, mfr_sku, trade, retail] = l.split(','); return { dw_sku, mfr_sku, trade: Number(trade), retail: Number(retail) }; })
.filter(r => !PILOT.has(r.dw_sku.toUpperCase()));
if (ONLYARG) { const [a, b] = ONLYARG.split('-').map(Number); work = work.slice(a, b + 1); }
const RESTORE = process.env.HOME + '/.claude/yolo-queue/executed-reversible/harlequin-full-rollout-restore-map.json';
const LEDGER = process.env.HOME + '/.claude/yolo-queue/executed-reversible/ledger.jsonl';
let restore = { ticket: 'TK-10879', agent: 'vp-dw-commerce', created_at: new Date().toISOString(),
action: 'Part B: add Single Roll variant to LIVE-ACTIVE sample-only Harlequin products', entries: [] };
if (fs.existsSync(RESTORE)) { try { restore = JSON.parse(fs.readFileSync(RESTORE)); if (!restore.entries) restore.entries = []; } catch {} }
const doneSet = new Set(restore.entries.filter(e => e.change === 'ADDED_VARIANT').map(e => e.dw_sku.toUpperCase()));
function save() { fs.mkdirSync(path.dirname(RESTORE), { recursive: true }); fs.writeFileSync(RESTORE, JSON.stringify(restore, null, 2)); }
const RESULTS = new URL('./data/rollout-add-results.json', import.meta.url);
let results = fs.existsSync(RESULTS) ? JSON.parse(fs.readFileSync(RESULTS)) : [];
const resultsByDw = new Map(results.map(r => [r.dw_sku, r]));
function pushResult(r) { resultsByDw.set(r.dw_sku, r); results = [...resultsByDw.values()]; fs.writeFileSync(RESULTS, JSON.stringify(results, null, 2)); }
async function locate(dw) {
let hits = await findProductByVariantSku(dw);
let ex = hits.find(h => (h.sku || '').toUpperCase() === dw.toUpperCase());
if (ex) return ex.product.id;
const sh = await findProductByVariantSku(dw + '-Sample');
ex = sh.find(h => (h.sku || '').toUpperCase() === (dw + '-SAMPLE').toUpperCase());
if (ex) return ex.product.id;
const pre = hits.find(h => (h.sku || '').toUpperCase().startsWith(dw.toUpperCase()));
return pre ? pre.product.id : null;
}
let added = 0, skipExist = 0, skipDone = 0, notFound = 0, errors = 0, notActive = 0, processed = 0;
for (const w of work) {
processed++;
const dw = w.dw_sku; const wantPrice = w.retail.toFixed(2);
if (doneSet.has(dw.toUpperCase())) { skipDone++; process.stderr.write(`${dw}\tSKIP-ALREADY-DONE(restore-map)\n`); continue; }
const gid = await locate(dw);
if (!gid) { notFound++; pushResult({ dw_sku: dw, action: 'SKIP-NOT-FOUND' }); process.stderr.write(`${dw}\tSKIP-NOT-FOUND\n`); continue; }
const prod = await getProductVariants(gid);
if (prod.status !== 'ACTIVE') { notActive++; pushResult({ dw_sku: dw, action: 'STOP-NOT-ACTIVE', status: prod.status }); process.stderr.write(`${dw}\tSTOP-NOT-ACTIVE ${prod.status}\n`); continue; }
const vs = prod.variants.edges.map(e => e.node);
const existingRoll = vs.find(v => (v.sku || '').toUpperCase() === dw.toUpperCase() || (v.title || '').toLowerCase() === 'single roll');
if (existingRoll) {
const priceOk = Number(existingRoll.price).toFixed(2) === wantPrice;
if (priceOk) { skipExist++; pushResult({ dw_sku: dw, action: 'SKIP-EXISTS-OK', variant_id: existingRoll.id, price: existingRoll.price }); process.stderr.write(`${dw}\tSKIP-EXISTS-OK\n`); }
else { errors++; pushResult({ dw_sku: dw, action: 'EXISTS-PRICE-MISMATCH', variant_id: existingRoll.id, price: existingRoll.price, want: wantPrice }); process.stderr.write(`${dw}\tEXISTS-PRICE-MISMATCH ${existingRoll.price}->${wantPrice}\n`); }
continue;
}
if (!APPLY) { pushResult({ dw_sku: dw, action: 'WOULD-ADD', price: wantPrice }); process.stderr.write(`${dw}\tWOULD-ADD Single Roll @ $${wantPrice}\n`); continue; }
const pidNum = gid.split('/').pop();
const body = { variant: { option1: 'Single Roll', sku: dw, price: wantPrice,
inventory_policy: 'continue', inventory_management: 'shopify',
taxable: true, requires_shipping: true, weight: 3.0, weight_unit: 'lb' } };
const r = await rest(`products/${pidNum}/variants.json`, 'POST', body);
if (r.status >= 200 && r.status < 300 && r.json.variant) {
const v = r.json.variant;
restore.entries.push({ dw_sku: dw, product_id: pidNum, product_gid: gid, handle: prod.handle,
variant_id: v.id, variant_gid: `gid://shopify/ProductVariant/${v.id}`,
change: 'ADDED_VARIANT', title: v.title, sku: v.sku, price: v.price, prior_value: null,
undo: `DELETE /products/${pidNum}/variants/${v.id}.json`, created_at: new Date().toISOString() });
save(); doneSet.add(dw.toUpperCase()); added++;
pushResult({ dw_sku: dw, action: 'ADDED', variant_id: v.id, price: v.price });
process.stderr.write(`${dw}\tADDED id=${v.id} @ $${v.price}\n`);
} else {
errors++; pushResult({ dw_sku: dw, action: 'ERROR', status: r.status, body: r.json });
process.stderr.write(`${dw}\tERROR ${r.status} ${JSON.stringify(r.json).slice(0, 160)}\n`);
}
await new Promise(res => setTimeout(res, 600));
if (APPLY && added > 0 && added % BATCH === 0) {
process.stderr.write(`\n--- BATCH CHECKPOINT: ${added} added so far (processed ${processed}/${work.length}) — pausing 90s ---\n`);
await new Promise(res => setTimeout(res, 90000));
}
}
save();
if (APPLY && restore.entries.length) {
fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
fs.appendFileSync(LEDGER, JSON.stringify({ ts: new Date().toISOString(), agent: 'vp-dw-commerce', ticket: 'TK-10879',
action: 'Part B: add Single Roll to LIVE-ACTIVE sample-only Harlequin products',
blast_radius: added, undo_cmd: 'node ~/Projects/harlequin-pilot-publish/rollback.mjs --map full --apply',
verify: 'GET each product; Single Roll @ retail, Sample @ 4.25', restore_map: RESTORE }) + '\n');
}
process.stderr.write(`\n${APPLY ? 'APPLY' : 'DRY-RUN'} DONE — processed ${processed}/${work.length}\n` +
`added=${added} skip-exists-ok=${skipExist} skip-already-done=${skipDone} not-found=${notFound} not-active=${notActive} errors=${errors}\n` +
`restore-map: ${RESTORE}\n`);