← back to Tk 11331 Exec
d3b_heldreview_stage.mjs
183 lines
#!/usr/bin/env node
// TK-11331 D3b-heldreview — classify the two HELD buckets + STAGE the STAGE-NEW/DISAMBIGUATE rows.
// Mirrors d3c_stage.mjs: hw_price=list*1.448, cost=list*0.80, sub_category='Acoustic Panel' for acoustic,
// settlement-review tag where flagged, image_url via per-number Meilisearch lookup, dw_sku LEFT NULL
// (assign-sku FROZEN), ON CONFLICT (pattern_name,color_name) DO NOTHING guard, id-scoped restore file.
// DISAMBIGUATE: stage new colorways of a normalized-collision pattern UNDER the existing staged raw
// spelling (canonical), so they join the existing pattern group instead of forking a whitespace variant.
// DROP-DUP: skip (exact or fuzzy Gray/Grey color match already staged).
// DRY-RUN by default; --commit writes.
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', 'd3b-heldreview-stage-restore.jsonl');
const RESULTS = path.join(HERE, 'data', 'd3b-heldreview-results.json');
const TABLE = path.join(HERE, 'data', 'd3b-heldreview-table.json');
const COMMIT = process.argv.includes('--commit');
const HOST = 'https://ms-e886719d86e7-4256.sfo.meilisearch.io';
const KEY = process.env.MOMENTUM_MS_KEY || '';
const INDEX = 'redesign-colors';
const SETTLEMENT = new Set(['Hula WC', 'Sea Line', 'Palomar', 'Oakstone']);
const COST_MULT = 0.80, HW_MULT = 1.448;
const KEEP = new Set(['Wallcovering', 'Acoustic']);
const pool = new Pool({ host: '/tmp', database: '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));
// fuzzy color key: lowercase, gray->grey, drop all non-alnum → catches Gray/Grey + spacing/punct variants
const fcolor = s => norm(s).replace(/gray/g, 'grey').replace(/[^a-z0-9]/g, '');
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 imageFor(number) {
try {
const r = await fetch(`${HOST}/indexes/${INDEX}/search`, {
method: 'POST', headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ q: String(number), limit: 5, attributesToRetrieve: ['number', 'medium_image_url', 'series', 'collections'] }),
});
if (!r.ok) return {};
const hits = (await r.json()).hits || [];
const h = hits.find(x => String(x.number) === String(number)) || hits[0];
if (!h) return {};
return { image: h.medium_image_url || null, series: h.series || (Array.isArray(h.collections) ? h.collections[0]?.name : null) || null };
} catch { return {}; }
}
async function main() {
const { rows: pr } = await pool.query('SELECT pattern_name, color_name FROM momentum_colorways');
const stagedPatternNorm = new Set(pr.map(r => norm(r.pattern_name)));
const stagedPairNorm = new Set(pr.map(r => norm(r.pattern_name) + '' + norm(r.color_name)));
// per norm(pattern): canonical raw spelling (longest/first existing) + fuzzy color set
const patRaw = new Map(); // normPattern -> Map(rawSpelling -> count)
const patFcolors = new Map(); // normPattern -> Set(fuzzyColor)
for (const r of pr) {
const n = norm(r.pattern_name);
if (!patRaw.has(n)) patRaw.set(n, new Map());
patRaw.get(n).set(r.pattern_name, (patRaw.get(n).get(r.pattern_name) || 0) + 1);
if (!patFcolors.has(n)) patFcolors.set(n, new Set());
patFcolors.get(n).add(fcolor(r.color_name));
}
const canonicalRaw = n => {
const m = patRaw.get(n); if (!m) return null;
return [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].length - b[0].length)[0][0];
};
const feed = readTSV();
const inscope = feed.filter(r => KEEP.has(r.category_name) && (r.pattern_name || '').trim() && (r.preferred_color_name || '').trim());
const seen = new Set(); const dedup = [];
for (const r of inscope) { const k = r.pattern_name + '' + r.preferred_color_name; if (seen.has(k)) continue; seen.add(k); dedup.push(r); }
// HELD extend-pattern GENUINELY-NEW set = norm(pattern) staged AND norm(pair) NOT staged
const held = dedup.filter(r => stagedPatternNorm.has(norm(r.pattern_name)) && !stagedPairNorm.has(norm(r.pattern_name) + '' + norm(r.preferred_color_name)));
const table = [];
const toStage = [];
for (const r of held) {
const np = norm(r.pattern_name);
const rawFeed = r.pattern_name;
const canon = canonicalRaw(np);
const disambiguate = canon !== rawFeed; // feed spelling differs from staged spelling (whitespace/case)
const fc = fcolor(r.preferred_color_name);
const fuzzyDup = patFcolors.get(np)?.has(fc);
let verdict, reason, stagePattern = canon || rawFeed;
if (fuzzyDup) {
verdict = 'DROP-DUP';
reason = `fuzzy-color match to an already-staged colorway of "${canon}" (spelling variant, e.g. Gray/Grey); fcolor="${fc}"`;
} else {
verdict = disambiguate ? 'DISAMBIGUATE' : 'STAGE-NEW';
reason = disambiguate
? `genuinely-new colorway; feed pattern "${rawFeed}" collides on normalization with staged "${canon}" — stage under canonical staged spelling to avoid whitespace fork`
: `genuinely-new colorway of already-staged pattern "${canon}"; no staged equivalent (norm pair + fuzzy color both absent)`;
toStage.push({ r, stagePattern });
}
table.push({
item: `${rawFeed.trim()} / ${r.preferred_color_name}`,
number: r.number, category: r.category_name, bucket: 'extend-pattern',
verdict, stage_pattern: verdict === 'DROP-DUP' ? null : stagePattern, reason,
});
}
// Cavern II bucket (all exact-staged) + Rectangular Hatch/Linear/Louvre/Vault (0 feed) — collision-pattern rows
// Cavern II rows are already TRUE-DUP (norm pair staged) so they never enter `held`; record them explicitly.
const COLLISION = ['Cavern II', 'Rectangular Hatch', 'Rectangular Linear', 'Rectangular Louvre', 'Rectangular Vault', 'Rectangular Vertex 9/16'];
const collisionSummary = [];
for (const cp of COLLISION) {
const cn = norm(cp);
const feedRows = dedup.filter(r => norm(r.pattern_name) === cn);
const staged = stagedPatternNorm.has(cn);
const canon = canonicalRaw(cn);
const newOnes = feedRows.filter(r => !stagedPairNorm.has(cn + '' + norm(r.preferred_color_name)) && !patFcolors.get(cn)?.has(fcolor(r.preferred_color_name)));
let verdict;
if (feedRows.length === 0) verdict = 'NO-OP (absent from current feed — nothing to onboard)';
else if (newOnes.length === 0) verdict = 'DROP-DUP (all feed colorways already staged under canonical spelling)';
else verdict = `DISAMBIGUATE (${newOnes.length} new colorways staged under canonical "${canon}")`;
collisionSummary.push({ pattern: cp, norm: cn, feed_colorways: feedRows.length, pattern_staged: staged, canonical_staged_spelling: canon, new_colorways: newOnes.length, verdict });
}
const counts = {
held_total: held.length,
stage_new: table.filter(t => t.verdict === 'STAGE-NEW').length,
disambiguate: table.filter(t => t.verdict === 'DISAMBIGUATE').length,
drop_dup: table.filter(t => t.verdict === 'DROP-DUP').length,
};
console.log('== CLASSIFICATION ==');
console.log(JSON.stringify(counts, null, 2));
console.log('== COLLISION PATTERNS ==');
console.table(collisionSummary);
fs.writeFileSync(TABLE, JSON.stringify({ counts, table, collisionSummary }, null, 2));
if (!COMMIT) { console.log(`\nDRY-RUN. toStage=${toStage.length}. Re-run with --commit.`); await pool.end(); return; }
// enrich images (per-number, ≤79 lookups)
for (const x of toStage) { const im = await imageFor(x.r.number); x.image = im.image || null; x.series = im.series || null; }
const withImg = toStage.filter(x => x.image).length;
console.log(`\nimage lookups: ${withImg}/${toStage.length} got image_url`);
const c = await pool.connect();
let inserted = 0, skipped = 0; const restore = [];
try {
await c.query('BEGIN');
for (const x of toStage) {
const r = x.r; const list = money(r.list_price);
const tags = SETTLEMENT.has(r.pattern_name.trim()) ? 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`,
[x.stagePattern, r.preferred_color_name, r.preferred_color_number || null, r.number || null,
x.image || null, list, (r.base_width || '').trim() || null, r.category_name,
(r.product_line_code || '').trim() || null, x.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: x.stagePattern, 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(), counts, attempted: toStage.length, inserted, guard_skips: skipped,
final_rows: finalCount, restore_file: RESTORE, inserted_ids: restore.map(x => x.id) };
fs.writeFileSync(RESULTS, JSON.stringify(results, null, 2));
console.log(`COMMITTED — inserted ${inserted}, guard-skipped ${skipped}. final_rows=${finalCount}`);
console.log(`Restore: ${RESTORE}`);
await pool.end();
}
main().catch(e => { console.error(e); process.exit(1); });