← back to Dwjs Consolidation 2026 04 23

audit_phase1.js

357 lines

#!/usr/bin/env node
/**
 * PHASE 1 — AUDIT (NO WRITES).
 *
 * Pulls all candidate products (DWBR + CCA + TRF + GGA + other DWB*) plus all
 * existing DWJS (for series classification), extracts mfr_sku metafield + tags,
 * classifies each as York/Brewster/Unknown, and proposes new DWJS-##### numbers
 * starting at 90000.
 *
 * Output:
 *  - mapping_audit.csv   — reviewable mapping for user
 *  - audit_summary.txt   — counts, flags, sample rows
 *  - audit_raw.json      — full structured dump for phase 3 ingestion
 *
 * IMPORTANT: emits CSV and JSON only. No Shopify writes.
 */

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 || '');
const API = '/admin/api/2024-10/graphql.json';
const OUT = __dirname;
const NEW_SKU_START = 90000;

// Which prefixes are candidates to renumber
const LEGACY_FILTER = '(sku:DWBR* OR sku:CCA* OR sku:TRF* OR sku:GGA*)';
// For classification, also pull existing DWJS so we can confirm series tagging
const DWJS_FILTER = 'sku:DWJS*';

function gql(body, retry = 0) {
  return new Promise((resolve, reject) => {
    const data = JSON.stringify(body);
    const req = https.request(
      { hostname: STORE, path: API, 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 {
            const j = JSON.parse(c);
            const tri = j?.extensions?.cost?.throttleStatus;
            if (j.errors && /throttle/i.test(JSON.stringify(j.errors)) && retry < 6) {
              await new Promise(r => setTimeout(r, 2000 * (retry + 1)));
              return resolve(gql(body, retry + 1));
            }
            if (tri && tri.currentlyAvailable < 200) await new Promise(r => setTimeout(r, 1000));
            resolve(j);
          } catch (e) {
            if (retry < 4) setTimeout(() => resolve(gql(body, retry + 1)), 2000 * (retry + 1));
            else resolve({ error: c.slice(0, 500) });
          }
        });
      }
    );
    req.on('error', err => {
      if (retry < 4) setTimeout(() => resolve(gql(body, retry + 1)), 2000 * (retry + 1));
      else reject(err);
    });
    req.setTimeout(60000, () => {
      req.destroy();
      if (retry < 4) resolve(gql(body, retry + 1));
      else reject(new Error('timeout'));
    });
    req.write(data);
    req.end();
  });
}

async function runBulk(filter) {
  // wait for any prior bulk op to clear
  for (let i = 0; i < 20; i++) {
    const r = await gql({ query: `{ currentBulkOperation { id status } }` });
    const st = r?.data?.currentBulkOperation?.status;
    if (!st || ['COMPLETED', 'FAILED', 'CANCELED', 'EXPIRED'].includes(st)) break;
    await new Promise(r => setTimeout(r, 3000));
  }
  const q = `
    {
      products(query: "${filter}") {
        edges {
          node {
            id
            title
            vendor
            productType
            status
            tags
            metafield(namespace: "custom", key: "manufacturer_sku") { value }
            mfr_global: metafield(namespace: "global", key: "manufacturer_sku") { value }
            coll_custom: metafield(namespace: "custom", key: "collection_name") { value }
            coll_global: metafield(namespace: "global", key: "Collection") { value }
            variants {
              edges {
                node { id sku title price }
              }
            }
          }
        }
      }
    }`;
  const r = await gql({
    query: `mutation {
      bulkOperationRunQuery(query: """${q}""") {
        bulkOperation { id status }
        userErrors { field message }
      }
    }`,
  });
  const errs = r?.data?.bulkOperationRunQuery?.userErrors || [];
  if (errs.length) {
    console.error('bulk start errors:', errs);
    // wait and retry
    await new Promise(r => setTimeout(r, 8000));
    return runBulk(filter);
  }
  // poll
  for (;;) {
    await new Promise(r => setTimeout(r, 4000));
    const p = await gql({ query: `{ currentBulkOperation { id status objectCount url errorCode } }` });
    const op = p?.data?.currentBulkOperation;
    if (!op) continue;
    if (op.status === 'COMPLETED') {
      console.log(`  bulk completed: ${op.objectCount} objects`);
      return op.url;
    }
    if (['FAILED', 'CANCELED', 'EXPIRED'].includes(op.status)) {
      throw new Error(`bulk ${op.status} ${op.errorCode || ''}`);
    }
  }
}

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

function parseJsonl(file) {
  const products = new Map();
  const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
  for (const line of lines) {
    const o = JSON.parse(line);
    if (o.id && o.id.includes('/Product/')) {
      products.set(o.id, {
        id: o.id,
        title: o.title,
        vendor: o.vendor,
        type: o.productType,
        status: o.status,
        tags: o.tags || [],
        mfr_sku: o?.metafield?.value || o?.mfr_global?.value || '',
        current_collection: o?.coll_custom?.value || o?.coll_global?.value || '',
        variants: [],
      });
    } else if (o.id && 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') });
    }
  }
  return [...products.values()];
}

function classify(p) {
  // Return {series: 'York'|'Brewster'|'Unknown', reason}
  const tagsLc = p.tags.map(t => t.toLowerCase());
  const hasTag = t => tagsLc.some(x => x === t.toLowerCase() || x.includes(t.toLowerCase()));
  const vendorLc = (p.vendor || '').toLowerCase();
  const bolt = p.variants.find(v => !/sample/i.test((v.sku || '') + (v.title || '')));
  const sku = (bolt ? bolt.sku : (p.variants[0] || {}).sku) || '';
  const prefix = sku.split('-')[0] || '';

  // Strong signals
  if (vendorLc === 'york' || vendorLc === 'york contract') return { series: 'York', reason: `vendor=${p.vendor}` };
  if (hasTag('york-contract') || hasTag('york contract')) return { series: 'York', reason: 'tag=York Contract' };
  if (hasTag('restoration elements') || hasTag('serene') || hasTag('hytex acoustics') ||
      hasTag('candice olson') || hasTag('ashford') || hasTag('magnolia') || hasTag('stacy garcia'))
    return { series: 'York', reason: 'tag=York sub-series' };

  if (hasTag('brewster')) return { series: 'Brewster', reason: 'tag:Brewster' };
  if (hasTag('brewster essentials')) return { series: 'Brewster', reason: 'tag=Brewster Essentials' };
  if (prefix === 'DWBR') return { series: 'Brewster', reason: 'sku prefix=DWBR' };
  if (prefix === 'CCA') return { series: 'Brewster', reason: 'CCA legacy (LA Walls → Brewster)' };

  if (prefix === 'TRF') return { series: 'Unknown', reason: 'TRF legacy — manual review' };
  if (prefix === 'GGA') return { series: 'Unknown', reason: 'GGA legacy — manual review' };

  return { series: 'Unknown', reason: 'no signal' };
}

function buildFlags(p) {
  const flags = [];
  if (!p.mfr_sku) flags.push('MISSING_MFR_SKU');
  const hasSample = p.variants.some(v => /sample/i.test((v.sku || '') + (v.title || '')));
  const hasBolt = p.variants.some(v => !/sample/i.test((v.sku || '') + (v.title || '')));
  if (!hasSample) flags.push('NO_SAMPLE_VARIANT');
  if (!hasBolt) flags.push('NO_BOLT_VARIANT');
  return flags;
}

function csvEscape(s) {
  if (s == null) return '';
  const str = String(s);
  if (/[",\n]/.test(str)) return '"' + str.replace(/"/g, '""') + '"';
  return str;
}

(async () => {
  console.log('=== PHASE 1 AUDIT (NO WRITES) ===');
  console.log(`target prefix: DWJS`);
  console.log(`new-number start: ${NEW_SKU_START}`);
  console.log(`legacy filter: ${LEGACY_FILTER}`);

  console.log('\n[1/2] bulk export: legacy prefixes');
  const urlLegacy = await runBulk(LEGACY_FILTER);
  const fileLegacy = path.join(OUT, 'raw_legacy.jsonl');
  await dl(urlLegacy, fileLegacy);
  const legacy = parseJsonl(fileLegacy);
  console.log(`parsed ${legacy.length} legacy products`);

  console.log('\n[2/2] bulk export: existing DWJS (for series classification audit)');
  const urlDwjs = await runBulk(DWJS_FILTER);
  const fileDwjs = path.join(OUT, 'raw_dwjs.jsonl');
  await dl(urlDwjs, fileDwjs);
  const dwjs = parseJsonl(fileDwjs);
  console.log(`parsed ${dwjs.length} DWJS products`);

  // Build the mapping CSV
  let counter = NEW_SKU_START;
  const rows = [];
  // track duplicate mfr_sku across set
  const mfrMap = {};
  for (const p of [...legacy, ...dwjs]) {
    if (p.mfr_sku) mfrMap[p.mfr_sku] = (mfrMap[p.mfr_sku] || 0) + 1;
  }

  for (const p of legacy) {
    const { series, reason } = classify(p);
    const flags = buildFlags(p);
    if (p.mfr_sku && mfrMap[p.mfr_sku] > 1) flags.push('DUPLICATE_MFR_IN_SET');
    if (series === 'Unknown') flags.push('UNKNOWN_SERIES');
    const newBase = counter++;
    for (const v of p.variants) {
      const oldSku = v.sku || '';
      const isSample = /sample/i.test(oldSku + v.title);
      const newSku = `DWJS-${newBase}${isSample ? '-Sample' : ''}`;
      rows.push({
        current_sku: oldSku,
        new_sku: newSku,
        product_id: p.id,
        variant_id: v.id,
        product_status: p.status,
        vendor: p.vendor,
        title: p.title,
        mfr_sku: p.mfr_sku,
        series_guess: series,
        series_reason: reason,
        current_collection: p.current_collection,
        price: v.price,
        flags: flags.join('|'),
        sample_or_bolt: isSample ? 'sample' : 'bolt',
      });
    }
  }

  // Also emit DWJS products (no SKU rename, but annotate series for tagging in phase 3)
  for (const p of dwjs) {
    const { series, reason } = classify(p);
    const flags = buildFlags(p);
    if (series === 'Unknown') flags.push('UNKNOWN_SERIES');
    for (const v of p.variants) {
      rows.push({
        current_sku: v.sku || '',
        new_sku: v.sku || '', // NO RENAME for DWJS
        product_id: p.id,
        variant_id: v.id,
        product_status: p.status,
        vendor: p.vendor,
        title: p.title,
        mfr_sku: p.mfr_sku,
        series_guess: series,
        series_reason: reason,
        current_collection: p.current_collection,
        price: v.price,
        flags: flags.join('|') + '|EXISTING_DWJS_NO_RENAME',
        sample_or_bolt: /sample/i.test((v.sku || '') + v.title) ? 'sample' : 'bolt',
      });
    }
  }

  // Write CSV
  const cols = ['current_sku','new_sku','product_id','variant_id','product_status','vendor','title','mfr_sku','series_guess','series_reason','current_collection','price','sample_or_bolt','flags'];
  const csv = [cols.join(',')].concat(rows.map(r => cols.map(c => csvEscape(r[c])).join(','))).join('\n');
  fs.writeFileSync(path.join(OUT, 'mapping_audit.csv'), csv);

  // Write raw JSON (the phase-3 input)
  fs.writeFileSync(path.join(OUT, 'audit_raw.json'), JSON.stringify(rows, null, 2));

  // Write summary
  const bySeries = {}, byPrefix = {}, byStatus = {}, byFlag = {};
  for (const r of rows) {
    bySeries[r.series_guess] = (bySeries[r.series_guess]||0)+1;
    const pref = (r.current_sku.split('-')[0] || '(empty)');
    byPrefix[pref] = (byPrefix[pref]||0)+1;
    byStatus[r.product_status] = (byStatus[r.product_status]||0)+1;
    for (const f of (r.flags || '').split('|').filter(Boolean)) byFlag[f] = (byFlag[f]||0)+1;
  }

  const missingMfr = rows.filter(r => r.flags.includes('MISSING_MFR_SKU') && !r.flags.includes('EXISTING_DWJS_NO_RENAME'));
  const unknownSeries = rows.filter(r => r.flags.includes('UNKNOWN_SERIES'));

  const summary = [
    '=== PHASE 1 AUDIT SUMMARY ===',
    `Timestamp: ${new Date().toISOString()}`,
    `Total variants in audit: ${rows.length}`,
    `Unique products in audit: ${new Set(rows.map(r => r.product_id)).size}`,
    '',
    '--- by prefix (current_sku) ---',
    ...Object.entries(byPrefix).sort((a,b)=>b[1]-a[1]).map(([k,v])=>`  ${v.toString().padStart(6)}  ${k}`),
    '',
    '--- by status ---',
    ...Object.entries(byStatus).sort((a,b)=>b[1]-a[1]).map(([k,v])=>`  ${v.toString().padStart(6)}  ${k}`),
    '',
    '--- by series_guess ---',
    ...Object.entries(bySeries).sort((a,b)=>b[1]-a[1]).map(([k,v])=>`  ${v.toString().padStart(6)}  ${k}`),
    '',
    '--- flags ---',
    ...Object.entries(byFlag).sort((a,b)=>b[1]-a[1]).map(([k,v])=>`  ${v.toString().padStart(6)}  ${k}`),
    '',
    `Missing mfr_sku (legacy only, non-DWJS): ${missingMfr.length}`,
    `Unknown series (needs human review): ${unknownSeries.length}`,
    '',
    '--- sample UNKNOWN_SERIES rows (first 10) ---',
    ...unknownSeries.slice(0,10).map(r => `  ${r.current_sku.padEnd(24)} ${r.vendor.padEnd(22)} ${r.title.slice(0,55)}`),
    '',
    '--- sample LEGACY rows by prefix (first 5 each) ---',
    ...['DWBR','CCA','TRF','GGA'].flatMap(pref => {
      const sub = rows.filter(r => r.current_sku.startsWith(pref + '-') && r.sample_or_bolt === 'bolt').slice(0,5);
      return [`[${pref}]`, ...sub.map(r => `  ${r.current_sku.padEnd(24)} mfr=${(r.mfr_sku||'').padEnd(14)} ${r.series_guess.padEnd(9)} ${r.title.slice(0,50)}`)];
    }),
    '',
    `Renumber range used: DWJS-${NEW_SKU_START} .. DWJS-${counter-1}`,
    '',
  ].join('\n');

  fs.writeFileSync(path.join(OUT, 'audit_summary.txt'), summary);
  console.log('\n' + summary);
  console.log(`\nFiles written to ${OUT}:`);
  console.log('  mapping_audit.csv   — reviewable CSV');
  console.log('  audit_summary.txt   — counts + samples');
  console.log('  audit_raw.json      — input for phase 3');
})().catch(e => { console.error('FATAL', e); process.exit(1); });