← back to Hollywood Import

hollywood-add-yard.mjs

83 lines

// Phase 2 — add a "Sold Per Yard" variant to the 812 sample-only Hollywood products so they're
// sellable by the yard. Models build-schu-rolls.mjs (PROVEN sample-safe self-heal):
//   GUARD still sample-only → productVariantsBulkCreate the yard variant → re-read → if Shopify
//   consumed the Default-Title $4.25 sample, RE-CREATE it. Then inventory 2026 on both variants.
// Sample is NEVER touched except to restore it. Cap-aborts (exit 3) on the daily variant limit
// (no cap fight). DRY-RUN by default; --apply --limit=N. Idempotent (skips products already 2-variant).
import fs from 'node:fs';
const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const STORE = (env.match(/^SHOPIFY_STORE=(.+)$/m) || [])[1].trim();
const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1].trim();
const EP = `https://${STORE}/admin/api/2024-10/graphql.json`;
const H = { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' };
const APPLY = process.argv.includes('--apply');
const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
const AUDIT = new URL('hollywood-add-yard-audit.jsonl', import.meta.url).pathname;
const LOCATION_ID = 'gid://shopify/Location/5795643504', TARGET_QTY = 2026;
const sleep = ms => new Promise(r => setTimeout(r, ms));

const LOOKUP = `query($id:ID!){node(id:$id){... on Product{id status options{name} variants(first:10){nodes{id price sku inventoryItem{id}}}}}}`;
const BULK = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$variants){productVariants{id price sku inventoryItem{id}} userErrors{field message}}}`;
const M_TRACK = `mutation($id:ID!){inventoryItemUpdate(id:$id,input:{tracked:true}){userErrors{message}}}`;
const M_ACT = `mutation($iid:ID!,$loc:ID!){inventoryActivate(inventoryItemId:$iid,locationId:$loc){userErrors{message}}}`;
const M_QTY = `mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{message}}}`;

async function gql(q, v) {
  for (let a = 0; a < 4; a++) {
    const r = await fetch(EP, { method: 'POST', headers: H, body: JSON.stringify({ query: q, variables: v }) });
    const j = await r.json();
    if (j.errors) {
      const s = JSON.stringify(j.errors);
      if (/throttled|exceeded|variant creation limit/i.test(s)) return { __cap: true };
      if (a === 3) throw new Error(s); await sleep(900 * (a + 1)); continue;
    }
    return j.data;
  }
}
const isSample = sku => /-sample$/i.test(sku || '') || /(sample|memo|swatch)/i.test(sku || '');

const items = JSON.parse(fs.readFileSync(new URL('phase2-812.json', import.meta.url), 'utf8'));
const done = new Set();
if (fs.existsSync(AUDIT)) for (const l of fs.readFileSync(AUDIT, 'utf8').trim().split('\n').filter(Boolean)) { try { const r = JSON.parse(l); if (['ADDED', 'skip'].includes(r.action)) done.add(r.pid); } catch {} }
let todo = items.filter(it => !done.has(it.pid));
if (LIMIT > 0) todo = todo.slice(0, LIMIT);
const out = APPLY ? fs.createWriteStream(AUDIT, { flags: 'a' }) : null;
console.log(`add-yard: ${todo.length} of 812 · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);

let added = 0, healed = 0, skip = 0, err = 0;
for (const it of todo) {
  const gid = `gid://shopify/Product/${it.pid}`;
  if (!APPLY) { console.log(`  ${it.dw}: would add "Sold Per Yard" @ $${it.hw} (sku ${it.dw}-Yard); sample ${it.dw}-Sample stays $4.25`); continue; }
  try {
    const d = await gql(LOOKUP, { id: gid }); if (d.__cap) { out.write(JSON.stringify({ pid: it.pid, action: 'CAP_ABORT', added }) + '\n'); out.end(); console.log(`\nCAP-ABORT: added=${added} this run. Exit 3.`); process.exit(3); }
    const p = d.node; const vs = p.variants.nodes;
    // GUARD: still sample-only (1 variant ≤ $4.30). If it already has a yard variant → skip (idempotent).
    if (!(vs.length === 1 && parseFloat(vs[0].price) <= 4.30)) { skip++; out.write(JSON.stringify({ pid: it.pid, dw: it.dw, action: 'skip', why: `variants=${vs.length}` }) + '\n'); continue; }
    const optName = (p.options[0] && p.options[0].name) || 'Title';
    const sampleSku = vs[0].sku;
    const r = await gql(BULK, { pid: gid, variants: [{ price: parseFloat(it.hw).toFixed(2), optionValues: [{ optionName: optName, name: 'Sold Per Yard' }], inventoryPolicy: 'CONTINUE', inventoryItem: { sku: it.dw + '-Yard', tracked: true } }] });
    if (r.__cap) { out.write(JSON.stringify({ pid: it.pid, action: 'CAP_ABORT', added }) + '\n'); out.end(); console.log(`\nCAP-ABORT: added=${added} this run. Exit 3.`); process.exit(3); }
    const bc = r.productVariantsBulkCreate; const ue = bc.userErrors || [];
    if (ue.length) { err++; out.write(JSON.stringify({ pid: it.pid, dw: it.dw, action: 'ERR', errs: ue }) + '\n'); await sleep(600); continue; }
    const yardV = bc.productVariants.find(v => !isSample(v.sku));
    // re-read + SELF-HEAL the sample if Shopify consumed the default
    const after = (await gql(LOOKUP, { id: gid })).node.variants.nodes;
    let didHeal = false;
    if (!after.some(v => isSample(v.sku))) {
      const sr = await gql(BULK, { pid: gid, variants: [{ price: '4.25', optionValues: [{ optionName: optName, name: 'Sample' }], inventoryPolicy: 'CONTINUE', inventoryItem: { sku: sampleSku || it.dw + '-Sample', tracked: false } }] });
      if (!sr.__cap && !(sr.productVariantsBulkCreate.userErrors || []).length) { didHeal = true; healed++; }
    }
    // inventory 2026 on the new yard variant (track+activate+setqty)
    if (yardV?.inventoryItem?.id) {
      const iid = yardV.inventoryItem.id;
      await gql(M_TRACK, { id: iid }); await gql(M_ACT, { iid, loc: LOCATION_ID });
      await gql(M_QTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities: [{ inventoryItemId: iid, locationId: LOCATION_ID, quantity: TARGET_QTY }] } });
    }
    added++; out.write(JSON.stringify({ pid: it.pid, dw: it.dw, action: 'ADDED', yardSku: it.dw + '-Yard', price: parseFloat(it.hw).toFixed(2), healedSample: didHeal }) + '\n');
    console.log(`  ✓ ${it.dw} +Yard $${it.hw}${didHeal ? ' (sample re-healed)' : ''}`);
    await sleep(500);
  } catch (e) { err++; out.write(JSON.stringify({ pid: it.pid, dw: it.dw, action: 'ERR', msg: String(e).slice(0, 120) }) + '\n'); await sleep(700); }
}
if (out) out.end();
console.log(`\nDONE: added=${added} healed=${healed} skip=${skip} err=${err} of ${todo.length}` + (APPLY ? ` · audit ${AUDIT}` : ' · DRY-RUN'));