← back to Harlequin Pilot Publish
02-add-single-roll.mjs
92 lines
// Idempotent: add a real "Single Roll" variant at computed retail to each pilot product.
// Sample variant already exists (verified) => left untouched.
// Records a restore-map (created variant ids) BEFORE completion for reversibility.
import fs from 'node:fs';
import path from 'node:path';
import { rest, getProductVariants } from './lib.mjs';
const APPLY = process.argv.includes('--apply');
const located = JSON.parse(fs.readFileSync(new URL('./data/located.json', import.meta.url)));
const RESTORE_MAP_PATH = process.env.HOME + '/.claude/yolo-queue/executed-reversible/harlequin-pilot-restore-map.json';
let restore = { ticket: 'TK-10876', agent: 'vp-dw-commerce', created_at: new Date().toISOString(),
action: 'harlequin-pilot: add Single Roll variant to 23 Diane Hill products', entries: [] };
if (fs.existsSync(RESTORE_MAP_PATH)) {
try { restore = JSON.parse(fs.readFileSync(RESTORE_MAP_PATH)); } catch {}
if (!restore.entries) restore.entries = [];
}
function saveRestore() {
fs.mkdirSync(path.dirname(RESTORE_MAP_PATH), { recursive: true });
fs.writeFileSync(RESTORE_MAP_PATH, JSON.stringify(restore, null, 2));
}
const results = [];
for (const p of located) {
if (!p.productGid) { results.push({ ...p, action: 'SKIP-NOT-FOUND' }); continue; }
const pidNum = p.productGid.split('/').pop();
// Re-read current variants FRESH (idempotency source of truth)
const prod = await getProductVariants(p.productGid);
const variants = prod.variants.edges.map(e => e.node);
const wantSku = p.dw_sku; // Single Roll base sku
const wantPrice = Number(p.retail).toFixed(2);
const existingRoll = variants.find(v =>
(v.sku || '').toUpperCase() === wantSku.toUpperCase() ||
(v.title || '').toLowerCase() === 'single roll');
if (existingRoll) {
const priceOk = Number(existingRoll.price).toFixed(2) === wantPrice;
results.push({ dw_sku: p.dw_sku, action: priceOk ? 'SKIP-EXISTS-OK' : 'EXISTS-PRICE-MISMATCH',
variant_id: existingRoll.id, price: existingRoll.price, want: wantPrice });
process.stderr.write(`${p.dw_sku}\t${priceOk ? 'SKIP-EXISTS-OK' : 'PRICE-MISMATCH ' + existingRoll.price + '->' + wantPrice}\n`);
continue;
}
if (!APPLY) {
results.push({ dw_sku: p.dw_sku, action: 'WOULD-ADD', price: wantPrice, option: 'Size=Single Roll' });
process.stderr.write(`${p.dw_sku}\tWOULD-ADD Single Roll @ $${wantPrice}\n`);
continue;
}
// ADD via REST — mirror the Sample variant's inventory settings (tracked + continue = always sellable)
const body = { variant: {
option1: 'Single Roll',
sku: wantSku,
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: p.dw_sku, product_id: pidNum, product_gid: p.productGid, handle: p.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(),
});
saveRestore(); // persist after EACH add (crash-safe reversibility)
results.push({ dw_sku: p.dw_sku, action: 'ADDED', variant_id: v.id, price: v.price });
process.stderr.write(`${p.dw_sku}\tADDED Single Roll id=${v.id} @ $${v.price}\n`);
} else {
results.push({ dw_sku: p.dw_sku, action: 'ERROR', status: r.status, body: r.json });
process.stderr.write(`${p.dw_sku}\tERROR ${r.status} ${JSON.stringify(r.json).slice(0,200)}\n`);
}
// >=90s gap is for BULK pushes; single-variant REST adds are light. Pace lightly to be safe.
await new Promise(res => setTimeout(res, 600));
}
fs.writeFileSync(new URL('./data/add-results.json', import.meta.url), JSON.stringify(results, null, 2));
saveRestore();
const added = results.filter(r => r.action === 'ADDED').length;
const skip = results.filter(r => r.action === 'SKIP-EXISTS-OK').length;
const err = results.filter(r => r.action === 'ERROR' || r.action === 'EXISTS-PRICE-MISMATCH' || r.action === 'SKIP-NOT-FOUND').length;
process.stderr.write(`\n${APPLY ? 'APPLY' : 'DRY-RUN'} DONE: added=${added} skip-ok=${skip} issues=${err}\nrestore-map: ${RESTORE_MAP_PATH}\n`);