← back to Dwjs Consolidation 2026 04 23

phase3_legacy_renumber.js

236 lines

#!/usr/bin/env node
/**
 * PHASE 3 — LEGACY RENUMBER (Wave A+B combined).
 *
 * Renumbers DWBR/CCA/TRF/GGA legacy products to DWJS-90000+.
 * Per product:
 *   1. productVariantsBulkUpdate — rename variant SKUs (DWBR|CCA|TRF|GGA-### → DWJS-9xxxx)
 *   2. metafieldsSet — set custom.manufacturer_sku (real mfr from fmpro)
 *                      + global.old_dw_sku (audit trail)
 *   3. tagsAdd — add Series: York (TRF) or Series: Brewster (DWBR/CCA/GGA)
 *
 * Reads: mapping_final.json  + mfr_resolved.json
 * Writes: phase3_legacy_execution.ndjson, phase3_legacy_failures.ndjson,
 *         phase3_legacy_state.json (resume)
 */
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 LOG   = path.join(OUT, 'phase3_legacy_run.log');
const EXEC  = path.join(OUT, 'phase3_legacy_execution.ndjson');
const FAILS = path.join(OUT, 'phase3_legacy_failures.ndjson');
const STATE = path.join(OUT, 'phase3_legacy_state.json');

function ts(){ return new Date().toISOString().replace('T',' ').slice(0,19); }
function log(m){ const l=`[${ts()}] ${m}\n`; process.stdout.write(l); fs.appendFileSync(LOG,l); }

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;
          const isThrottle = j.errors && /throttle/i.test(JSON.stringify(j.errors));
          if (isThrottle && retry < 6) {
            await new Promise(r=>setTimeout(r, 2000*(retry+1)));
            return resolve(gql(body, retry+1));
          }
          if (tri && tri.currentlyAvailable < 300) await new Promise(r=>setTimeout(r, 800));
          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('t')); });
    req.write(data); req.end();
  });
}

// Load data
const mapping = JSON.parse(fs.readFileSync(path.join(OUT,'mapping_final.json'),'utf8'));
const mfr = JSON.parse(fs.readFileSync(path.join(OUT,'mfr_resolved.json'),'utf8'));

// Group variants by product, filter to legacy only (new_sku starts with DWJS-9 AND prefix was legacy)
const products = new Map();
for (const r of mapping) {
  if ((r.flags||'').includes('EXISTING_DWJS_NO_RENAME')) continue;
  if (!r.new_sku.startsWith('DWJS-9')) continue;
  if (!products.has(r.product_id)) {
    // Determine prefix + series
    const oldPref = r.current_sku.split('-')[0];
    const seriesTag = oldPref === 'TRF' ? 'Series: York'
                    : (oldPref === 'CCA' || oldPref === 'GGA' || oldPref === 'DWBR') ? 'Series: Brewster'
                    : 'Series: Unknown';
    // Find resolved mfr via hyphenless bolt sku
    const boltCurrent = r.current_sku.replace('-sample','').replace('-Sample','');
    const key = boltCurrent.replace('-','');
    const mfrEntry = mfr[key] || {};
    products.set(r.product_id, {
      product_id: r.product_id,
      old_prefix: oldPref,
      series_tag: seriesTag,
      series: seriesTag.replace('Series: ',''),
      resolved_mfr: mfrEntry.resolved_mfr || '',
      source: mfrEntry.source || 'NONE',
      variants: [],
      status: r.product_status,
      title: r.title,
    });
  }
  products.get(r.product_id).variants.push({
    id: r.variant_id,
    old_sku: r.current_sku,
    new_sku: r.new_sku,
  });
}
log(`loaded ${products.size} legacy products to renumber`);

// Resume
let done = new Set();
if (fs.existsSync(STATE)) {
  try { done = new Set(JSON.parse(fs.readFileSync(STATE,'utf8'))); log(`resuming: ${done.size} already done`); } catch {}
}

// Sanity: check new SKU uniqueness
const newSkus = new Set();
const dups = [];
for (const p of products.values()) {
  for (const v of p.variants) {
    if (newSkus.has(v.new_sku)) dups.push(v.new_sku);
    newSkus.add(v.new_sku);
  }
}
if (dups.length) { log(`FATAL: duplicate new SKUs detected: ${dups.slice(0,5)}`); process.exit(1); }
log(`unique new SKUs: ${newSkus.size}`);

// Per-product worker
async function processProduct(p) {
  const pidGid = `gid://shopify/Product/${p.product_id.replace('gid://shopify/Product/','')}`;
  // Actually mapping stored product_id as full gid already
  const pid = p.product_id.startsWith('gid') ? p.product_id : `gid://shopify/Product/${p.product_id}`;

  // 1. Rename variant SKUs
  const variantUpdates = p.variants.map(v => ({ id: v.id, inventoryItem: { sku: v.new_sku } }));
  const rRename = await gql({
    query: `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
      productVariantsBulkUpdate(productId: $productId, variants: $variants) {
        productVariants { id sku }
        userErrors { field message }
      }
    }`,
    variables: { productId: pid, variants: variantUpdates }
  });
  const renameErrs = rRename?.data?.productVariantsBulkUpdate?.userErrors || [];
  const renameTopErrs = rRename?.errors || [];
  if (renameErrs.length || renameTopErrs.length) {
    return { ok:false, step:'rename', errs: [...renameErrs, ...renameTopErrs.map(e=>({message:e.message}))], product: p };
  }

  // 2. Set metafields
  const mfs = [];
  if (p.resolved_mfr) {
    mfs.push({ ownerId: pid, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: p.resolved_mfr });
  }
  // audit trail — save old bolt SKU
  const oldBolt = (p.variants.find(v => !/sample/i.test(v.old_sku)) || p.variants[0]).old_sku;
  mfs.push({ ownerId: pid, namespace: 'global', key: 'old_dw_sku', type: 'single_line_text_field', value: oldBolt });

  if (mfs.length) {
    const rMf = await gql({
      query: `mutation($mfs: [MetafieldsSetInput!]!) {
        metafieldsSet(metafields: $mfs) {
          metafields { id key }
          userErrors { field message }
        }
      }`,
      variables: { mfs }
    });
    const mfErrs = rMf?.data?.metafieldsSet?.userErrors || [];
    if (mfErrs.length) {
      return { ok:false, step:'metafields', errs: mfErrs, product: p };
    }
  }

  // 3. Add Series tag
  const rTag = await gql({
    query: `mutation($id: ID!, $tags: [String!]!) {
      tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
    }`,
    variables: { id: pid, tags: [p.series_tag] }
  });
  const tagErrs = rTag?.data?.tagsAdd?.userErrors || [];
  if (tagErrs.length) {
    return { ok:false, step:'tags', errs: tagErrs, product: p };
  }

  return { ok:true, product: p };
}

async function run() {
  log(`=== LEGACY RENUMBER — ${products.size} products ===`);
  const queue = [...products.values()].filter(p => !done.has(p.product_id));
  log(`to process: ${queue.length}`);

  const concurrency = 3;
  let ok=0, fail=0, progress=0;
  let inFlight = 0;
  let idx = 0;

  return new Promise(resolve => {
    const next = () => {
      while (inFlight < concurrency && idx < queue.length) {
        const p = queue[idx++];
        inFlight++;
        processProduct(p).then(r => {
          if (r.ok) {
            ok++;
            fs.appendFileSync(EXEC, JSON.stringify({
              ts: new Date().toISOString(), pid: r.product.product_id, series: r.product.series,
              old_prefix: r.product.old_prefix, resolved_mfr: r.product.resolved_mfr,
              variants: r.product.variants.map(v => ({ id: v.id, old: v.old_sku, new: v.new_sku }))
            }) + '\n');
          } else {
            fail++;
            fs.appendFileSync(FAILS, JSON.stringify({
              ts: new Date().toISOString(), pid: r.product.product_id, step: r.step, errs: r.errs,
              title: r.product.title,
            }) + '\n');
          }
          progress++;
          done.add(p.product_id);
          if (progress % 25 === 0) {
            fs.writeFileSync(STATE, JSON.stringify([...done]));
            log(`progress ${progress}/${queue.length}  ok=${ok}  fail=${fail}`);
          }
        }).catch(e => {
          fail++;
          fs.appendFileSync(FAILS, JSON.stringify({ pid: p.product_id, error: String(e) }) + '\n');
        }).finally(() => {
          inFlight--;
          if (idx < queue.length || inFlight) next();
          else {
            fs.writeFileSync(STATE, JSON.stringify([...done]));
            log(`DONE — total=${progress} ok=${ok} fail=${fail}`);
            resolve();
          }
        });
      }
    };
    next();
  });
}

run().catch(e => { log('FATAL ' + (e.stack||e.message||e)); process.exit(1); });