← back to Carnegie Reprice

reconcile.mjs

85 lines

// reconcile.mjs — Carnegie split dedup, LIVE-STORE-as-ground-truth (ledger is unreliable: a prior
// session's "runaway" cleanup deleted some products the ledger still claims exist).
// Phase 1 (dry, default): inventory every Carnegie SINGLE-COLORWAY product (2 variants = fabric+sample),
//   read its real metafield count, group by fabric DWAG sku, and emit a per-sku KEEP/DELETE/UPGRADE plan.
// Phase 2 (apply, GATED): archive the superseded bad dupes per the plan. Never deletes; archive is reversible.
//   node reconcile.mjs         # dry: build + print plan, write reconcile-plan.json
//   node reconcile.mjs apply   # GATED: archive the bad dupes the plan marks (keeps the good one per sku)
import fs from 'node:fs';
const DIR=new URL('.',import.meta.url).pathname;
const ENV=`${process.env.HOME}/Projects/secrets-manager/.env`;
const env=k=>{const m=fs.readFileSync(ENV,'utf8').split('\n').find(l=>l.startsWith(k+'='));return m?m.slice(k.length+1).trim().replace(/^["']|["']$/g,''):'';};
const TOKEN=env('SHOPIFY_ADMIN_TOKEN');let SHOP=env('SHOPIFY_STORE_DOMAIN')||env('SHOPIFY_STORE');if(SHOP&&!SHOP.includes('.'))SHOP+='.myshopify.com';
const API=`https://${SHOP}/admin/api/2024-10`;const H={'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'};
const MODE=process.argv[2]||'dry';const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const GOOD_MF=40;                                  // >=40 product-level metafields = a "good" complete product
async function shop(p,o={}){for(let a=0;a<6;a++){const r=await fetch(API+p,{headers:H,...o});if(r.status===429){await sleep(2000*(a+1));continue;}const t=await r.text();if(!r.ok)throw new Error(`HTTP ${r.status} ${p} :: ${t.slice(0,150)}`);return t?JSON.parse(t):{};}throw new Error('429 '+p);}

// enumerate ALL Carnegie products (active+draft)
async function allCarnegie(){const out=[];for(const status of ['active','draft']){let url=`/products.json?vendor=Carnegie&status=${status}&limit=250&fields=id,handle,title,status,options,variants`;while(url){const r=await fetch(API+url,{headers:H});if(r.status===429){await sleep(2000);continue;}const j=await r.json();out.push(...(j.products||[]));const link=r.headers.get('link')||'';const m=link.match(/<[^>]*[?&]page_info=([^>&]+)[^>]*>;\s*rel="next"/);url=m?`/products.json?limit=250&page_info=${m[1]}&fields=id,handle,title,status,options,variants`:null;await sleep(500);}}return out;}

console.log('[recon] enumerating Carnegie products (active+draft)…');
const all=await allCarnegie();
// SINGLE-COLORWAY = exactly one non-sample variant (the split products). MULTI = the old pattern products.
const single=[],multi=[];
for(const p of all){
  const nonSample=(p.variants||[]).filter(v=>!/sample/i.test(v.sku||'')&&!/sample/i.test(v.title||''));
  const hasColorOpt=(p.options||[]).some(o=>/^colou?r$/i.test(o.name));
  if(!hasColorOpt && nonSample.length===1) single.push({...p,fabSku:nonSample[0].sku});
  else multi.push(p);
}
console.log(`[recon] ${all.length} Carnegie products -> ${single.length} single-colorway (split), ${multi.length} multi/other`);

// metafield count per single-colorway product (ground truth)
console.log('[recon] reading metafield counts (ground truth)…');
let i=0;
for(const p of single){ const j=await shop(`/products/${p.id}/metafields.json`); p.mc=(j.metafields||[]).length; if(++i%50===0)console.log(`  …${i}/${single.length}`); await sleep(90); }

// group by fabric sku
const bySku=new Map();
for(const p of single){ if(!p.fabSku)continue; if(!bySku.has(p.fabSku))bySku.set(p.fabSku,[]); bySku.get(p.fabSku).push(p); }

// build plan
const plan={keep_delete:[],only_bad:[],singleton_good:[],singleton_bad:[]};
for(const [sku,prods] of bySku){
  const good=prods.filter(p=>p.mc>=GOOD_MF);
  const bad=prods.filter(p=>p.mc<GOOD_MF);
  if(good.length){
    // keep the best good (highest mc, prefer active); delete every other product for this sku
    good.sort((a,b)=>(b.status==='active')-(a.status==='active')||b.mc-a.mc);
    const keep=good[0];
    const del=[...good.slice(1),...bad];
    if(del.length) plan.keep_delete.push({sku,keep:{id:keep.id,handle:keep.handle,mc:keep.mc,status:keep.status},delete:del.map(d=>({id:d.id,handle:d.handle,mc:d.mc,status:d.status}))});
    else plan.singleton_good.push({sku,id:keep.id,handle:keep.handle});
  } else {
    // only bad product(s) exist for this sku -> upgrade candidate (or recreate)
    if(prods.length>1) plan.only_bad.push({sku,products:prods.map(p=>({id:p.id,handle:p.handle,mc:p.mc,status:p.status}))});
    else plan.singleton_bad.push({sku,id:prods[0].id,handle:prods[0].handle,mc:prods[0].mc,status:prods[0].status});
  }
}
const delCount=plan.keep_delete.reduce((a,r)=>a+r.delete.length,0);
fs.writeFileSync(DIR+'reconcile-plan.json',JSON.stringify({ts:new Date().toISOString(),GOOD_MF,summary:{skus:bySku.size,keep_delete_skus:plan.keep_delete.length,to_delete:delCount,only_bad_skus:plan.only_bad.length,singleton_good:plan.singleton_good.length,singleton_bad:plan.singleton_bad.length},plan},null,2));
console.log(`\n=== RECONCILE PLAN ===`);
console.log(`  distinct fabric SKUs (single-colorway): ${bySku.size}`);
console.log(`  SKUs with a GOOD product + dupes to remove: ${plan.keep_delete.length}  (=> ${delCount} products to ARCHIVE)`);
console.log(`  SKUs with ONLY-bad (needs upgrade/recreate): ${plan.only_bad.length + plan.singleton_bad.length}`);
console.log(`  SKUs clean (one good, no dupe): ${plan.singleton_good.length}`);
console.log(`  full plan -> reconcile-plan.json`);
for(const r of plan.keep_delete.slice(0,6)) console.log(`   ${r.sku}: KEEP ${r.keep.handle}(mf${r.keep.mc},${r.keep.status}) DEL ${r.delete.map(d=>d.handle+'(mf'+d.mc+','+d.status+')').join(' ')}`);

if(MODE!=='apply'){console.log(`\n[dry] nothing changed. Review reconcile-plan.json, then 'apply' to ARCHIVE the ${delCount} superseded dupes (gated).`);process.exit(0);}

// ---- APPLY (gated): archive the superseded dupes (reversible) ----
console.log(`\n[apply] archiving ${delCount} superseded dupe products (keeping the good one per sku)…`);
const undo=[];let ok=0,err=0;
for(const r of plan.keep_delete){
  for(const d of r.delete){
    try{ await shop(`/products/${d.id}.json`,{method:'PUT',body:JSON.stringify({product:{id:d.id,status:'archived'}})}); undo.push({id:d.id,handle:d.handle,was:d.status}); ok++; }
    catch(e){ err++; console.log(`  ERR ${d.handle}: ${String(e.message||e).slice(0,100)}`); }
    await sleep(400);
  }
  if(ok%25===0&&ok)console.log(`  …archived ${ok}/${delCount}`);
}
fs.writeFileSync(DIR+'reconcile-undo.json',JSON.stringify({ts:new Date().toISOString(),undo},null,2));
console.log(`[apply] archived ${ok}, err ${err}. Undo (un-archive) map -> reconcile-undo.json`);