← back to Harlequin Sample Price Analysis

scripts/tk10870-read-live.mjs

125 lines

#!/usr/bin/env node
// TK-10870 Action 1 — READ live Shopify variants for the 27 reprice candidates
// + the 2 collision products. Produces a plan of writes/skips + restore-map.
// READ-ONLY. No mutations.
import { readFileSync, writeFileSync } from 'node:fs';

const ENV = readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
const TOKEN = ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m)[1].trim();
const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';

async function gql(query, variables) {
  const r = await fetch(`https://${DOMAIN}/admin/api/${API}/graphql.json`, {
    method: 'POST',
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  const j = await r.json();
  if (j.errors) throw new Error('GQL errors: ' + JSON.stringify(j.errors));
  return j.data;
}

const plan = JSON.parse(readFileSync('/Users/macstudio3/Projects/harlequin-sample-price-analysis/artifacts/reprice-plan.json', 'utf8'));

// Candidates: 27 reprice_now, but EXCLUDE the two collisions per instructions.
const EXCLUDE = new Set(['DWHQ-335002', 'DWHQ-335024']);
const candidates = plan.reprice_now.filter(c => !EXCLUDE.has(c.dwhq_sku));

// Also read the 2 collision products for Action 3 context.
const collisionProductIds = ['7787420680243']; // Cranes shared product (335001/335002)
// DWHQ-335024 Locronan/Demoiselle — need to find its shopify_product_id; not in reprice_now. Read separately later.

const Q = `query($id: ID!) {
  product(id: $id) {
    id
    title
    vendor
    status
    variants(first: 50) {
      nodes { id title price sku inventoryItem { id } }
    }
  }
}`;

const results = [];
const writes = [];
const skips = [];
const restoreMap = [];

for (const c of candidates) {
  const gid = `gid://shopify/Product/${c.shopify_product_id}`;
  let data;
  try {
    data = await gql(Q, { id: gid });
  } catch (e) {
    results.push({ dwhq_sku: c.dwhq_sku, error: String(e) });
    continue;
  }
  const p = data.product;
  if (!p) { results.push({ dwhq_sku: c.dwhq_sku, error: 'product not found', gid }); continue; }
  const variants = p.variants.nodes;
  // Sample variant = title/sku contains -Sample; roll variant = the non-sample sellable one.
  const sampleV = variants.find(v => /sample/i.test(v.sku || '') || /sample/i.test(v.title || ''));
  const rollVs = variants.filter(v => v !== sampleV);
  const rec = {
    dwhq_sku: c.dwhq_sku, pattern: c.pattern, color: c.color, gid,
    title: p.title, status: p.status,
    target_retail: c.target_retail,
    variant_count: variants.length,
    variants: variants.map(v => ({ id: v.id, title: v.title, price: v.price, sku: v.sku })),
    roll_variant_count: rollVs.length,
  };
  if (rollVs.length === 0) {
    rec.disposition = 'NO_ROLL_VARIANT — sample-only live, cannot reprice (would need new variant, out of scope)';
    skips.push(rec);
  } else if (rollVs.length > 1) {
    rec.disposition = 'MULTIPLE_ROLL_VARIANTS — ambiguous, STOP/draft';
    skips.push(rec);
  } else {
    const rv = rollVs[0];
    rec.roll_variant = { id: rv.id, title: rv.title, price: rv.price, sku: rv.sku };
    const cur = parseFloat(rv.price);
    const tgt = c.target_retail;
    if (Math.abs(cur - tgt) < 0.005) {
      rec.disposition = `ALREADY_AT_TARGET (${cur} == ${tgt}) — SKIP`;
      skips.push(rec);
    } else {
      rec.disposition = `REPRICE ${cur} -> ${tgt}`;
      writes.push(rec);
      restoreMap.push({ dwhq_sku: c.dwhq_sku, gid, variant_id: rv.id, variant_sku: rv.sku, old_price: rv.price, new_price: String(tgt) });
    }
  }
  results.push(rec);
}

// Read collision product(s) too for context (read-only).
const collisions = [];
for (const pid of collisionProductIds) {
  const gid = `gid://shopify/Product/${pid}`;
  try {
    const data = await gql(Q, { id: gid });
    collisions.push(data.product);
  } catch (e) { collisions.push({ error: String(e), pid }); }
}

const out = {
  ticket: 'TK-10870', generated: new Date().toISOString(),
  excluded: [...EXCLUDE],
  summary: { candidates: candidates.length, writes: writes.length, skips: skips.length },
  writes, skips, restoreMap, collisions,
};
const OUT = '/Users/macstudio3/Projects/harlequin-sample-price-analysis/artifacts/tk10870-live-read.json';
writeFileSync(OUT, JSON.stringify(out, null, 2));
console.log('Candidates:', candidates.length, '| Writes needed:', writes.length, '| Skips:', skips.length);
console.log('---WRITES---');
for (const w of writes) console.log(`  ${w.dwhq_sku}  ${w.roll_variant.price} -> ${w.target_retail}  [${w.roll_variant.title}]`);
console.log('---SKIPS---');
for (const s of skips) console.log(`  ${s.dwhq_sku}  ${s.disposition}`);
console.log('---COLLISIONS (read-only context)---');
for (const c of collisions) {
  if (c.error) { console.log('  ERROR', c.pid, c.error); continue; }
  console.log(`  ${c.title} [${c.status}] variants: ${c.variants.nodes.map(v=>`${v.sku}=${v.price}`).join(', ')}`);
}
console.log('\nWrote', OUT);