← back to Sanderson Onboard

scripts/lib/weight-guard.mjs

82 lines

// weight-guard.mjs — TK-11414 (2026-09-10) prevention primitive.
// Steve's rule: NO product may go ACTIVE with a missing/zero product WEIGHT
// (zero-weight collapses orders into the lowest weight tier / free band and
// mis-costs DW freight). Mirrors the inventory-stamp-guard.mjs pattern: a pure,
// side-effect-free module onboarders import at two call sites —
//   1. create payload:  weight: resolveWeightLb(variant, product)  (unit POUNDS)
//   2. before activate:  const b = zeroWeightBlockers(product); if (b.length) don't flip ACTIVE
//
// Defaults come straight from the approved TK-11414 backfill (samples 0.25 lb,
// sellable per-product-type). Keep these in sync with that backfill.

export const SAMPLE_WEIGHT_LB = 0.25;
export const FALLBACK_LB = 2.0;

// product_type -> sellable default weight (POUNDS)
export 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();

/** Sample variant? (importers create `Sample` @ $4.25 + the real unit). */
export function isSampleVariant(variant = {}) {
  const label = norm(variant.title ?? variant.option1 ?? '');
  const sku = norm(variant.sku);
  if (label.includes('sample') || label.includes('memo')) return true;
  if (sku.endsWith('-sample') || sku.includes('sample')) return true;
  const p = Number(variant.price);
  return Number.isFinite(p) && Math.abs(p - 4.25) < 0.01;
}

/** Current weight in lb, or 0 if missing/unparseable. Accepts variant.weight (REST),
 *  variant.grams, or inventoryItem.measurement.weight.value (GraphQL). */
export function currentWeightLb(variant = {}) {
  const gql = variant?.inventoryItem?.measurement?.weight;
  if (gql && gql.value != null) {
    const v = Number(gql.value);
    return (norm(gql.unit) === 'kilograms') ? v * 2.20462 : v; // else assume POUNDS/GRAMS below
  }
  if (variant.grams != null) return Number(variant.grams) / 453.59237;
  if (variant.weight != null) {
    const v = Number(variant.weight);
    const u = norm(variant.weight_unit || 'lb');
    if (u.startsWith('kg')) return v * 2.20462;
    if (u === 'g' || u.startsWith('gram')) return v / 453.59237;
    if (u === 'oz') return v / 16;
    return v; // lb
  }
  return 0;
}

export function hasZeroWeight(variant = {}) {
  const w = currentWeightLb(variant);
  return !Number.isFinite(w) || w <= 0;
}

/** The default weight (lb) to assign a variant that has none. */
export function defaultWeightLb(variant = {}, product = {}) {
  if (isSampleVariant(variant)) return SAMPLE_WEIGHT_LB;
  return TYPE_DEFAULT_LB[product.productType || product.product_type] ?? FALLBACK_LB;
}

/** THE CREATE-SIDE GUARD: keep a real positive weight, else fill the default.
 *  Drop-in for the weight field in a create/productSet payload (returns lb). */
export function resolveWeightLb(variant = {}, product = {}) {
  const w = currentWeightLb(variant);
  return (Number.isFinite(w) && w > 0) ? w : defaultWeightLb(variant, product);
}

/** THE ACTIVATE-SIDE GUARD: sellable variants that would go live at zero weight.
 *  If this is non-empty, DO NOT flip the product to ACTIVE (Steve's rule). */
export function zeroWeightBlockers(product = {}) {
  const variants = product.variants?.edges?.map(e => e.node) ?? product.variants ?? [];
  return variants.filter(v => !isSampleVariant(v) && hasZeroWeight(v));
}