[object Object]

← back to Designer Wallcoverings

fix(weight-guard): use real product_type on import so fabrics/murals aren't stamped 3.0 lb

0c4f57716986a99c42c89ef7757647c3ef0778b6 · 2026-09-14 15:07:00 -0700 · Steve

createOrGetProduct passed a hardcoded 'Wallcovering' into addSampleVariant, so the
TK-11539 weight guard's per-type table (Fabric 1.0, Mural 4.0, …) was never used and
every import shipped at the 3.0 lb wallcovering default — 3x over-weight freight on
fabric lines (Carnegie) routed through the shared engine. Now threads
dto.specs.product_type (falling back to 'Wallcovering' for typeless imports, no change
to existing wallcovering behavior). Verified: tsc clean, 28 Jest tests pass.

(Commit also carries the surrounding TK-11403/TK-11539 write-path wiring in shopify.ts —
price-integrity gate + activate-then-price ordering — as it shares the same hunks.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ct1qVRFZ6mxuizbSCLDe1n

Files touched

Diff

commit 0c4f57716986a99c42c89ef7757647c3ef0778b6
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Sep 14 15:07:00 2026 -0700

    fix(weight-guard): use real product_type on import so fabrics/murals aren't stamped 3.0 lb
    
    createOrGetProduct passed a hardcoded 'Wallcovering' into addSampleVariant, so the
    TK-11539 weight guard's per-type table (Fabric 1.0, Mural 4.0, …) was never used and
    every import shipped at the 3.0 lb wallcovering default — 3x over-weight freight on
    fabric lines (Carnegie) routed through the shared engine. Now threads
    dto.specs.product_type (falling back to 'Wallcovering' for typeless imports, no change
    to existing wallcovering behavior). Verified: tsc clean, 28 Jest tests pass.
    
    (Commit also carries the surrounding TK-11403/TK-11539 write-path wiring in shopify.ts —
    price-integrity gate + activate-then-price ordering — as it shares the same hunks.)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Ct1qVRFZ6mxuizbSCLDe1n
---
 DW-Programming/ImportNewSkufromURL/lib/shopify.ts | 257 ++++++++++++++++++++--
 1 file changed, 242 insertions(+), 15 deletions(-)

diff --git a/DW-Programming/ImportNewSkufromURL/lib/shopify.ts b/DW-Programming/ImportNewSkufromURL/lib/shopify.ts
index 9e63a4c2..c04f6b09 100644
--- a/DW-Programming/ImportNewSkufromURL/lib/shopify.ts
+++ b/DW-Programming/ImportNewSkufromURL/lib/shopify.ts
@@ -5,6 +5,13 @@ import { ProductDTO, ShopifyProduct, ShopifyProductResult } from './types';
 import { getNextSKU } from './sku-counter';
 import { addToHistory } from './sku-history';
 import { assignDwSku, registerShopifyInfo } from './dw-sku-registry';
+// Price-integrity gate (TK-11403) — blocks a bad price before it is written.
+import { assertPriceIntegrity, resolveSampleFloor } from './price-integrity-gate';
+import { resolveNetCost } from './price-integrity-cost';
+import { recordOutcome } from './price-integrity-record';
+// Weight guard (TK-11539) — inject a positive inventoryItem.measurement.weight at variant
+// create so no imported product ships zero-weight (TK-11414 recurrence prevention).
+import { inventoryItemWithWeight } from './weight-guard';
 
 // ---- Brewster (& imprints) per-roll price list ----
 // The Brewster scraper returns NO price, so new SKUs would import at the $4.25 sample
@@ -254,7 +261,15 @@ async function findExistingProduct(fingerprint: string): Promise<ShopifyProduct
 }
 
 // Create product with metafield and sample variant
-async function createProductWithMetafield(dto: ProductDTO, priceUnit: string, unitLabel: string): Promise<ShopifyProduct | null> {
+// Fix (2026-09-14, activate-then-price ordering bug): createProductWithMetafield
+// used to decide ACTIVE from a PRE-gate price guess and Shopify would auto-create the
+// sellable variant at $0 — the real price is only written later in addSampleVariant(),
+// AFTER the price-integrity gate. A gate BLOCK / infra error / failed price write left
+// that ACTIVE, all-channels-published $0 product live (TK-11357 recurrence). Now this
+// function ALWAYS creates DRAFT and returns the baseline eligibility flags
+// (hasImage/hasWidth/hasPrice) so createOrGetProduct() can AND them with the real
+// post-gate outcome before ever promoting to ACTIVE + publishing.
+async function createProductWithMetafield(dto: ProductDTO, priceUnit: string, unitLabel: string): Promise<{ product: ShopifyProduct; hasImage: boolean; hasWidth: boolean; hasPrice: boolean } | null> {
   const mutation = `
     mutation ProductCreate($input: ProductInput!) {
       productCreate(input: $input) {
@@ -808,12 +823,16 @@ async function createProductWithMetafield(dto: ProductDTO, priceUnit: string, un
   // the product DRAFT and tag Needs-Price rather than publishing a $0/sample-priced roll.
   const numericUnitPrice = parseFloat(String(dto.price || '').replace(/[^0-9.]/g, ''));
   const hasPrice = Number.isFinite(numericUnitPrice) && numericUnitPrice > 4.25;
-  const productStatus = hasImage && hasWidth && hasPrice ? 'ACTIVE' : 'DRAFT';
+  // Fix (2026-09-14): NEVER decide ACTIVE here from this pre-gate guess anymore — see
+  // the comment on the function signature above. Always create DRAFT; hasImage/hasWidth/
+  // hasPrice still drive the Needs-* tags below AND are returned to the caller as the
+  // baseline eligibility gate it ANDs with the real price-integrity gate outcome.
+  const productStatus = 'DRAFT';
   const gateTags: string[] = [];
   if (!hasImage) gateTags.push('Needs-Image');
   if (!hasWidth) gateTags.push('Needs-Width');
   if (!hasPrice) gateTags.push('Needs-Price');
-  if (productStatus === 'DRAFT') {
+  if (!(hasImage && hasWidth && hasPrice)) {
     console.warn(
       `📝 Creating as DRAFT (gate): hasImage=${hasImage} hasWidth=${hasWidth} hasPrice=${hasPrice} — needs all three to go ACTIVE`
     );
@@ -922,14 +941,24 @@ async function createProductWithMetafield(dto: ProductDTO, priceUnit: string, un
     handle: product.handle,
   }));
 
-  return product;
+  return { product, hasImage, hasWidth, hasPrice };
+}
+
+// Result of the sell-price write — used by createOrGetProduct() to decide whether
+// the product is allowed to be promoted to ACTIVE + published (2026-09-14 fix).
+// priced:true ONLY when the sellable variant's productVariantsBulkUpdate price write
+// actually succeeded (no userErrors); gateBlocked:true when the price-integrity gate
+// blocked the write outright.
+export interface AddSampleVariantResult {
+  priced: boolean;
+  gateBlocked: boolean;
 }
 
 // Price BOTH the per-unit (Per Roll / Per Yard) variant AND the Sample variant that
 // were auto-created from the two Size option values. The per-unit variant carries the
 // real scraped vendor price (standing rule: every new SKU must have a price per unit);
 // the Sample variant is fixed at $4.25 (standing rule: every product has a Sample).
-async function addSampleVariant(productId: string, sku: string, price: string, unitLabel: string, unitKind: string): Promise<void> {
+async function addSampleVariant(productId: string, sku: string, price: string, unitLabel: string, unitKind: string, vendor: string = '', productType: string = 'Wallcovering'): Promise<AddSampleVariantResult> {
   // First, get the product's variants (Shopify auto-created them from productOptions)
   const getVariantsQuery = `
     query getProductVariants($id: ID!) {
@@ -981,7 +1010,66 @@ async function addSampleVariant(productId: string, sku: string, price: string, u
     const numericUnitPrice = numericPrice || '0.00';
     const unitVariant = findBySize(unitLabel) || existingVariants[0];
 
+    // ---- PRICE-INTEGRITY GATE (TK-11403) — BEFORE the two productVariantsBulk* writes ----
+    // Blocks the recurring bugs (A sample/default-price leak, B markup/absolute-floor,
+    // C $0-orderable) at the write choke point. FAIL-SAFE: resolveNetCost + the gate
+    // never throw out of the import path; a null cost is a non-blocking WARN
+    // (A-with-teeth) so ~97 no-cost vendors still import; only a real VIOLATION blocks.
+    let gateBlocked = false;
+    // TK-11403: resolve the per-vendor DECLARED sample price (default $4.25). This same
+    // value is both the Class-A gate floor AND the price actually written for the sample
+    // variant below, so a vendor with a higher declared sample (e.g. DW Bespoke $12) both
+    // gets its sample written at $12 and passes the gate instead of blocking on a $4.25 leak.
+    const sampleFloor = resolveSampleFloor(vendor);
+    try {
+      const netCost = await resolveNetCost(vendor, sku); // fail-safe → null on any error
+      const gate = assertPriceIntegrity({
+        dwSku: sku,
+        netCost,
+        sampleFloor,
+        variants: [
+          {
+            role: 'sellable',
+            price: Number(numericUnitPrice),
+            orderable: true, // CONTINUE + setInventoryQuantity(...,2025) below make it orderable
+            sku: `${sku}-${unitKind}`,
+            priceSource: numericPrice ? 'scraped' : 'defaulted',
+          },
+          { role: 'sample', price: sampleFloor, orderable: false, sku: `${sku}-Sample` },
+        ],
+      });
+      if (gate.warnings.length) {
+        recordOutcome({ vendor, dwSku: sku, outcome: 'warn', codes: gate.warnings.map(w => w.code) });
+      }
+      if (!gate.ok) {
+        console.error(JSON.stringify({ event: 'price_integrity_block', dwSku: sku, vendor, violations: gate.violations }));
+        recordOutcome({ vendor, dwSku: sku, outcome: 'block', codes: gate.violations.map(v => v.code) });
+        gateBlocked = true;
+      }
+    } catch (gateErr) {
+      // Gate INFRA error must NEVER block an import (A-with-teeth). Log + proceed.
+      console.error(JSON.stringify({
+        event: 'price_integrity_gate_error',
+        dwSku: sku,
+        vendor,
+        error: gateErr instanceof Error ? gateErr.message : String(gateErr),
+      }));
+      // (review 2026-09-14) Even on a gate INFRA error, keep the pure $0-orderable
+      // check FAIL-CLOSED. The sellable below is written CONTINUE + stocked (orderable),
+      // so a non-priced ($0) sellable is the exact recurring defect (TK-10825/10965/
+      // 11140/11301/11357). This check cannot throw, so enforce it here rather than
+      // lumping it with the cost-resolution risk that A-with-teeth intentionally waives.
+      if (!(Number(numericUnitPrice) > 0)) {
+        gateBlocked = true;
+      }
+    }
+    // (2026-09-14 fix) do NOT write the bad price — publish blocked. Return gateBlocked
+    // so createOrGetProduct() force-keeps the product DRAFT and skips publish, instead
+    // of relying on the caller to notice a swallowed void return.
+    if (gateBlocked) return { priced: false, gateBlocked: true };
+
     // 1) Update the per-unit variant
+    let priced = false;
     if (unitVariant) {
       const upd = await shopifyGraphQL<{
         productVariantsBulkUpdate: {
@@ -995,10 +1083,18 @@ async function addSampleVariant(productId: string, sku: string, price: string, u
             userErrors { field message }
           }
         }`,
-        { productId, variants: [{ id: unitVariant.node.id, price: numericUnitPrice, inventoryPolicy: 'CONTINUE', inventoryItem: { sku: `${sku}-${unitKind}`, tracked: true } }] }
+        // TK-11539: inventoryItemWithWeight() adds a positive measurement.weight (POUNDS)
+        // so the sellable variant is never created zero-weight.
+        { productId, variants: [{ id: unitVariant.node.id, price: numericUnitPrice, inventoryPolicy: 'CONTINUE', inventoryItem: inventoryItemWithWeight({ sku: `${sku}-${unitKind}`, tracked: true }, { role: 'sellable', productType }) }] }
       );
       const uerr = upd.productVariantsBulkUpdate.userErrors;
-      if (uerr?.length) console.error(JSON.stringify({ event: 'shopify_unit_variant_error', productId, errors: uerr }));
+      if (uerr?.length) {
+        console.error(JSON.stringify({ event: 'shopify_unit_variant_error', productId, errors: uerr }));
+      } else {
+        // (2026-09-14 fix) priced:true ONLY on a confirmed, error-free price write —
+        // this is what createOrGetProduct() gates ACTIVE + publish on.
+        priced = true;
+      }
       const iid = upd.productVariantsBulkUpdate.productVariants?.[0]?.inventoryItem?.id;
       if (iid) await setInventoryQuantity(iid, 2025);
     }
@@ -1017,7 +1113,10 @@ async function addSampleVariant(productId: string, sku: string, price: string, u
             userErrors { field message }
           }
         }`,
-        { productId, variants: [{ price: '4.25', optionValues: [{ name: 'Sample', optionName: 'Size' }], inventoryItem: { sku: `${sku}-Sample`, tracked: false } }] }
+        // TK-11539: sample variant also gets a positive measurement.weight (0.25 lb).
+        // TK-11403: write the per-vendor declared sample price (String(sampleFloor)); $4.25 for
+        // all non-declared vendors, higher for vendors in VENDOR_DECLARED_SAMPLE_PRICE.
+        { productId, variants: [{ price: String(sampleFloor), optionValues: [{ name: 'Sample', optionName: 'Size' }], inventoryItem: inventoryItemWithWeight({ sku: `${sku}-Sample`, tracked: false }, { role: 'sample', productType }) }] }
       );
       const cerr = cre.productVariantsBulkCreate.userErrors;
       if (cerr?.length && !JSON.stringify(cerr).toLowerCase().includes('already exists')) {
@@ -1032,6 +1131,7 @@ async function addSampleVariant(productId: string, sku: string, price: string, u
       unitLabel,
       unitPrice: numericUnitPrice,
     }));
+    return { priced, gateBlocked: false };
   } catch (error) {
     console.error(JSON.stringify({
       timestamp: new Date().toISOString(),
@@ -1039,7 +1139,11 @@ async function addSampleVariant(productId: string, sku: string, price: string, u
       productId,
       error: error instanceof Error ? error.message : String(error),
     }));
-    // Don't throw - variant pricing is best-effort
+    // Don't throw - variant pricing is best-effort, but DO signal failure so the
+    // caller does not promote an unpriced/unwritten product to ACTIVE + published
+    // (2026-09-14 fix — this swallowed exception used to leave the caller with no
+    // way to know the price write never landed).
+    return { priced: false, gateBlocked: false };
   }
 }
 
@@ -1293,6 +1397,80 @@ async function publishToAllChannels(productId: string): Promise<void> {
   }
 }
 
+// (2026-09-14 fix) Promote a DRAFT product to ACTIVE. Used ONLY after
+// createOrGetProduct() confirms the eligibility gate (hasImage/hasWidth/hasPrice)
+// AND the real price-integrity gate/price write both succeeded — never at
+// productCreate time off a pre-gate guess.
+async function setProductStatus(productId: string, status: 'ACTIVE' | 'DRAFT'): Promise<boolean> {
+  try {
+    const data = await shopifyGraphQL<{
+      productUpdate: {
+        product: { id: string; status: string } | null;
+        userErrors: Array<{ field: string[]; message: string }>;
+      };
+    }>(
+      `mutation productUpdateStatus($input: ProductInput!) {
+        productUpdate(input: $input) {
+          product { id status }
+          userErrors { field message }
+        }
+      }`,
+      { input: { id: productId, status } }
+    );
+    const uerr = data.productUpdate.userErrors;
+    if (uerr?.length) {
+      console.error(JSON.stringify({ event: 'shopify_status_update_error', productId, status, errors: uerr }));
+      return false;
+    }
+    console.log(JSON.stringify({ event: 'shopify_status_update_success', productId, status }));
+    return true;
+  } catch (error) {
+    console.error(JSON.stringify({
+      event: 'shopify_status_update_error',
+      productId,
+      status,
+      error: error instanceof Error ? error.message : String(error),
+    }));
+    return false;
+  }
+}
+
+// (2026-09-14 fix) Add tag(s) to an existing product WITHOUT clobbering its current
+// tag list (tagsAdd is additive, unlike productUpdate's tags field which replaces).
+// Used to mark a gate-blocked / unpriced product 'Needs-Price-Review' after create.
+async function addProductTags(productId: string, tags: string[]): Promise<void> {
+  if (!tags.length) return;
+  try {
+    const data = await shopifyGraphQL<{
+      tagsAdd: {
+        node: { id: string } | null;
+        userErrors: Array<{ field: string[]; message: string }>;
+      };
+    }>(
+      `mutation tagsAdd($id: ID!, $tags: [String!]!) {
+        tagsAdd(id: $id, tags: $tags) {
+          node { id }
+          userErrors { field message }
+        }
+      }`,
+      { id: productId, tags }
+    );
+    const uerr = data.tagsAdd.userErrors;
+    if (uerr?.length) {
+      console.error(JSON.stringify({ event: 'shopify_tags_add_error', productId, tags, errors: uerr }));
+    }
+  } catch (error) {
+    console.error(JSON.stringify({
+      event: 'shopify_tags_add_error',
+      productId,
+      tags,
+      error: error instanceof Error ? error.message : String(error),
+    }));
+    // Don't throw - tagging is best-effort, it must never block the DRAFT-keeping
+    // decision it's annotating.
+  }
+}
+
 // Sanitize product title to use as filename
 function sanitizeTitleForFilename(title: string): string {
   return title
@@ -1770,13 +1948,19 @@ export async function createOrGetProduct(dto: ProductDTO): Promise<ShopifyProduc
   const unitLabel = unitVariantLabel(dto.specs, priceUnit);
   const unitKind = unitKindOf(priceUnit);
 
-  // Always create a new product - do not check for existing
-  const product = await createProductWithMetafield(dto, priceUnit, unitLabel);
+  // Always create a new product - do not check for existing. (2026-09-14 fix) the
+  // product returned here is ALWAYS DRAFT now — createProductWithMetafield() no
+  // longer decides ACTIVE from a pre-gate price guess. eligibleForActive carries the
+  // baseline hasImage/hasWidth/hasPrice check; it's ANDed with the real price-integrity
+  // gate + price-write outcome (below) before this product is ever promoted.
+  const created = await createProductWithMetafield(dto, priceUnit, unitLabel);
 
   // If product creation returned null (validation failed), throw error to skip
-  if (!product) {
+  if (!created) {
     throw new Error('Product validation failed - title became empty after exclusion rules');
   }
+  const { product, hasImage, hasWidth, hasPrice } = created;
+  const eligibleForActive = hasImage && hasWidth && hasPrice;
 
   console.log(JSON.stringify({
     timestamp: new Date().toISOString(),
@@ -1806,7 +1990,14 @@ export async function createOrGetProduct(dto: ProductDTO): Promise<ShopifyProduc
     priceUnit,
   }));
 
-  await addSampleVariant(product.id, sku, price, unitLabel, unitKind);
+  // TK-11539: pass the REAL product type through for the create-side weight default so the
+  // weight-guard's per-type table (Fabric 1.0 lb, Mural 4.0 lb, …) is actually used instead
+  // of stamping every import at the Wallcovering default (3.0 lb). The DTO carries the type
+  // at dto.specs.product_type ("e.g. Wallcovering, Fabric" — see ProductSpecsSchema); fall
+  // back to 'Wallcovering' (the DW house default) only when it is absent, preserving prior
+  // behavior for typeless imports. Carnegie and other fabric lines route through this engine.
+  const resolvedProductType = dto.specs?.product_type || 'Wallcovering';
+  const variantResult = await addSampleVariant(product.id, sku, price, unitLabel, unitKind, vendorName, resolvedProductType);
 
   // Link the DW SKU to its Shopify product in the registry (audit + future dedup).
   if (assignment) {
@@ -1816,8 +2007,44 @@ export async function createOrGetProduct(dto: ProductDTO): Promise<ShopifyProduc
   // Add to SKU history
   await addToHistory(sku, dto.title);
 
-  // Publish to all sales channels
-  await publishToAllChannels(product.id);
+  // (2026-09-14 fix — activate-then-price ordering bug) Promote to ACTIVE + publish
+  // ONLY when BOTH: (1) the baseline eligibility gate passed (hasImage && hasWidth &&
+  // hasPrice, computed at create time) AND (2) the real price-integrity gate passed AND
+  // the real sellable-variant price write actually succeeded (variantResult.priced,
+  // !variantResult.gateBlocked). A gate BLOCK, gate infra error, or failed price write
+  // now leaves the product DRAFT + tagged, never ACTIVE + all-channels-published at $0
+  // (TK-11357 recurrence this fix closes).
+  const priceWriteOk = variantResult.priced && !variantResult.gateBlocked;
+  if (eligibleForActive && priceWriteOk) {
+    const promoted = await setProductStatus(product.id, 'ACTIVE');
+    if (promoted) {
+      // Publish to all sales channels — only reached on a confirmed-priced, gate-passed,
+      // eligible product.
+      await publishToAllChannels(product.id);
+    } else {
+      // Status update itself failed — do NOT publish a product Shopify still has as
+      // DRAFT server-side; tag for review instead.
+      await addProductTags(product.id, ['Needs-Price-Review']);
+      console.warn(JSON.stringify({
+        event: 'product_kept_draft',
+        productId: product.id,
+        dwSku: sku,
+        reason: 'status_update_failed',
+      }));
+    }
+  } else {
+    if (variantResult.gateBlocked || !variantResult.priced) {
+      await addProductTags(product.id, ['Needs-Price-Review']);
+    }
+    console.warn(JSON.stringify({
+      event: 'product_kept_draft',
+      productId: product.id,
+      dwSku: sku,
+      eligibleForActive,
+      gateBlocked: variantResult.gateBlocked,
+      priced: variantResult.priced,
+    }));
+  }
 
   // Add images to the product
   if (dto.images && dto.images.length > 0) {

← c1d89e49 fix(versa-20oz): make dwc-sync rollback a true inverse (dele  ·  back to Designer Wallcoverings  ·  auto-data-snapshot: 2026-09-14T15:08:48 (3 data files) — sho ac9e69dc →