← back to Dw Rotation Activator

lib/weight-gate.js

92 lines

// weight-gate.js — TK-11471 (enforcing Steve's TK-11414 rule) for dw-rotation-activator.
//
// NO product may go DRAFT->ACTIVE with a missing/zero product WEIGHT. Zero weight collapses an
// order into the lowest weight tier / free-shipping band and mis-costs DW freight on every order
// that touches the product.
//
// WHY THIS FILE EXISTS SEPARATELY rather than importing ~/Projects/designerwallcoverings/scripts/
// lib/weight-guard.mjs: same reasoning as this repo's private-label leak guard (DTD verdict B,
// 2026-07-21) — a ~/Projects app does not cross-import another app's tree. Constants are kept
// IDENTICAL to that module and to lib/weight_guard.py; weight-gate.test.js asserts that
// mechanically against the .mjs source so the two cannot drift into disagreeing about what a
// compliant weight is.
//
// SAMPLES COUNT. dw-active-weight-canary FAILs on ANY zero-weight ACTIVE variant — its live run
// on 2026-09-11 split the offenders 43 sample / 40 sellable. The .mjs's zeroWeightBlockers()
// filters samples OUT and is deprecated there; that bug is deliberately not reproduced here.
//
// FAIL-SAFE: returns {ok:false} on anything it cannot positively confirm. It can only BLOCK an
// activation, never cause one. In particular, a response that does not carry the weight FIELD is
// a failure, not a pass — otherwise a query regression that drops the field would silently
// re-open the hole with a green gate, which is the exact false-green class this ticket exists for.

const SAMPLE_WEIGHT_LB = 0.25;
const FALLBACK_LB = 2.0;
const TYPE_DEFAULT_LB = {
  'Wallcovering': 3.0, 'Wallcoverings': 3.0, 'Wallpaper': 3.0,
  'Metallic Wallcovering': 3.0, 'Commercial Wallcovering': 3.0,
  'Mural': 4.0,
  'Fabric': 1.0, 'Commercial Fabric': 1.0, 'Commercial Drapery': 1.0,
  'Trim': 0.5, 'Acoustic Panel': 6.0, 'Pillow': 1.5,
  'Upholstered Walls/Panels': 6.0, 'Tin Ceiling Tile': 2.0,
  'Hardware': 1.0, 'Furniture': 15.0, 'Memo Sample': 0.25,
};

const norm = (t) => String(t ?? '').trim().toLowerCase();

function isSampleVariant(v = {}) {
  const label = norm(v.title ?? v.option1 ?? '');
  const sku = norm(v.sku);
  if (label.includes('sample') || label.includes('memo')) return true;
  if (sku.endsWith('-sample') || sku.includes('sample')) return true;
  const p = Number(v.price);
  return Number.isFinite(p) && Math.abs(p - 4.25) < 0.01;
}

// Did the response actually CARRY the weight field? Distinguishes "measured zero" from
// "never asked / field absent" — an unmeasured input is never a pass (CLAUDE.md TK-11431 am.1).
function weightFieldPresent(v = {}) {
  const m = v?.inventoryItem?.measurement;
  return !!(m && Object.prototype.hasOwnProperty.call(m, 'weight'));
}

function currentWeightLb(v = {}) {
  const w = v?.inventoryItem?.measurement?.weight;
  if (!w || w.value == null) return 0;
  const val = Number(w.value);
  if (!Number.isFinite(val)) return 0;
  switch (norm(w.unit)) {
    case 'kilograms': return val * 2.20462;
    case 'grams':     return val / 453.59237;
    case 'ounces':    return val / 16;
    default:          return val; // POUNDS
  }
}

function defaultWeightLb(v = {}, productType) {
  if (isSampleVariant(v)) return SAMPLE_WEIGHT_LB;
  return TYPE_DEFAULT_LB[productType] ?? FALLBACK_LB;
}

/**
 * The gate. `n` is the LIVE product node from STATUS_Q.
 * @returns {{ok: boolean, reasons: string[]}}
 */
function weightGuard(n = {}) {
  const variants = n?.variants?.nodes || [];
  if (!variants.length) return { ok: false, reasons: ['weight: no variants on the live node — cannot assert weight>0'] };

  const unmeasured = variants.filter((v) => !weightFieldPresent(v));
  if (unmeasured.length) {
    return { ok: false, reasons: [`weight: ${unmeasured.length}/${variants.length} variant(s) came back WITHOUT the weight field — the query is not measuring weight; refusing to pass on unmeasured data`] };
  }
  const zero = variants.filter((v) => !(currentWeightLb(v) > 0));
  if (zero.length) {
    return { ok: false, reasons: [`weight>0: ${zero.length}/${variants.length} variant(s) at zero/missing weight (${zero.map((v) => v.sku || v.title || '?').join(', ')})`] };
  }
  return { ok: true, reasons: [] };
}

module.exports = { weightGuard, currentWeightLb, isSampleVariant, weightFieldPresent,
                   defaultWeightLb, SAMPLE_WEIGHT_LB, FALLBACK_LB, TYPE_DEFAULT_LB };