← back to Designerwallcoverings
build-romo-rolls.mjs
129 lines
'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 (!baseSku) { skipped++; continue; } // no SKU → skip; never build a colliding "-Roll" variant
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}.`);