[object Object]

← back to Dw Yolo Loop

refactor: add scripts/lib/shopify.mjs shared GQL client + migrate 2 read-only auditors (proof of pattern)

d2d776f89f93249210b1a28a325535a4e0e1a46c · 2026-06-16 00:28:08 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit d2d776f89f93249210b1a28a325535a4e0e1a46c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 16 00:28:08 2026 -0700

    refactor: add scripts/lib/shopify.mjs shared GQL client + migrate 2 read-only auditors (proof of pattern)
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 scripts/cole-son-price-audit.mjs       | 52 ++++++++++++++++++++
 scripts/kravet-2026-coverage-audit.mjs | 41 ++++++++++++++++
 scripts/lib/shopify.mjs                | 87 ++++++++++++++++++++++++++++++++++
 3 files changed, 180 insertions(+)

diff --git a/scripts/cole-son-price-audit.mjs b/scripts/cole-son-price-audit.mjs
new file mode 100644
index 0000000..7cd7a19
--- /dev/null
+++ b/scripts/cole-son-price-audit.mjs
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+/* Read-only Cole & Son pricing audit: join every live product to MAP (kravet_master_price,
+   MAP = wholesale x 1.5) via the custom.manufacturer_sku metafield. Classifies:
+   - sample-only $4.25 with a MAP available  → fixable (set yard variant to MAP)
+   - real price that differs from MAP         → off-MAP (over/under)
+   - no MAP match                             → can't auto-fix
+   Writes /tmp/cs_audit.json with the full fix plan. NO writes to Shopify. */
+import fs from 'node:fs';
+import { rest, restAll } from './lib/shopify.mjs';
+
+const MAP = {};
+fs.readFileSync('/tmp/cs_map.txt', 'utf8').trim().split('\n').forEach(l => {
+  const [sku, m] = l.split('|'); if (sku) MAP[sku.trim().toUpperCase()] = parseFloat(m);
+});
+
+const getAll = () => restAll(`/products.json?vendor=Cole%20%26%20Son&limit=250&fields=id,title,status,variants`, 'products');
+const isSample = v => (v.option1 || '').toLowerCase() === 'sample' || /-sample$/i.test(v.sku || '');
+
+(async () => {
+  const prods = await getAll();
+  console.log(`auditing ${prods.length} Cole & Son products...`);
+  const r = { fixable: [], offMap: [], noMap: [], ok: [] };
+  let i = 0;
+  for (const p of prods) {
+    if (++i % 200 === 0) console.log(`  …${i}/${prods.length}`);
+    const mf = await (await rest(`/products/${p.id}/metafields.json`)).json();
+    const msku = (mf.metafields || []).find(m => m.namespace === 'custom' && m.key === 'manufacturer_sku');
+    const code = msku ? String(msku.value).trim().toUpperCase() : null;
+    const map = code ? MAP[code] : null;
+    const yard = p.variants.filter(v => !isSample(v)).sort((a, b) => parseFloat(b.price) - parseFloat(a.price))[0] || p.variants[0];
+    const yardPrice = parseFloat(yard.price);
+    const rec = { id: p.id, vid: yard.id, title: p.title, status: p.status, code, map, cur: yardPrice };
+    if (!map) { r.noMap.push(rec); continue; }
+    if (yardPrice <= 4.25) r.fixable.push(rec);                         // sample-only, MAP available
+    else if (Math.abs(yardPrice - map) > 0.5) r.offMap.push(rec);       // real price ≠ MAP
+    else r.ok.push(rec);                                                 // at MAP
+  }
+  fs.writeFileSync('/tmp/cs_audit.json', JSON.stringify(r, null, 0));
+  const sum = k => r[k].length;
+  const active = a => a.filter(x => x.status === 'active').length;
+  console.log('\n=== COLE & SON PRICING AUDIT ===');
+  console.log(`at MAP (correct):        ${sum('ok')}  (active ${active(r.ok)})`);
+  console.log(`FIXABLE ($4.25 → MAP):   ${sum('fixable')}  (active+live ${active(r.fixable)})`);
+  console.log(`OFF-MAP (real ≠ MAP):    ${sum('offMap')}  (active ${active(r.offMap)})`);
+  console.log(`no MAP match:            ${sum('noMap')}  (active ${active(r.noMap)})`);
+  const overMap = r.offMap.filter(x => x.cur > x.map);
+  console.log(`  of off-MAP, OVER map:  ${overMap.length}  (total $${overMap.reduce((s,x)=>s+(x.cur-x.map),0).toFixed(0)} above MAP)`);
+  console.log('\nsample FIXABLE (now $4.25 → should be MAP):');
+  r.fixable.slice(0, 6).forEach(x => console.log(`  ${x.code}  $4.25 → $${x.map}   ${x.title.slice(0,45)}`));
+  console.log('\nsample OFF-MAP:');
+  r.offMap.slice(0, 6).forEach(x => console.log(`  ${x.code}  $${x.cur} → MAP $${x.map}  (${x.cur>x.map?'+':''}${(x.cur-x.map).toFixed(0)})  ${x.title.slice(0,40)}`));
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/kravet-2026-coverage-audit.mjs b/scripts/kravet-2026-coverage-audit.mjs
new file mode 100644
index 0000000..0268e65
--- /dev/null
+++ b/scripts/kravet-2026-coverage-audit.mjs
@@ -0,0 +1,41 @@
+#!/usr/bin/env node
+/* READ-ONLY: do we have a 2026 price for every live Kravet-family item?
+   For each Kravet-family vendor, pull active products + their manufacturer_sku metafield,
+   check membership in the 2026-priced SKU set (auth_pricing.new_map>0 → /tmp/auth2026_skus.txt).
+   Reports per-vendor coverage + writes /tmp/kravet_2026_gaps.csv (items with NO 2026 price). */
+import fs from 'node:fs';
+import { rest, restAll } from './lib/shopify.mjs';
+
+const SET = new Set(fs.readFileSync('/tmp/auth2026_skus.txt','utf8').trim().split('\n').map(s=>s.trim().toUpperCase()));
+const VENDORS = ['Kravet','Kravet Couture','Kravet Design','Lee Jofa','Lee Jofa Modern','Brunschwig & Fils',
+  'Cole & Son','GP & J Baker','Clarke And Clarke','Mulberry','Threads','Baker Lifestyle','Gaston y Daniela','Andrew Martin'];
+const norm = s => (s||'').toUpperCase().replace(/\s+/g,' ').trim();
+
+const getAll = vendor => restAll(`/products.json?vendor=${encodeURIComponent(vendor)}&status=active&limit=250&fields=id,title,handle,variants`, 'products');
+const isSample = v => (v.option1||'').toLowerCase()==='sample' || /-sample$/i.test(v.sku||'');
+
+(async()=>{
+  const summary=[];
+  const csv=['vendor,mfr_sku,dw_sku,title,handle'];
+  for(const vendor of VENDORS){
+    let prods; try{ prods=await getAll(vendor); }catch(e){ console.log(`  ${vendor}: ERR`); continue; }
+    if(!prods.length) continue;
+    let have=0, miss=0, noSku=0, n=0;
+    for(const p of prods){
+      n++;
+      const mf=await(await rest(`/products/${p.id}/metafields.json`)).json();
+      const code=norm(((mf.metafields||[]).find(x=>x.namespace==='custom'&&x.key==='manufacturer_sku')||{}).value||'');
+      const yard=p.variants.find(v=>!isSample(v))||p.variants[0];
+      if(!code){ noSku++; csv.push([vendor,'(no mfr_sku)',yard&&yard.sku||'',JSON.stringify(p.title),p.handle].join(',')); continue; }
+      if(SET.has(code)) have++;
+      else { miss++; csv.push([vendor,code,yard&&yard.sku||'',JSON.stringify(p.title),p.handle].join(',')); }
+    }
+    const pct=(100*have/prods.length).toFixed(1);
+    summary.push({vendor,total:prods.length,have,miss,noSku,pct});
+    console.log(`  ${vendor.padEnd(20)} ${prods.length} total | 2026:${have} (${pct}%) | missing:${miss} | no-mfr-sku:${noSku}`);
+  }
+  fs.writeFileSync('/tmp/kravet_2026_gaps.csv', csv.join('\n'));
+  const T=summary.reduce((a,s)=>({total:a.total+s.total,have:a.have+s.have,miss:a.miss+s.miss,noSku:a.noSku+s.noSku}),{total:0,have:0,miss:0,noSku:0});
+  console.log(`\n=== TOTAL: ${T.total} live Kravet items | have 2026 price: ${T.have} (${(100*T.have/T.total).toFixed(1)}%) | missing: ${T.miss} | no mfr_sku: ${T.noSku} ===`);
+  console.log(`gap list → /tmp/kravet_2026_gaps.csv (${T.miss+T.noSku} rows)`);
+})().catch(e=>{console.error(e);process.exit(1);});
diff --git a/scripts/lib/shopify.mjs b/scripts/lib/shopify.mjs
new file mode 100644
index 0000000..267920b
--- /dev/null
+++ b/scripts/lib/shopify.mjs
@@ -0,0 +1,87 @@
+/**
+ * Shared Shopify Admin client seam for DW scripts.
+ *
+ * One place that owns the store endpoint, API version, token load, retry/backoff,
+ * and Shopify cost-throttle sleep — so individual scripts stop re-inlining their own
+ * `X-Shopify-Access-Token` + fetch-retry boilerplate. The `gql()` THROTTLED backoff +
+ * cost-throttle pattern is lifted verbatim from scripts/price-sheets/add-roll-variant.mjs.
+ *
+ * Centralizing the write path here is also what makes the "writes go through
+ * shopify_api_queue" rule enforceable: there's now a single function a guard can wrap.
+ *
+ * Exports:
+ *   SHOP, VER, TOKEN, ENDPOINT     — connection constants
+ *   gql(query, vars)               — GraphQL Admin call w/ THROTTLED backoff + throttle sleep
+ *   rest(path, { method, body })   — REST Admin call (path begins after /admin/api/<VER>)
+ *   restAll(path)                  — REST GET that follows Link rel="next" pagination
+ *   getLocation()                  — primary location gid (read_locations scope required)
+ */
+import fs from 'node:fs';
+
+export const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+export const VER = '2024-10';
+export const TOKEN = (
+  fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8')
+    .match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || []
+)[1]?.trim();
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN in ~/Projects/secrets-manager/.env'); process.exit(1); }
+
+export const ENDPOINT = `https://${SHOP}/admin/api/${VER}`;
+const GQL_URL = `${ENDPOINT}/graphql.json`;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+export async function gql(query, vars) {
+  for (let a = 0; a < 8; a++) {
+    let j;
+    try {
+      const r = await fetch(GQL_URL, {
+        method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables: vars }),
+      });
+      j = await r.json();
+    } catch (e) { await sleep(1500 * (a + 1)); continue; }
+    if (j.errors) {
+      if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; }
+      return { __err: j.errors };
+    }
+    const t = j.extensions?.cost?.throttleStatus;
+    if (t && t.currentlyAvailable < 400) await sleep(1200);
+    return j.data;
+  }
+  throw new Error('gql retries exhausted');
+}
+
+export async function rest(path, { method = 'GET', body } = {}, tries = 5) {
+  for (let i = 0; i < tries; i++) {
+    const r = await fetch(`${ENDPOINT}${path}`, {
+      method,
+      headers: { 'X-Shopify-Access-Token': TOKEN, ...(body ? { 'Content-Type': 'application/json' } : {}) },
+      ...(body ? { body: JSON.stringify(body) } : {}),
+    });
+    if (r.status === 429 || r.status >= 500) { await sleep(1500 * (i + 1)); continue; }
+    await sleep(90);
+    return r;
+  }
+  throw new Error('rest fail ' + path);
+}
+
+export async function restAll(path, key) {
+  const collection = key || path.replace(/^\/([a-z_]+)\.json.*/, '$1');
+  const out = [];
+  let url = path;
+  while (url) {
+    const r = await rest(url);
+    const link = r.headers.get('Link') || '';
+    out.push(...(((await r.json())[collection]) || []));
+    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;
+}
+
+export async function getLocation() {
+  const loc = await gql(`{locations(first:5){nodes{id}}}`);
+  if (loc?.__err) throw new Error('cannot read locations: ' + JSON.stringify(loc.__err).slice(0, 200));
+  return loc.locations.nodes[0].id;
+}

← 540e242 Contract-scope auditor: read-only dw_unified canary for vend  ·  back to Dw Yolo Loop  ·  docs: verify resume-roll-adds REMAIN-counter edge-set concer 5159ca6 →