← back to Hollywood Import
tk11337-danapoint-promote.mjs
128 lines
// TK-11337 — promote the 1 genuine sample-only residual (Dana Point – Split Rock, pid 7694269251635)
// to a sellable per-yard product, matching the proven 919/812 Hollywood standard.
// • add "Sold Per Yard" variant @ $56.83 (wholesale 39.25 × 1.448, confirmed), sku XZW-511904-yard,
// inventoryPolicy CONTINUE, tracked, on_hand 2026 @ Location/5795643504
// • reorder → yard pos1, sample pos2
// • relabel sample sku DWHD-500256-Sample → XZW-511904-sample (fresh opaque XZW identity; no vendor leak)
// • metafields: global.unit_of_measure='Sold Per Yard', v_prods_quantity_order_min='5',
// v_prods_quantity_order_units='1', global.dw_sku / dwc.dw_sku='XZW-511904', global.length='30 yards'
// • register XZW-511904 in dw_sku_registry; update momentum_colorways.dw_sku (Mac2 staging)
// • NO "Type II" (product is 10.72oz, 60% glass fiber / 40% bio-based — Type II would be a false spec)
// Capture-first restore-map; --apply gated; --rollback reverses via the captured map.
import fs from 'node:fs'; import { execFileSync } from 'node:child_process';
const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env','utf8');
const STORE=(env.match(/^SHOPIFY_STORE=(.+)$/m)||[])[1].trim();
const TOKEN=((env.match(/^SHOPIFY_FULL_ACCESS_TOKEN=(.+)$/m)||env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m))||[])[1].trim(); // FULL first (needs write_inventory)
const API=`https://${STORE}/admin/api/2024-10`;
const GQL=`${API}/graphql.json`;
const H={'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'};
const PID='7694269251635', GID=`gid://shopify/Product/${PID}`;
const DW='XZW-511904', YARD_SKU=`${DW}-yard`, SAMP_SKU=`${DW}-sample`, PRICE='56.83';
const LOC='gid://shopify/Location/5795643504', QTY=2026;
const MFR='WSW-AL-03', HANDLE='alys-texture-ws-split-rock-wallcovering';
const MAP=new URL('tk11337-danapoint-restore-map.json',import.meta.url).pathname;
const APPLY=process.argv.includes('--apply'), ROLLBACK=process.argv.includes('--rollback');
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function gql(q,v){const r=await fetch(GQL,{method:'POST',headers:H,body:JSON.stringify({query:q,variables:v})});const j=await r.json();if(j.errors)throw new Error(JSON.stringify(j.errors));return j.data;}
async function rest(path,method,body){const r=await fetch(`${API}/${path}`,{method,headers:H,body:body?JSON.stringify(body):undefined});const t=await r.text();let j;try{j=JSON.parse(t)}catch{j={raw:t}}if(r.status>=300)throw new Error(`${method} ${path} → ${r.status} ${t.slice(0,200)}`);return j;}
const psql=sql=>execFileSync('psql',['-d','dw_unified','-tA','-c',sql],{env:{...process.env,PGHOST:'/tmp'},encoding:'utf8'}).trim();
const LOOKUP=`query($id:ID!){node(id:$id){... on Product{id status options{name} variants(first:20){nodes{id title price sku inventoryPolicy inventoryItem{id}}}}}}`;
const BULK=`mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$variants){productVariants{id price sku title inventoryItem{id}} userErrors{field message}}}`;
const M_TRACK=`mutation($id:ID!){inventoryItemUpdate(id:$id,input:{tracked:true}){userErrors{message}}}`;
const M_ACT=`mutation($iid:ID!,$loc:ID!){inventoryActivate(inventoryItemId:$iid,locationId:$loc){userErrors{message}}}`;
const M_QTY=`mutation($input:InventorySetQuantitiesInput!){inventorySetQuantities(input:$input){userErrors{message}}}`;
async function capture(){
if(fs.existsSync(MAP)){const s=JSON.parse(fs.readFileSync(MAP,'utf8'));console.log(' (preserving existing restore-map baseline ts='+s.ts+')');return s;}
const p=(await gql(LOOKUP,{id:GID})).node;
const mf=(await rest(`products/${PID}/metafields.json?limit=250`,'GET')).metafields||[];
const snap={ts:new Date().toISOString(),pid:PID,handle:HANDLE,status:p.status,options:p.options,
variants:p.variants.nodes.map(v=>({id:v.id,title:v.title,price:v.price,sku:v.sku,policy:v.inventoryPolicy})),
mint:DW, existing_metafield_keys:mf.filter(m=>['global','dwc'].includes(m.namespace)).map(m=>`${m.namespace}.${m.key}`)};
fs.writeFileSync(MAP,JSON.stringify(snap,null,2));
return snap;
}
async function upsertMF(namespace,key,type,value){
await rest(`products/${PID}/metafields.json`,'POST',{metafield:{namespace,key,type,value}}).catch(async e=>{
// if exists, find + update
const mf=(await rest(`products/${PID}/metafields.json?limit=250`,'GET')).metafields||[];
const ex=mf.find(m=>m.namespace===namespace&&m.key===key);
if(ex) await rest(`metafields/${ex.id}.json`,'PUT',{metafield:{id:ex.id,type,value}}); else throw e;
});
}
(async()=>{
if(ROLLBACK){
const snap=JSON.parse(fs.readFileSync(MAP,'utf8'));
console.log('ROLLBACK from',snap.ts);
const p=(await gql(LOOKUP,{id:GID})).node;
// delete the yard variant
const yard=p.variants.nodes.find(v=>v.sku===YARD_SKU);
if(yard){await rest(`products/${PID}/variants/${yard.id.split('/').pop()}.json`,'DELETE');console.log(' deleted yard variant');}
// restore sample sku
const samp=p.variants.nodes.find(v=>/-sample$/i.test(v.sku));
const oldSamp=snap.variants[0];
if(samp&&oldSamp&&samp.sku!==oldSamp.sku){await rest(`products/${PID}/variants/${samp.id.split('/').pop()}.json`,'PUT',{variant:{id:samp.id.split('/').pop(),sku:oldSamp.sku}});console.log(' restored sample sku →',oldSamp.sku);}
// delete metafields we created (those NOT in existing_metafield_keys)
const created=[['global','unit_of_measure'],['global','v_prods_quantity_order_min'],['global','v_prods_quantity_order_units'],['global','dw_sku'],['dwc','dw_sku'],['global','length']].filter(([ns,k])=>!snap.existing_metafield_keys.includes(`${ns}.${k}`));
const mf=(await rest(`products/${PID}/metafields.json?limit=250`,'GET')).metafields||[];
for(const [ns,k] of created){const ex=mf.find(m=>m.namespace===ns&&m.key===k);if(ex){await rest(`metafields/${ex.id}.json`,'DELETE');console.log(' deleted mf',ns+'.'+k);}}
psql(`DELETE FROM dw_sku_registry WHERE dw_sku='${DW}';`);
psql(`UPDATE momentum_colorways SET dw_sku=NULL WHERE (upper(pattern_sku)='${MFR}' OR upper(alt_sku)='${MFR}') AND dw_sku='${DW}';`);
console.log(' registry + staging reverted. ROLLBACK done.');
return;
}
const snap=await capture();
console.log('CAPTURED restore-map →',MAP);
console.log('BEFORE:',JSON.stringify(snap.variants),'options:',JSON.stringify(snap.options));
console.log(`PLAN: +"Sold Per Yard" $${PRICE} sku ${YARD_SKU} (CONTINUE, qty ${QTY}); sample→${SAMP_SKU}; yard pos1; mint ${DW}; 5yd min; NO Type II.`);
if(!APPLY){console.log('\nDRY-RUN. Re-run with --apply to execute.');return;}
const p=snap; const optName=(p.options[0]&&p.options[0].name)||'Title';
// 1) ensure yard variant (idempotent resume)
let live=(await gql(LOOKUP,{id:GID})).node.variants.nodes;
let yardV=live.find(v=>v.sku===YARD_SKU);
if(yardV){console.log(' = yard variant already exists',yardV.id,'(resuming)');}
else{
if(!(live.length===1&&parseFloat(live[0].price)<=4.30)){console.log('GUARD: not sample-only and no yard variant — aborting, no change.');return;}
const r=await gql(BULK,{pid:GID,variants:[{price:PRICE,optionValues:[{optionName:optName,name:'Sold Per Yard'}],inventoryPolicy:'CONTINUE',inventoryItem:{sku:YARD_SKU,tracked:true}}]});
const ue=r.productVariantsBulkCreate.userErrors||[];if(ue.length)throw new Error('bulkCreate: '+JSON.stringify(ue));
yardV=r.productVariantsBulkCreate.productVariants.find(v=>v.sku===YARD_SKU);
console.log(' ✓ yard variant',yardV.id,yardV.sku,'$'+yardV.price);
}
// self-heal sample if consumed
let after=(await gql(LOOKUP,{id:GID})).node.variants.nodes;
if(!after.some(v=>/sample/i.test(v.title)||/-sample$/i.test(v.sku||''))){
await gql(BULK,{pid:GID,variants:[{price:'4.25',optionValues:[{optionName:optName,name:'Sample'}],inventoryPolicy:'DENY',inventoryItem:{sku:SAMP_SKU,tracked:false}}]});
console.log(' ✓ sample re-healed');
after=(await gql(LOOKUP,{id:GID})).node.variants.nodes;
}
// 2) inventory 2026 on yard — price>0 guard (mirrors the create-path sample-only guard; TK-11357 $0-orderable class):
// on the RESUME path yardV is an EXISTING variant; never stamp on_hand=2026 + CONTINUE onto a $0/priceless variant,
// which would make it checkout-orderable for $0.00. Create path always sets PRICE (56.83) so this passes there.
const yardPrice=parseFloat(yardV.price);
if(!(yardPrice>0)){console.log(`GUARD: yard variant price is ${yardV.price==null?'null':'$'+yardV.price} — refusing to stamp on_hand=${QTY} (CONTINUE) on a $0/priceless variant (TK-11357). No inventory change.`);return;}
// 2) inventory 2026 on yard
if(yardV.inventoryItem?.id){const iid=yardV.inventoryItem.id;await gql(M_TRACK,{id:iid});await gql(M_ACT,{iid,loc:LOC});await gql(M_QTY,{input:{name:'on_hand',reason:'correction',ignoreCompareQuantity:true,quantities:[{inventoryItemId:iid,locationId:LOC,quantity:QTY}]}});console.log(' ✓ inventory 2026');}
// 3) relabel sample sku (only if it still carries the old code, and wasn't just re-healed to SAMP_SKU)
const samp=after.find(v=>/sample/i.test(v.title)||/-sample$/i.test(v.sku||''));
if(samp&&samp.sku!==SAMP_SKU){const nid=samp.id.split('/').pop();await rest(`products/${PID}/variants/${nid}.json`,'PUT',{variant:{id:nid,sku:SAMP_SKU}});console.log(' ✓ sample sku →',SAMP_SKU);}
// 4) reorder: yard pos1, sample pos2
const yId=yardV.id.split('/').pop(); const sId=(after.find(v=>/sample/i.test(v.title)||/-sample$/i.test(v.sku||'')).id).split('/').pop();
await rest(`products/${PID}.json`,'PUT',{product:{id:PID,variants:[{id:yId,position:1},{id:sId,position:2}]}});
console.log(' ✓ reordered (yard pos1)');
// 5) metafields
await upsertMF('global','unit_of_measure','single_line_text_field','Sold Per Yard');
await upsertMF('global','v_prods_quantity_order_min','single_line_text_field','5');
await upsertMF('global','v_prods_quantity_order_units','single_line_text_field','1');
await upsertMF('global','length','single_line_text_field','30 yards');
await upsertMF('global','dw_sku','single_line_text_field',DW);
await upsertMF('dwc','dw_sku','single_line_text_field',DW);
console.log(' ✓ metafields set (unit/min/units/length/dw_sku ×2) — NO Type II');
// 6) register + staging
psql(`INSERT INTO dw_sku_registry (dw_sku,vendor_prefix,vendor_name,mfr_sku,shopify_product_id,shopify_handle,status,min_order_qty,order_increment,created_at,updated_at) VALUES ('${DW}','XZW','Hollywood Wallcoverings','${MFR}',${PID},'${HANDLE}','ACTIVE',5,1,now(),now()) ON CONFLICT DO NOTHING;`);
psql(`UPDATE momentum_colorways SET dw_sku='${DW}',updated_at=now() WHERE (upper(pattern_sku)='${MFR}' OR upper(alt_sku)='${MFR}');`);
console.log(' ✓ registered XZW-511904 + staging dw_sku set');
console.log('\nDONE.');
})().catch(e=>{console.error('FATAL',e.message);process.exit(1);});