← 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
A shopify/scripts/luxury-draft-import/import-luxury-drafts.js
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 →