[object Object]

← back to Designerwallcoverings

Fix Innovations over-archive: classify unloaded-pattern SKUs UNMEASURED (never DISCONTINUED) and log real prior Shopify status

43488cdb5d76dddc2b400ea4cadbe7674e33eb9f · 2026-09-22 14:55:13 -0700 · steve

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

Files touched

Diff

commit 43488cdb5d76dddc2b400ea4cadbe7674e33eb9f
Author: steve <steve@designerwallcoverings.com>
Date:   Tue Sep 22 14:55:13 2026 -0700

    Fix Innovations over-archive: classify unloaded-pattern SKUs UNMEASURED (never DISCONTINUED) and log real prior Shopify status
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01DP24DdLpG6PHXgmjr47TVb
---
 .../archive-discontinued.mjs                       | 16 +++++++-
 scripts/innovations-mfr-reconcile/reconcile.mjs    | 45 +++++++++++++++++-----
 2 files changed, 50 insertions(+), 11 deletions(-)

diff --git a/scripts/innovations-mfr-reconcile/archive-discontinued.mjs b/scripts/innovations-mfr-reconcile/archive-discontinued.mjs
index 6042f41..a1ee550 100644
--- a/scripts/innovations-mfr-reconcile/archive-discontinued.mjs
+++ b/scripts/innovations-mfr-reconcile/archive-discontinued.mjs
@@ -20,6 +20,7 @@ const EXECUTE = process.argv.includes('--execute');
 const CONFIRMED = process.env.SCRUB_CONFIRM === 'I-AM-STEVE';
 
 const MUT = `mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id status } userErrors{ field message } } }`;
+const Q_STATUS = `query($id:ID!){ product(id:$id){ status } }`; // read REAL current status for the restore-map
 const BASE = 'https://www.innovationsusa.com';
 const SLUG = { rangoon: 'rangoon-silk' };
 
@@ -41,6 +42,9 @@ async function confirmGoneLive(row) {
 
 async function main() {
   const data = JSON.parse(readFileSync(join(HERE, 'reconcile.json'), 'utf8'));
+  // ONLY archive positively-confirmed DISCONTINUED rows. UNMEASURED rows (pattern page failed to
+  // load, so the SKU's live-status was never verified) are deliberately excluded — never archive
+  // on absence-of-a-page. This filter is the single gate that keeps UNMEASURED out of the loop.
   const rows = data.classify.filter(r => r.status === 'DISCONTINUED');
   console.log(`\n=== Archive ${rows.length} DISCONTINUED Innovations products — ${EXECUTE ? 'EXECUTE' : 'DRY-RUN'} ===\n`);
   rows.forEach(r => console.log(`  ${r.suffix.padEnd(10)} ${r.dw_sku || '(no dw_sku)'}  ${r.handle}`));
@@ -54,10 +58,20 @@ async function main() {
   const log = join(process.env.HOME, '.claude/yolo-queue/restore-maps/innovations-archive-discontinued.jsonl');
   let ok = 0, skipped = 0; const errs = [];
   for (const r of rows) {
+    // Belt-and-suspenders: never archive anything that isn't positively DISCONTINUED (UNMEASURED/
+    // LIVE/NO_SKU_MATCH must never reach the write path even if the filter above is edited).
+    if (r.status !== 'DISCONTINUED') { skipped++; console.log(`  ⏭ SKIP ${r.suffix} — status ${r.status}, not archive-eligible`); continue; }
     // Officer rev #1: live re-confirm the item page is gone before archiving.
     const v = await confirmGoneLive(r);
     if (!v.gone) { skipped++; console.log(`  ⏭ SKIP ${r.suffix} — still LIVE (${v.code} ${v.finalUrl || v.url}); not archiving`); continue; }
-    appendFileSync(log, JSON.stringify({ ts: new Date().toISOString(), handle: r.handle, shopify_id: r.shopify_id, prev_status: 'ACTIVE', live_check: v }) + '\n');
+    // Capture the product's REAL current Shopify status so an un-archive restores the true prior
+    // state instead of blindly forcing ACTIVE. Fall back to ACTIVE only if the read fails.
+    let prevStatus = 'ACTIVE';
+    try {
+      const cur = await gql(Q_STATUS, { id: r.shopify_id });
+      if (cur?.product?.status) prevStatus = cur.product.status;
+    } catch { /* read failed → keep the ACTIVE fallback */ }
+    appendFileSync(log, JSON.stringify({ ts: new Date().toISOString(), handle: r.handle, shopify_id: r.shopify_id, prev_status: prevStatus, live_check: v }) + '\n');
     const res = await gql(MUT, { input: { id: r.shopify_id, status: 'ARCHIVED' } });
     const ue = res?.productUpdate?.userErrors || [];
     if (ue.length) { errs.push(`${r.handle}: ${JSON.stringify(ue)}`); continue; }
diff --git a/scripts/innovations-mfr-reconcile/reconcile.mjs b/scripts/innovations-mfr-reconcile/reconcile.mjs
index fadd301..c6ae2c4 100644
--- a/scripts/innovations-mfr-reconcile/reconcile.mjs
+++ b/scripts/innovations-mfr-reconcile/reconcile.mjs
@@ -8,12 +8,15 @@
  *   - DISCONTINUED : derived SKU is a real SKU but NO LONGER on the mfr → archive candidate
  *   - NO_SKU_MATCH : handle suffix is an enumeration (sumatra-5, geode-12, innovations_zion),
  *                    not a real SKU → can't auto-decide, needs manual
+ *   - UNMEASURED   : the pattern's mfr page failed to load (or returned 0 SKUs), so this SKU's
+ *                    live-status could NOT be verified → NEVER archive. Absence of a page is not
+ *                    proof of discontinuation; only a SUCCESSFULLY-loaded page can prove that.
  * Also captures, per live SKU, the clean 900x900 swatch image URL (for the image fix).
  * Writes a proposal JSON+CSV. NO catalog write.
  */
 import { writeFileSync } from 'fs';
 import { dirname, join } from 'path';
-import { fileURLToPath } from 'url';
+import { fileURLToPath, pathToFileURL } from 'url';
 import { createRequire } from 'module';
 
 const HERE = dirname(fileURLToPath(import.meta.url));
@@ -26,6 +29,27 @@ const SKIP_PATTERNS = new Set(['by']); // 'by-...' handles carry the real SKU in
 
 const isRealSku = (s) => /^[A-Z]{2,4}-?\d{2,4}$/.test(s); // ORI20, J602, RS8493, SUM-221, VAN-001, HUN-05
 
+/**
+ * Pure classifier (exported for offline unit tests). A product is only DISCONTINUED on a
+ * POSITIVE signal — its pattern page loaded AND the SKU is absent from that live set. A SKU
+ * that is present in liveSkus is LIVE regardless of which page surfaced it (cross-page coverage
+ * for 'by-…' handles). A real SKU that is absent BUT whose pattern page never loaded is
+ * UNMEASURED, never DISCONTINUED — this is the fix for the shared-slug over-archive flaw where
+ * an unreachable/mis-slugged pattern cascaded its whole product set into archive-eligibility.
+ */
+export function classifyProduct(p, { liveSkus, loadedPatterns, liveSkuRaw = new Map(), swatchBySku = new Map() }) {
+  const norm = p.suffix.replace(/-/g, '');
+  if (!isRealSku(p.suffix)) return { ...p, status: 'NO_SKU_MATCH', mfr_sku: null };
+  if (liveSkus.has(norm)) {
+    return { ...p, status: 'LIVE', mfr_sku: liveSkuRaw.get(norm) || p.suffix,
+             swatch: swatchBySku.get(norm) || `${BASE}/storage/sku/900x900/${liveSkuRaw.get(norm)}.jpg` };
+  }
+  // Absent from liveSkus. Only a SUCCESSFULLY-loaded pattern page (>0 SKUs) makes absence mean
+  // discontinued; otherwise we simply did not measure this SKU and must not archive it.
+  const measured = loadedPatterns.has(p.patt);
+  return { ...p, status: measured ? 'DISCONTINUED' : 'UNMEASURED', mfr_sku: p.suffix, swatch: null };
+}
+
 async function fetchText(url) {
   const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow' });
   return r.ok ? await r.text() : '';
@@ -48,12 +72,14 @@ async function main() {
   const liveSkus = new Set();         // all live SKUs (uppercased, normalized no-dash)
   const liveSkuRaw = new Map();       // norm → raw mfr sku (with dash)
   const swatchBySku = new Map();      // norm → 900x900 swatch url
+  const loadedPatterns = new Set();   // patterns whose mfr page LOADED with >0 SKUs (measured)
   for (const p of patterns) {
     const slug = SLUG[p] || p;
     const html = await fetchText(`${BASE}/item/${slug}`) || await fetchText(`${BASE}/item/${slug}/`);
-    if (!html) { console.log(`  ⚠ ${slug}: no page`); continue; }
+    if (!html) { console.log(`  ⚠ ${slug}: no page (UNMEASURED — not archive-eligible)`); continue; }
     const skus = [...html.matchAll(new RegExp(`/item/${slug}/([a-z0-9-]+)`, 'gi'))].map(m => m[1].toUpperCase());
     const uniq = [...new Set(skus)];
+    if (uniq.length > 0) loadedPatterns.add(p);   // POSITIVE signal: this pattern was actually measured
     uniq.forEach(s => { const n = s.replace(/-/g, ''); liveSkus.add(n); liveSkuRaw.set(n, s); });
     // swatch urls present on the page
     [...html.matchAll(/storage\/sku\/900x900\/([A-Z0-9-]+)\.jpg/gi)].forEach(m => {
@@ -63,21 +89,17 @@ async function main() {
   }
   console.log(`Total live SKUs: ${liveSkus.size}\n`);
 
-  const classify = prods.map(p => {
-    const norm = p.suffix.replace(/-/g, '');
-    if (!isRealSku(p.suffix)) return { ...p, status: 'NO_SKU_MATCH', mfr_sku: null };
-    const live = liveSkus.has(norm);
-    return { ...p, status: live ? 'LIVE' : 'DISCONTINUED', mfr_sku: liveSkuRaw.get(norm) || p.suffix,
-             swatch: swatchBySku.get(norm) || (live ? `${BASE}/storage/sku/900x900/${liveSkuRaw.get(norm)}.jpg` : null) };
-  });
+  const classify = prods.map(p => classifyProduct(p, { liveSkus, loadedPatterns, liveSkuRaw, swatchBySku }));
 
   const byStatus = classify.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {});
   const discontinued = classify.filter(r => r.status === 'DISCONTINUED');
   const noMatch = classify.filter(r => r.status === 'NO_SKU_MATCH');
+  const unmeasured = classify.filter(r => r.status === 'UNMEASURED');
   console.log('CLASSIFICATION:', JSON.stringify(byStatus));
   console.log(`\nDISCONTINUED (archive candidates): ${discontinued.length}`);
   discontinued.forEach(r => console.log(`  ${r.suffix.padEnd(10)} ${r.dw_sku || '(no dw_sku)'}  ${r.handle}`));
   console.log(`\nNO_SKU_MATCH (enumerated handles, manual): ${noMatch.length} — e.g. ${noMatch.slice(0,8).map(r=>r.suffix).join(', ')}`);
+  if (unmeasured.length) console.log(`\nUNMEASURED (pattern page failed to load — NOT archive-eligible): ${unmeasured.length} — e.g. ${unmeasured.slice(0,8).map(r=>r.suffix).join(', ')}`);
 
   writeFileSync(join(HERE, 'reconcile.json'), JSON.stringify({ liveSkuCount: liveSkus.size, byStatus, classify }, null, 2));
   const csv = ['handle,dw_sku,suffix,status,mfr_sku,swatch,current_image',
@@ -85,4 +107,7 @@ async function main() {
   writeFileSync(join(HERE, 'reconcile.csv'), csv);
   console.log(`\n→ ${join(HERE,'reconcile.json')} | reconcile.csv`);
 }
-main().catch(e => { console.error(e); process.exit(1); });
+// Only run the network/DB pipeline when invoked directly (so unit tests can import classifyProduct).
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+  main().catch(e => { console.error(e); process.exit(1); });
+}

← 5efaa44 Collapse tautological try/catch pg require to plain require(  ·  back to Designerwallcoverings  ·  auto-data-snapshot: 2026-09-22T17:47:37 (1 data files) — dat 0f6c933 →