← back to York Yardgood Exec

york73.mjs

73 lines

#!/usr/bin/env node
// York contract yard-good fix — 73 verified products. Generalizes the YGL7005 template.
// DRY-RUN (default): resolves variants, prints before->after, writes NOTHING.
// --apply: metafields (Sold Per Yard, min5/step1, MSRP, wholesale) + variant price=retail + option "Sold Per Yard" + sellable@pos1.
// Reversible: writes data/restore-map.jsonl (old price/uom/min/position per product) BEFORE each apply.
// Resumable: skips products already at target (idempotent). --limit N --offset N to batch.
import { readFileSync, appendFileSync, existsSync } from 'node:fs';
const APPLY = process.argv.includes('--apply');
const arg = (f,d)=>{const i=process.argv.indexOf(f); return i>=0?process.argv[i+1]:d;};
const LIMIT = parseInt(arg('--limit','999')); const OFFSET = parseInt(arg('--offset','0'));
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 MSRP={45:90.0,52.5:105.0,85.0:170.0,20:50.0};   // per-pattern MSRP by net (portal)
const DIR=new URL('.',import.meta.url).pathname;
const targets = JSON.parse(readFileSync(DIR+'data/york73-target.json')).slice(OFFSET, OFFSET+LIMIT);
const REST = DIR+'data/restore-map.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 failed');}

const Q=`query($id:ID!){ product(id:$id){ id title status
  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,changes=[];
for(const t of targets){
  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.product_id,t.sell_sku,'(product not found)');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 curUom=p.uomMf?.value||'';
  const already = curUom==='Sold Per Yard' && sell.price===String(t.retail) && sell.position===1;
  const line={sku:t.sell_sku,pattern:t.pattern,pid:t.product_id,
    price:{from:sell.price,to:String(t.retail)},uom:{from:curUom||t.current_uom,to:'Sold Per Yard'},
    sellPos:sell.position,samplePos:sample?.position, retail:t.retail,net:t.net,msrp:MSRP[t.net]};
  changes.push(line);
  if(already){console.log('  = already',t.sell_sku,t.pattern,`$${sell.price}`);skip++;continue;}
  if(!APPLY){console.log(`  DRY ${t.sell_sku} ${t.pattern.padEnd(16)} $${sell.price}->$${t.retail} | ${curUom||'?'}->Sold Per Yard | sellPos ${sell.position}`);ok++;continue;}
  // ---- APPLY (customer-facing) ----
  appendFileSync(REST, JSON.stringify({ts:new Date().toISOString(),pid:t.product_id,sellVid:sell.id,sampleVid:sample?.id,
    old:{price:sell.price,uom:curUom,sellPos:sell.position,samplePos:sample?.position,title:sell.title}})+'\n');
  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:'us_msrp',type:ND,value:String(MSRP[t.net].toFixed(2))},
   {ownerId:pid,namespace:'custom',key:'wholesale_price',type:ND,value:String(t.net.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'});
  let m=await gql(`mutation($m:[MetafieldsSetInput!]!){metafieldsSet(metafields:$m){userErrors{field message}}}`,{m:mfs});
  const me=m.data.metafieldsSet.userErrors; if(me.length){console.log('  MF-ERR',t.sell_sku,JSON.stringify(me));fail++;continue;}
  let vu=await gql(`mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$pid,variants:$v){userErrors{field message}}}`,
    {pid,v:[{id:sell.id,price:String(t.retail),optionValues:[{optionName:'Size',name:'Sold Per Yard'}]}]});
  const ve=vu.data.productVariantsBulkUpdate.userErrors; if(ve.length){console.log('  VAR-ERR',t.sell_sku,JSON.stringify(ve));fail++;continue;}
  if(sample && sell.position!==1){
    await gql(`mutation($pid:ID!,$m:[ProductVariantPositionInput!]!){productVariantsBulkReorder(productId:$pid,positions:$m){userErrors{field message}}}`,
      {pid,m:[{id:sell.id,position:1},{id:sample.id,position:2}]});
  }
  console.log(`  OK ${t.sell_sku} ${t.pattern} $${sell.price}->$${t.retail} Sold Per Yard`);ok++;
  await sleep(400);
}
console.log(`\n${APPLY?'APPLIED':'DRY-RUN'} — ok=${ok} skip=${skip} fail=${fail} of ${targets.length}`);
if(!APPLY){
  const totNow=changes.reduce((a,c)=>a+parseFloat(c.price.from),0), totNew=changes.reduce((a,c)=>a+c.retail,0);
  console.log(`sum current $${totNow.toFixed(2)} -> sum retail $${totNew.toFixed(2)} (${(totNew/totNow).toFixed(2)}x avg lift)`);
}