← 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`);