← back to Sanderson Onboard
tk10873/reprice_lib.mjs
117 lines
// reprice_lib.mjs — pure helpers for the DRY-RUN reprice runbook + sanity gate.
// READ-ONLY logic; NO SHOPIFY/DB WRITES, NO http/fetch. Every function is
// deterministic and side-effect-free so test_reprice.mjs can exercise them.
export const SAMPLE_PRICE = 4.25;
export const VARIANT_ID_PLACEHOLDER = 'gid://shopify/ProductVariant/VARIANT_ID';
// Sane retail band for a single/double-roll Zoffany product. OUT-OF-BAND is a WARN
// (human-eyes flag), NOT a FAIL — the sanity gate only hard-fails on real invariant
// breaks (nonpositive / <= sample / below cost).
export const BAND_MIN = 20;
export const BAND_MAX = 2000;
// The productVariantsBulkUpdate mutation shape the FUTURE gated live tool would send.
// We emit the SHAPE (as a plain JS object), not a network call. The variant id is a
// placeholder because a dry run does not read live ids. Per the Shopify contract,
// SKU mutations use inventoryItem.sku — here the reprice does NOT change the sku, so
// inventoryItem.sku is echoed unchanged (identity), and only `price` moves.
export function buildWouldRun(dw_sku, price) {
const p = Number(price);
return {
mutation: 'productVariantsBulkUpdate',
// productId is resolved by the live tool from the variant/sku; placeholder here.
productId: 'gid://shopify/Product/PRODUCT_ID',
variables: {
variants: [
{
id: VARIANT_ID_PLACEHOLDER,
price: Number.isFinite(p) ? p.toFixed(2) : null,
// sku unchanged — identity echo so the shape matches the sku-mutation contract.
inventoryItem: { sku: dw_sku },
},
],
},
// The selection set the live tool reads back to confirm the write.
returns: 'productVariants { id price sku } userErrors { field message }',
};
}
// A TEMPLATE (not a populated rollback map) describing the pre-write snapshot the FUTURE
// gated live tool MUST capture BEFORE it writes, so the reprice is reversible. IMPORTANT:
// this dry-run CANNOT read live prices/ids, so old_price + variant_id here are TEMPLATE
// MARKERS, not real values — the dryrun JSON is NOT a usable rollback map on its own. The
// live tool is responsible for reading current variant.price + resolving the real variant
// id FIRST and materialising a populated restore map from this template. `_template:true`
// makes that unmistakable to any consumer.
export function restoreMapTemplate() {
return {
_template: true,
_warning: 'NOT a populated restore map — live tool must fill old_price/variant_id/product_id from a live read BEFORE any write',
product_id: '<TEMPLATE: resolve live product id>',
variant_id: '<TEMPLATE: resolve live sellable variant id — unproven in dry-run>',
old_price: '<TEMPLATE: read current variant.price BEFORE write>',
new_price: 'proposed_sellable_price', // the value this runbook proposes (real, top-level in the record)
};
}
// Back-compat alias (older name).
export const restoreMapSchema = restoreMapTemplate;
// The live tool must resolve AND verify a UNIQUE sellable variant per dw_sku before writing.
// The dry-run uses a placeholder id and therefore does NOT prove id-resolution succeeds or
// that exactly one sellable variant exists per product. Surfaced so no one treats the
// dry-run as proof the live write will bind to the right variant.
export const ID_RESOLUTION_DISCLOSURE =
'variant-id resolution is UNPROVEN by this dry-run (placeholder id); the live tool must resolve + verify a unique sellable variant per dw_sku.';
// Provenance cross-check for retail-harvest-backed rows that have an independently-computed
// our_price (= trade/0.65/0.85). A sharp divergence between the proposed retail and our_price
// is a scraper-unit-error signal (e.g. a yard price posted as a roll price). Pure, null-safe.
// Returns a WARN reason string or null. Only meaningful where our_price is present (~24 rows).
export function ourPriceDeviationWarn(proposed, ourPrice, pct = 0.15) {
const p = Number(proposed), o = Number(ourPrice);
if (!Number.isFinite(p) || !Number.isFinite(o) || o <= 0) return null;
const dev = Math.abs(p - o) / o;
return dev > pct
? `proposed $${p.toFixed(2)} deviates ${(dev * 100).toFixed(0)}% from our_price $${o.toFixed(2)} (>${(pct * 100).toFixed(0)}% — possible scraper unit error)`
: null;
}
// Classify a single proposed price against the sanity rules. Pure — no side effects.
// Inputs: proposed (number), sample (number), tradeFloor (number|null — staged_trade
// if present; null = no cost floor to check).
// Returns { verdict:'PASS'|'WARN'|'FAIL', reasons:[...] } where:
// FAIL — nonpositive, <= sample, or below cost floor (hard invariant break).
// WARN — price outside [BAND_MIN, BAND_MAX] band (human-eyes outlier, still exits 0).
// PASS — positive, > sample, >= tradeFloor (if present), in band.
export function classifyPrice(proposed, sample = SAMPLE_PRICE, tradeFloor = null) {
const price = Number(proposed);
const reasons = [];
if (!Number.isFinite(price) || price <= 0) {
return { verdict: 'FAIL', reasons: [`nonpositive proposed price: ${proposed}`] };
}
if (price <= Number(sample)) {
return { verdict: 'FAIL', reasons: [`proposed ${price} <= sample ${sample}`] };
}
if (tradeFloor != null && Number.isFinite(Number(tradeFloor)) && Number(tradeFloor) > 0 && price < Number(tradeFloor)) {
return { verdict: 'FAIL', reasons: [`proposed ${price} below cost/trade floor ${tradeFloor}`] };
}
if (price < BAND_MIN) reasons.push(`below band (< $${BAND_MIN})`);
if (price > BAND_MAX) reasons.push(`above band (> $${BAND_MAX})`);
return { verdict: reasons.length ? 'WARN' : 'PASS', reasons };
}
// Distribution summary over a numeric array (null-safe).
export function priceStats(values) {
const nums = values.map(Number).filter(n => Number.isFinite(n)).sort((a, b) => a - b);
const n = nums.length;
if (n === 0) return { count: 0, min: null, median: null, max: null };
return {
count: n,
min: nums[0],
median: nums[Math.floor(n / 2)],
max: nums[n - 1],
};
}