[object Object]

← back to Designerwallcoverings

Stroheim onboard: ready-to-run DRAFT scripts (DWST, BUDGET_CAT=backlog); HELD on price per DTD

7ec91f433c9d97fb0f2b3bd9a6115d64f3f405a6 · 2026-07-26 16:23:49 -0700 · Steve Abrams

Files touched

Diff

commit 7ec91f433c9d97fb0f2b3bd9a6115d64f3f405a6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jul 26 16:23:49 2026 -0700

    Stroheim onboard: ready-to-run DRAFT scripts (DWST, BUDGET_CAT=backlog); HELD on price per DTD
---
 scripts/stroheim-onboard/README.md          |  50 ++++++++++
 scripts/stroheim-onboard/build-payloads.mjs | 146 ++++++++++++++++++++++++++++
 scripts/stroheim-onboard/create-drafts.mjs  | 136 ++++++++++++++++++++++++++
 scripts/stroheim-onboard/mint-skus.mjs      |  59 +++++++++++
 4 files changed, 391 insertions(+)

diff --git a/scripts/stroheim-onboard/README.md b/scripts/stroheim-onboard/README.md
new file mode 100644
index 0000000..f8ecaed
--- /dev/null
+++ b/scripts/stroheim-onboard/README.md
@@ -0,0 +1,50 @@
+# Stroheim onboard → DRAFTS (staging-only until price lands)
+
+Onboards the 782 staged Stroheim wallcovering colorways (`dw_unified.stroheim_catalog`)
+to the live DW Shopify store as DRAFTS. Models the knoll / CMO-Paris / muralsource
+onboard pattern. **Prefix `DWST` (approved by Steve 2026-07-26).**
+
+## Status (2026-07-26)
+
+- ✅ Staging complete: 782 rows, 100% images / width / specs, 0 "Unknown", all `net_new`,
+  dedup-clean (0 Stroheim products on the live store).
+- ✅ PG tracking columns added: `stroheim_catalog.shopify_product_id`, `.on_shopify`.
+- ✅ Scripts built + proven: `build-payloads.mjs` reports **782 held no-price, 0 emitted**.
+- ⛔ **BLOCKED on PRICE** — `price_retail` is 100% NULL. Steve's hard rule forbids anything
+  at $0/NULL. Nothing is created until price is sourced. (DTD 2026-07-26, 5/5 = HOLD.)
+
+## The ONE unblock step
+
+Populate `stroheim_catalog.price_retail` (and optionally `price_trade`) via **either**:
+- a Fabricut/Stroheim **trade-login** price scrape (creds in the fabricut skill's
+  `dw-price-stock/.env`), **or**
+- a **cost-map** entry + trade discount → retail = `cost / 0.65 / 0.85`.
+
+## Run order (only after price lands)
+
+```sh
+cd ~/Projects/designerwallcoverings/scripts/stroheim-onboard
+node mint-skus.mjs --apply        # assign DWST-810001+ to priced+imaged net_new rows (staging only, reversible)
+node build-payloads.mjs           # → out/payloads.jsonl (skips any remaining no-price/no-image/unminted)
+# settlement gate (memo #3): chinoiserie/botanical/scenic patterns must pass the vision gate.
+#   template: ../muralsource-onboard/settlement-gate.mjs  → writes out/settlement-verdicts.jsonl (OK/BLOCK)
+node create-drafts.mjs             # DRY-RUN plan
+node create-drafts.mjs --apply     # create DRAFTS (BUDGET_CAT=backlog; PG-first writeback + DWST registry)
+```
+
+`create-drafts` creates **status=draft only** — not customer-facing, no activation.
+
+## Activation (SEPARATE, Steve-gated — NOT part of onboarding)
+
+`go-live.mjs` (template: `../muralsource-onboard/go-live.mjs`) flips status→active, sets
+inventory, and publishes to the 12 DW sales channels **with Google & YouTube EXCLUDED**
+(the $4.25 sample variant → GMC price disapproval). Gated by the 5-field activation canary.
+
+## Guardrails baked in
+
+- **BUDGET_CAT=backlog** (shared backlog budget — never `upload`).
+- Dedup by `mfr_sku` + live-Shopify SKU check + `created.jsonl` (resumable/idempotent).
+- Every product: 2 variants (roll @ price_retail + `{DW_SKU}-Sample` @ $4.25),
+  `custom.manufacturer_sku` + `dwc.manufacturer_sku` + `global.width`, title-cased
+  `Pattern Color | Stroheim`, no "Wallpaper" (→ Wallcovering), never "Unknown", ≥1 image.
+- PostgreSQL-first: `shopify_product_id` + `on_shopify` written back on create.
diff --git a/scripts/stroheim-onboard/build-payloads.mjs b/scripts/stroheim-onboard/build-payloads.mjs
new file mode 100644
index 0000000..97435bd
--- /dev/null
+++ b/scripts/stroheim-onboard/build-payloads.mjs
@@ -0,0 +1,146 @@
+#!/usr/bin/env node
+/**
+ * Build Stroheim DRAFT payloads from dw_unified.stroheim_catalog → out/payloads.jsonl.
+ * Mirrors muralsource-onboard/build-payloads.mjs (the knoll/CMO-Paris onboard pattern).
+ *
+ * Stroheim is a PRICED line by rule (retail = cost/0.65/0.85) — but the public catalog
+ * exposes NO price, so price_retail is 100% NULL until Steve sources it (trade-login scrape
+ * or cost-map, memo 072226A decision #2). This builder therefore HELDS every no-price row
+ * exactly like the reference; while price is NULL it emits 0 payloads — the pattern's own
+ * guardrail enforcing "nothing at $0/NULL price."
+ *
+ * Eligible pool = net_new AND price_retail > 0 AND ≥1 image AND proposed_dw_sku set (minted)
+ * AND not already pushed (shopify_product_id IS NULL). Dedup by mfr_sku. Every title is
+ * title-cased, never contains "Wallpaper" (→ Wallcovering), never "Unknown".
+ *
+ * Each product carries TWO variants: a sellable "Priced Per Single Roll" variant at
+ * price_retail + a {DW_SKU}-Sample $4.25 memo (0.25 / no inventory at draft time).
+ *
+ *   node build-payloads.mjs        # build payloads.jsonl + print first 3 + held counts
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execSync } from 'node:child_process';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const DB = 'postgresql:///dw_unified?host=/tmp';
+const BRAND = 'Stroheim';
+const SAMPLE_PRICE = '4.25';
+
+const titleCase = s => (s || '').replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase()).trim();
+// clean: force "Wallpaper"→Wallcovering, and strip Stroheim's trailing "-WP"/" WP" type marker
+// (it means wallcovering, already conveyed by product_type — leaving it makes titles read oddly).
+const clean = s => (s || '').replace(/wall\s*paper/ig, 'Wallcovering').replace(/[\s-]*\bWP\b\s*$/i, '').replace(/\s+/g, ' ').trim();
+const isBadWord = s => !s || /unknown/i.test(s);
+
+function rows() {
+  const json = execSync(`psql "${DB}" -tAc "select coalesce(json_agg(row_to_json(t))::text,'[]') from (
+    select mfr_sku, proposed_dw_sku as dw_sku, pattern_name, color_name, collection, product_type,
+           width, repeat_v, repeat_h, material, book,
+           price_retail, dedup_status, image_url, thumb_url, product_url, shopify_product_id
+    from stroheim_catalog
+    where mfr_sku is not null
+    order by mfr_sku) t"`, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
+  return JSON.parse(json.trim());
+}
+
+function titleFor(r) {
+  let pat = titleCase(clean(r.pattern_name));
+  if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku));            // never "Unknown"/empty
+  const rawCol = clean(r.color_name);
+  const col = rawCol && !isBadWord(rawCol) && rawCol.toLowerCase() !== (r.pattern_name || '').toLowerCase()
+    ? titleCase(rawCol) : '';
+  return `${pat}${col ? ' ' + col : ''} | ${BRAND}`.replace(/\s+/g, ' ').trim();
+}
+
+function tags(r) {
+  const out = new Map();                                            // lowercase → display
+  const add = v => { const s = titleCase(clean(String(v || ''))); if (s && !isBadWord(s) && !/^paper$/i.test(s) && !/wall\s*paper/i.test(s)) out.set(s.toLowerCase(), s); };
+  ['Stroheim', 'Wallcovering', 'display_variant'].forEach(t => out.set(t.toLowerCase(), t));
+  add(r.pattern_name); add(r.color_name); add(r.book);
+  return [...out.values()].join(', ');                              // always ≥2 (Stroheim, Wallcovering, display_variant)
+}
+function repeatStr(r) {
+  const v = (r.repeat_v && r.repeat_v !== '0') ? r.repeat_v : '';
+  const h = (r.repeat_h && r.repeat_h !== '0') ? r.repeat_h : '';
+  if (!v && !h) return '';
+  return [h && `H ${h}`, v && `V ${v}`].filter(Boolean).join(' · ');
+}
+function bodyHtml(r) {
+  let pat = titleCase(clean(r.pattern_name)); if (isBadWord(pat)) pat = titleCase(clean(r.mfr_sku));
+  const intro = `${pat} from ${BRAND} — a designer wallcovering. Priced per single roll; request a $4.25 memo sample to see the material in hand before ordering.`;
+  const rowsHtml = [
+    ['Pattern', clean(r.pattern_name)], ['Color', r.color_name], ['Material', r.material],
+    ['Width', r.width], ['Repeat', repeatStr(r)], ['Book', r.book],
+    ['Manufacturer SKU', r.mfr_sku],
+  ].filter(([, v]) => v != null && String(v).trim() !== '' && !isBadWord(String(v)));
+  return `<p>${intro}</p><table>${rowsHtml.map(([k, v]) => `<tr><td><strong>${k}</strong></td><td>${clean(String(v))}</td></tr>`).join('')}</table>`;
+}
+function metafields(r) {
+  const mf = [];
+  const S = (ns, key, value, type = 'single_line_text_field') => { if (value != null && String(value).trim() !== '' && !isBadWord(String(value))) mf.push({ namespace: ns, key, type, value: String(value) }); };
+  S('global', 'Brand', BRAND);
+  S('global', 'width', r.width);                                    // REQUIRED per task
+  S('global', 'repeat', repeatStr(r));
+  S('global', 'unit_of_measure', 'Priced Per Single Roll');
+  S('custom', 'pattern_name', titleCase(clean(r.pattern_name)));
+  S('custom', 'supplier_name', BRAND);
+  S('custom', 'manufacturer_sku', r.mfr_sku);                       // custom.manufacturer_sku
+  S('custom', 'color', r.color_name);
+  S('custom', 'real_color_name', r.color_name);
+  S('custom', 'material', r.material, 'multi_line_text_field');
+  S('custom', 'product_class', 'Wallcovering');
+  S('dwc', 'manufacturer_sku', r.mfr_sku);                          // dwc.manufacturer_sku
+  return mf;
+}
+function galleryUrls(r) {
+  const urls = new Set();
+  if (r.image_url) urls.add(r.image_url);
+  return [...urls];
+}
+
+function main() {
+  const data = rows();
+  const seen = new Set();
+  const fd = fs.openSync(path.join(OUT, 'payloads.jsonl'), 'w');
+  let n = 0, heldNoPrice = 0, heldNoImg = 0, heldNoSku = 0, heldPushed = 0, dupe = 0;
+  for (const r of data) {
+    if (!r.mfr_sku || seen.has(r.mfr_sku)) { dupe++; continue; }     // dedup by mfr_sku
+    seen.add(r.mfr_sku);
+    if (r.shopify_product_id) { heldPushed++; continue; }            // already live — skip
+    if (r.price_retail == null || Number(r.price_retail) <= 0) { heldNoPrice++; continue; }  // HARD RULE
+    const gallery = galleryUrls(r);
+    if (gallery.length === 0) { heldNoImg++; continue; }             // ≥1 image required
+    if (!r.dw_sku) { heldNoSku++; continue; }                        // must be minted (mint-skus.mjs)
+    const sku = r.dw_sku;
+    const price = Number(r.price_retail).toFixed(2);
+    const o = {
+      mfr_sku: r.mfr_sku, sku, dw_sku: r.dw_sku,
+      image_url: gallery[0], gallery,
+      product: {
+        title: titleFor(r), vendor: BRAND, product_type: 'Wallcovering',
+        status: 'draft', tags: tags(r), body_html: bodyHtml(r),
+        options: [{ name: 'Size' }],
+        variants: [
+          { sku, price, option1: 'Priced Per Single Roll', taxable: true, requires_shipping: true, inventory_policy: 'deny', inventory_management: null },
+          { sku: `${sku}-Sample`, price: SAMPLE_PRICE, option1: 'Sample', taxable: true, requires_shipping: true, inventory_policy: 'deny', inventory_management: null },
+        ],
+      },
+      metafields: metafields(r),
+    };
+    fs.writeSync(fd, JSON.stringify(o) + '\n'); n++;
+  }
+  fs.closeSync(fd);
+  console.log(`✓ ${n} payloads → out/payloads.jsonl`);
+  console.log(`  held: no-price ${heldNoPrice} · no-image ${heldNoImg} · no-sku(unminted) ${heldNoSku} · already-pushed ${heldPushed} · dupe-mfr ${dupe}`);
+  if (n === 0) { console.log('  ⛔ 0 payloads — expected while price is NULL. Source price → mint-skus.mjs → re-run.'); return; }
+  const all = fs.readFileSync(path.join(OUT, 'payloads.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
+  for (const o of all.slice(0, 3)) console.log(`  [${o.sku}] "${o.product.title}" · Roll $${o.product.variants[0].price} + Sample $${o.product.variants[1].price} · mf:${o.metafields.length} · tags:${o.product.tags.split(', ').length} · imgs:${o.gallery.length}`);
+  const bad = all.filter(o => /wall\s*paper|unknown/i.test(o.product.title));
+  console.log(bad.length ? `  ⛔ ${bad.length} titles contain banned word(s)` : `  ✓ 0 titles contain "Wallpaper"/"Unknown"`);
+  const noWidth = all.filter(o => !o.metafields.some(m => m.namespace === 'global' && m.key === 'width'));
+  console.log(noWidth.length ? `  ⛔ ${noWidth.length} payloads lack global.width` : `  ✓ every payload has global.width`);
+}
+main();
diff --git a/scripts/stroheim-onboard/create-drafts.mjs b/scripts/stroheim-onboard/create-drafts.mjs
new file mode 100644
index 0000000..436b1cf
--- /dev/null
+++ b/scripts/stroheim-onboard/create-drafts.mjs
@@ -0,0 +1,136 @@
+#!/usr/bin/env node
+/**
+ * Create Stroheim products as DRAFT on the live store (designer-laboratory-sandbox),
+ * set metafields, attach + localize the image to the Shopify CDN, and — PostgreSQL-FIRST —
+ * write shopify_product_id + on_shopify back to stroheim_catalog the moment it's minted,
+ * plus REGISTER the DWST SKU in dw_sku_registry (status=draft). go-live.mjs (a SEPARATE,
+ * Steve-gated step — NOT this session) flips the registry to active, sets inventory, and
+ * publishes to the 12 DW sales channels with Google & YouTube EXCLUDED.
+ *
+ * Mirrors muralsource-onboard/create-drafts.mjs. Stroheim is PRICED (2 variants: roll + sample).
+ *
+ * SETTLEMENT (memo 072226A #3): only creates a draft for a SKU whose out/settlement-verdicts.jsonl
+ * verdict is OK. A BLOCK — or a SKU with no verdict yet — is SKIPPED. Run settlement-gate.mjs first.
+ *
+ *   node create-drafts.mjs                     # DRY-RUN: show plan
+ *   node create-drafts.mjs --apply --limit=2   # canary
+ *   node create-drafts.mjs --apply             # all remaining (resumable, backlog-cap aware)
+ *
+ * Cap-aware: draws from the shared daily variant budget under 'backlog' (NEVER 'upload' — the
+ * new-arrivals uploader keeps its slice). Override: DW_STROHEIM_BUDGET.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+import { execFileSync } from 'node:child_process';
+import { rest, gql, restAll, SHOP, TOKEN } from '../lib/shopify.mjs';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.join(HERE, 'out');
+const require = createRequire(import.meta.url);
+const budget = require('../variant-budget/budget.cjs');
+const BUDGET_CAT = process.env.DW_STROHEIM_BUDGET || 'backlog';   // SHARED backlog budget — never 'upload'
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const PGENV = { ...process.env, PGHOST: '/tmp', PGDATABASE: 'dw_unified' };
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const M_MF = `mutation($mf:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$mf){ userErrors{field message} } }`;
+
+const loadJsonl = f => fs.existsSync(path.join(OUT, f))
+  ? fs.readFileSync(path.join(OUT, f), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)) : [];
+
+// PG-FIRST write-back: stamp product_id + on_shopify onto the staging row AND register the
+// DWST SKU (status=draft). go-live flips it to active.
+function dbWriteBack(sku, mfrSku, pid) {
+  const q = sku.replace(/'/g, "''"), mq = String(mfrSku || '').replace(/'/g, "''");
+  try {
+    execFileSync(PSQL, ['-d', 'dw_unified', '-c',
+      `UPDATE stroheim_catalog SET shopify_product_id='${pid}', on_shopify=true WHERE proposed_dw_sku='${q}';
+       INSERT INTO dw_sku_registry (dw_sku, vendor_prefix, vendor_name, mfr_sku, shopify_product_id, status, min_order_qty, order_increment, created_at, updated_at)
+       VALUES ('${q}','DWST','Stroheim','${mq}','${pid}','draft',1,1,NOW(),NOW())
+       ON CONFLICT (dw_sku) DO UPDATE SET shopify_product_id=EXCLUDED.shopify_product_id, updated_at=NOW();`],
+      { env: PGENV, stdio: 'ignore' });
+  } catch (e) { /* non-fatal — created.jsonl is durable; go-live re-registers */ }
+}
+
+async function attachImage(pid, url, filename) {
+  if (!url) return 'no-url';
+  try {
+    const resp = await fetch(url, { signal: AbortSignal.timeout(30000) });
+    if (!resp.ok) return `dl-${resp.status}`;
+    const b64 = Buffer.from(await resp.arrayBuffer()).toString('base64');
+    const r = await rest(`/products/${pid}/images.json`, { method: 'POST', body: { image: { attachment: b64, filename } } });
+    return (r.status === 200 || r.status === 201) ? 'ok' : `up-${r.status}`;
+  } catch (e) { return 'err'; }
+}
+
+async function liveStroheimSkus() {
+  try {
+    const prods = await restAll(`/products.json?vendor=${encodeURIComponent('Stroheim')}&fields=id,variants&limit=250`, 'products');
+    const s = new Set();
+    for (const p of prods) for (const v of (p.variants || [])) if (v.sku) s.add(v.sku.toUpperCase());
+    return s;
+  } catch { return new Set(); }
+}
+
+async function main() {
+  const payloads = loadJsonl('payloads.jsonl');
+  const verdict = new Map(loadJsonl('settlement-verdicts.jsonl').map(r => [r.sku.toUpperCase(), r.verdict]));
+  const liveSkus = await liveStroheimSkus();
+  const created = loadJsonl('created.jsonl');
+  const createdSku = new Set(created.map(c => c.sku.toUpperCase()));
+
+  const gatedOk = payloads.filter(p => verdict.get(p.sku.toUpperCase()) === 'OK');
+  const blocked = payloads.filter(p => verdict.get(p.sku.toUpperCase()) === 'BLOCK').length;
+  const ungated = payloads.filter(p => !verdict.has(p.sku.toUpperCase())).length;
+  let todo = gatedOk.filter(p => !liveSkus.has(p.sku.toUpperCase()) && !createdSku.has(p.sku.toUpperCase()));
+  if (LIMIT) todo = todo.slice(0, LIMIT);
+
+  const headroom = budget.remaining(BUDGET_CAT);
+  console.log(`Store ${SHOP} · token …${TOKEN.slice(-4)} · payloads ${payloads.length} · settlement OK ${gatedOk.length} / BLOCK ${blocked} / ungated ${ungated} · live ${liveSkus.size} · to create ${todo.length} · '${BUDGET_CAT}' headroom ${headroom} variants (~${Math.floor(headroom/2)} products) · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+  if (ungated) console.log(`  ℹ ${ungated} payloads not yet settlement-gated — run settlement-gate.mjs --apply first (they are skipped here).`);
+  if (!APPLY) {
+    for (const o of todo.slice(0, 3)) console.log(`  [${o.sku}] "${o.product.title}" · Roll $${o.product.variants[0].price} + Sample $${o.product.variants[1].price} · mf:${o.metafields.length} · imgs:${o.gallery.length}`);
+    console.log(`DRY-RUN. --apply (optionally --limit=N) to create drafts.`);
+    return;
+  }
+
+  const fd = fs.openSync(path.join(OUT, 'created.jsonl'), 'a');
+  let ok = 0, fail = 0, mfFail = 0, imgFail = 0;
+  for (const o of todo) {
+    const grant = budget.take(BUDGET_CAT, o.product.variants.length);
+    if (grant < o.product.variants.length) { if (grant) budget.refund(BUDGET_CAT, grant); console.error(`\n⛔ '${BUDGET_CAT}' budget exhausted today (granted ${grant}/${o.product.variants.length}). Stop — resume tomorrow (idempotent).`); break; }
+    try {
+      const res = await rest('/products.json', { method: 'POST', body: { product: o.product } });
+      const body = await res.json().catch(() => ({}));
+      if (res.status === 201 && body?.product?.id) {
+        const pid = body.product.id;
+        dbWriteBack(o.sku, o.mfr_sku, pid);                          // PG-FIRST + register
+        if (o.metafields.length) {
+          const gid = `gid://shopify/Product/${pid}`;
+          const d = await gql(M_MF, { mf: o.metafields.map(m => ({ ownerId: gid, namespace: m.namespace, key: m.key, type: m.type, value: m.value })) });
+          const ue = d?.metafieldsSet?.userErrors || d?.__err || [];
+          if (ue.length) { mfFail++; console.error(`  ⚠ mf ${o.sku}: ${JSON.stringify(ue).slice(0,160)}`); }
+        }
+        let imgOk = 0;
+        for (let i = 0; i < o.gallery.length; i++) {
+          const res2 = await attachImage(pid, o.gallery[i], `${o.mfr_sku}_${i}.jpg`);
+          if (res2 === 'ok') imgOk++; else if (i === 0 && res2 !== 'no-url') console.error(`  ⚠ img ${o.sku}: ${res2}`);
+        }
+        if (imgOk === 0) imgFail++;
+        fs.writeSync(fd, JSON.stringify({ mfr_sku: o.mfr_sku, sku: o.sku, product_id: String(pid), imgs: imgOk, status: 'draft', created_at: new Date().toISOString() }) + '\n');
+        ok++; if (ok % 20 === 0) console.log(`  ...created ${ok} (fail ${fail}, mfFail ${mfFail}, imgFail ${imgFail})`);
+      } else {
+        budget.refund(BUDGET_CAT, o.product.variants.length);
+        const errStr = JSON.stringify(body).toLowerCase();
+        if (/exceed|limit|too many|maximum/.test(errStr) && /variant|product|day/.test(errStr)) { console.error(`\n⛔ Shopify cap on ${o.sku}. Stop — resume tomorrow.`); break; }
+        fail++; console.error(`  FAIL ${o.sku}: HTTP ${res.status} ${JSON.stringify(body).slice(0,200)}`);
+      }
+    } catch (e) { budget.refund(BUDGET_CAT, o.product.variants.length); fail++; console.error(`  ERR ${o.sku}: ${String(e).slice(0,160)}`); await sleep(700); }
+  }
+  fs.closeSync(fd);
+  console.log(`\nDONE. created=${ok} failed=${fail} mfFail=${mfFail} imgFail=${imgFail} of ${todo.length}`);
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/stroheim-onboard/mint-skus.mjs b/scripts/stroheim-onboard/mint-skus.mjs
new file mode 100644
index 0000000..ad766a8
--- /dev/null
+++ b/scripts/stroheim-onboard/mint-skus.mjs
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+/**
+ * Mint canonical DWST SKUs into dw_unified.stroheim_catalog.proposed_dw_sku.
+ *
+ * Stroheim is a NEW vendor-scoped series (prefix DWST, approved by Steve 2026-07-26,
+ * memo 072226A decision #1). The prefix is confirmed free (0 existing DWST rows in
+ * dw_sku_registry). Numbering: DWST-810001+ (6-digit, fresh block, no collision with
+ * DWKN-25xxxx / DWMS-32xxxx).
+ *
+ * REVERSIBILITY: this writes ONLY to the staging table's proposed_dw_sku column — it does
+ * NOT touch dw_sku_registry or Shopify. Wipe with:
+ *   UPDATE stroheim_catalog SET proposed_dw_sku=NULL;
+ * The canonical registration (dw_sku_registry) happens later, at create-drafts time.
+ *
+ * ELIGIBILITY GATE — mints ONLY for rows that can actually become a real product:
+ *   price_retail IS NOT NULL AND price_retail > 0   (Steve's hard rule: nothing at $0/NULL)
+ *   AND image_url IS NOT NULL                        (≥1 image required)
+ *   AND dedup_status = 'net_new'                     (never-duplicate honored)
+ *   AND proposed_dw_sku IS NULL                      (idempotent — never re-mint)
+ *
+ * So while price is 100% NULL (the current state), this mints ZERO rows by design.
+ *
+ *   node mint-skus.mjs          # DRY-RUN: report how many are eligible
+ *   node mint-skus.mjs --apply  # assign DWST-###### to eligible rows (staging only)
+ */
+import { execFileSync } from 'node:child_process';
+
+const APPLY = process.argv.includes('--apply');
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const PGENV = { ...process.env, PGHOST: '/tmp', PGDATABASE: 'dw_unified' };
+const BASE = 810001;                                 // DWST-810001+ (fresh, collision-free block)
+
+const q = sql => execFileSync(PSQL, ['-d', 'dw_unified', '-tAc', sql], { env: PGENV, encoding: 'utf8' }).trim();
+
+const ELIGIBLE = `price_retail IS NOT NULL AND price_retail > 0 AND image_url IS NOT NULL
+  AND dedup_status = 'net_new' AND proposed_dw_sku IS NULL`;
+
+const nEligible = Number(q(`SELECT count(*) FROM stroheim_catalog WHERE ${ELIGIBLE}`));
+const nMinted   = Number(q(`SELECT count(*) FROM stroheim_catalog WHERE proposed_dw_sku IS NOT NULL`));
+console.log(`Stroheim SKU mint · prefix DWST · base ${BASE} · already minted ${nMinted} · eligible-to-mint ${nEligible} · ${APPLY ? 'APPLY' : 'DRY-RUN'}`);
+
+if (nEligible === 0) {
+  console.log('  ✓ nothing eligible (expected while price is NULL — HELD by design). No writes.');
+  process.exit(0);
+}
+if (!APPLY) { console.log('  DRY-RUN — pass --apply to assign DWST SKUs (staging only, reversible).'); process.exit(0); }
+
+// Deterministic, gap-free assignment ordered by mfr_sku, continuing after any existing max.
+const existingMax = Number(q(`SELECT COALESCE(MAX(NULLIF(regexp_replace(proposed_dw_sku,'^DWST-','',''),'')::int),${BASE - 1})
+  FROM stroheim_catalog WHERE proposed_dw_sku LIKE 'DWST-%'`)) || (BASE - 1);
+const sql = `WITH ranked AS (
+    SELECT id, ROW_NUMBER() OVER (ORDER BY mfr_sku) AS rn
+    FROM stroheim_catalog WHERE ${ELIGIBLE})
+  UPDATE stroheim_catalog s
+    SET proposed_dw_sku = 'DWST-' || (${existingMax} + ranked.rn)::text
+  FROM ranked WHERE s.id = ranked.id;`;
+q(sql);
+const after = Number(q(`SELECT count(*) FROM stroheim_catalog WHERE proposed_dw_sku IS NOT NULL`));
+console.log(`  ✓ minted → ${after - nMinted} new DWST SKUs (total minted now ${after}). Registry write deferred to create-drafts.`);

← acc693b stout-onboard: fix Sirv (cdn.estout.com) image fetch — add s  ·  back to Designerwallcoverings  ·  sanderson-onboard: DWXH- drafts (dual mfr_sku mf, retail Rol 07fb88e →