← back to Dw Add Sellable Variant Tk10902
add-variant-pf.mjs
92 lines
// TK-10456 — Default-Title-aware sellable-variant builder for the 63 Pierre Frey
// products that add-variant.mjs skips. Their lone $4.25 -Sample variant sits on
// Shopify's default option (option1 = "Default Title"), so add-variant.mjs's
// opt==='Sample' guard rejects them. This normalizes each PF product to the same
// clean shape MDC uses:
// option[0].name -> "Type", sample variant option1 -> "Sample",
// then add a "Sold per Yard" sellable variant at the real DW price.
//
// Usage: node add-variant-pf.mjs <candidates.json> [--limit N] [--live]
// default = DRY-RUN (no writes). --live performs the writes.
// Filters to vendor === 'Pierre Frey' candidates automatically.
//
// Guards (fail-safe — never mint a new SKU, never activate): exactly one variant,
// sku ends -Sample, price == 4.25, sellable SKU (sample minus -sample) not already
// present, final_price > 4.25. On --live: PUT sample (option name+value) then POST
// the sellable variant; both steps ledgered with undo.
import fs from 'fs';
import { execSync } from 'child_process';
const TOKEN = execSync(`grep -E '^SHOPIFY_ADMIN_TOKEN=' ${process.env.HOME}/Projects/secrets-manager/.env | cut -d= -f2-`).toString().trim();
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const LEDGER = `${process.env.HOME}/.claude/yolo-queue/executed-reversible/ledger.jsonl`;
const RUNLOG = `${process.cwd()}/pf-build-run-log.jsonl`;
const args = process.argv.slice(2);
const file = args.find(a => !a.startsWith('--'));
const LIVE = args.includes('--live');
const limArg = args.find(a => a.startsWith('--limit'));
const LIMIT = limArg ? parseInt(args[args.indexOf(limArg) + 1], 10) : Infinity;
async function shopify(path, method = 'GET', body = null) {
for (let i = 0; i < 4; i++) {
try {
const res = await fetch(`https://${DOMAIN}/admin/api/2024-10${path}`, {
method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
return { status: res.status, json: await res.json().catch(() => ({})) };
} catch (e) { await new Promise(r => setTimeout(r, 2500)); }
}
return { status: 0, json: {} };
}
const appendJsonl = (p, o) => fs.appendFileSync(p, JSON.stringify(o) + '\n');
const stripSample = (sku) => sku.replace(/-sample$/i, '');
const candidates = JSON.parse(fs.readFileSync(file, 'utf8')).filter(c => c.vendor === 'Pierre Frey');
const batch = candidates.slice(0, LIMIT);
let built = 0, skipped = 0;
const skips = [];
console.log(`\n=== ${LIVE ? 'LIVE' : 'DRY-RUN'} PF build — ${batch.length} Pierre Frey candidates ===\n`);
for (const c of batch) {
const pid = c.product_id;
const { status, json } = await shopify(`/products/${pid}.json`);
if (status !== 200 || !json.product) { skipped++; skips.push({ pid, reason: `fetch_failed_${status}` }); continue; }
const p = json.product;
const variants = p.variants || [];
if (variants.length !== 1) { skipped++; skips.push({ pid, reason: `variant_count=${variants.length}`, title: p.title }); continue; }
const sv = variants[0];
const svSku = (sv.sku || '').trim();
if (!/-sample$/i.test(svSku) || parseFloat(sv.price) !== 4.25) { skipped++; skips.push({ pid, reason: `not_sample sku=${svSku} price=${sv.price}`, title: p.title }); continue; }
const sellSku = stripSample(svSku);
if (!sellSku || sellSku === svSku) { skipped++; skips.push({ pid, reason: `sku_strip_failed ${svSku}`, title: p.title }); continue; }
const price = Number(c.final_price).toFixed(2);
if (!(parseFloat(price) > 4.25)) { skipped++; skips.push({ pid, reason: `bad_price ${price}`, title: p.title }); continue; }
const plan = { pid, vendor: c.vendor, title: p.title, sample_sku: svSku, sellable_sku: sellSku,
from_opt: sv.option1, new_sample_opt: 'Sample', sellable_opt: 'Sold per Yard', price };
if (!LIVE) {
console.log(`WOULD BUILD-PF ${pid} ${sellSku} @ $${price} (normalize opt "${sv.option1}"->"Sample", add "Sold per Yard")`);
built++; appendJsonl(RUNLOG, { ts: new Date().toISOString(), mode: 'dry', action: 'would_build_pf', ...plan });
continue;
}
// 1. normalize the sample variant onto a clean "Type"/"Sample" option
const r1 = await shopify(`/products/${pid}.json`, 'PUT', { product: { id: Number(pid), options: [{ id: p.options[0].id, name: 'Type' }] } });
const r2 = await shopify(`/variants/${sv.id}.json`, 'PUT', { variant: { id: sv.id, option1: 'Sample' } });
// 2. add the sellable variant
const r3 = await shopify(`/products/${pid}/variants.json`, 'POST', { variant: { option1: 'Sold per Yard', price, sku: sellSku, taxable: true, inventory_management: null } });
if (r3.status === 201 && r2.status === 200) {
built++;
appendJsonl(RUNLOG, { ts: new Date().toISOString(), mode: 'live', action: 'built_pf', variant_id: r3.json?.variant?.id, ...plan });
appendJsonl(LEDGER, { ts: new Date().toISOString(), agent: 'claude-run-10456', ticket: 'TK-10456',
action: `PF build ${sellSku} @ $${price} + normalized sample opt "${sv.option1}"->"Sample" (${c.title || p.title})`,
blast_radius: 1, undo_cmd: `DELETE variants/${r3.json?.variant?.id}; PUT variants/${sv.id} option1="${sv.option1}"; PUT product ${pid} option name back`,
verify: `GET product ${pid} -> "Sold per Yard" variant $${price}` });
console.log(`BUILT-PF ${pid} ${sellSku} @ $${price}`);
} else { skipped++; skips.push({ pid, reason: `write_failed r2=${r2.status} r3=${r3.status}`, title: p.title }); }
}
fs.writeFileSync(`${process.cwd()}/pf-build-skips.json`, JSON.stringify(skips, null, 1));
console.log(`\n=== ${LIVE ? 'BUILT-PF' : 'WOULD BUILD-PF'}: ${built} SKIPPED: ${skipped} ===`);