← 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); });