[object Object]

← back to Sanderson Onboard

TK-11414: stop the SDG weight leak at the source (create_sdg.mjs)

8d09eed92fc751e6706acf4219047679b5885b92 · 2026-09-11 09:17:29 -0700 · Steve Abrams

SDG publish cadence shipped every variant with NO weight -> 385 went live zero-weight overnight.
- vendored lib/weight-guard.mjs
- create payload: sellable + Sample carry weight (per-type default / 0.25lb)
- goLive: sets default weight on any zero-weight variant before activating (enforces the rule
  + self-heals the finish-pending backlog).

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

Files touched

Diff

commit 8d09eed92fc751e6706acf4219047679b5885b92
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Sep 11 09:17:29 2026 -0700

    TK-11414: stop the SDG weight leak at the source (create_sdg.mjs)
    
    SDG publish cadence shipped every variant with NO weight -> 385 went live zero-weight overnight.
    - vendored lib/weight-guard.mjs
    - create payload: sellable + Sample carry weight (per-type default / 0.25lb)
    - goLive: sets default weight on any zero-weight variant before activating (enforces the rule
      + self-heals the finish-pending backlog).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/create_sdg.mjs       | 17 ++++++++--
 scripts/lib/weight-guard.mjs | 81 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 95 insertions(+), 3 deletions(-)

diff --git a/scripts/create_sdg.mjs b/scripts/create_sdg.mjs
index b6bd725..4cb5b1d 100644
--- a/scripts/create_sdg.mjs
+++ b/scripts/create_sdg.mjs
@@ -68,8 +68,8 @@ function payload(it) {
     tags: tags.join(', '), body_html: body, metafields, images: [{ src: it.image }],
     options: [{ name: 'Size' }],
     variants: [
-      { sku: dw, price: String(parseFloat(it.retail_usd).toFixed(2)), option1: `Sold Per ${unit}`, taxable: true, requires_shipping: true },
-      { sku: `${dw}-Sample`, price: '4.25', option1: 'Sample', taxable: true, requires_shipping: true },
+      { sku: dw, price: String(parseFloat(it.retail_usd).toFixed(2)), option1: `Sold Per ${unit}`, taxable: true, requires_shipping: true, weight: defaultWeightLb({ sku: dw, option1: `Sold Per ${unit}` }, { productType: it.product_type }), weight_unit: 'lb' }, // TK-11414
+      { sku: `${dw}-Sample`, price: '4.25', option1: 'Sample', taxable: true, requires_shipping: true, weight: SAMPLE_WEIGHT_LB, weight_unit: 'lb' }, // TK-11414
     ],
   } };
 }
@@ -82,8 +82,10 @@ async function gql(query, variables) {
     return j.data;
   }
 }
-const Q_V = `query($id:ID!){product(id:$id){status vendor tags variants(first:10){edges{node{id sku title price inventoryItem{id}}}}}}`;
+const Q_V = `query($id:ID!){product(id:$id){status vendor tags productType variants(first:10){edges{node{id sku title price inventoryItem{id measurement{weight{value}}}}}}}}`;
+const M_WEIGHT = `mutation($id:ID!,$w:Float!){inventoryItemUpdate(id:$id,input:{measurement:{weight:{value:$w,unit:POUNDS}}}){userErrors{message}}}`; // TK-11414
 import { safeStampQuantity } from './lib/inventory-stamp-guard.mjs';  // GUARD TK-11357 (shared guard)
+import { defaultWeightLb, hasZeroWeight, SAMPLE_WEIGHT_LB } from './lib/weight-guard.mjs'; // GUARD TK-11414 (weight go-live)
 // ── GUARD TK-11357 BEGIN ─ do not edit without re-running the fixture proof ──────────
 // A $0 / quote-only sellable variant must NEVER receive positive stock: positive stock is what
 // flips availableForSale=true, making it checkout-orderable at $0 (lineage TK-10825 -> 10965 ->
@@ -127,6 +129,15 @@ async function goLive(pid) {
   // never re-checks. Routing the stamp through the shared guard makes the invariant hold at the
   // mutation itself rather than only in an upstream manifest field.
   const quantities = safeQuantities(d.product, vnodes, LOCATION_ID, TARGET_QTY);
+  // TK-11414: never let a product go live at zero weight. Set the default on any zero-weight
+  // variant BEFORE activating — enforces the rule AND self-heals finish-pending drafts built
+  // before weight was wired (freight gate: zero-weight collapses orders into the 0.5lb tier).
+  for (const v of vnodes) {
+    if (hasZeroWeight(v)) {
+      const w = defaultWeightLb(v, { productType: d.product.productType });
+      await gql(M_WEIGHT, { id: v.inventoryItem.id, w });
+    }
+  }
   for (const iid of items) { await gql(M_TRACK, { id: iid }); await gql(M_ACT, { iid, loc: LOCATION_ID }); }
   await gql(M_QTY, { input: { name: 'on_hand', reason: 'correction', ignoreCompareQuantity: true, quantities } });
   await gql(M_PUB, { id: gid, pubs: PUBLICATIONS.map(p => ({ publicationId: p })) });
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));
+}

← 1fe3fca auto-data-snapshot: 2026-09-11T05:32:16 (5 data files) — pil  ·  back to Sanderson Onboard  ·  TK-11471: per-RUN weight proof for the SDG publish cadence 5507351 →