← back to Designer Wallcoverings
audits/designtex-uom-fix/bulk-flip-remaining.mjs
77 lines
#!/usr/bin/env node
// Designtex UOM bulk relabel: "Single Roll" -> "Yard", "Priced Per Single Roll" -> "Priced Per Yard".
// DTD verdict 2026-06-22 (3/3): RELABEL-ONLY, price untouched. Scope = 285 DWDX flip-candidates ONLY.
// The 9 DWAH "Rocket" products are EXCLUDED (different prefix, not in designtex_catalog).
//
// SAFETY:
// - Dry-run by default. Pass --execute to write.
// - Reads flip-candidates-285.json (the canary's 5 are already done; --skip-done excludes them).
// - Price is NEVER sent in the variant mutation, so it cannot be altered.
// - Batches of 50 with a >=90s gap between batches (DW bulk-push rule).
//
// Usage:
// node bulk-flip-remaining.mjs # dry run, prints what it WOULD do
// node bulk-flip-remaining.mjs --execute # live writes
// node bulk-flip-remaining.mjs --execute --skip-done # skip the 5 canary SKUs
import fs from 'fs';
import os from 'os';
import path from 'path';
const EXECUTE = process.argv.includes('--execute');
const SKIP_DONE = process.argv.includes('--skip-done');
const DONE_SKUS = new Set(['DWDX-220230','DWDX-220229','DWDX-220228','DWDX-220231','DWDX-220008']);
const env = fs.readFileSync(path.join(os.homedir(),'Projects/secrets-manager/.env'),'utf8');
const tok = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].trim();
const DOMAIN='designer-laboratory-sandbox.myshopify.com', VER='2024-10';
async function gql(query, variables){
const r = await fetch(`https://${DOMAIN}/admin/api/${VER}/graphql.json`,{
method:'POST',
headers:{'X-Shopify-Access-Token':tok,'Content-Type':'application/json'},
body:JSON.stringify({query,variables})});
return r.json();
}
const variantMut = `mutation($productId:ID!, $variants:[ProductVariantsBulkInput!]!){
productVariantsBulkUpdate(productId:$productId, variants:$variants){
productVariants{ id title price } userErrors{ field message } }
}`;
const mfMut = `mutation($mf:[MetafieldsSetInput!]!){
metafieldsSet(metafields:$mf){ metafields{ id value } userErrors{ field message } }
}`;
const here = path.dirname(new URL(import.meta.url).pathname);
let rows = JSON.parse(fs.readFileSync(path.join(here,'flip-candidates-285.json'),'utf8'));
if (SKIP_DONE) rows = rows.filter(r => !DONE_SKUS.has(r.sku));
console.log(`Mode: ${EXECUTE ? 'EXECUTE (live writes)' : 'DRY RUN'} | rows: ${rows.length} | skip-done: ${SKIP_DONE}`);
if (!EXECUTE){
console.log('Sample of what would change:');
rows.slice(0,5).forEach(r => console.log(` ${r.sku} Single Roll->Yard (price ${r.price} unchanged)`));
console.log('Re-run with --execute to apply.');
process.exit(0);
}
const BATCH=50, results=[];
for (let i=0; i<rows.length; i+=BATCH){
const batch = rows.slice(i, i+BATCH);
for (const r of batch){
const v = await gql(variantMut, { productId:r.productId,
variants:[{ id:r.variantId, optionValues:[{ optionName:'Title', name:'Yard' }] }] });
const m = await gql(mfMut, { mf:[{ ownerId:r.productId, namespace:'global', key:'unit_of_measure',
type:'single_line_text_field', value:'Priced Per Yard' }] });
const ve = v.data?.productVariantsBulkUpdate?.userErrors||[];
const me = m.data?.metafieldsSet?.userErrors||[];
results.push({ sku:r.sku, ok: ve.length===0 && me.length===0, ve, me });
if (ve.length||me.length) console.error('ERR', r.sku, JSON.stringify(ve), JSON.stringify(me));
await new Promise(x=>setTimeout(x,400));
}
console.log(`Batch ${i/BATCH+1} done (${Math.min(i+BATCH,rows.length)}/${rows.length}).`);
if (i+BATCH < rows.length){ console.log('Sleeping 90s (DW bulk-push gap)...'); await new Promise(x=>setTimeout(x,90000)); }
}
const failed = results.filter(r=>!r.ok);
console.log(`DONE. ${results.length-failed.length} ok, ${failed.length} failed.`);
fs.writeFileSync(path.join(here,'bulk-flip-results.json'), JSON.stringify(results,null,2));