← back to Designerwallcoverings

scripts/anna-french-rolls/af-build-noroll.js

95 lines

'use strict';
/* Build the missing "Sold Per Roll" variant for Anna French sample-only products that the
   reprice pass logged as no-roll (out/af-reprice-noroll.jsonl). Price = NEW single-roll basis
   retail x 0.45 / 0.65 / 0.85 (cost=retail×0.45, sell=cost/0.65/0.85), from anna_french_catalog.
   Per product: re-verify LIVE sample-only (1 variant <= $4.255) + Size-option, then add ONE
   "Sold Per Roll" variant (sku = sample sku minus -SAMPLE) and set custom.packaging note.
   Guards: PRICE_CAP $2000, Title-option hard-skip (migration needed, eats the sample),
   daily variant-cap abort (exit 3, resumable), done-file. DRY unless COMMIT=1. REPRICE_LIMIT=N canary. */
const fs = require('fs');
const { execSync } = require('child_process');
const ENV = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
const EP = 'https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
const PGENV = { PGHOST: '/tmp', PGPORT: '5432', PGUSER: 'stevestudio2', PGDATABASE: 'dw_unified', PATH: '/opt/homebrew/opt/postgresql@14/bin:/usr/bin:/bin' };
const DIR = __dirname + '/out';
const NOROLL = DIR + '/af-reprice-noroll.jsonl';
const DONE = DIR + '/af-build-noroll-done.txt';
const CREATED = DIR + '/af-build-noroll-created.jsonl';
const SKIPPED = DIR + '/af-build-noroll-skipped.jsonl';
const COMMIT = process.env.COMMIT === '1';
const LIMIT = parseInt(process.env.REPRICE_LIMIT || '0', 10) || Infinity;
const PRICE_CAP = 2000;
const LIMIT_RE = /exceed|daily.*limit|limit.*reached|too many|cap/i;
const PKG = 'Sold by the single roll; packaged in double roll.';
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function g(q, v) {
  for (let i = 0; i < 7; i++) {
    let r; const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 25000);
    try { r = await fetch(EP, { method: 'POST', signal: ac.signal, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) }); }
    catch (e) { clearTimeout(to); await sleep(1500 * (i + 1)); continue; }
    clearTimeout(to);
    if (r.status === 429 || r.status >= 500) { await sleep(2000 * (i + 1)); continue; }
    const j = await r.json();
    if (j.errors && JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (i + 1)); continue; }
    return j;
  }
  throw new Error('exhausted');
}
const VERIFY = `query($id:ID!){ product(id:$id){ status options{name} variants(first:5){nodes{sku price}} packaging:metafield(namespace:"custom",key:"packaging"){value} } }`;
const MUT = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
  productVariantsBulkCreate(productId:$pid, variants:$variants){ productVariants{ id price sku } userErrors{ message } } }`;
const META = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ message } } }`;

function priceMap() { // mfr_sku -> new single-roll price
  const raw = execSync(`psql -tAc "select upper(mfr_sku), round(price_retail*0.45/0.65/0.85,2) from anna_french_catalog where price_retail>0"`, { env: PGENV, encoding: 'utf8' });
  const m = {}; for (const l of raw.split('\n').filter(Boolean)) { const [k, p] = l.split('|'); m[k] = parseFloat(p); } return m;
}

(async () => {
  const pm = priceMap();
  const rows = fs.readFileSync(NOROLL, 'utf8').split('\n').filter(Boolean).map(JSON.parse);
  const done = new Set(fs.existsSync(DONE) ? fs.readFileSync(DONE, 'utf8').split('\n').filter(Boolean) : []);
  const todo = rows.filter(r => !done.has(r.sid)).slice(0, LIMIT);
  console.log(`mode=${COMMIT ? 'COMMIT' : 'DRY'} noroll=${rows.length} done=${done.size} todo=${todo.length}${LIMIT !== Infinity ? ' (limit ' + LIMIT + ')' : ''}`);
  const doneS = fs.createWriteStream(DONE, { flags: 'a' }), crS = fs.createWriteStream(CREATED, { flags: 'a' }), skS = fs.createWriteStream(SKIPPED, { flags: 'a' });
  const t = { made: 0, titleOpt: 0, notSample: 0, noPrice: 0, gone: 0, err: 0 };
  for (const r of todo) {
    const gid = r.sid.startsWith('gid://') ? r.sid : `gid://shopify/Product/${r.sid}`;
    const price = pm[(r.mfr || '').toUpperCase()];
    if (!(price > 0 && price < PRICE_CAP)) { t.noPrice++; skS.write(JSON.stringify({ sid: r.sid, mfr: r.mfr, reason: 'no/oob-price' }) + '\n'); if (COMMIT) doneS.write(r.sid + '\n'); continue; }
    const lk = await g(VERIFY, { id: gid });
    const p = lk.data && lk.data.product;
    if (!p || p.status !== 'ACTIVE') { t.gone++; if (COMMIT) doneS.write(r.sid + '\n'); skS.write(JSON.stringify({ sid: r.sid, reason: 'not-active' }) + '\n'); continue; }
    const optName = (p.options[0] && p.options[0].name) || 'Size';
    if (optName !== 'Size') { t.titleOpt++; skS.write(JSON.stringify({ sid: r.sid, mfr: r.mfr, reason: 'title-option-needs-migration' }) + '\n'); if (COMMIT) doneS.write(r.sid + '\n'); continue; }
    const vs = p.variants.nodes;
    if (!(vs.length === 1 && parseFloat(vs[0].price) <= 4.255)) { t.notSample++; skS.write(JSON.stringify({ sid: r.sid, mfr: r.mfr, reason: 'not-sample-only', vs: vs.map(v => ({ sku: v.sku, price: v.price })) }) + '\n'); if (COMMIT) doneS.write(r.sid + '\n'); continue; }
    const base = String(vs[0].sku || '').replace(/-SAMPLE$/i, '');
    if (!COMMIT) { t.made++; console.log(`  DRY would build ${base} "Sold Per Roll" $${price}  ${r.title}`); continue; }
    const variants = [{ price: String(price), optionValues: [{ optionName: 'Size', name: 'Sold Per Roll' }], inventoryPolicy: 'CONTINUE', inventoryItem: { sku: base, tracked: false } }];
    const rr = await g(MUT, { pid: gid, variants });
    const res = rr.data && rr.data.productVariantsBulkCreate;
    const e = (res && res.userErrors) || [];
    if (e.length) {
      if (LIMIT_RE.test(JSON.stringify(e))) { console.log('daily variant-cap hit — aborting (resumable):', JSON.stringify(e)); break; }
      t.err++; skS.write(JSON.stringify({ sid: r.sid, mfr: r.mfr, reason: 'err:' + JSON.stringify(e) }) + '\n'); continue;
    }
    // self-heal guard: confirm the $4.25 sample still exists (named-variant add can migrate a default variant)
    const chk = await g(VERIFY, { id: gid });
    const cvs = (chk.data && chk.data.product && chk.data.product.variants.nodes) || [];
    const sampleAlive = cvs.some(v => parseFloat(v.price) <= 4.255);
    if (!sampleAlive) skS.write(JSON.stringify({ sid: r.sid, mfr: r.mfr, warn: 'SAMPLE-MISSING-AFTER-BUILD', vs: cvs.map(v => ({ sku: v.sku, price: v.price })) }) + '\n');
    // packaging metafield
    if (!(p.packaging && p.packaging.value)) await g(META, { mf: [{ ownerId: gid, namespace: 'custom', key: 'packaging', type: 'single_line_text_field', value: PKG }] });
    const nv = res.productVariants[0];
    t.made++; doneS.write(r.sid + '\n');
    crS.write(JSON.stringify({ sid: r.sid, mfr: r.mfr, variantId: nv.id, sku: nv.sku, price: nv.price, sampleAlive, title: r.title }) + '\n');
    console.log(`  +${t.made} ${nv.sku} "Sold Per Roll" $${nv.price}  sample=${sampleAlive ? 'ok' : 'GONE!'}  ${String(r.title).slice(0, 36)}`);
    await sleep(320);
  }
  doneS.end(); crS.end(); skS.end();
  console.log('\n' + JSON.stringify(t, null, 2));
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });