← 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
M scripts/innovations-mfr-reconcile/archive-discontinued.mjsM scripts/innovations-mfr-reconcile/reconcile.mjs
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 →