← back to Designer Wallcoverings
audits/designtex-uom-fix/build-fillin-dataset.mjs
126 lines
#!/usr/bin/env node
// Build the Designtex fill-in dataset by joining crawled sidecars to dw_unified.designtex_catalog rows.
// READ-ONLY (reads PG + sidecars, writes a JSON plan). $0 (local).
//
// Produces designtex-fillin-plan.json:
// - per DB row: the spec values + image set the crawl recovered, keyed by mfr_sku,
// with UOM forced to YARD, pattern_repeat from "Repeat", care from "Cleaning Code",
// all_images from per-colorway gallery, plus corrected color_name where DB drifted.
// - dedupe: rows whose mfr_sku does NOT match any live colorway SKU on its pattern page
// (e.g. mfr_sku='gilded') => ARCHIVE candidate.
// - discontinued: rows on the 14 confirmed-404 patterns => ARCHIVE candidate.
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
const HERE = path.dirname(new URL(import.meta.url).pathname);
const SIDE = path.join(HERE, '..', '..', 'enrich-cache', 'designtex');
const DISCONTINUED_SLUGS = new Set(['burnish','imprint','kith','layer','plexus','tacit','talula','twinkle','zipper','circulate','zip-code','zip-line','silicone-element-celliant','spark']);
const RENAMES = { 'bocce-plaid': 'bocce', 'kin': 'kindred' }; // old DB slug -> new live slug
const slugOf = (url) => (url || '').replace(/^https?:\/\/shop\.designtex\.com\//, '').replace(/\/$/, '');
const normSku = (s) => (s || '').replace(/[^0-9A-Za-z]/g, '').toUpperCase();
// load sidecars into a map: slug -> sidecar, and a colorway index: mfr_sku -> {slug, colorway}
const sidecars = {};
const cwIndex = {};
for (const f of fs.readdirSync(SIDE)) {
if (!f.endsWith('.json') || f === '_manifest.json') continue;
const j = JSON.parse(fs.readFileSync(path.join(SIDE, f), 'utf8'));
sidecars[j.slug] = j;
for (const c of j.colorways || []) {
if (c.mfr_sku) cwIndex[normSku(c.mfr_sku)] = { slug: j.slug, colorway: c, specs: j.specs };
}
}
// Pull DB rows via psql (peer auth, no password)
const sql = `COPY (SELECT row_to_json(t) FROM (SELECT id, mfr_sku, dw_sku, pattern_name, color_name, product_url, shopify_product_id, unit_of_measure, pattern_repeat, maintenance, fire_rating, material FROM designtex_catalog ORDER BY id) t) TO STDOUT;`;
const raw = execSync(`psql -d dw_unified -tAc ${JSON.stringify(sql)}`, { maxBuffer: 1 << 28 }).toString();
const rows = raw.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
// spec field mapping
const SPEC_MAP = {
pattern_repeat: (s) => s['Repeat'] || null,
maintenance: (s) => s['Cleaning Code'] ? `Cleaning Code: ${s['Cleaning Code']}` + (s['Approved Disinfectants'] ? `; Approved Disinfectants: ${s['Approved Disinfectants']}` : '') : null,
fire_rating: (s) => s['Flammability'] || null,
material: (s) => s['Content'] || null,
finish: (s) => s['Finish'] || null,
width: (s) => s['Width'] || null,
composition: (s) => s['Content'] || null,
};
const plan = { generated_at: new Date().toISOString(), source: 'enrich-cache/designtex sidecars', rows: [], stats: {} };
let matched = 0, archiveDiscontinued = 0, archiveOrphan = 0, noMatch = 0;
for (const r of rows) {
const sku = normSku(r.mfr_sku);
let slug = slugOf(r.product_url);
if (RENAMES[slug]) slug = RENAMES[slug];
if (DISCONTINUED_SLUGS.has(slugOf(r.product_url))) {
plan.rows.push({ id: r.id, dw_sku: r.dw_sku, mfr_sku: r.mfr_sku, pattern_name: r.pattern_name,
action: 'ARCHIVE', reason: 'pattern page 404 + not in vendor sitemap (discontinued)',
shopify_product_id: r.shopify_product_id });
archiveDiscontinued++;
continue;
}
let hit = cwIndex[sku];
let mfrBackfill = null;
if (!hit) {
// mfr_sku is the slug (placeholder) not a colorway SKU (e.g. 'gilded','betwixt').
// DTD 3/3 (2026-06-23): these are REAL products (esp. the 85 live ACTIVE parents).
// FILL them using the pattern page's base specs + first/representative colorway image,
// and backfill mfr_sku to the pattern's first real colorway SKU. NEVER archive over a
// mfr_sku-hygiene issue. Only genuinely-404 patterns (handled above) get ARCHIVE.
const sc = sidecars[slug];
if (!sc) {
plan.rows.push({ id: r.id, dw_sku: r.dw_sku, mfr_sku: r.mfr_sku, pattern_name: r.pattern_name,
action: 'REVIEW', reason: `no sidecar for slug '${slug}' (not 404, but uncrawled)`,
shopify_product_id: r.shopify_product_id });
noMatch++;
continue;
}
const firstCw = (sc.colorways || [])[0];
hit = { slug: sc.slug, colorway: firstCw || { color_name: r.color_name, images: [], swatch_image: null }, specs: sc.specs };
mfrBackfill = firstCw && firstCw.mfr_sku ? firstCw.mfr_sku : null;
}
const s = hit.specs || {};
const fill = {};
for (const [col, fn] of Object.entries(SPEC_MAP)) { const v = fn(s); if (v) fill[col] = v; }
// always force YARD
fill.unit_of_measure = 'YARD';
// corrected color name from live swatch label — ONLY for per-colorway rows whose mfr_sku
// matched a live colorway directly. For parent rows (mfrBackfill set) the parent's existing
// color_name is authoritative; do NOT overwrite it with the pattern's first colorway.
const liveColor = hit.colorway.color_name;
const colorCorrection = (!mfrBackfill && liveColor && liveColor.toLowerCase() !== (r.color_name || '').toLowerCase())
? { from: r.color_name, to: liveColor } : null;
// images: per-colorway gallery (flat + detail) + swatch
const imgs = [...(hit.colorway.images || [])];
if (hit.colorway.swatch_image) imgs.push(hit.colorway.swatch_image);
const row = {
id: r.id, dw_sku: r.dw_sku, mfr_sku: r.mfr_sku, pattern_name: r.pattern_name,
action: 'FILL',
matched_slug: hit.slug,
set: fill,
color_correction: colorCorrection,
all_images: [...new Set(imgs)],
uom_was: r.unit_of_measure,
shopify_product_id: r.shopify_product_id,
};
if (mfrBackfill) { row.mfr_sku_backfill = { from: r.mfr_sku, to: mfrBackfill }; row.is_parent_row = true; }
plan.rows.push(row);
matched++;
}
plan.stats = { total: rows.length, fill: matched, archive_discontinued: archiveDiscontinued, archive_orphan: archiveOrphan, review_no_sidecar: noMatch, parent_rows_filled: plan.rows.filter(x=>x.is_parent_row).length };
fs.writeFileSync(path.join(HERE, 'designtex-fillin-plan.json'), JSON.stringify(plan, null, 2));
console.log(JSON.stringify(plan.stats, null, 2));
// sample
console.log('\nSample FILL row:'); console.log(JSON.stringify(plan.rows.find((r) => r.action === 'FILL'), null, 2).slice(0, 900));
console.log('\nArchive-orphan rows:'); plan.rows.filter((r) => r.action === 'ARCHIVE' && r.reason.includes('orphan')).slice(0, 5).forEach((r) => console.log(' ', r.dw_sku, r.mfr_sku, r.pattern_name, '|', r.reason));