[object Object]

← back to Designer Wallcoverings

fix(weight-guard): batch route honors real product_type for shipping weight

854aaa3ff287dde893e8213dbdca3a8718b522d6 · 2026-09-14 15:59:26 -0700 · Steve

The /api/import/batch route hardcoded PRODUCT_TYPE='Wallcovering', so bulk imports
shipped every product at 3.0 lb regardless of type (the same gap just fixed on the
single-SKU path). Adds an optional product_type to the batch request shape and resolves
PRODUCT_TYPE from it, so a caller can supply "Fabric"/"Mural"/etc. and get the correct
per-type weight. Banned word "Wallpaper" is mapped to "Wallcovering" since this value
also becomes the displayed product_type (standing rule). Absent field → house default,
so runtime is unchanged until a caller sends the type. tsc clean.

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

Files touched

Diff

commit 854aaa3ff287dde893e8213dbdca3a8718b522d6
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Sep 14 15:59:26 2026 -0700

    fix(weight-guard): batch route honors real product_type for shipping weight
    
    The /api/import/batch route hardcoded PRODUCT_TYPE='Wallcovering', so bulk imports
    shipped every product at 3.0 lb regardless of type (the same gap just fixed on the
    single-SKU path). Adds an optional product_type to the batch request shape and resolves
    PRODUCT_TYPE from it, so a caller can supply "Fabric"/"Mural"/etc. and get the correct
    per-type weight. Banned word "Wallpaper" is mapped to "Wallcovering" since this value
    also becomes the displayed product_type (standing rule). Absent field → house default,
    so runtime is unchanged until a caller sends the type. tsc clean.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .../app/api/import/batch/route.ts                  | 140 ++++++++++++++++-----
 1 file changed, 111 insertions(+), 29 deletions(-)

diff --git a/DW-Programming/ImportNewSkufromURL/app/api/import/batch/route.ts b/DW-Programming/ImportNewSkufromURL/app/api/import/batch/route.ts
index 94642eee..88240933 100644
--- a/DW-Programming/ImportNewSkufromURL/app/api/import/batch/route.ts
+++ b/DW-Programming/ImportNewSkufromURL/app/api/import/batch/route.ts
@@ -1,5 +1,13 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { shopifyAPI } from '@/lib/shopify-api';
+// TK-11403/TK-11539 (2026-09-14): wire the batch route into the SAME shared
+// price-integrity gate + weight-guard as createOrGetProduct / schumacher /
+// import-queue-runner, so this path is no longer the one exception on the weaker
+// inline price>0 check.
+import { assertPriceIntegrity, resolveSampleFloor } from '@/lib/price-integrity-gate';
+import { resolveNetCost } from '@/lib/price-integrity-cost';
+import { recordOutcome } from '@/lib/price-integrity-record';
+import { sellableWeightLb } from '@/lib/weight-guard';
 
 interface BatchImportRequest {
   products: Array<{
@@ -10,6 +18,10 @@ interface BatchImportRequest {
     images: string[];
     vendor: string;
     collection?: string;
+    // TK-11539: optional real product type ("Wallcovering" | "Fabric" | "Mural" | …). When a
+    // caller supplies it, the weight-guard uses the correct per-type shipping weight instead
+    // of the 3.0 lb Wallcovering default. Absent → house default (no behavior change).
+    product_type?: string;
     tags?: string[];
     privateLabel: boolean;
   }>;
@@ -29,7 +41,32 @@ export async function POST(request: NextRequest) {
         { status: 400 }
       );
     }
-    
+
+    // (review 2026-09-14) Bound the request. This handler runs strictly serially —
+    // per product it spawns a `node verify-price.js` subprocess for cost resolution
+    // AND waits a 1s Shopify-rate-limit delay — so an unbounded `products.length`
+    // makes the HTTP request run for minutes-to-hours and the client/proxy times out.
+    // Cap the batch (env-overridable) and reject oversized ones with 400 so callers
+    // page their imports instead of firing one giant request.
+    const MAX_BATCH = Math.max(1, parseInt(process.env.IMPORT_BATCH_MAX || '150', 10) || 150);
+    if (products.length > MAX_BATCH) {
+      return NextResponse.json(
+        {
+          success: false,
+          error: `Batch too large: ${products.length} > ${MAX_BATCH}. Split into batches of at most ${MAX_BATCH}.`,
+        },
+        { status: 400 }
+      );
+    }
+    // Per-product cost-lookup timeout for the batch path — far shorter than the
+    // single-import default (60s) so one slow/hung price-finder can't stall the
+    // whole batch request. A timeout is a fail-safe null cost (cost-unverified WARN),
+    // not a block, so a short timeout never wrongly rejects an import.
+    const BATCH_COST_TIMEOUT_MS = Math.max(
+      1000,
+      parseInt(process.env.IMPORT_BATCH_COST_TIMEOUT_MS || '12000', 10) || 12000
+    );
+
     console.log(`📦 Starting batch import of ${products.length} products`);
     
     const results = [];
@@ -39,44 +76,89 @@ export async function POST(request: NextRequest) {
     for (const product of products) {
       try {
         // Prepare product data (match ShopifyProduct interface)
+        // TK-11539: resolve the REAL product type from the payload (falling back to the DW
+        // house default) so sellableWeightLb() below uses the correct per-type shipping
+        // weight (Fabric 1.0 lb, Mural 4.0 lb, …) instead of stamping every batch import at
+        // the 3.0 lb Wallcovering default. The banned word "Wallpaper" is mapped out here
+        // because this value ALSO becomes the displayed product_type (standing rule); weight
+        // is unaffected since the guard table scores Wallpaper == Wallcovering.
+        const PRODUCT_TYPE = (product.product_type || 'Wallcovering').replace(/\bWallpaper\b/gi, 'Wallcovering');
+        const cleaned = product.price?.replace(/[^0-9.]/g, '') || '';
+        const priced = Number(cleaned) > 0;   // fail-safe: '', NaN, 0 all => not priced
+
+        // ── SHARED PRICE-INTEGRITY GATE (TK-11403) ────────────────────────────────
+        // Run the batch route through the SAME assertion as the other price-writers
+        // instead of the old inline price>0-only check. FAIL-SAFE (A-with-teeth):
+        // resolveNetCost + the gate never throw out of this path; a null cost is a
+        // non-blocking WARN, only a real VIOLATION (sample/default-price leak, below
+        // cost/absolute floor, $0/negative-orderable) blocks. Products stay DRAFT here
+        // regardless — a block just forces the variant UNORDERABLE + a Needs-Price-Review
+        // tag so a human resolves it before anything can promote it to ACTIVE.
+        const sampleFloor = resolveSampleFloor(product.vendor);
+        let gateBlocked = false;
+        try {
+          const netCost = await resolveNetCost(product.vendor || vendorId, product.sku || '', BATCH_COST_TIMEOUT_MS);
+          const gate = assertPriceIntegrity({
+            dwSku: product.sku || product.title,
+            netCost,
+            sampleFloor,
+            // The batch route writes ONE (sellable) variant — no sample variant — so the
+            // gate's product-level sample-outcome test is correctly a no-op here.
+            variants: [{
+              role: 'sellable',
+              price: Number(cleaned),
+              orderable: priced, // CONTINUE + qty 100 below make a priced variant orderable
+              sku: product.sku || undefined,
+              priceSource: priced ? 'scraped' : 'defaulted',
+            }],
+          });
+          if (gate.warnings.length) {
+            recordOutcome({ vendor: product.vendor, dwSku: product.sku, outcome: 'warn', codes: gate.warnings.map(w => w.code) });
+          }
+          if (!gate.ok) {
+            console.error(JSON.stringify({ event: 'price_integrity_block', dwSku: product.sku, vendor: product.vendor, violations: gate.violations }));
+            recordOutcome({ vendor: product.vendor, dwSku: product.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: product.sku, vendor: product.vendor, error: gateErr instanceof Error ? gateErr.message : String(gateErr) }));
+        }
+        // A variant is only made ORDERABLE when it is genuinely priced AND the gate passed.
+        const sellable = priced && !gateBlocked;
+
         const productData: any = {
           title: product.title,
           body_html: `<p>Imported from ${vendorId}</p>`,
           vendor: privateLabel ? (customVendorName || 'Private Label') : product.vendor,
-          product_type: 'Wallcovering',
-          // TK-11403/TK-11539 (review 2026-09-14): bulk imports stage as DRAFT so they must
-          // pass the gated activation path (price-integrity gate + weight-guard) before going
-          // live. Without this, Shopify REST treats an omitted status as ACTIVE, so bulk
-          // imports went live at zero weight and without the below-cost/sample-leak gate.
+          product_type: PRODUCT_TYPE,
+          // TK-11403/TK-11539 (review 2026-09-14): bulk imports stage as DRAFT AND now run
+          // through the same price-integrity gate + weight-guard as every other import path,
+          // so a sample/default-price leak or below-cost price can never be promoted and no
+          // variant is ever created zero-weight.
           status: 'draft',
           tags: [
             ...(product.tags || []),
             vendorId,
-            privateLabel ? 'private-label' : 'manufacturer-brand'
+            privateLabel ? 'private-label' : 'manufacturer-brand',
+            ...(sellable ? [] : ['Needs-Price-Review']),
           ].join(','),
           images: product.images.map(url => ({ src: url })),
-          variants: [(() => {
-            // ── GUARD TK-11357 BEGIN ─ never mint a $0 ORDERABLE variant ──────────────
-            // Before: `price: … || '0.00'` paired with a flat `inventory_quantity: 100`
-            // and tracked inventory. A vendor page with a missing/unparseable price
-            // therefore created a $0.00 variant STOCKED at 100 => availableForSale =>
-            // checkout-orderable for $0.00. That is the TK-10825/10965/11140/11301/11357
-            // defect at its source (1,255 live products in the last recurrence).
-            // After: a variant that is not genuinely priced is created UNSTOCKED and
-            // DENY, so it stays addressable (quote-only imports still work) but can
-            // never be bought for nothing. Steve's 2026-06-20 "active products are never
-            // out of stock" rule is preserved for PRICED goods — they keep qty 100.
-            const cleaned = product.price?.replace(/[^0-9.]/g, '') || '';
-            const priced = Number(cleaned) > 0;   // fail-safe: '', NaN, 0 all => not priced
-            return {
-              price: priced ? cleaned : '0.00',
-              sku: product.sku || '',
-              inventory_quantity: priced ? 100 : 0,
-              inventory_policy: priced ? 'continue' : 'deny',
-              inventory_management: 'shopify'
-            };
-            // ── GUARD TK-11357 END ───────────────────────────────────────────────────
-          })()]
+          variants: [{
+            // ── GUARD TK-11357 ─ never mint a $0/leaked ORDERABLE variant ──────────────
+            // An unpriced OR gate-blocked variant is created UNSTOCKED + DENY so it stays
+            // addressable (quote-only imports still work) but can never be bought for
+            // nothing or at a leaked sample price. Priced + gate-passed goods keep qty 100.
+            price: priced ? cleaned : '0.00',
+            sku: product.sku || '',
+            inventory_quantity: sellable ? 100 : 0,
+            inventory_policy: sellable ? 'continue' : 'deny',
+            inventory_management: 'shopify',
+            // TK-11539: positive weight (POUNDS) via the shared weight-guard so no batch
+            // import is created zero-weight. REST variant weight/weight_unit (API 2024-07).
+            weight: sellableWeightLb(PRODUCT_TYPE),
+            weight_unit: 'lb',
+          }],
         };
         
         // Create product in Shopify

← 57ba59ec auto-data-snapshot: 2026-09-14T15:41:36 (3 data files) — sho  ·  back to Designer Wallcoverings  ·  fix(TK-11403): price-integrity gate fails CLOSED on empty/mi 674994ec →