← back to Dwjs Consolidation 2026 04 23

audit_completeness.js

150 lines

#!/usr/bin/env node
/**
 * Post-renumber completeness audit for all 1,507 legacy products.
 * Checks per product:
 *   - bolt variant exists with price > 0
 *   - sample variant exists at $4.25
 *   - inventory tracked & quantity > 0 on both
 *   - at least 1 image
 *   - custom.manufacturer_sku present
 *   - global.old_dw_sku present
 *   - product status not archived
 *   - tags include Series: York|Brewster
 *   - body_html not empty
 * Output CSV of any products with issues.
 */
const https = require('https');
const fs = require('fs');
const path = require('path');

const STORE='designer-laboratory-sandbox.myshopify.com';
const TOKEN=(process.env.SHOPIFY_ADMIN_TOKEN || '');

function gql(body, retry=0) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = https.request({ hostname:STORE, path:'/admin/api/2024-10/graphql.json', method:'POST',
      headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)} },
      res => { let c=''; res.on('data',d=>c+=d); res.on('end', async ()=>{
        try { resolve(JSON.parse(c)); } catch { if (retry<3) setTimeout(()=>resolve(gql(body,retry+1)), 2000); else resolve({error:c.slice(0,300)}); }
      }); }
    );
    req.on('error', err => { if (retry<3) setTimeout(()=>resolve(gql(body,retry+1)), 2000); else reject(err); });
    req.setTimeout(60000, ()=>{ req.destroy(); if (retry<3) resolve(gql(body,retry+1)); else reject(new Error('t')); });
    req.write(data); req.end();
  });
}

async function bulk() {
  const q = `{
    products(query: "sku:DWJS-9*") {
      edges { node {
        id title status vendor tags bodyHtml
        images: featuredImage { url }
        mfr: metafield(namespace:"custom", key:"manufacturer_sku") { value }
        oldSku: metafield(namespace:"global", key:"old_dw_sku") { value }
        variants {
          edges { node { id sku title price inventoryQuantity inventoryPolicy inventoryItem { tracked } } }
        }
      } }
    }
  }`;
  for (let i=0;i<10;i++){
    const r=await gql({query:'{ currentBulkOperation { status } }'});
    const st=r?.data?.currentBulkOperation?.status;
    if (!st || ['COMPLETED','FAILED','CANCELED','EXPIRED'].includes(st)) break;
    await new Promise(r=>setTimeout(r,3000));
  }
  await gql({query:`mutation{ bulkOperationRunQuery(query: """${q}""") { bulkOperation{id} userErrors{message} } }`});
  for (;;) {
    await new Promise(r=>setTimeout(r,4000));
    const p=await gql({query:'{ currentBulkOperation { status objectCount url } }'});
    const op=p?.data?.currentBulkOperation;
    console.log(`  bulk ${op?.status} objects=${op?.objectCount||0}`);
    if (op?.status==='COMPLETED') return op.url;
    if (['FAILED','CANCELED','EXPIRED'].includes(op?.status)) throw new Error(op.status);
  }
}

function dl(url,file){return new Promise((r,j)=>{const out=fs.createWriteStream(file);https.get(url,res=>{res.pipe(out);out.on('finish',()=>out.close(()=>r(file)))}).on('error',j)})}

(async()=>{
  console.log('scoping new DWJS-9xxxx products...');
  const url = await bulk();
  const file = '/Users/macstudio3/Projects/dwjs-consolidation-2026-04-23/audit_completeness.jsonl';
  await dl(url, file);
  const products = new Map();
  for (const line of fs.readFileSync(file,'utf8').split('\n').filter(Boolean)) {
    const o = JSON.parse(line);
    if (o.id?.includes('/Product/')) {
      products.set(o.id, {
        id: o.id, title: o.title, status: o.status, vendor: o.vendor, tags: o.tags||[],
        bodyHtml: o.bodyHtml || '',
        imageUrl: o.images?.url || '',
        mfr: o.mfr?.value || '',
        oldSku: o.oldSku?.value || '',
        variants: [],
      });
    } else if (o.id?.includes('/ProductVariant/') && o.__parentId) {
      const p = products.get(o.__parentId);
      if (p) p.variants.push({
        id: o.id, sku: o.sku||'', title: o.title||'',
        price: parseFloat(o.price||'0'),
        inv: o.inventoryQuantity,
        policy: o.inventoryPolicy,
        tracked: o.inventoryItem?.tracked,
      });
    }
  }
  console.log(`products pulled: ${products.size}`);
  const issues = [];
  let allOk = 0;

  for (const p of products.values()) {
    const bolt = p.variants.find(v => !/sample/i.test(v.sku + v.title));
    const sample = p.variants.find(v => /sample/i.test(v.sku + v.title));
    const flags = [];
    if (p.status === 'ARCHIVED') flags.push('ARCHIVED');
    if (!p.imageUrl) flags.push('NO_IMAGE');
    if (!p.mfr) flags.push('NO_MFR_METAFIELD');
    if (!p.oldSku) flags.push('NO_OLD_DW_SKU');
    if (!p.bodyHtml || p.bodyHtml.trim().length < 10) flags.push('NO_BODY');
    if (!bolt) flags.push('NO_BOLT_VARIANT');
    else {
      if (!bolt.price || bolt.price <= 0) flags.push('BOLT_PRICE_ZERO');
      if (bolt.tracked === false) flags.push('BOLT_NOT_TRACKED');
      if (bolt.inv == null || bolt.inv <= 0) flags.push('BOLT_INVENTORY_ZERO');
    }
    if (!sample) flags.push('NO_SAMPLE');
    else {
      if (Math.abs(sample.price - 4.25) > 0.001) flags.push(`SAMPLE_PRICE_${sample.price}`);
      if (sample.tracked === false) flags.push('SAMPLE_NOT_TRACKED');
      if (sample.inv == null || sample.inv <= 0) flags.push('SAMPLE_INVENTORY_ZERO');
    }
    const seriesTag = p.tags.find(t => /^Series:\s*(York|Brewster)/i.test(t));
    if (!seriesTag) flags.push('NO_SERIES_TAG');
    if (flags.length === 0) { allOk++; continue; }
    issues.push({ id: p.id, title: p.title, status: p.status, oldSku: p.oldSku, mfr: p.mfr, boltPrice: bolt?.price||0, samplePrice: sample?.price||0, boltInv: bolt?.inv, sampleInv: sample?.inv, flags: flags.join('|') });
  }

  // Count by flag
  const flagCount = {};
  for (const i of issues) for (const f of i.flags.split('|')) flagCount[f]=(flagCount[f]||0)+1;
  console.log(`\n=== AUDIT RESULT ===`);
  console.log(`products checked: ${products.size}`);
  console.log(`clean: ${allOk}`);
  console.log(`with issues: ${issues.length}`);
  console.log(`\nissue flag counts:`);
  Object.entries(flagCount).sort((a,b)=>b[1]-a[1]).forEach(([k,v])=>console.log(`  ${v.toString().padStart(5)}  ${k}`));

  // Write CSV of issues
  const cols = ['id','title','status','oldSku','mfr','boltPrice','samplePrice','boltInv','sampleInv','flags'];
  const csvEsc = s => { if (s==null) return ''; const str=String(s); return /[",\n]/.test(str) ? '"'+str.replace(/"/g,'""')+'"' : str; };
  const lines = [cols.join(',')].concat(issues.map(i => cols.map(c => csvEsc(i[c])).join(',')));
  fs.writeFileSync('/Users/macstudio3/Projects/dwjs-consolidation-2026-04-23/completeness_issues.csv', lines.join('\n'));
  console.log(`\nsaved: completeness_issues.csv (${issues.length} rows)`);
  // sample
  console.log('\nfirst 5 issue rows:');
  for (const i of issues.slice(0,5)) console.log(`  ${i.oldSku.padEnd(20)} ${i.title.slice(0,50).padEnd(50)} flags=${i.flags}`);
})().catch(e => { console.error(e); process.exit(1); });