← back to Dw Unbuyable Recovery Pilot

tk11041-innovations-reconcile/rescrape/stage-reprice.mjs

139 lines

#!/usr/bin/env node
// TK-11041 — turn 7 arriving Innovations pattern prices into a STAGED repricing proposal.
// Writes NOTHING to dw_unified or Shopify. Output is a JSON + markdown proposal for the gated write.
//
// THE CHAIN, verified empirically against 57 rows of dw_unified.innovations_catalog that carry
// list, trade and our_price together — not assumed from the formula in CLAUDE.md:
//     LIST  --x0.90-->  NET (our trade cost, 10% discount, settled by Sales Order O-111475)
//     NET   --x1.81-->  RETAIL            (1 / 0.65 / 0.85 = 1.8100, matched to 4dp on 57/57 rows)
//
// USAGE
//   node stage-reprice.mjs --basis list --prices '{"CSO":45.95,...}'   # vendor quoted LIST
//   node stage-reprice.mjs --basis net  --prices '{"CSO":41.36,...}'   # vendor quoted NET
//   node stage-reprice.mjs --selftest                                  # $0, no DB
//
// WHY --basis IS REQUIRED AND HAS NO DEFAULT: getting list-vs-net backwards is a silent 10%
// error straight into customer-facing retail. The vendor's emailed PDFs print LIST under a
// column headed "Net Price", so the label on the page is NOT trustworthy — a human must say
// which one the number is. Defaulting either way would be guessing with someone's money.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const TERMS = path.join(HERE, 'data/pattern-order-terms.json');
const PREFIX_OF = { Costine:'CSO', Enchase:'ENS', Flanders:'FLN', Province:'PRO',
                    Skylark:'SKL', Tatami:'TTM', 'Vinyl Cork':'VCO' };
const DISCOUNT = 0.90;   // 10% off list — vendor Sales Order O-111475, 3x corroborated

const r2 = n => Math.round(n * 100) / 100;
// DECIMAL-SAFE money math via integer cents — matches a PostgreSQL NUMERIC round(list*0.90,2) write.
// Plain Math.round(list*0.90*100)/100 hits an IEEE-754 artifact on half-cent boundaries
// (132.95*0.90 = 119.655 reads as 119.6549999 -> rounds DOWN to 119.65, a silent 1c Flanders
// underprice). Integer cents keeps 119.655 -> 11965.5 -> half-up 11966 -> $119.66.
const cents  = d => Math.round(d * 100);
const netFromList = list => Math.round(cents(list) * 9 / 10) / 100;    // 10% off, exact decimal
// Retail = net / 0.65 / 0.85 — canonical DW markup, matching production writer stage-innov-reprice.mjs
// line 40 (RETAIL = c => round(c/0.65/0.85)). (Was MARKUP=1.81, a 4dp approx 1c high on boundaries.)
const retailOf = net => Math.round(cents(net) / 0.65 / 0.85) / 100;

// Returns {list, net, retail} or throws. Never silently coerces.
function derive(value, basis) {
  if (!Number.isFinite(value) || value <= 0) throw new Error(`price must be a positive number, got ${value}`);
  if (basis === 'list') { const net = netFromList(value);      return { list: r2(value), net, retail: retailOf(net) }; }
  if (basis === 'net')  { const net = r2(value);               return { list: r2(cents(value) * 10 / 9) / 100, net, retail: retailOf(net) }; }
  throw new Error(`basis must be "list" or "net", got ${JSON.stringify(basis)}`);
}

// Fall-2025 list, used ONLY as a sanity band. A quote far off this trend means something is wrong
// (wrong pattern, wrong unit, a per-roll number mistaken for per-yard) — worth a human look before
// it reaches a customer. It is NOT a price source: it is 11 months old and provably superseded.
const OCT2025_LIST = { CSO:45.95, ENS:69.95, FLN:112.95, PRO:29.95, SKL:129.95, TTM:59.95, VCO:49.95 };
function sanity(prefix, list) {
  const ref = OCT2025_LIST[prefix];
  if (!ref) return { ok: true, note: 'no reference' };
  const delta = (list - ref) / ref;
  if (delta < -0.02) return { ok: false, flag: 'BELOW_2025', note:
    `quoted list $${list} is ${(delta*100).toFixed(1)}% BELOW the Oct-2025 list $${ref}. Prices have ` +
    `only risen on this line; a drop is more likely a wrong unit or wrong pattern than a real cut.` };
  if (delta > 0.35) return { ok: false, flag: 'ABOVE_35PCT', note:
    `quoted list $${list} is +${(delta*100).toFixed(1)}% over Oct-2025 $${ref}. Possible, but large ` +
    `enough to confirm before it reaches retail.` };
  return { ok: true, note: `+${(delta*100).toFixed(1)}% vs Oct-2025 — within the expected drift band` };
}

function selftest() {
  let bad = 0;
  const ck = (n, c) => { console.log(`  ${c ? 'PASS' : 'FAIL'}  ${n}`); if (!c) bad++; };
  console.log('[selftest] TK-11041 stage-reprice');
  const L = derive(45.95, 'list');
  ck('list 45.95 -> net 41.36 (x0.90)', L.net === 41.36);
  ck('list 45.95 -> retail 74.86 (net / 0.65 / 0.85)', L.retail === r2(41.36 / 0.65 / 0.85));
  const N = derive(41.36, 'net');
  ck('net 41.36 round-trips to list ~45.96', Math.abs(N.list - 45.96) < 0.02);
  ck('net and list bases agree on retail', N.retail === L.retail);
  // the checks that matter: refuse rather than guess
  let threw = false; try { derive(45.95, undefined); } catch { threw = true; }
  ck('missing basis THROWS (never defaults to list)', threw);
  threw = false; try { derive(45.95, 'trade'); } catch { threw = true; }
  ck('unknown basis THROWS', threw);
  threw = false; try { derive(0, 'list'); } catch { threw = true; }
  ck('a $0 price THROWS, never becomes $0 retail', threw);
  threw = false; try { derive(-5, 'list'); } catch { threw = true; }
  ck('a negative price THROWS', threw);
  ck('sanity flags a suspiciously LOW quote', sanity('FLN', 60).ok === false);
  ck('sanity flags a suspiciously HIGH quote', sanity('FLN', 200).ok === false);
  ck('sanity passes a plausible rise', sanity('FLN', 119).ok === true);
  ck('terms file covers all 39', Object.keys(JSON.parse(fs.readFileSync(TERMS,'utf8'))).length === 39);
  console.log(bad === 0 ? '[selftest] ALL PASS' : `[selftest] ${bad} FAILURE(S)`);
  process.exit(bad ? 1 : 0);
}

const argv = process.argv.slice(2);
if (argv.includes('--selftest')) selftest();
const basis = argv[argv.indexOf('--basis') + 1];
const pricesRaw = argv[argv.indexOf('--prices') + 1];
if (!argv.includes('--basis') || !argv.includes('--prices')) {
  console.error('usage: node stage-reprice.mjs --basis list|net --prices \'{"CSO":45.95,...}\'');
  console.error('       --basis is REQUIRED and has no default: list-vs-net is a silent 10% error.');
  process.exit(2);
}
const quoted = JSON.parse(pricesRaw);
const terms = JSON.parse(fs.readFileSync(TERMS, 'utf8'));

const out = [], flags = [], missing = [];
for (const [sku, t] of Object.entries(terms).sort()) {
  const prefix = PREFIX_OF[t.pattern];
  const q = quoted[prefix] ?? quoted[t.pattern];
  if (q === undefined) { missing.push(sku); continue; }
  const d = derive(q, basis);
  const s = sanity(prefix, d.list);
  if (!s.ok) flags.push({ sku, pattern: t.pattern, ...s });
  out.push({ mfr_sku: sku, pattern: t.pattern, quoted: q, quoted_basis: basis,
             list: d.list, net_cost: d.net, proposed_retail: d.retail, unit: 'Per Yd',
             min_order_yds: t.min_order_yds, made_to_order: t.made_to_order,
             width: t.width, warehouse: t.warehouse, sanity: s.note });
}

const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15) + 'Z';
const doc = {
  ticket: 'TK-11041', generated: ts, basis, quoted, discount: DISCOUNT, markup: '1/0.65/0.85',
  formula: 'list x0.90 = net cost; retail = net / 0.65 / 0.85 (canonical DW markup, matches stage-innov-reprice.mjs). Verified on 57 rows.',
  priced: out.length, not_priced: missing,
  sanity_flags: flags,
  made_to_order_warning: out.some(r => r.made_to_order)
    ? 'Skylark (SKL-001..004) is MADE TO ORDER — no stock, no cut-fee code, MTO warehouse. Decide ' +
      'deliberately whether it should be a buyable stocked roll or stay quote/lead-time, and set ' +
      'its lead time, before it goes sellable.' : null,
  written_to_db: false, written_to_shopify: false,
  next_step: 'Human review, then the GATED write. innovations_catalog columns already exist: ' +
             'price_retail(list), price_trade(net), our_price(retail), price_unit, price_source.',
  rows: out,
};
const outPath = path.join(HERE, `data/staged-reprice-${ts}.json`);
fs.writeFileSync(outPath, JSON.stringify(doc, null, 2));
console.log(`[*] basis=${basis}  priced ${out.length}/39` + (missing.length ? `  MISSING: ${missing.join(',')}` : ''));
for (const f of flags) console.log(`[!] SANITY ${f.flag} ${f.pattern}: ${f.note}`);
if (doc.made_to_order_warning) console.log(`[!] ${doc.made_to_order_warning}`);
console.log(`[*] staged -> ${outPath}\n[*] NO db write · NO shopify write · gated write is a separate, human step`);