← 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
A scripts/lib/weight-guard.mjsM scripts/sanderson-onboard/build-payloads.mjsM scripts/sanderson-onboard/go-live.mjs
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 →