← back to York Reprice 2026 08
bridge-reprice.mjs
76 lines
import fs from 'node:fs';
import { execSync } from 'node:child_process';
const SHOP='designer-laboratory-sandbox.myshopify.com',VER='2024-10';
const TOKEN=(fs.readFileSync(process.env.HOME+'/Projects/secrets-manager/.env','utf8').match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)||[])[1]?.trim();
const URL=`https://${SHOP}/admin/api/${VER}/graphql.json`;
const args=Object.fromEntries(process.argv.slice(2).map(a=>{const[k,v]=a.replace(/^--/,'').split('=');return[k,v===undefined?true:v];}));
const APPLY=args.apply===true&&args['i-am-steve']===true;
const INCLUDE_DOWN=args['include-down']===true;
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
const gql=async(q,v)=>{for(let a=0;a<8;a++){let j;try{const r=await fetch(URL,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:q,variables:v})});j=await r.json();}catch(e){await sleep(1500*(a+1));continue;}if(j.errors){if(JSON.stringify(j.errors).includes('THROTTLED')){await sleep(2000*(a+1));continue;}throw new Error(JSON.stringify(j.errors));}const t=j.extensions?.cost?.throttleStatus;if(t&&t.currentlyAvailable<400)await sleep(1200);return j.data;}throw new Error('retries');};
const norm=s=>String(s||'').toUpperCase().trim();
const isSample=v=>/(sample|memo|swatch)/i.test((v.title||'')+(v.sku||''))||/-sample$/i.test(v.sku||'');
const words=s=>new Set(String(s||'').toLowerCase().replace(/[^a-z0-9 ]/g,' ').split(/\s+/).filter(w=>w.length>2));
const share=(a,b)=>{const A=words(a),B=words(b);for(const w of A)if(B.has(w))return true;return false;};
const M=new Map();
for(const line of fs.readFileSync('data/master-priced-by-sku.csv','utf8').trim().split('\n').slice(1)){const c=line.split(',');M.set(norm(c[0]),{newp:parseFloat(c[1]),fb:c[2]==='1',status:c[3],pattern:c[4]});}
// SINGLE-ROLL awareness (parity with york-cadence): naturals (grasscloth/sisal/weave/paperweave/cork)
// sell at brewster_york_master.roll_price_single, NOT MAP. Without this, master-priced-by-sku.csv (MAP-only)
// false-flagged every correctly-priced natural as a reprice and would push it UP to MAP (135 of 279 on 2026-07-28).
const RPS=new Map();
try{ const out=execSync(`PGPASSWORD=DW2024! /opt/homebrew/opt/postgresql@14/bin/psql -h 127.0.0.1 -U dw_admin -d dw_unified -tA -c "select upper(trim(mfr_sku))||chr(31)||roll_price_single from brewster_york_master where roll_price_single>0"`,{encoding:'utf8'});
for(const line of out.trim().split('\n').filter(Boolean)){const [s,p]=line.split(String.fromCharCode(31)); if(!RPS.has(s))RPS.set(s,parseFloat(p));}
console.log(` single-roll targets loaded: ${RPS.size}`);
}catch(e){ console.log(' ⚠ single-roll load failed (falling back to MAP-only):',String(e.message||e).slice(0,80)); }
// VALID-MAP SET (parity with york-cadence): york_master_aug2026 has dup rows per SKU (two roll sizes →
// conflicting us_map, e.g. NW3503 carries BOTH $102 and $123.25). master-priced-by-sku.csv keeps only ONE
// (first-wins), so bridge would push a correctly-priced live roll to the OTHER dup value. Skip when live
// already matches ANY valid master MAP row.
const VMAP=new Map();
try{ const out=execSync(`PGPASSWORD=DW2024! /opt/homebrew/opt/postgresql@14/bin/psql -h 127.0.0.1 -U dw_admin -d dw_unified -tA -c "select upper(trim(export_sku))||chr(31)||coalesce(us_map,round(us_msrp*0.75,2)) from york_master_aug2026 where export_sku is not null"`,{encoding:'utf8'});
for(const line of out.trim().split('\n').filter(Boolean)){const [s,p]=line.split(String.fromCharCode(31)); if(!VMAP.has(s))VMAP.set(s,new Set()); VMAP.get(s).add(parseFloat(p));}
console.log(` valid-MAP sets loaded: ${VMAP.size}`);
}catch(e){ console.log(' ⚠ valid-MAP load failed (falling back to single CSV price):',String(e.message||e).slice(0,80)); }
let cur=null, live=[];
// JS catalog ~6,193 products; old 90×60=5,400 cap truncated the scan (missed ~793). 120×100 headroom.
for(let pg=0;pg<120;pg++){
const Q=`query($c:String){products(first:100,after:$c,query:"vendor:'Jeffrey Stevens'"){pageInfo{hasNextPage endCursor} nodes{id status title dwc:metafield(namespace:"dwc",key:"manufacturer_sku"){value} cust:metafield(namespace:"custom",key:"manufacturer_sku"){value} variants(first:20){nodes{id sku title price}}}}}`;
const d=(await gql(Q,{c:cur}))?.products; if(!d)break;
for(const p of d.nodes){const mfr=norm(p.dwc?.value||p.cust?.value); if(!mfr)continue;
const rolls=p.variants.nodes.filter(v=>!isSample(v)); if(!rolls.length)continue;
const roll=rolls.reduce((a,b)=>parseFloat(b.price)>parseFloat(a.price)?b:a);
live.push({mfr,pid:p.id,vid:roll.id,cur:parseFloat(roll.price),status:p.status,title:p.title});}
if(!d.pageInfo.hasNextPage)break; cur=d.pageInfo.endCursor;
}
console.log(`live JS w/ mfr_sku+roll: ${live.length} | mode: ${APPLY?'⚠️ LIVE APPLY':'DRY-RUN'}${INCLUDE_DOWN?' +DOWN':''}`);
const b={up:[],down:[],fix:[],already:0,anomaly:[],mismatch:[],notmaster:0,notsell:0,badprice:0};
for(const p of live){
const m=M.get(p.mfr); if(!m){b.notmaster++;continue;}
if(!['Active','New'].includes(m.status)||p.status!=='ACTIVE'){b.notsell++;continue;}
const map=m.newp,cur=p.cur;
if(!Number.isFinite(map)||map<=0){b.badprice++;continue;} // dirty master-priced-by-sku.csv -> NaN/0 target; never write it
if(Math.abs(cur-map)<0.01){b.already++;continue;}
const rps=RPS.get(p.mfr); if(rps&&Math.abs(cur-rps)<0.01){b.already++;continue;} // already at valid single-roll price (naturals) — not a reprice
const vm=VMAP.get(p.mfr); if(vm&&[...vm].some(v=>Math.abs(cur-v)<0.01)){b.already++;continue;} // already at a valid master MAP row (dup-MAP SKUs) — not a reprice
if(m.pattern&&!share(p.title,m.pattern)){b.mismatch.push({mfr:p.mfr,master:m.pattern,live:p.title});continue;}
const rec={pid:p.pid,vid:p.vid,mfr:p.mfr,cur,map,fb:m.fb,title:p.title};
if(cur<=10)b.fix.push(rec); else if(map/cur>2.5||map/cur<0.4)b.anomaly.push(rec);
else if(map>cur)b.up.push(rec); else b.down.push(rec);
}
console.log(` already at MAP: ${b.already}`);
console.log(` UP to MAP (increases): ${b.up.length} | fix garbage(<=$10): ${b.fix.length}`);
console.log(` DOWN to MAP (decreases): ${b.down.length} ${INCLUDE_DOWN?'(WILL apply)':'(HELD unless --include-down)'}`);
console.log(` ⚠ anomaly(skip): ${b.anomaly.length} | ⚠ title-mismatch(skip): ${b.mismatch.length}`);
console.log(` live-not-in-master: ${b.notmaster} | not-sellable/active(skip): ${b.notsell} | ⚠ bad master price NaN/0(skip): ${b.badprice}`);
const toWrite=[...b.up,...b.fix,...(INCLUDE_DOWN?b.down:[])];
console.log(` => WOULD WRITE: ${toWrite.length}`);
fs.writeFileSync('data/bridge-reprice-plan.json',JSON.stringify(b,null,2));
console.log(' up samples:'); for(const r of b.up.slice(0,6))console.log(` ${r.mfr} ${r.title.slice(0,28)}: $${r.cur}->$${r.map.toFixed(2)}`);
if(b.down.length){console.log(' DOWN samples:'); for(const r of b.down.slice(0,6))console.log(` ${r.mfr} ${r.title.slice(0,28)}: $${r.cur}->$${r.map.toFixed(2)}`);}
if(b.mismatch.length){console.log(' ⚠ title-mismatch (skipped):'); for(const r of b.mismatch.slice(0,8))console.log(` ${r.mfr} master="${r.master}" live="${r.live.slice(0,34)}"`);}
if(!APPLY){console.log('\nDRY-RUN. Apply UP+fix: node bridge-reprice.mjs --apply --i-am-steve (add --include-down for decreases)');process.exit(0);}
const Mut=`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkUpdate(productId:$pid,variants:$variants){userErrors{message}}}`;
let done=0,err=0;
for(const r of toWrite){try{const d=await gql(Mut,{pid:r.pid,variants:[{id:r.vid,price:r.map.toFixed(2)}]});const ue=d.productVariantsBulkUpdate?.userErrors||[];if(ue.length){err++;if(err<=10)console.log('err',r.mfr,JSON.stringify(ue));}else done++;}catch(e){err++;}if((done+err)%200===0)console.log(` ${done} ok / ${err} err`);}
console.log(`\nDONE — ${done} set, ${err} err.`);