← back to Mfr Review Viewer Corruption
scripts/vendor-sweep-analyze.mjs
146 lines
#!/usr/bin/env node
// vendor-sweep-analyze.mjs — READ-ONLY batch analyzer for the "DW# == Mfr SKU" sweep.
//
// For ONE vendor, enumerate the DW#==mfr placeholder SKUs from dw_unified
// (mfr_sku == numeric tail of dw_sku, numeric-only i.e. no real alpha code), then
// per SKU reuse lib/candidates.mjs candidatesForSku() to resolve the FileMaker
// masters + the delete/keep suggestion, and aggregate a plan JSON.
//
// PURELY READ-ONLY: psql SELECTs + FileMaker _find reads via candidatesForSku.
// Writes ONLY its own plan JSON to data/plans/<vendor>-<ISO>.json. Never touches
// dw_unified, Shopify, or FileMaker records. Paces FM reads gently.
//
// Usage:
// node scripts/vendor-sweep-analyze.mjs "Glitter Walls"
// node scripts/vendor-sweep-analyze.mjs "Glitter Walls" --limit 20 # sample run
import { execFile } from 'node:child_process';
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dir = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.join(__dir, '..');
const PLANS_DIR = path.join(ROOT, 'data', 'plans');
const PSQL = process.env.PSQL_BIN || '/opt/homebrew/opt/postgresql@14/bin/psql';
function arg(k) { const i = process.argv.indexOf('--' + k); return i >= 0 ? process.argv[i + 1] : undefined; }
const VENDOR = process.argv.slice(2).find((a) => !a.startsWith('--'));
const LIMIT = arg('limit') ? Number(arg('limit')) : null;
const PACE_MS = arg('pace') ? Number(arg('pace')) : 250; // gentle FM pacing
if (!VENDOR) { console.error('Usage: vendor-sweep-analyze.mjs "<vendor>" [--limit N]'); process.exit(2); }
function psql(sql) {
return new Promise((resolve) => {
execFile(PSQL, ['dw_unified', '-tAc', sql], { maxBuffer: 1024 * 1024 * 64 }, (err, stdout) => {
if (err) return resolve([]);
resolve((stdout || '').trim().split('\n').filter(Boolean));
});
});
}
// Enumerate the vendor's DW#==mfr placeholder base SKUs (strip -Sample, dedupe).
async function enumerateSkus(vendor) {
const esc = vendor.replace(/'/g, "''");
// SCOPE (Steve 2026-08-27, "only look at active patterns"): ACTIVE-only.
const statusFilter = (process.env.STATUS_FILTER || 'ACTIVE').trim().toUpperCase();
const statusClause = statusFilter === 'ALL' ? '' : `\n AND status='${statusFilter.replace(/'/g, "''")}'`;
const sql = `
SELECT DISTINCT regexp_replace(dw_sku, '[-_ ]?[Ss]ample$', '') AS base_sku
FROM shopify_products
WHERE vendor ILIKE '%${esc}%'${statusClause}
AND mfr_sku IS NOT NULL AND mfr_sku <> ''
AND mfr_sku !~ '[A-Za-z]'
AND regexp_replace(coalesce(mfr_sku,''),'[^0-9]','','g') <> ''
AND regexp_replace(coalesce(mfr_sku,''),'[^0-9]','','g')
= regexp_replace(coalesce(dw_sku,''),'[^0-9]','','g')
ORDER BY base_sku`;
const rows = await psql(sql);
return rows.map((r) => r.trim()).filter(Boolean);
}
async function main() {
console.log(`\n=== vendor-sweep-analyze (READ-ONLY): "${VENDOR}" ===`);
let skus = await enumerateSkus(VENDOR);
const totalFound = skus.length;
if (LIMIT) skus = skus.slice(0, LIMIT);
console.log(`DW#==mfr placeholder SKUs found: ${totalFound}${LIMIT ? ` (analyzing first ${skus.length})` : ''}\n`);
const { candidatesForSku } = await import('file://' + path.join(ROOT, 'lib', 'candidates.mjs'));
const plan = [];
let recordsToDelete = 0, mastersToFix = 0, skippedGuard = 0, noFmMatch = 0;
const sampleReport = [];
for (let i = 0; i < skus.length; i++) {
const sku = skus[i];
let out;
try { out = await candidatesForSku(sku); }
catch (e) { console.log(` ${sku}: candidatesForSku error — ${e.message}`); noFmMatch++; continue; }
const fmRecs = out.filemaker || [];
const sug = out.suggestion || { realMfr: '', keepRid: '', deleteRids: [] };
if (!fmRecs.length) { noFmMatch++; if (i < skus.length) process.stdout.write(`.`); continue; }
const deleteRids = (sug.deleteRids || []).map(String);
if (!deleteRids.length) {
// no delete suggested — either nothing to prune or the guard blocked it
// (guard = no keeper survives). Detect the guard case: placeholders exist but canPrune=false.
const placeholders = fmRecs.filter((r) => r.mfrPlaceholder && !r.sampleOrdered);
const keepers = fmRecs.filter((r) => r.sampleOrdered || (!r.mfrPlaceholder && /[A-Za-z]/.test(r.noteMfr || r.mfrPattern || '')));
if (placeholders.length && !keepers.length) { skippedGuard++; }
if (i < skus.length) process.stdout.write(`.`);
await new Promise((r) => setTimeout(r, PACE_MS));
continue;
}
plan.push({
dw_sku: sku,
keepRecordId: String(sug.keepRid || ''),
realMfr: String(sug.realMfr || ''),
deleteRecordIds: deleteRids,
});
recordsToDelete += deleteRids.length;
if (sug.keepRid && sug.realMfr) mastersToFix++;
if (sampleReport.length < 6) {
sampleReport.push({ dw_sku: sku, keep: sug.keepRid, realMfr: sug.realMfr, delete: deleteRids });
}
process.stdout.write(`+`);
await new Promise((r) => setTimeout(r, PACE_MS));
}
console.log('\n');
// ---- write the plan JSON ----
if (!existsSync(PLANS_DIR)) mkdirSync(PLANS_DIR, { recursive: true });
const iso = new Date().toISOString().replace(/[:.]/g, '-');
const slug = VENDOR.replace(/[^A-Za-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const planFile = path.join(PLANS_DIR, `${slug}-${iso}.json`);
const counts = {
vendor: VENDOR,
generated_at: new Date().toISOString(),
skus_analyzed: skus.length,
skus_found_total: totalFound,
skus_with_deletes: plan.length,
records_to_delete: recordsToDelete,
masters_to_fix: mastersToFix,
skus_skipped_by_guard: skippedGuard,
skus_no_fm_match: noFmMatch,
};
writeFileSync(planFile, JSON.stringify({ counts, plan }, null, 2));
console.log('=== COUNTS ===');
for (const [k, v] of Object.entries(counts)) console.log(` ${k.padEnd(22)}: ${v}`);
console.log(`\nplan written: ${path.relative(ROOT, planFile)}`);
if (sampleReport.length) {
console.log('\n=== sample SKUs ===');
for (const s of sampleReport) {
console.log(` ${s.dw_sku} keep=${s.keep || '(none)'} realMfr=${s.realMfr || '(none)'} delete=[${s.delete.join(', ')}]`);
}
}
console.log('\n[READ-ONLY — no records written. Execute with scripts/execute-sweep.mjs --plan <file> (dry-run) then --apply]');
}
main().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });