← 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
M DW-Programming/ImportNewSkufromURL/app/api/import/batch/route.ts
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 →