← back to Dw Sku Integrity

apply-plans/phillip-jeffries-tk11131/apply-3-ambiguous.mjs

121 lines

// GATED — customer-facing Shopify write. Do NOT run without Steve's explicit go.
// TK-11131 follow-up: the 3 Savile Suiting Pinstripe SKUs left out of the 897-item run.
// Idempotent per-FIELD: custom + global are evaluated and written INDEPENDENTLY, so
// a row with only one field populated still gets the other backfilled, and a field
// already set is never overwritten. The undo-map is written from the SET-batch
// SUCCESS result (only rows actually written), so it is a reliable rollback source.
import fs from 'fs';

const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const DRY = !process.argv.includes('--apply');

const rows = JSON.parse(fs.readFileSync(new URL('./backfill-mapping-3-ambiguous.json', import.meta.url)));

async function gql(query, variables) {
  const r = await fetch(`https://${SHOP}/admin/api/2024-10/graphql.json`, {
    method: 'POST',
    headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables })
  });
  return r.json();
}

const CHECK_Q = `query($ids: [ID!]!) {
  nodes(ids: $ids) {
    ... on Product {
      id
      m1: metafield(namespace: "custom", key: "manufacturer_sku") { value }
      m2: metafield(namespace: "global", key: "manufacturer_sku") { value }
    }
  }
}`;
const SET_M = `mutation($metafields: [MetafieldsSetInput!]!) {
  metafieldsSet(metafields: $metafields) {
    metafields { id namespace key value }
    userErrors { field message }
  }
}`;

const check = await gql(CHECK_Q, { ids: rows.map(r => r.shopify_gid) });
const existing = new Map(check.data.nodes.filter(Boolean).map(n => [n.id, { m1: n.m1?.value, m2: n.m2?.value }]));

// Decide per-FIELD what to write. Skip a row only when BOTH fields are already set;
// a row with exactly one field set still backfills the other.
const toWrite = []; // { row, needCustom, needGlobal }
for (const r of rows) {
  const e = existing.get(r.shopify_gid) || { m1: null, m2: null };
  const needCustom = !e.m1;
  const needGlobal = !e.m2;
  if (!needCustom && !needGlobal) {
    console.log(`SKIP (both already set): ${r.sku} -> custom=${e.m1} global=${e.m2}`);
    continue;
  }
  if (!needCustom || !needGlobal) {
    const fill = needCustom ? 'custom' : 'global';
    console.log(`PARTIAL (backfilling ${fill}): ${r.sku}`);
  }
  toWrite.push({ row: r, needCustom, needGlobal });
}

// AMBIGUOUS-ROW CONFIRMATION GATE (TK-11131 follow-up).
// Every row in backfill-mapping-3-ambiguous.json is a PROVENANCE_REVIEW SKU: it was
// NOT auto-resolved in the 897-item run and its real_mfr_sku is an UNVERIFIED guess
// (e.g. DWJP-15047 & DWJP-15049 share the identical title "Savile Suiting Pinstripe -
// White" with the same candidate set and no recorded justification). Writing that to
// the live customer-facing manufacturer_sku metafield without a human sign-off would
// bake in a guess. So --apply REFUSES to write any ambiguous row unless it carries an
// explicit "confirmed": true in the mapping JSON (added by a human after verifying the
// mapping). No confirmation => dry-run/report only, never a write.
const confirmedWrites = toWrite.filter(w => w.row.confirmed === true);
const unconfirmed = toWrite.filter(w => w.row.confirmed !== true);

if (unconfirmed.length) {
  console.log(`\n⛔ ${unconfirmed.length} ambiguous row(s) NOT confirmed — will NOT be written:`);
  for (const { row } of unconfirmed) {
    console.log(`   NEEDS CONFIRMATION: ${row.sku} "${row.title}" -> ${row.real_mfr_sku} (unverified)`);
  }
  console.log(`   To write one, a human must verify the mapping and add "confirmed": true to that row`);
  console.log(`   in backfill-mapping-3-ambiguous.json.\n`);
}

console.log(`${confirmedWrites.length} confirmed to write, ${unconfirmed.length} awaiting confirmation, out of ${rows.length}.`);
if (DRY) {
  for (const { row, needCustom, needGlobal } of confirmedWrites) {
    const fields = [needCustom && 'custom', needGlobal && 'global'].filter(Boolean).join('+');
    console.log(`DRY: would set ${row.sku} (${row.shopify_gid}) -> ${row.real_mfr_sku} [${fields}]`);
  }
  process.exit(0);
}

if (!confirmedWrites.length) {
  console.log('Nothing confirmed to write — no live metafields changed. (Confirm rows to proceed.)');
  process.exit(0);
}

const metafields = confirmedWrites.flatMap(({ row, needCustom, needGlobal }) => {
  const mf = [];
  if (needCustom) mf.push({ ownerId: row.shopify_gid, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: row.real_mfr_sku });
  if (needGlobal) mf.push({ ownerId: row.shopify_gid, namespace: 'global', key: 'manufacturer_sku', type: 'single_line_text_field', value: row.real_mfr_sku });
  return mf;
});
if (metafields.length) {
  const res = await gql(SET_M, { metafields });
  if (res.errors) { console.error('ERR', JSON.stringify(res.errors)); process.exit(1); }
  const errs = res.data?.metafieldsSet?.userErrors || [];
  if (errs.length) {
    console.error('ERR', JSON.stringify(errs));
  } else {
    // undo-map derived from the SUCCESS result — only rows actually written, only
    // the fields written (before = null, since we only wrote empty fields).
    const undoMap = confirmedWrites.map(({ row, needCustom, needGlobal }) => ({
      gid: row.shopify_gid,
      sku: row.sku,
      wrote: { custom: needCustom, global: needGlobal },
      before: { custom: null, global: null },
    }));
    fs.writeFileSync(new URL('./undo-map-3-ambiguous.json', import.meta.url), JSON.stringify(undoMap, null, 1));
    console.log('SET:', confirmedWrites.map(({ row }) => `${row.sku}->${row.real_mfr_sku}`).join(', '));
  }
}