← back to Designerwallcoverings
restore build-romo-rolls.mjs from git history — was missing from working tree, causing romo-verify CRITICAL alerts
f12b5198aa8a161b78d67398e4b4572aea3a9071 · 2026-08-03 04:08:29 -0700 · Steve
Files touched
Diff
commit f12b5198aa8a161b78d67398e4b4572aea3a9071
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Aug 3 04:08:29 2026 -0700
restore build-romo-rolls.mjs from git history — was missing from working tree, causing romo-verify CRITICAL alerts
---
build-romo-rolls.mjs | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 127 insertions(+)
diff --git a/build-romo-rolls.mjs b/build-romo-rolls.mjs
new file mode 100644
index 0000000..ccafea3
--- /dev/null
+++ b/build-romo-rolls.mjs
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * Romo-group "Sold Per Roll" builder — matches LIVE Shopify products to romo_catalog
+ * cost by the custom.manufacturer_sku metafield (the W-code), NOT dw_sku.
+ *
+ * Why a dedicated script: romo_catalog.dw_sku is DWRM-* (a parallel Romo import not on
+ * Shopify); the LIVE products are DWLA-* and carry the manufacturer code only in the
+ * custom.manufacturer_sku metafield. So build-roll-scale.js (dw_sku-keyed, product_map-
+ * driven) cannot reach them. This bridges live<->cost by manufacturer_sku.
+ *
+ * Price = romo_catalog.our_price (= (cost+tariff)/0.65/0.85; markup 1.90-1.99x cost,
+ * never below cost — verified). Adds ONE "Sold Per Roll" variant. Guards mirror
+ * build-roll-scale: only single-variant <=$4.255 sample-only products, never overwrites
+ * the sample, never flips ACTIVE, shared daily variant budget, ROLL_CAP, resumable.
+ *
+ * Env: ROLL_VENDOR (brand, required), ROLL_CAP (default 3), DRY_RUN=1 (no writes).
+ */
+import fs from 'fs';
+import { execFileSync } from 'child_process';
+import { createRequire } from 'module';
+const require = createRequire(import.meta.url);
+const budget = require('./scripts/variant-budget/budget.cjs');
+
+const ENV = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (ENV.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1];
+const EP = 'https://designer-laboratory-sandbox.myshopify.com/admin/api/2024-10/graphql.json';
+const PGENV = { PGHOST: '/tmp', PGPORT: '5432', PGUSER: 'stevestudio2', PGDATABASE: 'dw_unified', PATH: '/opt/homebrew/opt/postgresql@14/bin:/usr/bin:/bin' };
+
+const VENDOR = process.env.ROLL_VENDOR;
+const CAP = parseInt(process.env.ROLL_CAP || '3', 10);
+const DRY = process.env.DRY_RUN === '1';
+if (!VENDOR) { console.error('ROLL_VENDOR required'); process.exit(1); }
+const slug = VENDOR.toLowerCase().replace(/[^a-z0-9]+/g, '-');
+const DONE = `${process.cwd()}/romo-rolls-done-${slug}.txt`;
+const CREATED = `${process.cwd()}/romo-rolls-created-${slug}.jsonl`;
+const LIMIT_RE = /exceed|daily.*limit|limit.*reached|throttl|too many/i;
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function g(q, v) {
+ for (let i = 0; i < 7; i++) {
+ let r; const ac = new AbortController(); const to = setTimeout(() => ac.abort(), 25000);
+ try { r = await fetch(EP, { method: 'POST', signal: ac.signal, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) }); }
+ catch (e) { clearTimeout(to); await sleep(1500 * (i + 1)); continue; }
+ clearTimeout(to);
+ if (r.status === 429 || r.status >= 500) { await sleep(2000 * (i + 1)); continue; }
+ let j; try { j = await r.json(); } catch (e) { await sleep(2000 * (i + 1)); continue; }
+ if (j.errors && JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (i + 1)); continue; }
+ return j;
+ }
+ throw new Error('exhausted retries');
+}
+
+// preload mfr_sku(upper,trim) -> our_price for this brand
+function priceMap() {
+ const raw = execFileSync('psql', ['-tAc',
+ `SELECT upper(trim(mfr_sku))||'~~'||our_price FROM romo_catalog WHERE brand ILIKE '%${VENDOR.replace(/'/g, "''")}%' AND mfr_sku<>'' AND our_price>trade_price_per_roll AND our_price>4.26`],
+ { env: PGENV, encoding: 'utf8' });
+ const m = new Map();
+ raw.split('\n').filter(Boolean).forEach(l => { const [k, p] = l.split('~~'); if (!m.has(k)) m.set(k, parseFloat(p)); });
+ return m;
+}
+
+const LIST = `query($q:String!,$after:String){ products(first:30, query:$q, after:$after){ pageInfo{hasNextPage endCursor} nodes{ id title options{name} metafield(namespace:"custom",key:"manufacturer_sku"){value} variants(first:5){nodes{id sku price selectedOptions{value}}} } } }`;
+const MUT = `mutation($pid: ID!, $variants: [ProductVariantsBulkInput!]!) {
+ productVariantsBulkCreate(productId: $pid, variants: $variants) { productVariants { id price sku } userErrors { message } } }`;
+const UPD = `mutation($pid: ID!, $variants: [ProductVariantsBulkInput!]!) {
+ productVariantsBulkUpdate(productId: $pid, variants: $variants) { productVariants { id } userErrors { message } } }`;
+
+const PM = priceMap();
+console.log(`[${VENDOR}] ${DRY ? 'DRY-RUN' : 'LIVE'} | price-map mfr_skus=${PM.size} | cap=${CAP}`);
+const done = new Set(fs.existsSync(DONE) ? fs.readFileSync(DONE, 'utf8').split('\n').filter(Boolean) : []);
+let made = 0, skipped = 0, after = null, scanned = 0;
+
+while (made < CAP) {
+ const lk = await g(LIST, { q: `vendor:"${VENDOR}" status:active`, after });
+ const conn = lk.data && lk.data.products;
+ if (!conn) { console.log('no products / error', JSON.stringify(lk.errors || {})); break; }
+ for (const p of conn.nodes) {
+ if (made >= CAP) break;
+ const baseSku = (p.variants.nodes[0] && p.variants.nodes[0].sku || '').replace(/-Sample$/i, '');
+ if (done.has(baseSku)) continue;
+ scanned++;
+ const vs = p.variants.nodes;
+ // sample-only guard: exactly one variant, <= $4.255
+ if (!(vs.length === 1 && parseFloat(vs[0].price) <= 4.255)) { skipped++; continue; }
+ const mfr = (p.metafield && p.metafield.value || '').toUpperCase().trim();
+ const price = mfr && PM.get(mfr);
+ if (!price) { skipped++; continue; }
+ const optName = (p.options[0] && p.options[0].name) || 'Title';
+ if (DRY) {
+ console.log(` WOULD BUILD ${baseSku} "${p.title.slice(0, 32)}" mfr=${mfr} roll=$${price.toFixed(2)}`);
+ made++; continue;
+ }
+ if (budget.take('roll', 1) < 1) { console.log('Daily variant budget spent — stopping (resumable).'); after = null; break; }
+ // CRITICAL: a product whose only variant is Shopify's implicit default ("Default Title")
+ // cannot coexist with a named-option variant — bulkCreate would REPLACE the sample. So
+ // first RENAME the default variant's option value to "Sample" (update, not create), which
+ // preserves it; only then does creating "Sold Per Roll" ADD a 2nd variant.
+ const v0 = vs[0];
+ const v0opt = (v0.selectedOptions && v0.selectedOptions[0] && v0.selectedOptions[0].value) || '';
+ if (/^default title$/i.test(v0opt)) {
+ const ur = await g(UPD, { pid: p.id, variants: [{ id: v0.id, optionValues: [{ optionName: optName, name: 'Sample' }] }] });
+ const ue = (ur.data && ur.data.productVariantsBulkUpdate && ur.data.productVariantsBulkUpdate.userErrors) || [];
+ if (ue.length) { skipped++; console.log(` ERR rename-default ${baseSku}: ${JSON.stringify(ue)}`); continue; }
+ }
+ const variants = [{ price: String(price), optionValues: [{ optionName: optName, name: 'Sold Per Roll' }], inventoryPolicy: 'CONTINUE', inventoryItem: { sku: `${baseSku}-Roll`, tracked: false } }];
+ const rr = await g(MUT, { pid: p.id, variants });
+ // safety net: if create failed but the rename already ran, the product is sample-only
+ // (option value "Sample") — still valid, not roll-only. No sample is ever lost.
+ const res = rr.data && rr.data.productVariantsBulkCreate;
+ const e = (res && res.userErrors) || [];
+ if (e.length) {
+ if (LIMIT_RE.test(JSON.stringify(e))) { console.log('Variant daily-limit/throttle — aborting:', JSON.stringify(e)); after = null; break; }
+ skipped++; console.log(` ERR ${baseSku}: ${JSON.stringify(e)}`); continue;
+ }
+ const nv = res.productVariants[0];
+ made++; done.add(baseSku);
+ fs.appendFileSync(DONE, baseSku + '\n');
+ fs.appendFileSync(CREATED, JSON.stringify({ pid: p.id, sku: nv.sku, price: nv.price, mfr, title: p.title }) + '\n');
+ console.log(` BUILT ${baseSku} "${p.title.slice(0, 28)}" roll=$${nv.price}`);
+ await sleep(300);
+ }
+ if (!conn.pageInfo.hasNextPage || after === null && made >= CAP) break;
+ if (!conn.pageInfo.hasNextPage) break;
+ after = conn.pageInfo.endCursor;
+}
+console.log(`\n[${VENDOR}] ${DRY ? 'DRY-RUN' : 'LIVE'} done: ${made} ${DRY ? 'would-build' : 'built'}, ${skipped} skipped, scanned ${scanned}.`);
← b99b761 auto-save: 2026-08-03T03:50:03 (3 files) — scripts/knoll-onb
·
back to Designerwallcoverings
·
auto-save: 2026-08-03T04:20:12 (3 files) — scripts/sample-sp 68538fa →