← back to Tk 11331 Exec
d3b_discontinue.mjs
99 lines
// TK-11331 DECISION-3b (follow-on B) — DISCONTINUE-review handling on the Hollywood
// Wallcoverings (Momentum private-label) candidates.
// GONE-AT-SOURCE (34: Hallandale + Shubert) : ACTIVE -> DRAFT (REVERSIBLE) + add tag
// 'discontinue-review'. NEVER hard-archive.
// JUNK-TEST (1: test1 / XBG-44000) : ACTIVE -> ARCHIVED (junk).
// STILL-LIVE (5: Hollywood Lounge Chevron): EXCLUDED — do NOT touch (flag for re-map).
// INCONCLUSIVE (82) : leave as-is, no change.
//
// Rule order: PostgreSQL dw_unified MIRROR first (Mac2-local socket, immediate local reads),
// THEN Shopify (authoritative). Kamatera shopify_products is canonical for prod-serving and
// will re-sync from Shopify — we FLAG it, never cross-write.
// Records a restore map (old status + old tags) BEFORE each write. Verify = Admin re-read.
// DRY-RUN by default. --apply to write.
import fs from 'fs';
import {execSync} from 'child_process';
import {gql,sleep} from './lib.mjs';
const APPLY=process.argv.includes('--apply');
const SRC='data/d3b-discontinue-candidates.json';
const RESTORE='data/d3b-discontinue-restore.jsonl';
const REVIEW_TAG='discontinue-review';
const all=JSON.parse(fs.readFileSync(SRC,'utf8'));
const psql=sql=>execSync(`psql -h /tmp -d dw_unified -Atc ${JSON.stringify(sql)}`,{encoding:'utf8'}).trim();
const pgLit=s=>"'"+String(s).replace(/'/g,"''")+"'";
// Serialize a JS string[] into a Postgres text-array literal exactly like the stored format.
const toPgArray=arr=>'{'+arr.map(t=>'"'+String(t).replace(/\\/g,'\\\\').replace(/"/g,'\\"')+'"').join(',')+'}';
const READ=`query($id:ID!){product(id:$id){id status tags handle title}}`;
const UPDATE=`mutation($in:ProductInput!){productUpdate(input:$in){product{id status tags} userErrors{field message}}}`;
const VERIFY=`query($id:ID!){product(id:$id){status tags}}`;
const GONE=all.filter(r=>r.bucket==='CONFIRMED-GONE-AT-SOURCE');
const JUNK=all.filter(r=>r.bucket==='JUNK-TEST');
const LIVE=all.filter(r=>r.bucket==='STILL-LIVE');
const INC =all.filter(r=>r.bucket==='INCONCLUSIVE');
console.log(`${APPLY?'APPLY':'DRY-RUN'} D3b discontinue — GONE=${GONE.length} JUNK=${JUNK.length} STILL-LIVE(excluded)=${LIVE.length} INCONCLUSIVE(untouched)=${INC.length}`);
const rstream=APPLY?fs.createWriteStream(RESTORE,{flags:'a'}):null;
let goneDone=0,junkDone=0,fail=0,skip=0; const fails=[];
async function apply(row, targetStatus, addTag){
const gid=`gid://shopify/Product/${row.pid}`;
const p=(await gql(READ,{id:gid})).product;
if(!p){ fail++; fails.push({pid:row.pid,dw_sku:row.dw_sku,reason:'product-not-found'}); console.log(` ERR ${row.dw_sku} not found`); return; }
if(p.status===targetStatus && (!addTag || (p.tags||[]).includes(REVIEW_TAG))){
skip++; console.log(` SKIP ${row.dw_sku} already status=${p.status}${addTag?' +tag':''}`); return; }
const oldTags=(p.tags||[]).slice();
const newTags=addTag ? Array.from(new Set([...oldTags,REVIEW_TAG])) : oldTags;
if(!APPLY){
console.log(` would ${p.status}->${targetStatus} ${row.dw_sku} (${row.title})${addTag&&!oldTags.includes(REVIEW_TAG)?` +tag ${REVIEW_TAG}`:''}`);
if(targetStatus==='DRAFT') goneDone++; else junkDone++; return; }
// restore record BEFORE write
const rec={ts:new Date().toISOString(),pid:row.pid,gid,dw_sku:row.dw_sku,bucket:row.bucket,
old_status:p.status,new_status:targetStatus,old_tags:oldTags,tag_added:addTag&&!oldTags.includes(REVIEW_TAG)?REVIEW_TAG:null,
undo:targetStatus==='DRAFT'?'set status ACTIVE + remove discontinue-review tag':'set status ACTIVE (unarchive)'};
rstream.write(JSON.stringify(rec)+'\n');
// 1) PG mirror first
try{
let sql=`update shopify_products set status=${pgLit(targetStatus)}`;
if(addTag && !oldTags.includes(REVIEW_TAG)) sql+=`, tags=${pgLit(toPgArray(newTags))}`;
sql+=` where shopify_id=${pgLit(gid)};`;
psql(sql);
}catch(e){ fail++; fails.push({pid:row.pid,dw_sku:row.dw_sku,stage:'pg',err:String(e).slice(0,200)}); console.log(` PG-ERR ${row.dw_sku}`); return; }
// 2) Shopify authoritative
const input={id:gid,status:targetStatus};
if(addTag) input.tags=newTags;
const res=await gql(UPDATE,{in:input});
const ue=res.productUpdate.userErrors;
if(ue.length){ fail++; fails.push({pid:row.pid,dw_sku:row.dw_sku,stage:'shopify',ue}); console.log(` SHOP-ERR ${row.dw_sku}`,JSON.stringify(ue)); return; }
await sleep(300);
// 3) VERIFY Admin re-read
const after=(await gql(VERIFY,{id:gid})).product;
const okStatus=after.status===targetStatus;
const okTag=!addTag || (after.tags||[]).includes(REVIEW_TAG);
if(!(okStatus&&okTag)){ fail++; fails.push({pid:row.pid,dw_sku:row.dw_sku,stage:'verify',status:after.status,hasTag:(after.tags||[]).includes(REVIEW_TAG)}); console.log(` VERIFY-FAIL ${row.dw_sku} status=${after.status} tag=${(after.tags||[]).includes(REVIEW_TAG)}`); return; }
if(targetStatus==='DRAFT') goneDone++; else junkDone++;
console.log(` OK ${row.dw_sku} ${rec.old_status}->${after.status}${addTag?` +${REVIEW_TAG}`:''} | ${row.title}`);
await sleep(150);
}
(async()=>{
console.log('\n-- GONE-AT-SOURCE -> DRAFT + discontinue-review --');
for(const r of GONE) await apply(r,'DRAFT',true);
console.log('\n-- JUNK-TEST -> ARCHIVED --');
for(const r of JUNK) await apply(r,'ARCHIVED',false);
console.log('\n-- STILL-LIVE: EXCLUDED, untouched --');
for(const r of LIVE) console.log(` LEAVE ${r.dw_sku} (${r.title}) — flag for re-map`);
if(rstream) rstream.end();
console.log(`\nD3b discontinue ${APPLY?'APPLIED':'DRY'} — gone->draft=${goneDone} junk->archived=${junkDone} fail=${fail} skip=${skip} | still-live-left=${LIVE.length} inconclusive-left=${INC.length}`);
if(fails.length) fs.writeFileSync('data/d3b-discontinue-fails.json',JSON.stringify(fails,null,1));
process.exit(fail?1:0);
})();