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