← back to Jeffrey Stevens Margin Fix

addvariant-2.mjs

62 lines

#!/usr/bin/env node
// Add a sellable "Sold Per Single Roll" variant to the 2 ACTIVE sample-only JS roll goods
// (TL1925 Keansburg Coral, CL2572 Cohasset Charcoal). Adapted from TK-11331 d3b_addvariant.
// Roll goods: variant "Sold Per Single Roll" @ retail (master cost/0.65/0.85), min 2, sellable pos1, sample pos2.
// DRY-RUN default; --apply writes. Reversible: data/addvariant2-restore.jsonl (created variant id -> delete to undo).
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';
const REST=new URL('.',import.meta.url).pathname+'data/addvariant2-restore.jsonl';
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const gql=(q,v)=>fetch(SHOP,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})}).then(r=>r.json());
const isSample=s=>((s||'').toLowerCase().endsWith('sample'));
// the 2 targets: product_id, retail, bare sellable sku
const TARGETS=[
  {pid:'6702968111155',sku:'DWJS-14580',retail:67.87,name:'TL1925 Keansburg Coral'},
  {pid:'6702988656691',sku:'DWJS-14674',retail:54.30,name:'CL2572 Cohasset Charcoal'},
];
const READ=`query($id:ID!){product(id:$id){id handle status options{name} variants(first:20){nodes{id sku title position price}}}}`;
const CREATE=`mutation($pid:ID!,$v:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$v,strategy:DEFAULT){productVariants{id sku title position} userErrors{field message}}}`;
const REORDER=`mutation($pid:ID!,$m:[ProductVariantPositionInput!]!){productVariantsBulkReorder(productId:$pid,positions:$m){userErrors{field message}}}`;
const SETMF=`mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{field message}}}`;
let ok=0,fail=0,skip=0;
for(const t of TARGETS){
  const gid=`gid://shopify/Product/${t.pid}`;
  const p=(await gql(READ,{id:gid})).data?.product;
  if(!p){console.log('  ERR',t.name,'not found');fail++;continue;}
  const vs=p.variants.nodes.slice().sort((a,b)=>a.position-b.position);
  const sample=vs.find(v=>isSample(v.sku));
  const sellable=vs.filter(v=>!isSample(v.sku));
  if(sellable.length){console.log('  SKIP',t.name,'already has sellable',sellable[0].sku);skip++;continue;}
  if(!sample){console.log('  ERR',t.name,'no sample variant');fail++;continue;}
  const optName=p.options?.[0]?.name||'Title';
  if(!APPLY){console.log(`  would ADD ${t.sku} "Sold Per Single Roll" @ $${t.retail} pos1 (opt="${optName}"), demote ${sample.sku} pos2, min=2`);ok++;continue;}
  const restore={ts:new Date().toISOString(),pid:t.pid,handle:p.handle,sampleVid:sample.id,createdVid:null,
    undo:'delete createdVid via productVariantsBulkDelete; order auto-restores to sample@pos1'};
  const c=await gql(CREATE,{pid:gid,v:[{price:t.retail.toFixed(2),taxable:true,inventoryPolicy:'CONTINUE',inventoryItem:{sku:t.sku,tracked:true},optionValues:[{optionName:optName,name:'Sold Per Single Roll'}]}]});
  const cue=c.data.productVariantsBulkCreate.userErrors;
  if(cue.length){console.log('  CREATE-ERR',t.name,JSON.stringify(cue));fail++;continue;}
  const nv=c.data.productVariantsBulkCreate.productVariants.find(v=>!isSample(v.sku));
  restore.createdVid=nv.id; appendFileSync(REST,JSON.stringify(restore)+'\n');
  await sleep(400);
  const ro=await gql(REORDER,{pid:gid,m:[{id:nv.id,position:1},{id:sample.id,position:2}]});
  if(ro.data.productVariantsBulkReorder.userErrors.length){console.log('  REORDER-ERR',t.name,JSON.stringify(ro.data.productVariantsBulkReorder.userErrors));fail++;continue;}
  await sleep(400);
  const mf=await gql(SETMF,{mf:[
    {ownerId:gid,namespace:'global',key:'v_prods_quantity_order_min',type:T,value:'2'},
    {ownerId:gid,namespace:'global',key:'v_prods_quantity_order_units',type:T,value:'2'},
    {ownerId:nv.id,namespace:'custom',key:'v_prod_quantity_order_min',type:T,value:'2'},
  ]});
  if(mf.data.metafieldsSet.userErrors.length){console.log('  MF-ERR',t.name,JSON.stringify(mf.data.metafieldsSet.userErrors));fail++;continue;}
  // verify
  const after=(await gql(READ,{id:gid})).data.product;
  const av=after.variants.nodes.slice().sort((a,b)=>a.position-b.position);
  const okPos=av[0]&&!isSample(av[0].sku)&&Math.abs(Number(av[0].price)-t.retail)<0.01;
  console.log(`  ${okPos?'OK':'VERIFY-FAIL'} ${t.name} — sellable ${t.sku}@$${t.retail} pos1 (now pos1=${av[0]?.sku}@$${av[0]?.price})`);
  okPos?ok++:fail++;
  await sleep(300);
}
console.log(`\n${APPLY?'APPLIED':'DRY-RUN'} — ok=${ok} fail=${fail} skip=${skip}`);