← back to Designerwallcoverings
scripts/anna-french-rolls/af-reprice-live.js
106 lines
'use strict';
/* Anna French (DWAA) single-roll LIVE reprice — Steve-authorized 2026-06-17 (option 3:
accept Mar last-known-good retail). For each active Anna French ROLL product (title !~ fabric)
joined to anna_french_catalog.price_retail:
newPrice = round(retail * 0.45 / 0.65 / 0.85, 2) (cost=retail×0.45, sell=cost/0.65/0.85)
- If a real ROLL variant exists (price > $4.255, the non-sample variant): UPDATE its price.
- Set product metafield custom.packaging = single-roll/double-pack note (if absent).
- If NO roll variant yet (still sample-only): log to out/af-reprice-noroll.jsonl, DO NOT create
(variant creation is daily-capped — handled by af-build with the new plan).
NEVER touches the $4.25 sample variant. DRY by default; set COMMIT=1 to write.
REPRICE_LIMIT=N caps the run (canary). Resumable via out/af-reprice-done.txt. */
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 DONE = DIR + '/af-reprice-done.txt';
const LOG = DIR + '/af-reprice-log.jsonl';
const NOROLL = DIR + '/af-reprice-noroll.jsonl';
const COMMIT = process.env.COMMIT === '1';
const LIMIT = parseInt(process.env.REPRICE_LIMIT || '0', 10) || Infinity;
const PRICE_CAP = 2000; // safety: never push a roll price above this without review
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 Q = `query($id:ID!){ product(id:$id){ id title status
options{name}
variants(first:20){ nodes{ id sku price selectedOptions{name value} } }
packaging: metafield(namespace:"custom", key:"packaging"){ id value } } }`;
const UPD = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
productVariantsBulkUpdate(productId:$pid, variants:$variants){ productVariants{ id price } userErrors{ message } } }`;
const META = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{ message } } }`;
const PKG = 'Sold by the single roll; packaged in double roll.';
function rows() {
const raw = execSync(`psql -tAc "
select a.shopify_id, a.mfr_sku, round(c.price_retail*0.45/0.65/0.85,2) newprice
from shopify_products a join anna_french_catalog c on upper(c.mfr_sku)=upper(a.mfr_sku)
where a.vendor ilike '%anna%french%' and a.status='ACTIVE' and c.price_retail>0 and a.title !~* 'fabric'
order by a.shopify_id"`, { env: PGENV, encoding: 'utf8' });
return raw.split('\n').filter(Boolean).map(l => { const [id, mfr, np] = l.split('|'); return { sid: id, mfr, newPrice: parseFloat(np) }; });
}
(async () => {
const all = rows();
const done = new Set(fs.existsSync(DONE) ? fs.readFileSync(DONE, 'utf8').split('\n').filter(Boolean) : []);
const todo = all.filter(r => !done.has(r.sid)).slice(0, LIMIT);
console.log(`mode=${COMMIT ? 'COMMIT' : 'DRY'} total=${all.length} done=${done.size} todo=${todo.length}${LIMIT !== Infinity ? ' (limit ' + LIMIT + ')' : ''}`);
const doneS = fs.createWriteStream(DONE, { flags: 'a' }), logS = fs.createWriteStream(LOG, { flags: 'a' }), norollS = fs.createWriteStream(NOROLL, { flags: 'a' });
const t = { repriced: 0, alreadyOk: 0, pkgSet: 0, pkgOk: 0, noRoll: 0, skipCap: 0, err: 0, gone: 0 };
for (const r of todo) {
const gid = r.sid.startsWith('gid://') ? r.sid : `gid://shopify/Product/${r.sid}`;
const lk = await g(Q, { id: gid });
const p = lk.data && lk.data.product;
if (!p || p.status !== 'ACTIVE') { t.gone++; if (COMMIT) doneS.write(r.sid + '\n'); continue; }
// roll variant = the non-sample variant (price > 4.255). Prefer a "Roll"-named option value.
const vs = p.variants.nodes;
const nonSample = vs.filter(v => parseFloat(v.price) > 4.255);
let roll = nonSample.find(v => v.selectedOptions.some(o => /roll/i.test(o.value))) || (nonSample.length === 1 ? nonSample[0] : null);
const rec = { sid: r.sid, mfr: r.mfr, title: p.title.slice(0, 50), newPrice: r.newPrice };
if (r.newPrice <= 0 || r.newPrice >= PRICE_CAP) { t.skipCap++; rec.action = 'skip-price-cap'; logS.write(JSON.stringify(rec) + '\n'); if (COMMIT) doneS.write(r.sid + '\n'); continue; }
if (!roll) { t.noRoll++; rec.action = 'no-roll-variant'; rec.variants = vs.map(v => ({ sku: v.sku, price: v.price })); norollS.write(JSON.stringify(rec) + '\n'); logS.write(JSON.stringify(rec) + '\n'); if (COMMIT) doneS.write(r.sid + '\n'); continue; }
rec.oldPrice = roll.price; rec.variantId = roll.id;
// 1) reprice if differs
const differs = Math.abs(parseFloat(roll.price) - r.newPrice) >= 0.005;
if (differs) {
if (COMMIT) {
const rr = await g(UPD, { pid: gid, variants: [{ id: roll.id, price: String(r.newPrice) }] });
const e = (rr.data && rr.data.productVariantsBulkUpdate && rr.data.productVariantsBulkUpdate.userErrors) || [];
if (e.length) { t.err++; rec.action = 'err:' + JSON.stringify(e); logS.write(JSON.stringify(rec) + '\n'); continue; }
}
t.repriced++; rec.action = 'repriced';
} else { t.alreadyOk++; rec.action = 'price-ok'; }
// 2) packaging metafield
if (!(p.packaging && p.packaging.value)) {
if (COMMIT) {
const mr = await g(META, { mf: [{ ownerId: gid, namespace: 'custom', key: 'packaging', type: 'single_line_text_field', value: PKG }] });
const me = (mr.data && mr.data.metafieldsSet && mr.data.metafieldsSet.userErrors) || [];
if (me.length) { rec.pkgErr = JSON.stringify(me); } else t.pkgSet++;
} else t.pkgSet++;
rec.pkg = 'set';
} else { t.pkgOk++; rec.pkg = 'ok'; }
logS.write(JSON.stringify(rec) + '\n');
if (COMMIT) doneS.write(r.sid + '\n');
console.log(` ${rec.action}${differs ? ` $${roll.price}->$${r.newPrice}` : ` $${roll.price}`} pkg=${rec.pkg} ${rec.title}`);
await sleep(280);
}
doneS.end(); logS.end(); norollS.end();
console.log('\n' + JSON.stringify(t, null, 2));
})().catch(e => { console.error('FATAL', e.message); process.exit(1); });