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