← 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(', '));
}
}