← 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],
  };
}