← back to Jeffrey Stevens Margin Fix

reprice-221.mjs

60 lines

#!/usr/bin/env node
// Reprice the 221 verified-below-cost JS products (retail = verified_cost/0.65/0.85).
// UOM-aware (master UOM is authoritative — avoids width-parse trap):
//   YD  -> Sold Per Yard + min5/step1 (product+variant) + variant option 'Sold Per Yard' + reprice + sellable pos1  (Steve: "52/54 must be per yard")
//   EA/BDR/other -> price-only reprice, keep current unit.
// DRY-RUN default; --apply writes. Reversible: data/reprice221-restore.jsonl. Idempotent.
import { readFileSync, appendFileSync } from 'node:fs';
const APPLY=process.argv.includes('--apply');
const TOKEN=readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8').split('\n').find(l=>l.startsWith('SHOPIFY_ADMIN_TOKEN=')).split('=')[1].trim();
const SHOP='https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
const T='single_line_text_field', ND='number_decimal';
const DIR=new URL('.',import.meta.url).pathname;
const rows=JSON.parse(readFileSync(DIR+'data/js882-cost-manifest.json')).filter(r=>r.verified_cost>0);
const REST=DIR+'data/reprice221-restore.jsonl';
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function gql(q,v){for(let a=0;a<4;a++){try{const r=await fetch(SHOP,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});const j=await r.json();if(j.errors&&/throttl/i.test(JSON.stringify(j.errors))){await sleep(2000);continue;}return j;}catch(e){await sleep(1500);}}throw new Error('gql');}
const Q=`query($id:ID!){product(id:$id){uomMf:metafield(namespace:"global",key:"unit_of_measure"){value} variants(first:15){edges{node{id title price position sku}}}}}`;
let ok=0,skip=0,fail=0,yd=0,pon=0;
for(const t of rows){
  const isYD=String(t.m_uom).toUpperCase()==='YD';
  const pid=`gid://shopify/Product/${t.product_id}`;
  const r=await gql(Q,{id:pid}); const p=r.data&&r.data.product;
  if(!p){console.log('  MISS',t.sell_sku);fail++;continue;}
  const vs=p.variants.edges.map(e=>e.node);
  const sample=vs.find(v=>/sample/i.test(v.title)||v.price==='4.25'||/-sample$/i.test(v.sku||''));
  const sell=vs.find(v=>v!==sample)||vs[0];
  const newp=String(t.retail); const curUom=p.uomMf?.value||'';
  const targetUom=isYD?'Sold Per Yard':curUom;
  const already = sell.price===newp && (!isYD || (curUom==='Sold Per Yard' && sell.position===1));
  if(already){console.log('  = already',t.sell_sku);skip++;continue;}
  const tag=isYD?'[YD->per-yard]':'[price-only '+t.m_uom+']';
  if(!APPLY){console.log(`  DRY ${t.sell_sku} ${tag} $${sell.price}->$${newp} (cost $${t.verified_cost})`);ok++;isYD?yd++:pon++;continue;}
  appendFileSync(REST,JSON.stringify({pid:t.product_id,sellVid:sell.id,sampleVid:sample?.id,old:{price:sell.price,uom:curUom,pos:sell.position,title:sell.title}})+'\n');
  if(isYD){
    const mfs=[
      {ownerId:pid,namespace:'global',key:'unit_of_measure',type:T,value:'Sold Per Yard'},
      {ownerId:pid,namespace:'global',key:'v_prods_quantity_order_min',type:T,value:'5'},
      {ownerId:pid,namespace:'global',key:'v_prods_quantity_order_units',type:T,value:'1'},
      {ownerId:pid,namespace:'custom',key:'wholesale_price',type:ND,value:t.verified_cost.toFixed(2)},
      {ownerId:pid,namespace:'custom',key:'us_msrp',type:ND,value:(t.verified_cost*2).toFixed(2)},
      {ownerId:sell.id,namespace:'custom',key:'v_prod_quantity_order_min',type:T,value:'5'},
      {ownerId:sell.id,namespace:'custom',key:'v_prods_quantity_order_units',type:T,value:'1'},
    ];
    if(sample) mfs.push({ownerId:sample.id,namespace:'custom',key:'v_prod_quantity_order_min',type:T,value:'1'});
    const m=await gql(`mutation($m:[MetafieldsSetInput!]!){metafieldsSet(metafields:$m){userErrors{message}}}`,{m:mfs});
    if(m.data.metafieldsSet.userErrors.length){console.log('  MF-ERR',t.sell_sku,JSON.stringify(m.data.metafieldsSet.userErrors));fail++;continue;}
    const vu=await gql(`mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$pid,variants:$v){userErrors{message}}}`,
      {pid,v:[{id:sell.id,price:newp,optionValues:[{optionName:'Size',name:'Sold Per Yard'}]}]});
    if(vu.data.productVariantsBulkUpdate.userErrors.length){console.log('  VAR-ERR',t.sell_sku,JSON.stringify(vu.data.productVariantsBulkUpdate.userErrors));fail++;continue;}
    if(sample && sell.position!==1) await gql(`mutation($pid:ID!,$m:[ProductVariantPositionInput!]!){productVariantsBulkReorder(productId:$pid,positions:$m){userErrors{message}}}`,{pid,m:[{id:sell.id,position:1},{id:sample.id,position:2}]});
    yd++;
  } else {
    const vu=await gql(`mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$pid,variants:$v){userErrors{message}}}`,{pid,v:[{id:sell.id,price:newp}]});
    if(vu.data.productVariantsBulkUpdate.userErrors.length){console.log('  VAR-ERR',t.sell_sku,JSON.stringify(vu.data.productVariantsBulkUpdate.userErrors));fail++;continue;}
    pon++;
  }
  console.log(`  OK ${t.sell_sku} ${tag} $${sell.price}->$${newp}`);ok++;await sleep(400);
}
console.log(`\n${APPLY?'APPLIED':'DRY-RUN'} — ok=${ok} (yd=${yd} price-only=${pon}) skip=${skip} fail=${fail} of ${rows.length}`);