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