[object Object]

← back to Designer Wallcoverings

add luxury-line DRAFT importer (Fromental/Gracie/Zuber, quote-only, settlement-held)

217795cb0468d54fb23e8fdbfee357df83ab4240 · 2026-07-09 20:23:24 -0700 · Steve

Files touched

Diff

commit 217795cb0468d54fb23e8fdbfee357df83ab4240
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 9 20:23:24 2026 -0700

    add luxury-line DRAFT importer (Fromental/Gracie/Zuber, quote-only, settlement-held)
---
 .../luxury-draft-import/import-luxury-drafts.js    | 237 +++++++++++++++++++++
 1 file changed, 237 insertions(+)

diff --git a/shopify/scripts/luxury-draft-import/import-luxury-drafts.js b/shopify/scripts/luxury-draft-import/import-luxury-drafts.js
new file mode 100644
index 00000000..0c110b7b
--- /dev/null
+++ b/shopify/scripts/luxury-draft-import/import-luxury-drafts.js
@@ -0,0 +1,237 @@
+#!/usr/bin/env node
+/**
+ * import-luxury-drafts.js — DRAFT-only Shopify import for the 3 luxury wallcovering lines.
+ *   Fromental (fromental_catalog, DWFR-)  |  Gracie (gracie_catalog, DWGA-)  |  Zuber (zuber_catalog, DWZB-)
+ *
+ * HARD RAILS (per pending-approval memos + DW standing rules):
+ *   - status=DRAFT ONLY. Never ACTIVE, never publish. ACTIVE flip is a SEPARATE settlement-gated step.
+ *   - Quote-only: quote rows -> NULL price + tags 'quotes','Needs-Price'. Priced rows keep real price.
+ *   - Every product gets a {DW_SKU}-Sample variant @ $4.25, no inventory tracking.
+ *   - "Wallpaper" banned in title/desc -> "Wallcovering". Title Case. No "Unknown".
+ *   - PostgreSQL is source of truth. Stamp dw_sku/shopify_product_id/on_shopify back; mirror shopify_products.
+ *   - NEVER reuse a SKU number. DWFR/DWGA/DWZB all start fresh (verified 0 existing).
+ *
+ * Usage:
+ *   node import-luxury-drafts.js <fromental|gracie|zuber> [--go] [--limit N]
+ *   (dry-run unless --go)
+ */
+const https = require('https');
+const fs = require('fs');
+const os = require('os');
+const { execFileSync } = require('child_process');
+
+const LINE = process.argv[2];
+const GO = process.argv.includes('--go');
+const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? parseInt(process.argv[i + 1], 10) : Infinity; })();
+
+const CFG = {
+  fromental: { table: 'fromental_catalog', prefix: 'DWFR', brand: 'Fromental', vendor: 'Fromental',
+               uom: 'Made to Order (Quote)', priced: true },
+  gracie:    { table: 'gracie_catalog',    prefix: 'DWGA', brand: 'Gracie',    vendor: 'Gracie',
+               uom: 'Priced Per Panel (Quote)', priced: false },
+  zuber:     { table: 'zuber_catalog',     prefix: 'DWZB', brand: 'Zuber',     vendor: 'Zuber',
+               uom: 'Made to Order (Quote)', priced: false },
+}[LINE];
+if (!CFG) { console.error('usage: import-luxury-drafts.js <fromental|gracie|zuber> [--go] [--limit N]'); process.exit(1); }
+
+const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const psql = sql => execFileSync('psql', ['-d', 'dw_unified', '-At', '-F', '\x1f', '-c', sql],
+  { encoding: 'utf8', maxBuffer: 1 << 28 }).trim();
+const q = s => (s == null ? '' : String(s).replace(/'/g, "''"));
+
+function gql(query, variables) {
+  const d = JSON.stringify({ query, variables });
+  return new Promise(res => {
+    const r = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(d) } },
+      x => { let b = ''; x.on('data', c => b += c); x.on('end', () => { try { res(JSON.parse(b)); } catch { res({ raw: b }); } }); });
+    r.on('error', e => res({ err: e.message })); r.write(d); r.end();
+  });
+}
+
+// ---- text helpers ----
+const cleanWallpaper = s => !s ? s : s.replace(/\bWallpapers\b/gi, 'Wallcoverings').replace(/\bWallpaper\b/gi, 'Wallcovering');
+const SMALL = new Set(['a','an','the','and','but','or','for','nor','on','at','to','by','of','in','with','as']);
+function titleCase(s) {
+  if (!s) return s;
+  const words = s.split(/\s+/);
+  return words.map((w, i) => {
+    const lw = w.toLowerCase();
+    if (i !== 0 && SMALL.has(lw)) return lw;
+    return w.charAt(0).toUpperCase() + w.slice(1);
+  }).join(' ');
+}
+const j = s => { try { return JSON.parse(s); } catch { return null; } };
+
+// ---- build title (Pattern [Color] | Brand), no Unknown, no Wallpaper ----
+function buildTitle(r) {
+  let pat = (r.pattern_name || '').trim();
+  const col = (r.color_name || '').trim();
+  // guard against Unknown / error-ish names -> fall back to mfr_sku
+  if (!pat || /^(unknown|page not found|404|error|not found|undefined|null)$/i.test(pat)) pat = r.mfr_sku;
+  let base = col ? `${pat} ${col}` : pat;
+  base = titleCase(cleanWallpaper(base));
+  return `${base} | ${CFG.brand}`;
+}
+
+// ---- build tags: quotes/Needs-Price (quote rows) + brand + ai tags/styles/colors + product_type ----
+function buildTags(r, isQuote) {
+  const t = new Set();
+  if (isQuote) { t.add('quotes'); t.add('Needs-Price'); }
+  t.add(CFG.brand);
+  (j(r.ai_tags) || []).forEach(x => x && t.add(String(x)));
+  (j(r.ai_styles) || []).forEach(x => x && t.add(String(x)));
+  (j(r.ai_colors) || []).forEach(c => c && c.name && t.add(String(c.name)));
+  if (r.collection) t.add(r.collection);
+  // activation-blocked note so it never accidentally activates without settlement pass
+  t.add('Needs-Settlement-Review');
+  return [...t].filter(Boolean).slice(0, 40);
+}
+
+const productType = r => (r.product_type && /fabric/i.test(r.product_type)) ? 'Fabric' : 'Wallcovering';
+
+function metafields(r, isQuote) {
+  const m = [
+    { namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: r.mfr_sku },
+    { namespace: 'dwc', key: 'manufacturer_sku', type: 'single_line_text_field', value: r.mfr_sku },
+    { namespace: 'global', key: 'Brand', type: 'single_line_text_field', value: CFG.brand },
+    { namespace: 'global', key: 'unit_of_measure', type: 'single_line_text_field', value: CFG.uom },
+  ];
+  if (r.color_name) {
+    m.push({ namespace: 'custom', key: 'color', type: 'single_line_text_field', value: r.color_name });
+    m.push({ namespace: 'custom', key: 'real_color_name', type: 'single_line_text_field', value: r.color_name });
+  }
+  if (r.width) m.push({ namespace: 'global', key: 'width', type: 'single_line_text_field', value: r.width });
+  if (r.pattern_repeat) m.push({ namespace: 'global', key: 'repeat', type: 'single_line_text_field', value: r.pattern_repeat });
+  return m.filter(x => x.value && String(x.value).trim() !== '');
+}
+
+const PRODUCT_SET = `
+mutation ps($input: ProductSetInput!) {
+  productSet(synchronous: true, input: $input) {
+    product { id handle status }
+    userErrors { field message }
+  }
+}`;
+
+async function nextNumber(prefix) {
+  // highest existing suffix in registry OR shopify_products, +1; fresh lines start at 100001
+  const reg = psql(`SELECT COALESCE(MAX((regexp_replace(dw_sku,'^${prefix}-?',''))::int),0)
+                    FROM dw_sku_registry WHERE dw_sku ~ '^${prefix}-?[0-9]+$'`) || '0';
+  const shp = psql(`SELECT COALESCE(MAX((regexp_replace(sku,'^${prefix}-?','','i'))::int),0)
+                    FROM shopify_products WHERE sku ~* '^${prefix}-?[0-9]+$'`) || '0';
+  const hi = Math.max(parseInt(reg, 10) || 0, parseInt(shp, 10) || 0);
+  return hi >= 100000 ? hi + 1 : 100001; // fresh range
+}
+
+(async () => {
+  console.log(`\n=== ${LINE.toUpperCase()} DRAFT import (${CFG.prefix}-, ${STORE}) ===`);
+  console.log(`Cost: $0 (Shopify Admin API is free). Settlement-gated ACTIVE flip held.\n`);
+
+  const rows = psql(`SELECT id, mfr_sku, pattern_name, color_name, collection, width, pattern_repeat,
+      product_type, image_url, gallery_images, description, ai_description, ai_tags, ai_styles, ai_colors,
+      ${LINE === 'fromental' ? 'price' : 'NULL::numeric'} AS price,
+      dw_sku, shopify_product_id
+    FROM ${CFG.table}
+    WHERE dw_sku IS NULL AND (on_shopify IS NOT TRUE)
+    ORDER BY id`)
+    .split('\n').filter(Boolean).map(line => {
+      const c = line.split('\x1f');
+      return { id: c[0], mfr_sku: c[1], pattern_name: c[2], color_name: c[3], collection: c[4],
+        width: c[5], pattern_repeat: c[6], product_type: c[7], image_url: c[8], gallery_images: c[9],
+        description: c[10], ai_description: c[11], ai_tags: c[12], ai_styles: c[13], ai_colors: c[14],
+        price: c[15], dw_sku: c[16], shopify_product_id: c[17] };
+    });
+
+  const alreadyDone = parseInt(psql(`SELECT count(*) FROM ${CFG.table} WHERE dw_sku IS NOT NULL`) || '0', 10);
+  console.log(`Rows needing draft: ${rows.length} (already stamped: ${alreadyDone})`);
+  const work = rows.slice(0, LIMIT);
+  if (!GO) {
+    console.log(`DRY RUN — re-run with --go. Would create ${work.length} DRAFTs.`);
+    const s = work[0];
+    if (s) {
+      const priced = CFG.priced && s.price && parseFloat(s.price) > 0;
+      console.log('Sample:', { title: buildTitle(s), type: productType(s), priced: !!priced,
+        price: priced ? s.price : null, tags: buildTags(s, !priced).slice(0, 8) });
+    }
+    return;
+  }
+
+  let num = await nextNumber(CFG.prefix);
+  console.log(`Starting SKU: ${CFG.prefix}-${num}\n`);
+  let ok = 0, fail = 0, batch = 0;
+
+  for (const r of work) {
+    const dwSku = `${CFG.prefix}-${num}`;
+    const isPriced = CFG.priced && r.price && parseFloat(r.price) > 0;
+    const isQuote = !isPriced;
+    const title = buildTitle(r);
+    if (/unknown/i.test(title)) { console.log(`  ✗ SKIP (Unknown in title): ${r.mfr_sku}`); fail++; continue; }
+    const tags = buildTags(r, isQuote);
+    const body = cleanWallpaper((r.description && r.description.length > 5 ? r.description : r.ai_description) || '');
+
+    // images: image_url + gallery
+    const imgs = [];
+    if (r.image_url) imgs.push(r.image_url);
+    (r.gallery_images ? r.gallery_images.replace(/^\{|\}$/g, '').split(',').map(x => x.replace(/^"|"$/g, '')) : [])
+      .forEach(u => { if (u && !imgs.includes(u)) imgs.push(u); });
+
+    const variants = [{
+      optionValues: [{ optionName: 'Title', name: 'Sample' }],
+      sku: `${dwSku}-Sample`, price: '4.25', inventoryPolicy: 'CONTINUE',
+      inventoryItem: { tracked: false },
+    }];
+    if (isPriced) {
+      variants.unshift({
+        optionValues: [{ optionName: 'Title', name: 'Default' }],
+        sku: dwSku, price: String(parseFloat(r.price).toFixed(2)),
+        inventoryPolicy: 'CONTINUE', inventoryItem: { tracked: false },
+      });
+    }
+
+    const input = {
+      title, vendor: CFG.vendor, productType: productType(r), status: 'DRAFT',
+      tags,
+      descriptionHtml: body || undefined,
+      productOptions: [{ name: 'Title', values: variants.map(v => ({ name: v.optionValues[0].name })) }],
+      variants,
+      metafields: metafields(r, isQuote),
+      files: imgs.slice(0, 20).map(u => ({ originalSource: u, contentType: 'IMAGE' })),
+    };
+
+    const res = await gql(PRODUCT_SET, { input });
+    const p = res?.data?.productSet?.product;
+    const errs = res?.data?.productSet?.userErrors;
+    if (p && p.id && (!errs || errs.length === 0)) {
+      const pid = p.id.replace('gid://shopify/Product/', '');
+      // stamp staging + mirror shopify_products
+      psql(`UPDATE ${CFG.table} SET dw_sku='${q(dwSku)}', shopify_product_id='${q(pid)}',
+              on_shopify=false, imported_at=now(), updated_at=now() WHERE id=${r.id}`);
+      psql(`INSERT INTO shopify_products
+              (shopify_id, handle, title, vendor, product_type, sku, dw_sku, mfr_sku, vendor_prefix,
+               tags, status, image_url, price, pattern_name, body_html, has_sample_variant,
+               has_product_variant, has_description, synced_at)
+            VALUES ('${q(pid)}','${q(p.handle)}','${q(title)}','${q(CFG.vendor)}','${q(productType(r))}',
+               '${q(dwSku)}','${q(dwSku)}','${q(r.mfr_sku)}','${CFG.prefix}',
+               '${q(tags.join(', '))}','draft','${q(imgs[0] || '')}',
+               ${isPriced ? parseFloat(r.price).toFixed(2) : 'NULL'},
+               '${q(r.pattern_name || r.mfr_sku)}','${q(body)}', true, ${isPriced}, ${body.length > 0}, now())
+            ON CONFLICT DO NOTHING`);
+      ok++; num++;
+      if (ok % 25 === 0) process.stdout.write(`\r  drafted ${ok}/${work.length}…`);
+    } else {
+      fail++;
+      console.log(`\n  ✗ ${r.mfr_sku}: ${JSON.stringify(errs || res?.errors || res).slice(0, 220)}`);
+    }
+    batch++;
+    // metered: ~4/s; a longer breath every 100 to honor the >=90s-between-BULK rule politeness
+    await sleep(280);
+    if (batch % 100 === 0) { process.stdout.write(`\n  …breather (batch ${batch}) 90s\n`); await sleep(90000); }
+  }
+  console.log(`\n✓ ${LINE}: created ${ok} DRAFTs (failed ${fail}). SKU range ${CFG.prefix}-... assigned.`);
+  const rng = psql(`SELECT min(dw_sku)||' … '||max(dw_sku) FROM ${CFG.table} WHERE dw_sku IS NOT NULL`);
+  console.log(`  ${CFG.table} dw_sku range: ${rng}`);
+})().catch(e => { console.error('ERR', e.stack || e.message); process.exit(1); });

← 42ffdd9a auto-save: 2026-07-09T19:41:55 (5 files) — pending-approval/  ·  back to Designer Wallcoverings  ·  fix luxury importer: nextNumber reads staging max (no SKU re 1b495493 →