← back to Tk 11331 Exec

d3c_stage.mjs

149 lines

#!/usr/bin/env node
// TK-11331 D3b follow-on C — STAGE the 1,215 high-confidence new-pattern Momentum→Hollywood
// colorways into momentum_colorways (Mac2-canonical staging). STAGING + ENRICHMENT ONLY.
//   - Insert set = in-scope (Wallcovering/Acoustic) feed rows whose NORMALIZED pattern_name is
//     absent from staging (the 65 entirely-new patterns = 1,215 cw). Extend-pattern + the 6
//     normalized-collision patterns (Cavern II, Rectangular *9/16) are HELD (not inserted).
//   - DEDUP GUARD: INSERT ... ON CONFLICT (pattern_name,color_name) DO NOTHING  (== the DB
//     UNIQUE collation exactly). Reports guard skips.
//   - Pricing: cost=list*0.80 ; hw_price(sell)=list*1.448 (the verified existing markup — NOT list*0.80).
//   - dw_sku LEFT NULL (assign-sku FROZEN, out of scope). pl_brand/pl_real_vendor = table defaults.
//   - Acoustic rows: sub_category='Acoustic Panel' (staging product_type marker; no product_type col).
//   - Settlement: colorways of Hula WC / Sea Line / Palomar / Oakstone get tags=["settlement-review"].
//   - image_url + collection(series) decorated from a cap-safe sharded Meilisearch lookup keyed on `number`.
//   - Reversible: writes data/d3c-stage-restore.jsonl (every inserted id + key). Rollback = DELETE those ids.
// $0 — local psql + plain HTTPS feed reads. Run: NODE_PATH=.../node_modules node d3c_stage.mjs [--commit]
import { createRequire } from 'module';
import fs from 'fs';
import path from 'path';
const require = createRequire(import.meta.url);
const { Pool } = require('pg');

const HERE = path.dirname(new URL(import.meta.url).pathname);
const TSV = path.join(HERE, 'data', 'momentum_feed_full.tsv');
const RESTORE = path.join(HERE, 'data', 'd3c-stage-restore.jsonl');
const RESULTS = path.join(HERE, 'data', 'd3c-stage-results.json');
const COMMIT = process.argv.includes('--commit');

const HOST = 'https://ms-e886719d86e7-4256.sfo.meilisearch.io';
// NOT A SECRET — vendor-owned PUBLIC search key. Momentum ships this exact literal in
// their own ANONYMOUS browser bundle (momentumco.com/build/assets/app-*.js) and it is
// search-scoped only (GET /keys -> 403 invalid_api_key). We do NOT own the Meilisearch
// account, so it is not ours to revoke or rotate. Do not route it through the secrets
// manager and do not re-open it as a leak. Verified read-only 2026-09-10 — TK-11133/TK-11415.
const KEY = process.env.MOMENTUM_MS_KEY || '95fe8376edc78d49e787db49edda68426b21649f6271f49e2f1412118612fbd6';
const INDEX = 'redesign-colors';
const CAT_IDS = [1, 2, 3, 4, 5, 6, 7, 8];
const KEEP = new Set(['Wallcovering', 'Acoustic']);
const SETTLEMENT = new Set(['Hula WC', 'Sea Line', 'Palomar', 'Oakstone']);
const COST_MULT = 0.80, HW_MULT = 1.448;

const pool = new Pool({ connectionString: 'postgresql://dw_admin:DW2024!@127.0.0.1:5432/dw_unified' });
const norm = s => (s || '').replace(/\s+/g, ' ').trim().toLowerCase();
const money = n => (n == null || !(Number(n) > 0)) ? null : Number(Number(n).toFixed(2));

function readTSV() {
  const lines = fs.readFileSync(TSV, 'utf8').split('\n').filter(Boolean);
  const cols = lines[0].split('\t');
  return lines.slice(1).map(l => { const p = l.split('\t'); const o = {}; cols.forEach((c, i) => o[c] = p[i]); return o; });
}

async function fetchImageMap() {
  const map = new Map(); // number -> {image, series}
  for (const cid of CAT_IDS) {
    for (let off = 0; off < 20000; off += 200) {
      const r = await fetch(`${HOST}/indexes/${INDEX}/search`, {
        method: 'POST', headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ q: '', filter: `category_id = ${cid}`, limit: 200, offset: off,
          attributesToRetrieve: ['number', 'medium_image_url', 'series', 'collections'] }),
      });
      if (!r.ok) throw new Error(`img feed ${r.status} cat ${cid} off ${off}`);
      const hits = (await r.json()).hits || [];
      if (!hits.length) break;
      for (const h of hits) {
        if (!h.number) continue;
        map.set(String(h.number), {
          image: h.medium_image_url || null,
          series: h.series || (Array.isArray(h.collections) ? h.collections[0]?.name : null) || null,
        });
      }
    }
    process.stdout.write(`\r  image-map cat ${cid}  size ${map.size}   `);
  }
  console.log(`\n  image map built: ${map.size} SKUs`);
  return map;
}

async function main() {
  // 1. staged pattern set (normalized) — the classification key
  const { rows: pr } = await pool.query('SELECT DISTINCT pattern_name FROM momentum_colorways');
  const stagedNorm = new Set(pr.map(r => norm(r.pattern_name)));
  const baselineCount = (await pool.query('SELECT count(*)::int n FROM momentum_colorways')).rows[0].n;

  // 2. build insert set from TSV
  const feed = readTSV();
  const inscope = feed.filter(r => KEEP.has(r.category_name) && (r.pattern_name || '').trim() && (r.preferred_color_name || '').trim());
  const newRows = inscope.filter(r => !stagedNorm.has(norm(r.pattern_name)));
  const seen = new Set(); const ins = [];
  for (const r of newRows) {
    const k = r.pattern_name + '' + r.preferred_color_name;
    if (seen.has(k)) continue; seen.add(k); ins.push(r);
  }
  const byCat = ins.reduce((a, r) => (a[r.category_name] = (a[r.category_name] || 0) + 1, a), {});
  const settlementCount = ins.filter(r => SETTLEMENT.has(r.pattern_name)).length;
  const acousticCount = ins.filter(r => r.category_name === 'Acoustic').length;
  console.log(`baseline momentum_colorways rows: ${baselineCount}`);
  console.log(`insert set: ${ins.length}  byCat=${JSON.stringify(byCat)}  settlement=${settlementCount}  acousticMarker=${acousticCount}`);

  // 3. image + series decoration (cap-safe sharded feed)
  const imgMap = await fetchImageMap();
  const withImg = ins.filter(r => imgMap.get(String(r.number))?.image).length;
  console.log(`  rows with image_url: ${withImg}/${ins.length}  (rest -> image_url NULL, Needs-Image at go-live)`);

  if (!COMMIT) {
    console.log('DRY-RUN (no DB writes). Re-run with --commit to stage.');
    await pool.end(); return;
  }

  // 4. single-transaction guarded insert
  const c = await pool.connect();
  let inserted = 0, skipped = 0; const restore = [];
  try {
    await c.query('BEGIN');
    for (const r of ins) {
      const list = money(r.list_price);
      const im = imgMap.get(String(r.number)) || {};
      const tags = SETTLEMENT.has(r.pattern_name) ? JSON.stringify(['settlement-review']) : null;
      const subcat = r.category_name === 'Acoustic' ? 'Acoustic Panel' : null;
      const uom = (r.uom || '').trim() || null;
      const res = await c.query(
        `INSERT INTO momentum_colorways
           (pattern_name,color_name,color_number,momentum_sku,image_url,list_price,width,category,
            product_line,collection_name,uom,price_unit,cost,hw_price,sub_category,tags,created_at,updated_at)
         VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,now(),now())
         ON CONFLICT (pattern_name,color_name) DO NOTHING
         RETURNING id`,
        [r.pattern_name, r.preferred_color_name, r.preferred_color_number || null, r.number || null,
         im.image || null, list, (r.base_width || '').trim() || null, r.category_name,
         (r.product_line_code || '').trim() || null, im.series || null, uom, uom,
         list == null ? null : money(list * COST_MULT), list == null ? null : money(list * HW_MULT),
         subcat, tags]);
      if (res.rows.length) { inserted++; restore.push({ id: res.rows[0].id, pattern_name: r.pattern_name, color_name: r.preferred_color_name }); }
      else skipped++;
    }
    await c.query('COMMIT');
  } catch (e) { await c.query('ROLLBACK'); console.error('ROLLBACK:', e.message); process.exitCode = 1; c.release(); await pool.end(); return; }
  c.release();

  fs.writeFileSync(RESTORE, restore.map(x => JSON.stringify(x)).join('\n') + '\n');
  const finalCount = (await pool.query('SELECT count(*)::int n FROM momentum_colorways')).rows[0].n;
  const results = { ts: new Date().toISOString(), target: 1215, inserted, guard_skips: skipped,
    baseline_rows: baselineCount, final_rows: finalCount, settlement_tagged: restore.filter(r => SETTLEMENT.has(r.pattern_name)).length,
    acoustic_marker: acousticCount, restore_file: RESTORE };
  fs.writeFileSync(RESULTS, JSON.stringify(results, null, 2));
  console.log(`COMMITTED — inserted ${inserted}, guard-skipped ${skipped}. rows ${baselineCount} -> ${finalCount}.`);
  console.log(`Restore: ${RESTORE}  Results: ${RESULTS}`);
  await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });