← 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); });