[object Object]

← back to Designerwallcoverings

TK-11414: weight go-live guard — shared weight-guard.mjs + wire sanderson onboarder

5027a3a68853d43fb193620caec476fa7a27887c · 2026-09-10 13:40:37 -0700 · Steve Abrams

- lib/weight-guard.mjs: pure module (sample 0.25lb, per-type sellable defaults, zero-weight
  detection + activate-blocker), mirrors inventory-stamp-guard.mjs pattern.
- sanderson build-payloads: variants ship with weight (Roll=Wallcovering default, Sample 0.25).
- sanderson go-live: validate() now HOLDS a product as draft if the sellable variant is zero weight.
Enforces Steve's rule: never let a product go live at zero weight (freight mis-cost gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 5027a3a68853d43fb193620caec476fa7a27887c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 13:40:37 2026 -0700

    TK-11414: weight go-live guard — shared weight-guard.mjs + wire sanderson onboarder
    
    - lib/weight-guard.mjs: pure module (sample 0.25lb, per-type sellable defaults, zero-weight
      detection + activate-blocker), mirrors inventory-stamp-guard.mjs pattern.
    - sanderson build-payloads: variants ship with weight (Roll=Wallcovering default, Sample 0.25).
    - sanderson go-live: validate() now HOLDS a product as draft if the sellable variant is zero weight.
    Enforces Steve's rule: never let a product go live at zero weight (freight mis-cost gate).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/lib/weight-guard.mjs                 | 81 ++++++++++++++++++++++++++++
 scripts/sanderson-onboard/build-payloads.mjs |  6 ++-
 scripts/sanderson-onboard/go-live.mjs        |  4 +-
 3 files changed, 88 insertions(+), 3 deletions(-)

diff --git a/scripts/lib/weight-guard.mjs b/scripts/lib/weight-guard.mjs
new file mode 100644
index 0000000..67d600e
--- /dev/null
+++ b/scripts/lib/weight-guard.mjs
@@ -0,0 +1,81 @@
+// 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));
+}
diff --git a/scripts/sanderson-onboard/build-payloads.mjs b/scripts/sanderson-onboard/build-payloads.mjs
index 0a076b1..594875b 100644
--- a/scripts/sanderson-onboard/build-payloads.mjs
+++ b/scripts/sanderson-onboard/build-payloads.mjs
@@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
 import { specMetafields } from '../_spec-to-metafields.mjs';
 import { resolveHero } from './hero-modal.mjs';   // TK-11070 modal-code hero (fixes shared-default inversion)
+import { SAMPLE_WEIGHT_LB, TYPE_DEFAULT_LB } from '../lib/weight-guard.mjs'; // TK-11414 weight go-live guard
 
 // TK-11070: opt-in live hero resolution. Default OFF so a plain re-run stays offline/deterministic and
 // does not fire ~500 vendor fetches unexpectedly. With --live-heroes, images() uses the modal-code
@@ -253,9 +254,10 @@ async function main() {
         options: [{ name: 'Format' }],
         variants: [
           // sellable Roll @ retail (quote-fallback for the 1 unpriced row → 0.00 + Needs-Price handled at go-live)
-          { sku: dw, price: priced ? String(Number(r.retail_usd).toFixed(2)) : '0.00', option1: 'Roll', taxable: true, requires_shipping: true },
+          // TK-11414: weight required (never ship a zero-weight sellable → freight mis-cost). Wallcovering default.
+          { sku: dw, price: priced ? String(Number(r.retail_usd).toFixed(2)) : '0.00', option1: 'Roll', taxable: true, requires_shipping: true, weight: TYPE_DEFAULT_LB['Wallcovering'], weight_unit: 'lb' },
           // Sample — no inventory tracking (task: "no inventory")
-          { sku: `${dw}-Sample`, price: SAMPLE_PRICE, option1: 'Sample', taxable: true, requires_shipping: true, inventory_management: null },
+          { sku: `${dw}-Sample`, price: SAMPLE_PRICE, option1: 'Sample', taxable: true, requires_shipping: true, inventory_management: null, weight: SAMPLE_WEIGHT_LB, weight_unit: 'lb' },
         ],
       },
       metafields: metafields(r),
diff --git a/scripts/sanderson-onboard/go-live.mjs b/scripts/sanderson-onboard/go-live.mjs
index 1965b6d..e532a37 100644
--- a/scripts/sanderson-onboard/go-live.mjs
+++ b/scripts/sanderson-onboard/go-live.mjs
@@ -23,6 +23,7 @@ import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { execSync } from 'node:child_process';
 
+import { hasZeroWeight } from '../lib/weight-guard.mjs'; // TK-11414 weight go-live gate
 const HERE = path.dirname(fileURLToPath(import.meta.url));
 const OUT = path.join(HERE, 'out');
 const DB = 'postgresql:///dw_unified?host=/tmp';
@@ -58,7 +59,7 @@ async function gql(query, variables = {}) {
   }
 }
 const Q_PROD = `query($id:ID!){ product(id:$id){ status descriptionHtml tags featuredImage{url}
-  mediaCount{count} variants(first:10){edges{node{ sku price inventoryItem{id} }}} } }`;
+  mediaCount{count} variants(first:10){edges{node{ sku price inventoryItem{id measurement{weight{value unit}}} }}} } }`;
 const M_TRACK = `mutation($id:ID!){ inventoryItemUpdate(id:$id, input:{tracked:true}){ userErrors{message} } }`;
 const M_ACTIVATE = `mutation($iid:ID!,$loc:ID!){ inventoryActivate(inventoryItemId:$iid, locationId:$loc){ userErrors{message} } }`;
 const M_SETQTY = `mutation($input:InventorySetQuantitiesInput!){ inventorySetQuantities(input:$input){ userErrors{message code} } }`;
@@ -74,6 +75,7 @@ function validate(p) {
   if (!sample) fails.push('sample-variant');
   if (!sellable) fails.push('sellable-variant');
   if (!sellable || !(Number(sellable.price) > 0)) fails.push('pricing');       // no $0/null on the sellable
+  if (sellable && hasZeroWeight(sellable)) fails.push('weight>0');              // TK-11414: never go live at zero weight (freight gate)
   if (!p.descriptionHtml || !p.descriptionHtml.replace(/<[^>]*>/g, '').trim()) fails.push('description');
   if (!p.tags || p.tags.length < 2) fails.push('tags>=2');
   if (!(p.featuredImage?.url) && !(p.mediaCount?.count > 0)) fails.push('image');

← e76ef3e auto-data-snapshot: 2026-09-10T13:39:50 (2 data files) — scr  ·  back to Designerwallcoverings  ·  TK-10895: require recorded adjudication to release a dirty G 045ae79 →