← back to Hollywood Import

generate-stage-report.mjs

69 lines

// Build staged price updates + dry-run report from crawl + vendor_catalog export. READ-ONLY
// (writes only local files). hw_price = round(list × 1.448, 2). Flags anomalies for review.
import fs from 'node:fs';
const MULT = 1.448;
const r2 = (n) => Math.round(n * 100) / 100;
const map = JSON.parse(fs.readFileSync(new URL('pattern-price-map.json', import.meta.url)));
const rows = fs.readFileSync(new URL('vc_hollywood.tsv', import.meta.url), 'utf8').trim().split('\n').map(l => l.split('\t'));

// pattern_slugs with >1 price across cards = ambiguous → exclude from auto-apply
const COLLISION = new Set(['capri']);

const updates = [], discontinued = [], noSlug = [], flags = [];
let unchanged = 0;
for (const [id, dw, mfr, pat, col, slug, list, hw, uom] of rows) {
  if (!slug) { noSlug.push({ id, dw, mfr, pat, col }); continue; }
  const ps = slug.split('-')[0];
  const hit = map[ps];
  if (!hit) { discontinued.push({ id, dw, mfr, pat, col, slug, oldList: list }); continue; }
  const newList = hit.price;
  const newHw = r2(newList * MULT);
  const oldList = list ? parseFloat(list) : null;
  const oldHw = hw ? parseFloat(hw) : null;
  const listChanged = oldList == null || Math.abs(oldList - newList) > 0.005;
  const hwChanged = oldHw == null || Math.abs(oldHw - newHw) > 0.005;
  if (!listChanged && !hwChanged) { unchanged++; continue; }
  const rec = { id: +id, dw_sku: dw, mfr_sku: mfr, slug, pattern_slug: ps,
    oldList, newList, oldHw, newHw, unit: hit.unit, oldUom: uom, momentumPattern: hit.pattern };
  // anomaly flags
  if (COLLISION.has(ps)) { rec.flag = 'ambiguous_pattern_slug_multi_price'; flags.push(rec); continue; }
  if (oldList != null && oldList > 0 && Math.abs(newList - oldList) / oldList > 0.25)
    rec.flag = 'big_swing_' + Math.round((newList - oldList) / oldList * 100) + '%';
  if (hit.unit && hit.unit !== 'YD') rec.flag = (rec.flag ? rec.flag + ';' : '') + 'unit_' + hit.unit;
  if (rec.flag) flags.push(rec);
  updates.push(rec);
}

// write staged updates (auto-appliable) + SQL for Kamatera
fs.writeFileSync(new URL('stage-updates.jsonl', import.meta.url), updates.map(u => JSON.stringify(u)).join('\n'));
const sql = updates.map(u =>
  `UPDATE vendor_catalog SET specs = jsonb_set(jsonb_set(jsonb_set(specs,'{list_price}','"${u.newList}"'),'{hw_price}','"${u.newHw}"'),'{retail_price}','"${u.newHw}"') WHERE id=${u.id};`
).join('\n');
fs.writeFileSync(new URL('apply-vendor-catalog.sql', import.meta.url),
  '-- RUN ON KAMATERA (canonical). vendor_catalog is replicated FROM Kamatera → Mac2.\n' +
  '-- Refreshes list_price + hw_price(=list×1.448) + retail_price for ' + updates.length + ' rows.\nBEGIN;\n' + sql + '\nCOMMIT;\n');

const report = {
  generated: 'dry-run (no writes)',
  multiplier: MULT,
  totals: {
    vendor_catalog_rows: rows.length,
    matched_live_momentum: updates.length + unchanged + flags.filter(f=>f.flag?.startsWith('ambig')).length,
    to_update: updates.length,
    unchanged_confirmed: unchanged,
    discontinued_on_momentum: discontinued.length,
    no_momentum_slug_unmatched: noSlug.length,
  },
  flags_count: flags.length,
  unit_breakdown: updates.reduce((a,u)=>{a[u.unit]=(a[u.unit]||0)+1;return a;},{}),
  sample_changes: updates.filter(u=>!u.flag).slice(0, 10),
  flagged_samples: flags.slice(0, 15),
};
fs.writeFileSync(new URL('momentum-price-report.json', import.meta.url), JSON.stringify(report, null, 2));
// discontinued + noSlug lists for Steve
fs.writeFileSync(new URL('discontinued-on-momentum.tsv', import.meta.url),
  'dw_sku\tmfr_sku\tpattern\tcolor\tslug\toldList\n' + discontinued.map(d=>[d.dw,d.mfr,d.pat,d.col,d.slug,d.oldList].join('\t')).join('\n'));
fs.writeFileSync(new URL('unmatched-no-slug.tsv', import.meta.url),
  'dw_sku\tmfr_sku\tpattern\tcolor\n' + noSlug.map(d=>[d.dw,d.mfr,d.pat,d.col].join('\t')).join('\n'));
console.log(JSON.stringify(report, null, 2));