← back to Dw Unbuyable Recovery Pilot

tk10978-scalamandre/build-plan.mjs

138 lines

#!/usr/bin/env node
// TK-10978 — Scalamandre ADD-ROLL recovery plan builder (READ-ONLY).
//
// Reads data/scalamandre-exact-recovery.csv (156 rows). Builds a CLEAN,
// deduped, match-sanity-annotated plan of products to ADD a "Sold Per Roll"
// variant to. Recomputes roll retail LIVE from scalamandre_catalog by
// catalog_dw_sku (never trusts the CSV/mirror number).
//
// HARD EXCLUSIONS (skip, never price):
//   - price-conflict products: any shopify_num_id that appears on >1 CSV row
//     (the CSV carries 2 candidate catalog matches at different prices for 6
//      products → cost is ambiguous). These 6 (12 rows) are dropped.
//   - a row whose catalog_dw_sku is blank.
//   - a catalog_dw_sku not found in scalamandre_catalog with price_trade>0.
//
// MATCH SANITY: carries the live-title pattern/color AND the catalog
// pattern_name/color_name so the executor (and this builder) can confirm the
// content match is real before pricing. Builder flags mismatches.
//
// Output: data/plan.json (clean actionable set) + data/excluded.json.
// This script performs NO Shopify or DB writes.
import { readFileSync, writeFileSync } from 'node:fs';
import { queryRows } from '../lib/db.mjs';

const HERE = new URL('.', import.meta.url).pathname;
const CSV = `${HERE}../data/scalamandre-exact-recovery.csv`;

// --- parse CSV (simple, fields have no embedded commas in the id/sku/dwsku cols) ---
const lines = readFileSync(CSV, 'utf8').trim().split('\n');
const header = lines[0].split(',');
const rows = lines.slice(1).map(line => {
  // title can't contain a comma in this data (verified: " - Color | Scalamandre"), so a plain split is safe.
  const parts = line.split(',');
  // header: shopify_num_id,shopify_sku,title,catalog_dw_sku,price_trade,computed_roll_retail
  return {
    shopify_num_id: parts[0],
    shopify_sku: parts[1],
    title: parts[2],
    catalog_dw_sku: parts[3],
    price_trade_csv: parts[4],
    computed_roll_retail_csv: parts[5],
  };
});

// --- identify price-conflict products (id appears on >1 row) ---
const byId = new Map();
for (const r of rows) { byId.set(r.shopify_num_id, (byId.get(r.shopify_num_id) || 0) + 1); }
const conflictIds = new Set([...byId.entries()].filter(([, n]) => n > 1).map(([id]) => id));

// --- normalize helpers for match sanity ---
const norm = s => (s || '').toUpperCase().replace(/[^A-Z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();
// live title -> pattern (before first ' - ') + color (last segment before ' | ')
function parseTitle(title) {
  const beforePipe = title.split('|')[0].trim();           // "Adelaide Beaded Sisal - Burnished Gold"
  const segs = beforePipe.split(' - ').map(s => s.trim());
  const color = segs[segs.length - 1];
  const pattern = segs.slice(0, -1).join(' - ');            // handles "Abyssal - Mural"
  return { pattern, color };
}

const excluded = [];
const candidateRows = rows.filter(r => {
  if (conflictIds.has(r.shopify_num_id)) { return false; }  // handled below in bulk
  if (!r.catalog_dw_sku || r.catalog_dw_sku.trim() === '') {
    excluded.push({ ...r, reason: 'blank catalog_dw_sku' }); return false;
  }
  return true;
});
// record the conflict exclusions once per id
for (const id of conflictIds) {
  const rs = rows.filter(r => r.shopify_num_id === id);
  excluded.push({ shopify_num_id: id, title: rs[0].title, reason: 'price-conflict (multiple candidate catalog matches at different prices)',
    candidates: rs.map(r => ({ catalog_dw_sku: r.catalog_dw_sku || '(blank)', price_trade: r.price_trade_csv })) });
}

// --- pull LIVE catalog truth for the candidate DWSC skus ---
const dwscList = [...new Set(candidateRows.map(r => r.catalog_dw_sku))];
const inClause = dwscList.map(s => `'${s.replace(/'/g, "''")}'`).join(',');
const catRows = queryRows(`
  SELECT dw_sku, pattern_name, color_name, price_trade,
         round(price_trade/0.65/0.85, 2) AS roll_retail
  FROM scalamandre_catalog
  WHERE dw_sku IN (${inClause}) AND price_trade > 0`);
const catBy = new Map(catRows.map(c => [c.dw_sku, c]));

const plan = [];
for (const r of candidateRows) {
  const cat = catBy.get(r.catalog_dw_sku);
  if (!cat) { excluded.push({ ...r, reason: 'catalog_dw_sku not found with price_trade>0' }); continue; }

  const rollSku = r.shopify_sku.replace(/-Sample$/i, '');   // DWA-89268-Sample -> DWA-89268
  const { pattern: tPat, color: tColor } = parseTitle(r.title);
  const patMatch = norm(cat.pattern_name).includes(norm(tPat)) || norm(tPat).includes(norm(cat.pattern_name));
  const colMatch = norm(cat.color_name) === norm(tColor) || norm(cat.color_name).includes(norm(tColor)) || norm(tColor).includes(norm(cat.color_name));

  plan.push({
    pid: r.shopify_num_id,
    title: r.title,
    sampleSku: r.shopify_sku,             // existing $4.25 Sample variant SKU (untouched)
    rollSku,                              // numeric DW-SKU — NO mint
    catalog_dw_sku: r.catalog_dw_sku,
    price_trade: Number(cat.price_trade),
    rollRetail: Number(cat.roll_retail), // LIVE-recomputed, authoritative
    csv_retail: Number(r.computed_roll_retail_csv),
    title_pattern: tPat, title_color: tColor,
    cat_pattern: cat.pattern_name, cat_color: cat.color_name,
    patMatch, colMatch,
    matchOk: patMatch && colMatch,
  });
}

// sanity: retail recompute must equal the CSV (proves no drift) — flag if not
for (const p of plan) {
  p.retail_matches_csv = Math.abs(p.rollRetail - p.csv_retail) < 0.02;
}

const mismatches = plan.filter(p => !p.matchOk);
const retailDrift = plan.filter(p => !p.retail_matches_csv);

const report = {
  ticket: 'TK-10978',
  generated_at: new Date().toISOString(),
  mode: 'READ-ONLY plan builder — no writes',
  csv_rows: rows.length,
  distinct_products: byId.size,
  conflict_products_excluded: conflictIds.size,
  clean_actionable: plan.length,
  match_mismatches: mismatches.length,
  retail_drift_vs_csv: retailDrift.length,
};
console.log(JSON.stringify(report, null, 2));
if (mismatches.length) { console.log('\nMATCH MISMATCHES (will be skipped at execute):'); mismatches.forEach(m => console.log(`  ${m.pid} "${m.title}"  title(${m.title_pattern}/${m.title_color}) vs cat(${m.cat_pattern}/${m.cat_color})`)); }
if (retailDrift.length) { console.log('\nRETAIL DRIFT vs CSV:'); retailDrift.forEach(m => console.log(`  ${m.pid} live=${m.rollRetail} csv=${m.csv_retail}`)); }

writeFileSync(`${HERE}data/plan.json`, JSON.stringify({ report, plan }, null, 2));
writeFileSync(`${HERE}data/excluded.json`, JSON.stringify(excluded, null, 2));
console.log(`\nplan.json: ${plan.length} clean products; excluded.json: ${excluded.length} rows`);