← back to Dwjs Consolidation 2026 04 23

autofix_inventory.js

196 lines

#!/usr/bin/env node
/**
 * Auto-fix the universal-standard issues on 1,508 renumbered products:
 *   - SAMPLE variants: price → 4.25 (if not already), inventory tracked=true,
 *     inventoryPolicy=CONTINUE, quantity → 2026 (if <=0)
 *   - BOLT variants:   inventory tracked=true, inventoryPolicy=CONTINUE,
 *                      quantity → 2026 (if <=0). LEAVE price alone if >0.
 *
 * Uses productVariantsBulkUpdate for variant fields + inventoryAdjustQuantities
 * for stock delta.
 */
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 LOG = '/Users/macstudio3/Projects/dwjs-consolidation-2026-04-23/autofix_inventory.log';
const EXEC = '/Users/macstudio3/Projects/dwjs-consolidation-2026-04-23/autofix_inventory.ndjson';
const FAIL = '/Users/macstudio3/Projects/dwjs-consolidation-2026-04-23/autofix_failures.ndjson';
const STATE = '/Users/macstudio3/Projects/dwjs-consolidation-2026-04-23/autofix_state.json';

function log(m){ const l=`[${new Date().toISOString().replace('T',' ').slice(0,19)}] ${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:'/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 {
          const j = JSON.parse(c);
          const tri = j?.extensions?.cost?.throttleStatus;
          const isThr = j.errors && /throttle/i.test(JSON.stringify(j.errors));
          if (isThr && 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<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();
  });
}

// Load the audit jsonl to find targets
const audit = fs.readFileSync('/Users/macstudio3/Projects/dwjs-consolidation-2026-04-23/audit_completeness.jsonl','utf8').split('\n').filter(Boolean).map(JSON.parse);
const products = new Map();
for (const o of audit) {
  if (o.id?.includes('/Product/')) products.set(o.id, { id:o.id, title:o.title, status:o.status, 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,
      inventoryItemId: null, // need to fetch separately if we want qty updates
    });
  }
}
log(`loaded ${products.size} products from audit`);

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

// For each product, build the variant updates we need
function buildUpdates(p) {
  const vuBulk = []; // for productVariantsBulkUpdate
  const reasons = [];
  for (const v of p.variants) {
    const isSample = /sample/i.test(v.sku + v.title);
    const upd = { id: v.id };
    let touched = false;
    if (isSample) {
      if (Math.abs((v.price||0) - 4.25) > 0.001 && v.price !== 4.25) {
        upd.price = '4.25';
        touched = true;
        reasons.push(`sample price ${v.price}→4.25`);
      }
    }
    // inventory tracking
    if (v.tracked === false) {
      upd.inventoryItem = { tracked: true };
      touched = true;
      reasons.push(`${isSample?'sample':'bolt'} tracked=true`);
    }
    // inventoryPolicy: keep CONTINUE so products don't go out of stock
    if (v.policy !== 'CONTINUE') {
      upd.inventoryPolicy = 'CONTINUE';
      touched = true;
      reasons.push(`${isSample?'sample':'bolt'} policy=CONTINUE`);
    }
    if (touched) vuBulk.push(upd);
  }
  return { vuBulk, reasons };
}

async function applyBulkUpdate(productId, variants) {
  const m = `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
    productVariantsBulkUpdate(productId: $productId, variants: $variants) {
      productVariants { id sku price inventoryPolicy inventoryItem { tracked } }
      userErrors { field message }
    }
  }`;
  const r = await gql({ query: m, variables: { productId, variants } });
  const errs = r?.data?.productVariantsBulkUpdate?.userErrors || [];
  const topErrs = r?.errors || [];
  return { ok: errs.length===0 && topErrs.length===0, errs: [...errs, ...topErrs.map(e=>({message:e.message}))] };
}

// Set inventory quantity via inventorySetQuantities (works whether tracked or not, once tracked=true)
async function setInventoryQuantity(variantId, quantity) {
  // need inventoryItem id — fetch it
  const r = await gql({ query: `{ productVariant(id:"${variantId}") { inventoryItem { id inventoryLevels(first:1) { edges { node { location { id } } } } } } }` });
  const item = r?.data?.productVariant?.inventoryItem;
  if (!item?.id) return { ok:false, errs:[{message:'no inventory item'}] };
  const loc = item.inventoryLevels?.edges?.[0]?.node?.location?.id;
  if (!loc) return { ok:false, errs:[{message:'no location'}] };
  const m = `mutation($input: InventorySetQuantitiesInput!) {
    inventorySetQuantities(input: $input) {
      inventoryAdjustmentGroup { id }
      userErrors { field message }
    }
  }`;
  const rr = await gql({ query: m, variables: { input: {
    reason: 'correction', name: 'available',
    ignoreCompareQuantity: true,
    quantities: [{ inventoryItemId: item.id, locationId: loc, quantity }],
  } } });
  const errs = rr?.data?.inventorySetQuantities?.userErrors || [];
  return { ok: errs.length===0, errs };
}

async function processProduct(p) {
  const { vuBulk, reasons } = buildUpdates(p);
  let ops = [];
  if (vuBulk.length) {
    const r = await applyBulkUpdate(p.id, vuBulk);
    ops.push({ step:'variants', ok:r.ok, errs:r.errs });
    if (!r.ok) return { ok:false, ops, reasons };
  }
  // Inventory quantity fix: set qty=2026 on any variant where inv <= 0
  for (const v of p.variants) {
    if (v.inv == null || v.inv > 0) continue;
    const r = await setInventoryQuantity(v.id, 2026);
    ops.push({ step:'inventory', vid:v.id, ok:r.ok, errs:r.errs });
    if (!r.ok) return { ok:false, ops, reasons };
    reasons.push(`${/sample/i.test(v.sku+v.title)?'sample':'bolt'} qty=2026`);
  }
  return { ok:true, ops, reasons };
}

(async () => {
  log('=== AUTOFIX INVENTORY + SAMPLE PRICE ===');
  const queue = [...products.values()].filter(p => !done.has(p.id));
  log(`queue: ${queue.length}`);

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

  await new Promise(resolve => {
    const next = () => {
      while (inFlight < concurrency && idx < queue.length) {
        const p = queue[idx++];
        inFlight++;
        processProduct(p).then(r => {
          if (r.reasons.length === 0) { skip++; }
          else if (r.ok) { ok++; fs.appendFileSync(EXEC, JSON.stringify({ ts:new Date().toISOString(), pid:p.id, reasons:r.reasons })+'\n'); }
          else { fail++; fs.appendFileSync(FAIL, JSON.stringify({ pid:p.id, ops:r.ops })+'\n'); }
          progress++;
          done.add(p.id);
          if (progress % 25 === 0) {
            fs.writeFileSync(STATE, JSON.stringify([...done]));
            log(`progress ${progress}/${queue.length}  ok=${ok}  skip=${skip}  fail=${fail}`);
          }
        }).catch(e => { fail++; fs.appendFileSync(FAIL, JSON.stringify({ pid:p.id, error:String(e) })+'\n'); })
        .finally(() => {
          inFlight--;
          if (idx < queue.length || inFlight) next();
          else {
            fs.writeFileSync(STATE, JSON.stringify([...done]));
            log(`DONE — progress=${progress} ok=${ok} skip=${skip} fail=${fail}`);
            resolve();
          }
        });
      }
    };
    next();
  });
})().catch(e => { log('FATAL ' + (e.stack||e.message||e)); process.exit(1); });