← back to Designerwallcoverings
scripts/artmura-onboard/apply-markup.js
84 lines
#!/usr/bin/env node
/**
* Artmura retail markup — DW sell price = round(Cost / 0.75 / 0.85) to whole dollar.
* Cost basis = price_newwall_retail captured at onboard (immutable → idempotent re-runs).
* Writes the YARD variant price on the live store; Sample stays $5 (sample policy).
*
* node apply-markup.js # DRY RUN (no writes)
* node apply-markup.js --apply # write live
*
* Env: SHOPIFY_ADMIN_TOKEN (required), SHOPIFY_STORE (default sandbox=prod)
*/
const fs = require('fs');
const path = require('path');
const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const API = '2024-10';
const APPLY = process.argv.includes('--apply');
if (!TOKEN) { console.error('SHOPIFY_ADMIN_TOKEN required'); process.exit(1); }
const DATA = path.join(__dirname, 'data', 'artmura.json');
const HANDLES = path.join(__dirname, 'data', 'artmura-store-handles.json');
const markup = cost => Math.round(cost / 0.75 / 0.85); // whole dollar
const isSample = v => (v.option1 || '').toLowerCase() === 'sample' || /-sample$/i.test(v.sku || '');
const api = (p, opts = {}) => fetch(`https://${STORE}/admin/api/${API}${p}`, {
...opts, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(opts.headers || {}) },
});
async function getAllArtmura() {
let url = `/products.json?vendor=Artmura&status=active&limit=250&fields=id,handle,title,variants`;
const out = [];
while (url) {
const res = await api(url);
const link = res.headers.get('Link') || '';
out.push(...((await res.json()).products || []));
const m = link.split(',').find(s => s.includes('rel="next"'));
url = m ? m.slice(m.indexOf('<') + 1, m.indexOf('>')).replace(/^https:\/\/[^/]+\/admin\/api\/[^/]+/, '') : null;
}
return out;
}
(async () => {
const d = JSON.parse(fs.readFileSync(DATA, 'utf8'));
const handleMap = JSON.parse(fs.readFileSync(HANDLES, 'utf8')); // {MFR_SKU: dw_handle}
const mfrByHandle = Object.fromEntries(Object.entries(handleMap).map(([sku, h]) => [h, sku.toUpperCase()]));
const costByMfr = {};
d.products.forEach(r => { costByMfr[(r.mfr_sku || '').toUpperCase()] = Number(r.price_newwall_retail) || 0; });
const live = await getAllArtmura();
console.log(`live Artmura products: ${live.length} | mode: ${APPLY ? 'APPLY (writing)' : 'DRY RUN'}\n`);
let matched = 0, toChange = 0, unmatched = [], changes = [];
for (const p of live) {
const mfr = mfrByHandle[p.handle];
const cost = mfr ? costByMfr[mfr] : null;
const yard = p.variants.find(v => !isSample(v));
if (!mfr || !cost || !yard) { unmatched.push(p.handle); continue; }
matched++;
const want = markup(cost);
const cur = Math.round(Number(yard.price));
if (cur !== want) { toChange++; changes.push({ pid: p.id, vid: yard.id, handle: p.handle, mfr, cost, cur: yard.price, want }); }
}
console.log(`matched ${matched}/${live.length} | need price change: ${toChange} | unmatched: ${unmatched.length}`);
if (unmatched.length) console.log('UNMATCHED:', unmatched.slice(0, 10).join(', '), unmatched.length > 10 ? `… +${unmatched.length - 10}` : '');
console.log('\nfirst 8 changes:');
changes.slice(0, 8).forEach(c => console.log(` ${c.mfr.padEnd(12)} $${c.cur} → $${c.want} ${c.handle}`));
if (!APPLY) { console.log(`\nDRY RUN — would update ${toChange} Yard variants. Re-run with --apply to write.`); return; }
console.log(`\nwriting ${toChange} Yard prices...`);
let ok = 0, fail = 0;
for (const c of changes) {
try {
const res = await api(`/variants/${c.vid}.json`, { method: 'PUT', body: JSON.stringify({ variant: { id: c.vid, price: String(c.want) } }) });
if (res.ok) { ok++; if (ok % 25 === 0) console.log(` …${ok}/${toChange}`); }
else { fail++; console.log(` FAIL ${c.mfr} ${res.status}: ${(await res.text()).slice(0, 120)}`); }
} catch (e) { fail++; console.log(` ERR ${c.mfr}: ${e.message}`); }
await new Promise(r => setTimeout(r, 120)); // ~8/s, under 2-bucket leak
}
console.log(`\nDONE — ${ok} updated, ${fail} failed.`);
})().catch(e => { console.error(e); process.exit(1); });