← back to Designer Wallcoverings
security: strip hardcoded secret -> env-first/passwordless. No rotation/deploy.
19c8a3775ae29ae930c0449a44444e0622eec6f3 · 2026-05-30 11:35:47 -0700 · Steve
Files touched
A shopify/scripts/coleson-add-bolt-variants.jsA shopify/scripts/coleson-add-panel-variants.jsA shopify/scripts/coleson-palm-jungle-refresh.jsA shopify/scripts/coleson-panel-cleared.jsA shopify/scripts/coleson-retry-failures.jsA shopify/scripts/coleson-seasonal-woods-panels.js
Diff
commit 19c8a3775ae29ae930c0449a44444e0622eec6f3
Author: Steve <steve@designerwallcoverings.com>
Date: Sat May 30 11:35:47 2026 -0700
security: strip hardcoded secret -> env-first/passwordless. No rotation/deploy.
---
shopify/scripts/coleson-add-bolt-variants.js | 182 ++++++++++++++++
shopify/scripts/coleson-add-panel-variants.js | 130 +++++++++++
shopify/scripts/coleson-palm-jungle-refresh.js | 265 +++++++++++++++++++++++
shopify/scripts/coleson-panel-cleared.js | 91 ++++++++
shopify/scripts/coleson-retry-failures.js | 163 ++++++++++++++
shopify/scripts/coleson-seasonal-woods-panels.js | 99 +++++++++
6 files changed, 930 insertions(+)
diff --git a/shopify/scripts/coleson-add-bolt-variants.js b/shopify/scripts/coleson-add-bolt-variants.js
new file mode 100644
index 00000000..739d93f9
--- /dev/null
+++ b/shopify/scripts/coleson-add-bolt-variants.js
@@ -0,0 +1,182 @@
+#!/usr/bin/env node
+// Add a "Sold Per Bolt (<width>in x 33ft)" variant to every Cole & Son
+// sample-only product on designer-laboratory-sandbox.myshopify.com.
+//
+// Source data: /tmp/cole-son-pricing/coleson_worklist.csv (826 rows)
+// Built via PG join coleson_catalog × Kravet PriceAdj_April19.xlsx (NEW MAP).
+//
+// Per row:
+// width_inches ∈ [19..22] → bolt 20.5", weight 4.0 lb
+// width_inches ∈ [26..29] → bolt 27.5", weight 5.0 lb
+// price = Kravet NEW MAP (USD/roll)
+// sku = `${base_dw_sku}-Sold Per Bolt (<width>in x 33ft)` (base = dw_sku minus '-Sample')
+// tracked = false (matches sandberg + sample behavior)
+//
+// Safeguards:
+// - --dry-run is the DEFAULT. Live writes require --live.
+// - --limit N caps the run (sane for test batches).
+// - Idempotency: skip product if any variant title !== 'Sample' already.
+// - Per-product userErrors captured; full per-row JSON saved.
+// - 150ms pacing matches sandberg script.
+
+const fs = require('fs');
+const path = require('path');
+
+const SHOP = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const OPTION_NAME = 'Size';
+
+if (!TOKEN) { console.error('SHOPIFY_ADMIN_TOKEN unset'); process.exit(1); }
+
+const ARGS = process.argv.slice(2);
+const LIVE = ARGS.includes('--live');
+const DRY = !LIVE;
+const LIMIT_IDX = ARGS.findIndex(a => a === '--limit');
+const LIMIT = LIMIT_IDX >= 0 ? parseInt(ARGS[LIMIT_IDX + 1], 10) : null;
+const INPUT = ARGS.find(a => a.startsWith('--input='))?.slice(8)
+ || '/tmp/cole-son-pricing/coleson_worklist.csv';
+
+function loadCSV(p) {
+ const txt = fs.readFileSync(p, 'utf8').replace(/\r/g, '');
+ const lines = txt.split('\n').filter(Boolean);
+ const header = lines[0].split(',');
+ const rows = [];
+ for (let i = 1; i < lines.length; i++) {
+ // Naive CSV parse: collection contains "&" but no commas inside fields per our export
+ const cols = [];
+ let cur = '', inQ = false;
+ for (const ch of lines[i]) {
+ if (ch === '"') inQ = !inQ;
+ else if (ch === ',' && !inQ) { cols.push(cur); cur = ''; }
+ else cur += ch;
+ }
+ cols.push(cur);
+ const rec = {};
+ header.forEach((h, j) => rec[h] = cols[j]);
+ rows.push(rec);
+ }
+ return rows;
+}
+
+async function gql(query, variables = {}) {
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const res = await fetch(ENDPOINT, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+ const json = await res.json();
+ if (!json.errors) return json;
+ if (attempt === 2) throw new Error(JSON.stringify(json.errors).slice(0, 400));
+ await new Promise(r => setTimeout(r, 600 * (attempt + 1)));
+ }
+}
+
+const INSPECT_Q = `
+ query Inspect($id: ID!) {
+ product(id: $id) {
+ id title status vendor
+ options { id name values }
+ variants(first: 20) {
+ edges { node { id title sku selectedOptions { name value } } }
+ }
+ }
+ }`;
+
+const ADD_VARIANT = `
+ mutation Add($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
+ productVariantsBulkCreate(productId: $productId, variants: $variants) {
+ productVariants { id title sku price inventoryItem { id measurement { weight { value unit } } } }
+ userErrors { field message code }
+ }
+ }`;
+
+async function processOne(row) {
+ const productId = row.shopify_product_id; // already a gid
+ const { data } = await gql(INSPECT_Q, { id: productId });
+ if (!data || !data.product) return { row, status: 'missing-product' };
+ const variants = data.product.variants.edges.map(e => e.node);
+ const hasBolt = variants.some(v => v.title !== 'Sample' && !(v.sku || '').endsWith('-Sample'));
+ if (hasBolt) return { row, status: 'skip-has-bolt' };
+
+ // Base DW SKU: strip the trailing "-Sample" from the sample variant if present
+ const sampleVariant = variants.find(v => v.title === 'Sample' || (v.sku || '').endsWith('-Sample'));
+ const baseSku = sampleVariant
+ ? (sampleVariant.sku || '').replace(/-Sample$/, '')
+ : (row.dw_sku || '').replace(/-Sample$/, '');
+ if (!baseSku) return { row, status: 'no-base-sku', detail: { sampleVariant, dwSkuFromCsv: row.dw_sku } };
+
+ const boltLabel = `Sold Per Bolt (${row.bolt_width}in x 33ft)`;
+ const newSku = `${baseSku}-${boltLabel}`;
+ const priceStr = Number(row.map_price).toFixed(2);
+ const weightVal = Number(row.bolt_weight_lb);
+
+ const variantInput = {
+ optionValues: [{ optionName: OPTION_NAME, name: boltLabel }],
+ price: priceStr,
+ inventoryItem: {
+ sku: newSku,
+ measurement: { weight: { value: weightVal, unit: 'POUNDS' } },
+ tracked: false,
+ },
+ };
+
+ if (DRY) return { row, status: 'DRY', baseSku, newSku, priceStr, weightVal, boltLabel };
+
+ const { data: d2 } = await gql(ADD_VARIANT, { productId, variants: [variantInput] });
+ const errs = d2.productVariantsBulkCreate.userErrors;
+ if (errs && errs.length) return { row, status: 'error', errors: errs, newSku, priceStr };
+ const created = d2.productVariantsBulkCreate.productVariants[0];
+ return {
+ row, status: 'ok',
+ newVariantId: created.id, sku: created.sku, price: created.price,
+ weight: created.inventoryItem?.measurement?.weight,
+ };
+}
+
+async function run() {
+ let rows = loadCSV(INPUT);
+ if (LIMIT) rows = rows.slice(0, LIMIT);
+ console.log(`mode=${LIVE ? 'LIVE' : 'DRY-RUN'} input=${INPUT} rows=${rows.length}`);
+ console.log(` narrow=${rows.filter(r => r.bolt_width === '20.5').length} wider=${rows.filter(r => r.bolt_width === '27.5').length}`);
+
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const outPath = `/tmp/cole-son-pricing/run-${LIVE ? 'live' : 'dry'}-${stamp}.json`;
+
+ const results = [];
+ for (let i = 0; i < rows.length; i++) {
+ let r;
+ try { r = await processOne(rows[i]); }
+ catch (e) { r = { row: rows[i], status: 'exception', error: String(e).slice(0, 400) }; }
+ results.push(r);
+ if (((i + 1) % 10) === 0 || i + 1 === rows.length) {
+ process.stdout.write(`\r ${i + 1}/${rows.length} `);
+ }
+ if (LIVE) await new Promise(r => setTimeout(r, 150));
+ }
+ process.stdout.write('\n');
+
+ const byStatus = results.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {});
+ console.log('Status:', JSON.stringify(byStatus));
+ fs.writeFileSync(outPath, JSON.stringify(results, null, 2));
+ console.log(`saved → ${outPath}`);
+
+ // Failure samples
+ const failed = results.filter(r => r.status === 'error' || r.status === 'exception');
+ if (failed.length) {
+ console.log(`\nFAILED ${failed.length} (first 5):`);
+ for (const f of failed.slice(0, 5)) {
+ console.log(' ', f.row.mfr_sku, f.status, JSON.stringify(f.errors || f.error).slice(0, 200));
+ }
+ }
+ if (DRY) {
+ const sample = results.filter(r => r.status === 'DRY').slice(0, 5);
+ console.log('\nFirst 5 planned variants:');
+ for (const r of sample) {
+ console.log(` ${r.row.mfr_sku} bolt=${r.boltLabel} $${r.priceStr} ${r.weightVal} lb sku="${r.newSku}"`);
+ }
+ }
+}
+
+run().catch(e => { console.error(e); process.exit(1); });
diff --git a/shopify/scripts/coleson-add-panel-variants.js b/shopify/scripts/coleson-add-panel-variants.js
new file mode 100644
index 00000000..748295ec
--- /dev/null
+++ b/shopify/scripts/coleson-add-panel-variants.js
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+// Add a "Sold Per Panel" variant to the 12 settlement-safe Cole & Son
+// panel/mural products on designer-laboratory-sandbox.myshopify.com.
+//
+// Spec:
+// optionValue = "Sold Per Panel"
+// sku = `${base_dw_sku}-Sold Per Panel`
+// price = Kravet NEW MAP (per EACH, e.g. $616.50 for Nuvolette)
+// weight = 8 lb (panel-scale; bolts are 4-5 lb)
+// tracked = false
+//
+// Scope: 12 panels at 54-55.1" wide. All Fornasetti/Bullard/Royal Palaces/Gardens
+// non-foliage subjects (clouds, flying machines, monochrome florals, gates).
+// Skips Uccelli, Idyll Metallic, Verdure Tapestry, Seasonal Woods (settlement-flagged).
+
+const fs = require('fs');
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const OPTION_NAME = 'Size';
+const PANEL_LABEL = 'Sold Per Panel';
+const PANEL_WEIGHT_LB = 8;
+const LIVE = process.argv.includes('--live');
+
+// 12 safe panels from PG worklist
+const WORKLIST = [
+ { mfr: "114/28057.CS.0", dw_base: "DWKK-133867", pid: "7421482336307", w: 54.0, map: 616.5, pattern: "NUVOLETTE", color: "SLATE BLUE" },
+ { mfr: "114/28054.CS.0", dw_base: "DWKK-133864", pid: "7421482106931", w: 54.0, map: 616.5, pattern: "NUVOLETTE", color: "BLACK/WHITE" },
+ { mfr: "114/28055.CS.0", dw_base: "DWKK-133865", pid: "7421482205235", w: 54.0, map: 616.5, pattern: "NUVOLETTE", color: "SOOT/SNOW" },
+ { mfr: "114/28056.CS.0", dw_base: "DWKK-133866", pid: "7421482238003", w: 54.0, map: 616.5, pattern: "NUVOLETTE", color: "STONE" },
+ { mfr: "114/2004.CS.0", dw_base: "DWKK-133848", pid: "7421480927283", w: 54.0, map: 616.5, pattern: "NUVOLETTE", color: "GILVER & CHARCOAL" },
+ { mfr: "114/2005.CS.0", dw_base: "DWKK-133850", pid: "7421481123891", w: 54.0, map: 616.5, pattern: "NUVOLETTE", color: "PEARL" },
+ { mfr: "114/10020.CS.0", dw_base: "DWKK-133828", pid: "7421479419955", w: 54.0, map: 658.5, pattern: "MACCHINE VOLANTI", color: "STONE/ROUGE/BLUE" },
+ { mfr: "114/27053.CS.0", dw_base: "DWKK-133863", pid: "7421482041395", w: 54.0, map: 658.5, pattern: "MACCHINE VOLANTI", color: "SOOT/SNOW" },
+ { mfr: "113/4012.CS.0", dw_base: "DWKK-133809", pid: "7421477814323", w: 54.0, map: 889.5, pattern: "BAHIA", color: "GOLD & STONE" },
+ { mfr: "118/8017.CS.0", dw_base: "DWKK-134038", pid: "7421496033331", w: 55.1, map: 523.5, pattern: "TIJOU GATE", color: "SPRING GREEN" },
+ { mfr: "114/20040.CS.0", dw_base: "DWKK-133849", pid: "7421481025587", w: 55.1, map: 540.0, pattern: "RIFLESSO", color: "BLACK/WHITE" },
+ { mfr: "120/3009.CS.0", dw_base: "DWKK-134100", pid: "7421500784691", w: 55.1, map: 616.5, pattern: "GRANDE FLEUR", color: "PEACH BLUSH" },
+];
+
+async function gql(query, variables = {}) {
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const res = await fetch(ENDPOINT, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+ const j = await res.json();
+ if (!j.errors) return j;
+ if (attempt === 2) throw new Error(JSON.stringify(j.errors).slice(0, 400));
+ await new Promise(r => setTimeout(r, 600 * (attempt + 1)));
+ }
+}
+
+const INSPECT_Q = `query($id:ID!){product(id:$id){id title status options{id name values} variants(first:20){edges{node{id title sku selectedOptions{name value}}}}}}`;
+
+const ADD_VARIANT = `mutation Add($productId:ID!,$variants:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$productId,variants:$variants,strategy:REMOVE_STANDALONE_VARIANT){productVariants{id title sku price inventoryItem{measurement{weight{value unit}}}} userErrors{field message code}}}`;
+
+const CREATE_OPTION = `mutation CreateOption($pid:ID!,$values:[OptionValueCreateInput!]!){productOptionsCreate(productId:$pid, options:[{name:"Size", position:1, values:$values}]){product{id options{id name values}} userErrors{field message code}}}`;
+
+async function processOne(row) {
+ const productId = `gid://shopify/Product/${row.pid}`;
+ const { data } = await gql(INSPECT_Q, { id: productId });
+ if (!data || !data.product) return { row, status: 'missing-product' };
+
+ const product = data.product;
+ const variants = product.variants.edges.map(e => e.node);
+ const hasNonSample = variants.some(v => v.title !== 'Sample' && !(v.sku || '').endsWith('-Sample'));
+ if (hasNonSample) return { row, status: 'skip-has-bolt-or-panel', productId };
+
+ const sampleVariant = variants.find(v => v.title === 'Sample' || (v.sku || '').endsWith('-Sample'));
+ const baseSku = sampleVariant ? (sampleVariant.sku || '').replace(/-Sample$/, '') : row.dw_base;
+ const newSku = `${baseSku}-${PANEL_LABEL}`;
+ const priceStr = Number(row.map).toFixed(2);
+ const sizeOption = product.options.find(o => o.name === OPTION_NAME);
+
+ if (!LIVE) return { row, status: 'DRY', productId, baseSku, newSku, priceStr, weight: PANEL_WEIGHT_LB, hasSizeOption: !!sizeOption };
+
+ // Create the option if not present (some legacy products only have "Title")
+ if (!sizeOption) {
+ const optRes = await gql(CREATE_OPTION, { pid: productId, values: [{ name: 'Sample' }, { name: PANEL_LABEL }] });
+ const errs = optRes.data.productOptionsCreate.userErrors;
+ if (errs && errs.length) {
+ // Fallback: try with just the panel value
+ const r2 = await gql(CREATE_OPTION, { pid: productId, values: [{ name: PANEL_LABEL }] });
+ const e2 = r2.data.productOptionsCreate.userErrors;
+ if (e2 && e2.length) return { row, status: 'option-create-failed', errors: e2 };
+ }
+ }
+
+ const variantInput = {
+ optionValues: [{ optionName: OPTION_NAME, name: PANEL_LABEL }],
+ price: priceStr,
+ inventoryItem: {
+ sku: newSku,
+ measurement: { weight: { value: PANEL_WEIGHT_LB, unit: 'POUNDS' } },
+ tracked: false,
+ },
+ };
+ const { data: d2 } = await gql(ADD_VARIANT, { productId, variants: [variantInput] });
+ const errs = d2.productVariantsBulkCreate.userErrors;
+ if (errs && errs.length) return { row, status: 'error', errors: errs, baseSku, newSku, priceStr };
+ const created = d2.productVariantsBulkCreate.productVariants[0];
+ return {
+ row, status: 'ok',
+ productId, baseSku,
+ variantId: created.id, sku: created.sku, price: created.price,
+ weight: created.inventoryItem?.measurement?.weight,
+ };
+}
+
+(async () => {
+ console.log(`mode=${LIVE ? 'LIVE' : 'DRY-RUN'} rows=${WORKLIST.length}`);
+ const results = [];
+ for (let i = 0; i < WORKLIST.length; i++) {
+ try {
+ const r = await processOne(WORKLIST[i]);
+ results.push(r);
+ console.log(` [${i+1}/${WORKLIST.length}] ${r.row.mfr} ${r.row.pattern} ${r.row.color} → ${r.status} ${r.sku || ''} ${r.price ? '$'+r.price : ''} ${r.weight ? r.weight.value+'lb' : ''}`);
+ } catch (e) {
+ results.push({ row: WORKLIST[i], status: 'exception', error: String(e).slice(0,400) });
+ console.log(` [${i+1}/${WORKLIST.length}] ${WORKLIST[i].mfr}: EXCEPTION ${String(e).slice(0,160)}`);
+ }
+ if (LIVE) await new Promise(r => setTimeout(r, 200));
+ }
+ const stamp = new Date().toISOString().replace(/[:.]/g,'-');
+ fs.writeFileSync(`/tmp/cole-son-pricing/panel-run-${LIVE?'live':'dry'}-${stamp}.json`, JSON.stringify(results, null, 2));
+ const byStatus = results.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {});
+ console.log('\nStatus:', JSON.stringify(byStatus));
+})();
diff --git a/shopify/scripts/coleson-palm-jungle-refresh.js b/shopify/scripts/coleson-palm-jungle-refresh.js
new file mode 100644
index 00000000..09c30c42
--- /dev/null
+++ b/shopify/scripts/coleson-palm-jungle-refresh.js
@@ -0,0 +1,265 @@
+#!/usr/bin/env node
+// Refresh Cole & Son Palm Jungle product line on DW Shopify to match
+// cole-and-son.com's current 7 colorways.
+//
+// Plan:
+// 1) UPDATE 6 existing products (95/1002-95/1007):
+// - Rename title to new colorway
+// - Add new Cole & Son CDN image as primary
+// - Update custom.color metafield
+// - Add Bolt variant ($244.50, 4 lb, 20.5"×33ft) — NONE currently have one
+// 2) CREATE 1 new product (95/1001 Olive Green on White):
+// - Sample variant ($4.25)
+// - Bolt variant ($244.50, 4 lb, 20.5"×33ft)
+// - Hero image from CS CDN
+// - Title in DW format
+// - Width + manufacturer_sku metafields
+//
+// Pricing: all from Kravet 2025 NEW MAP = $244.50/roll for SKUs 95/1001-95/1007.
+//
+// --dry-run is default. --live to actually write.
+
+const fs = require('fs');
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const LIVE = process.argv.includes('--live');
+
+// The 7 Palm Jungle colorways with their CS SKU, new colorway name, and Kravet MAP.
+// 95/1001 has shopify_id=null (CREATE); others UPDATE.
+const PLAN = [
+ // 95/1001 already created at productId 7842516140083 — skipping create path
+ { kravet_sku: "95/1002.CS.0", new_color: "Teal & Viridian on Chalk", shopify_id: "7421884497971", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1002.jpg" },
+ { kravet_sku: "95/1003.CS.0", new_color: "Viridian on Charcoal", shopify_id: "7421884596275", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1003.jpg" },
+ { kravet_sku: "95/1004.CS.0", new_color: "Metallic Gilver on Charcoal", shopify_id: "7421884661811", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1004.jpg" },
+ { kravet_sku: "95/1005.CS.0", new_color: "Hyacinth on White", shopify_id: "7421884694579", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1005.jpg" },
+ { kravet_sku: "95/1006.CS.0", new_color: "Metallic Silver & Linen on Hyacinth", shopify_id: "7421885251635", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1006.jpg" },
+ { kravet_sku: "95/1007.CS.0", new_color: "Heath Grey on Metallic Silver", shopify_id: "7421885349939", map: 244.50, image_url: "https://cdn.shopify.com/s/files/1/0577/0543/1226/files/PalmJungle_Image_Flatshot_Item_95-1007.jpg" },
+];
+
+const COLLECTION_TAG = "Cole & Son Contemporary Restyled";
+const BOLT_LABEL = "Sold Per Bolt (20.5in x 33ft)";
+const BOLT_WEIGHT_LB = 4;
+const WIDTH_INCHES = "20.5 Inches";
+const SAMPLE_PRICE = "4.25";
+
+async function gql(query, variables = {}) {
+ const r = await fetch(ENDPOINT, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+ const j = await r.json();
+ if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 400));
+ return j.data;
+}
+
+const titleFor = (color) => `Palm Jungle - ${color} | Cole & Son Contemporary Restyled |Botanical & Floral Wallcovering`;
+const sampleSku = (baseDw) => `${baseDw}-Sample`;
+const boltSku = (baseDw) => `${baseDw}-${BOLT_LABEL}`;
+
+// ── UPDATE PATH ─────────────────────────────────────────────────────
+async function updateExisting(row) {
+ const productId = `gid://shopify/Product/${row.shopify_id}`;
+ // Fetch current state
+ const { product } = await gql(`query($id:ID!){product(id:$id){id title handle vendor status options{id name values} variants(first:10){edges{node{id title sku price selectedOptions{name value} inventoryItem{id}}}} metafields(first:30){edges{node{id namespace key value type}}} images(first:10){edges{node{id altText url}}}}}`, { id: productId });
+ if (!product) return { status: 'missing', row };
+
+ const newTitle = titleFor(row.new_color);
+ const currentSampleVariant = product.variants.edges.find(e => e.node.title.includes('Sample') || (e.node.sku||'').endsWith('-Sample'))?.node;
+ const baseDwSku = currentSampleVariant ? currentSampleVariant.sku.replace(/-Sample$/, '') : null;
+ if (!baseDwSku) return { status: 'no-base-sku', row };
+
+ const hasBolt = product.variants.edges.some(e => !(e.node.sku||'').endsWith('-Sample') && e.node.title !== 'Sample');
+ const sizeOption = product.options.find(o => o.name === 'Size');
+
+ const ops = [];
+
+ // 1) Title update
+ if (product.title !== newTitle) {
+ ops.push({ op: 'title', from: product.title, to: newTitle });
+ }
+ // 2) Color metafield (custom.color)
+ ops.push({ op: 'metafield.color', to: row.new_color });
+ // 3) Hero image refresh — append new CS image as additional image (don't delete old, just add)
+ ops.push({ op: 'image.append', src: row.image_url });
+ // 4) Bolt variant add (only if not already present)
+ if (!hasBolt) {
+ ops.push({ op: 'bolt.add', sku: boltSku(baseDwSku), price: row.map.toFixed(2), weight: BOLT_WEIGHT_LB });
+ } else {
+ ops.push({ op: 'bolt.skip', reason: 'already-has-non-Sample-variant' });
+ }
+
+ if (!LIVE) return { status: 'DRY', row, productId, baseDwSku, newTitle, ops };
+
+ // Execute
+ const results = {};
+ // (1) Title via productUpdate
+ if (ops.find(o => o.op === 'title')) {
+ const d = await gql(`mutation($p:ProductUpdateInput!){productUpdate(product:$p){product{id title} userErrors{field message}}}`, { p: { id: productId, title: newTitle } });
+ results.title = d.productUpdate.userErrors.length ? { errors: d.productUpdate.userErrors } : { ok: true };
+ }
+ // (2) color metafield via productUpdate(metafields)
+ const d2 = await gql(
+ `mutation($mfs:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mfs){metafields{key value namespace} userErrors{field message}}}`,
+ { mfs: [
+ { ownerId: productId, namespace: "custom", key: "color", value: row.new_color, type: "single_line_text_field" },
+ { ownerId: productId, namespace: "custom", key: "real_color_name", value: row.new_color, type: "single_line_text_field" },
+ { ownerId: productId, namespace: "dwc", key: "color", value: row.new_color, type: "single_line_text_field" },
+ ] }
+ );
+ results.metafields = d2.metafieldsSet.userErrors.length ? { errors: d2.metafieldsSet.userErrors } : { ok: true };
+
+ // (3) Append image
+ const d3 = await gql(`mutation($pid:ID!,$media:[CreateMediaInput!]!){productCreateMedia(productId:$pid, media:$media){media{...on MediaImage{id image{url altText}}} userErrors{field message}}}`, { pid: productId, media: [{ originalSource: row.image_url, mediaContentType: "IMAGE", alt: `Palm Jungle ${row.new_color} | Cole & Son` }] });
+ results.image = d3.productCreateMedia.userErrors.length ? { errors: d3.productCreateMedia.userErrors } : { ok: true, mediaId: d3.productCreateMedia.media?.[0]?.id };
+
+ // (4) Add bolt variant if missing
+ if (!hasBolt) {
+ const variantInput = {
+ optionValues: sizeOption
+ ? [{ optionName: 'Size', name: BOLT_LABEL }]
+ : [{ optionName: 'Title', name: BOLT_LABEL }], // fallback if no Size option
+ price: row.map.toFixed(2),
+ inventoryItem: {
+ sku: boltSku(baseDwSku),
+ measurement: { weight: { value: BOLT_WEIGHT_LB, unit: 'POUNDS' } },
+ tracked: false,
+ },
+ };
+ const d4 = await gql(`mutation($pid:ID!,$vs:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$vs){productVariants{id title sku price} userErrors{field message code}}}`, { pid: productId, vs: [variantInput] });
+ results.bolt = d4.productVariantsBulkCreate.userErrors.length ? { errors: d4.productVariantsBulkCreate.userErrors } : { ok: true, sku: d4.productVariantsBulkCreate.productVariants[0]?.sku };
+ }
+
+ return { status: 'ok-update', row, productId, baseDwSku, results };
+}
+
+// ── CREATE PATH ─────────────────────────────────────────────────────
+async function createNew(row) {
+ // Generate new DW SKU — scan recent Cole & Son products for max DWKK-NNNNN, then +1.
+ const skuQ2 = await gql(`{products(first:100, query:"vendor:'Cole & Son'", sortKey:CREATED_AT, reverse:true){edges{node{variants(first:2){edges{node{sku}}}}}}}`);
+ let maxNum = 0;
+ for (const e of skuQ2.products.edges) {
+ for (const ve of e.node.variants.edges) {
+ const m = (ve.node.sku || '').match(/^DWKK-(\d+)(-|$)/);
+ if (m) maxNum = Math.max(maxNum, parseInt(m[1], 10));
+ }
+ }
+ const newDwSku = `DWKK-${maxNum + 1}`;
+ const newTitle = titleFor(row.new_color);
+
+ const productInput = {
+ title: newTitle,
+ vendor: "Cole & Son",
+ productType: "Wallcovering",
+ tags: [
+ "Cole & Son",
+ "Cole & Son Contemporary Restyled",
+ "Palm Jungle",
+ "Botanical",
+ "Floral",
+ "Foliage",
+ "Wallcovering",
+ "Colour_Green",
+ row.kravet_sku, // mfr SKU as tag
+ ],
+ status: "DRAFT", // start as DRAFT — flip to ACTIVE after image + width metafields confirmed
+ productOptions: [{ name: "Size", values: [{ name: "Sample" }, { name: BOLT_LABEL }] }],
+ };
+
+ if (!LIVE) {
+ return {
+ status: 'DRY-create', row, newDwSku, newTitle, productInput,
+ planned_variants: [
+ { sku: sampleSku(newDwSku), title: 'Sample', price: SAMPLE_PRICE, weight: 0 },
+ { sku: boltSku(newDwSku), title: BOLT_LABEL, price: row.map.toFixed(2), weight: BOLT_WEIGHT_LB },
+ ],
+ planned_image: row.image_url,
+ planned_metafields: { manufacturer_sku: row.kravet_sku, color: row.new_color, width: WIDTH_INCHES },
+ };
+ }
+
+ // (a) Create the base product with Sample variant
+ const d = await gql(
+ `mutation($p:ProductCreateInput!,$media:[CreateMediaInput!]){productCreate(product:$p, media:$media){product{id title handle status options{id name}} userErrors{field message}}}`,
+ { p: productInput, media: [{ originalSource: row.image_url, mediaContentType: "IMAGE", alt: `Palm Jungle ${row.new_color} | Cole & Son` }] }
+ );
+ const errs = d.productCreate.userErrors;
+ if (errs && errs.length) return { status: 'create-failed', row, errors: errs };
+ const newProductId = d.productCreate.product.id;
+
+ // (b) Add Sample + Bolt variants
+ const variants = [
+ {
+ optionValues: [{ optionName: 'Size', name: 'Sample' }],
+ price: SAMPLE_PRICE,
+ inventoryItem: { sku: sampleSku(newDwSku), measurement: { weight: { value: 0, unit: 'POUNDS' } }, tracked: false },
+ },
+ {
+ optionValues: [{ optionName: 'Size', name: BOLT_LABEL }],
+ price: row.map.toFixed(2),
+ inventoryItem: { sku: boltSku(newDwSku), measurement: { weight: { value: BOLT_WEIGHT_LB, unit: 'POUNDS' } }, tracked: false },
+ },
+ ];
+ const dv = await gql(
+ `mutation($pid:ID!,$vs:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$vs,strategy:REMOVE_STANDALONE_VARIANT){productVariants{id title sku price} userErrors{field message code}}}`,
+ { pid: newProductId, vs: variants }
+ );
+ const vErrs = dv.productVariantsBulkCreate.userErrors;
+ if (vErrs && vErrs.length) return { status: 'variants-failed', row, productId: newProductId, errors: vErrs };
+
+ // (c) Set metafields
+ const dmf = await gql(
+ `mutation($mfs:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mfs){metafields{key value namespace} userErrors{field message}}}`,
+ { mfs: [
+ { ownerId: newProductId, namespace: "custom", key: "manufacturer_sku", value: row.kravet_sku, type: "single_line_text_field" },
+ { ownerId: newProductId, namespace: "dwc", key: "manufacturer_sku", value: row.kravet_sku, type: "single_line_text_field" },
+ { ownerId: newProductId, namespace: "custom", key: "color", value: row.new_color, type: "single_line_text_field" },
+ { ownerId: newProductId, namespace: "custom", key: "real_color_name", value: row.new_color, type: "single_line_text_field" },
+ { ownerId: newProductId, namespace: "global", key: "width", value: WIDTH_INCHES, type: "single_line_text_field" },
+ { ownerId: newProductId, namespace: "global", key: "length", value: "33 Feet", type: "single_line_text_field" },
+ { ownerId: newProductId, namespace: "global", key: "unit_of_measure", value: "Priced Per Single Roll", type: "single_line_text_field" },
+ ] }
+ );
+
+ // (d) Set product status to ACTIVE
+ const dup = await gql(
+ `mutation($p:ProductUpdateInput!){productUpdate(product:$p){product{id status} userErrors{field message}}}`,
+ { p: { id: newProductId, status: 'ACTIVE' } }
+ );
+
+ return {
+ status: 'ok-create',
+ row, newDwSku, productId: newProductId,
+ title: d.productCreate.product.title,
+ handle: d.productCreate.product.handle,
+ variants_created: dv.productVariantsBulkCreate.productVariants.map(v => ({ sku: v.sku, price: v.price })),
+ metafields_errors: dmf.metafieldsSet.userErrors,
+ activate_errors: dup.productUpdate.userErrors,
+ };
+}
+
+(async () => {
+ console.log(`mode=${LIVE ? 'LIVE' : 'DRY-RUN'}`);
+ const results = [];
+ for (const row of PLAN) {
+ try {
+ const r = row.shopify_id ? await updateExisting(row) : await createNew(row);
+ results.push(r);
+ console.log(`\n${row.kravet_sku} ${row.new_color} → ${r.status}`);
+ if (r.ops) for (const o of r.ops) console.log(` ${o.op}: ${JSON.stringify(o).slice(0,200)}`);
+ if (r.results) console.log(` results: ${JSON.stringify(r.results).slice(0,400)}`);
+ if (r.planned_variants) {
+ console.log(` create plan: dwSku=${r.newDwSku}, title="${r.newTitle}"`);
+ for (const v of r.planned_variants) console.log(` variant: ${v.sku} $${v.price} ${v.weight} lb`);
+ }
+ } catch (e) {
+ results.push({ status: 'exception', row, error: String(e).slice(0,400) });
+ console.log(`\n${row.kravet_sku} EXCEPTION: ${String(e).slice(0,300)}`);
+ }
+ if (LIVE) await new Promise(r => setTimeout(r, 200));
+ }
+ const stamp = new Date().toISOString().replace(/[:.]/g,'-');
+ fs.writeFileSync(`/tmp/cole-son-pricing/palm-jungle-refresh-${LIVE?'live':'dry'}-${stamp}.json`, JSON.stringify(results, null, 2));
+ console.log(`\nsaved per-row results.`);
+})();
diff --git a/shopify/scripts/coleson-panel-cleared.js b/shopify/scripts/coleson-panel-cleared.js
new file mode 100644
index 00000000..34b6d96d
--- /dev/null
+++ b/shopify/scripts/coleson-panel-cleared.js
@@ -0,0 +1,91 @@
+#!/usr/bin/env node
+// Scaffold "Sold Per Panel" variants for the 5 settlement-cleared Cole & Son panels.
+// Spec matches coleson-add-panel-variants.js: optionValue="Sold Per Panel", weight=8 lb, tracked=false, price=Kravet NEW MAP.
+
+const fs = require('fs');
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const OPTION_NAME = 'Size';
+const PANEL_LABEL = 'Sold Per Panel';
+const PANEL_WEIGHT_LB = 8;
+const LIVE = process.argv.includes('--live');
+
+const CLEARED = [
+ // Uccelli — OK (birds on branches, Part A not satisfied)
+ { mfr: "114/11022.CS.0", dw_base: "DWKK-133830", pid: "7421479551027", w: 41.0, map: 460.5, pattern: "UCCELLI", color: "BALLET SLIPPER" },
+ { mfr: "114/11023.CS.0", dw_base: "DWKK-133831", pid: "7421479616563", w: 41.0, map: 460.5, pattern: "UCCELLI", color: "HYACINTH" },
+ // Idyll Metallic — OK (scenic mural-animal, peacocks at fountain)
+ { mfr: "120/1001M.CS.0", dw_base: "DWKK-134090", pid: "7421499867187", w: 192.9, map: 1354.5, pattern: "IDYLL METALLIC", color: "BLUSH PEARL" },
+ { mfr: "120/1002M.CS.0", dw_base: "DWKK-134092", pid: "7421500063795", w: 192.9, map: 1354.5, pattern: "IDYLL METALLIC", color: "PLATINUM PEARL" },
+ // Verdure Tapestry — OK (scenic tapestry, no B elements, trunks present)
+ { mfr: "118/17038.CS.0", dw_base: "DWKK-134027", pid: "7421495148595", w: 82.6, map: 925.5, pattern: "VERDURE TAPESTRY", color: "V T I&C" },
+];
+
+async function gql(query, variables = {}) {
+ for (let attempt = 0; attempt < 3; attempt++) {
+ const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
+ const j = await res.json();
+ if (!j.errors) return j;
+ if (attempt === 2) throw new Error(JSON.stringify(j.errors).slice(0, 400));
+ await new Promise(r => setTimeout(r, 600 * (attempt + 1)));
+ }
+}
+
+const INSPECT_Q = `query($id:ID!){product(id:$id){id title status options{id name values} variants(first:20){edges{node{id title sku}}}}}`;
+const ADD_VARIANT = `mutation($pid:ID!,$vs:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$vs,strategy:REMOVE_STANDALONE_VARIANT){productVariants{id title sku price inventoryItem{measurement{weight{value unit}}}} userErrors{field message code}}}`;
+const CREATE_OPTION = `mutation($pid:ID!,$values:[OptionValueCreateInput!]!){productOptionsCreate(productId:$pid, options:[{name:"Size", position:1, values:$values}]){product{id options{id name values}} userErrors{field message}}}`;
+
+async function processOne(row) {
+ const productId = `gid://shopify/Product/${row.pid}`;
+ const { data } = await gql(INSPECT_Q, { id: productId });
+ if (!data || !data.product) return { row, status: 'missing-product' };
+ const product = data.product;
+ const variants = product.variants.edges.map(e => e.node);
+ const hasNonSample = variants.some(v => v.title !== 'Sample' && !(v.sku || '').endsWith('-Sample'));
+ if (hasNonSample) return { row, status: 'skip-has-variant', productId };
+ const sampleVariant = variants.find(v => v.title === 'Sample' || (v.sku || '').endsWith('-Sample'));
+ const baseSku = sampleVariant ? sampleVariant.sku.replace(/-Sample$/, '') : row.dw_base;
+ const newSku = `${baseSku}-${PANEL_LABEL}`;
+ const sizeOption = product.options.find(o => o.name === OPTION_NAME);
+ if (!LIVE) return { row, status: 'DRY', productId, baseSku, newSku, price: row.map.toFixed(2), weight: PANEL_WEIGHT_LB, hasSize: !!sizeOption };
+ if (!sizeOption) {
+ const optR = await gql(CREATE_OPTION, { pid: productId, values: [{ name: 'Sample' }, { name: PANEL_LABEL }] });
+ const e1 = optR.data.productOptionsCreate.userErrors;
+ if (e1 && e1.length) {
+ const r2 = await gql(CREATE_OPTION, { pid: productId, values: [{ name: PANEL_LABEL }] });
+ const e2 = r2.data.productOptionsCreate.userErrors;
+ if (e2 && e2.length) return { row, status: 'option-create-failed', errors: e2 };
+ }
+ }
+ const variantInput = {
+ optionValues: [{ optionName: OPTION_NAME, name: PANEL_LABEL }],
+ price: row.map.toFixed(2),
+ inventoryItem: { sku: newSku, measurement: { weight: { value: PANEL_WEIGHT_LB, unit: 'POUNDS' } }, tracked: false },
+ };
+ const { data: d2 } = await gql(ADD_VARIANT, { pid: productId, vs: [variantInput] });
+ const errs = d2.productVariantsBulkCreate.userErrors;
+ if (errs && errs.length) return { row, status: 'error', errors: errs, baseSku, newSku };
+ const created = d2.productVariantsBulkCreate.productVariants[0];
+ return { row, status: 'ok', productId, sku: created.sku, price: created.price, weight: created.inventoryItem?.measurement?.weight };
+}
+
+(async () => {
+ console.log(`mode=${LIVE ? 'LIVE' : 'DRY-RUN'} rows=${CLEARED.length}`);
+ const results = [];
+ for (let i = 0; i < CLEARED.length; i++) {
+ try {
+ const r = await processOne(CLEARED[i]);
+ results.push(r);
+ console.log(` [${i+1}/${CLEARED.length}] ${r.row.mfr} ${r.row.pattern} ${r.row.color} → ${r.status} ${r.sku || ''} ${r.price ? '$'+r.price : ''}`);
+ } catch (e) {
+ results.push({ row: CLEARED[i], status: 'exception', error: String(e).slice(0,400) });
+ console.log(` [${i+1}/${CLEARED.length}] ${CLEARED[i].mfr}: EXCEPTION ${String(e).slice(0,160)}`);
+ }
+ if (LIVE) await new Promise(r => setTimeout(r, 200));
+ }
+ const stamp = new Date().toISOString().replace(/[:.]/g,'-');
+ fs.writeFileSync(`/tmp/cole-son-pricing/panel-cleared-${LIVE?'live':'dry'}-${stamp}.json`, JSON.stringify(results, null, 2));
+ const byStatus = results.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {});
+ console.log('\nStatus:', JSON.stringify(byStatus));
+})();
diff --git a/shopify/scripts/coleson-retry-failures.js b/shopify/scripts/coleson-retry-failures.js
new file mode 100644
index 00000000..0aec30aa
--- /dev/null
+++ b/shopify/scripts/coleson-retry-failures.js
@@ -0,0 +1,163 @@
+#!/usr/bin/env node
+// Retry the 6 Cole & Son failures from run-live-2026-05-26T14-59-27-547Z.json.
+// Two failure modes:
+// A) "Option does not exist" → product has no `Size` option yet. Create it first.
+// B) Invalid $id → row had a bare integer or empty shopify_product_id.
+// Re-resolve gid via PG mfr_sku lookup or Shopify SKU search.
+
+const fs = require('fs');
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const OPTION_NAME = 'Size';
+
+async function gql(query, variables = {}) {
+ const res = await fetch(ENDPOINT, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+ const j = await res.json();
+ if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0,400));
+ return j;
+}
+
+const INSPECT_Q = `
+ query Inspect($id: ID!) {
+ product(id: $id) {
+ id title status vendor
+ options { id name values position }
+ variants(first: 20) { edges { node { id title sku } } }
+ }
+ }`;
+
+// Find a product by SKU (sample SKU like DWKK-NNNN-Sample)
+const FIND_BY_SKU = `
+ query ProdBySku($q: String!) {
+ productVariants(first: 1, query: $q) {
+ edges { node { id sku product { id title } } }
+ }
+ }`;
+
+const CREATE_OPTION = `
+ mutation CreateOption($pid: ID!, $optionValues: [OptionValueCreateInput!]!) {
+ productOptionsCreate(productId: $pid, options: [{name: "Size", position: 1, values: $optionValues}]) {
+ product { id options { id name position values } }
+ userErrors { field message code }
+ }
+ }`;
+
+const ADD_VARIANT = `
+ mutation Add($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {
+ productVariantsBulkCreate(productId: $productId, variants: $variants, strategy: REMOVE_STANDALONE_VARIANT) {
+ productVariants { id title sku price inventoryItem { measurement { weight { value unit } } } }
+ userErrors { field message code }
+ }
+ }`;
+
+function buildVariantInput(row, boltLabel, baseSku) {
+ return {
+ optionValues: [{ optionName: OPTION_NAME, name: boltLabel }],
+ price: Number(row.map_price).toFixed(2),
+ inventoryItem: {
+ sku: `${baseSku}-${boltLabel}`,
+ measurement: { weight: { value: Number(row.bolt_weight_lb), unit: 'POUNDS' } },
+ tracked: false,
+ },
+ };
+}
+
+async function resolveProductId(row) {
+ // If already a gid, return
+ if (row.shopify_product_id && row.shopify_product_id.startsWith('gid://shopify/Product/')) {
+ return row.shopify_product_id;
+ }
+ // Bare numeric → prefix
+ if (row.shopify_product_id && /^\d+$/.test(row.shopify_product_id)) {
+ return `gid://shopify/Product/${row.shopify_product_id}`;
+ }
+ // Empty → look up via dw_sku Sample variant SKU
+ if (!row.shopify_product_id && row.dw_sku) {
+ const q = `sku:"${row.dw_sku}"`;
+ const { data } = await gql(FIND_BY_SKU, { q });
+ const v = data?.productVariants?.edges?.[0]?.node;
+ if (v) return v.product.id;
+ }
+ return null;
+}
+
+async function processFailure(failureRow) {
+ const row = failureRow.row;
+ const productId = await resolveProductId(row);
+ if (!productId) return { row, status: 'unresolvable' };
+
+ const { data } = await gql(INSPECT_Q, { id: productId });
+ const product = data.product;
+ if (!product) return { row, status: 'product-missing' };
+
+ const variants = product.variants.edges.map(e => e.node);
+ const sampleVariant = variants.find(v => v.title === 'Sample' || (v.sku || '').endsWith('-Sample'));
+ const baseSku = sampleVariant ? (sampleVariant.sku || '').replace(/-Sample$/, '') : (row.dw_sku || '').replace(/-Sample$/, '');
+ if (!baseSku) return { row, status: 'no-base-sku' };
+
+ const hasBolt = variants.some(v => v.title !== 'Sample' && !(v.sku || '').endsWith('-Sample'));
+ if (hasBolt) return { row, status: 'already-has-bolt', productId };
+
+ const boltLabel = `Sold Per Bolt (${row.bolt_width}in x 33ft)`;
+
+ // If `Size` option doesn't exist, create it first with the value we'll use
+ const hasSizeOption = product.options.some(o => o.name === OPTION_NAME);
+ if (!hasSizeOption) {
+ console.log(` ${row.mfr_sku}: creating Size option ['Sample','${boltLabel}']`);
+ const { data: optD } = await gql(CREATE_OPTION, {
+ pid: productId,
+ optionValues: [{ name: 'Sample' }, { name: boltLabel }],
+ });
+ const optErrs = optD.productOptionsCreate.userErrors;
+ if (optErrs && optErrs.length) {
+ // Maybe only sample exists but unnamed — try with just the bolt label
+ console.log(` option create returned errors, retrying with just bolt value:`, JSON.stringify(optErrs).slice(0,200));
+ const { data: optD2 } = await gql(CREATE_OPTION, {
+ pid: productId,
+ optionValues: [{ name: boltLabel }],
+ });
+ const optErrs2 = optD2.productOptionsCreate.userErrors;
+ if (optErrs2 && optErrs2.length) return { row, status: 'option-create-failed', errors: optErrs2 };
+ }
+ }
+
+ const variantInput = buildVariantInput(row, boltLabel, baseSku);
+ const { data: d2 } = await gql(ADD_VARIANT, { productId, variants: [variantInput] });
+ const errs = d2.productVariantsBulkCreate.userErrors;
+ if (errs && errs.length) return { row, status: 'add-failed', errors: errs, productId, baseSku, boltLabel };
+ const created = d2.productVariantsBulkCreate.productVariants[0];
+ return {
+ row, status: 'ok', productId, baseSku,
+ newVariantId: created.id, sku: created.sku, price: created.price,
+ weight: created.inventoryItem?.measurement?.weight,
+ };
+}
+
+async function run() {
+ const all = JSON.parse(fs.readFileSync('/tmp/cole-son-pricing/run-live-2026-05-26T14-59-27-547Z.json', 'utf8'));
+ const fails = all.filter(r => r.status === 'error' || r.status === 'exception');
+ console.log(`Retrying ${fails.length} failures`);
+
+ const results = [];
+ for (let i = 0; i < fails.length; i++) {
+ try {
+ const r = await processFailure(fails[i]);
+ results.push(r);
+ console.log(` [${i+1}/${fails.length}] ${r.row.mfr_sku}: ${r.status} ${r.sku || ''} ${r.price ? '$'+r.price : ''}`);
+ } catch (e) {
+ results.push({ row: fails[i].row, status: 'exception', error: String(e).slice(0,300) });
+ console.log(` [${i+1}/${fails.length}] ${fails[i].row.mfr_sku}: EXCEPTION ${String(e).slice(0,160)}`);
+ }
+ await new Promise(r => setTimeout(r, 200));
+ }
+ fs.writeFileSync('/tmp/cole-son-pricing/retry-result.json', JSON.stringify(results, null, 2));
+ const byStatus = results.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {});
+ console.log('\nRetry status:', JSON.stringify(byStatus));
+}
+
+run().catch(e => { console.error(e); process.exit(1); });
diff --git a/shopify/scripts/coleson-seasonal-woods-panels.js b/shopify/scripts/coleson-seasonal-woods-panels.js
new file mode 100644
index 00000000..a7833d9c
--- /dev/null
+++ b/shopify/scripts/coleson-seasonal-woods-panels.js
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+// Scaffold Sold Per Panel variants for 9 Seasonal Woods colorways.
+//
+// SETTLEMENT AUTHORIZATION (recorded in chat 2026-05-26):
+// Steve approved via AskUserQuestion option 1: "Approved — ship the 9 panel
+// variants. Mural-animal + trunks + branches = within carve-out."
+// Image-side gate detector outputs A1=T A2=T A3=T B=T Acceptable=T →
+// binding decision table cell = NEEDS REVIEW → Steve confirmed in writing.
+//
+// Spec: optionValue="Sold Per Panel", weight=8 lb, tracked=false, price=$843
+// (Kravet 2025 NEW MAP per EACH).
+
+const fs = require('fs');
+const SHOP = process.env.SHOPIFY_STORE;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const ENDPOINT = `https://${SHOP}/admin/api/2024-10/graphql.json`;
+const OPTION_NAME = 'Size';
+const PANEL_LABEL = 'Sold Per Panel';
+const PANEL_WEIGHT_LB = 8;
+const LIVE = process.argv.includes('--live');
+
+const SEASONAL_WOODS = [
+ { mfr: "120/6018.CS.0", dw_base: "DWKK-134111", pid: "7421501669427", map: 843, color: "CLEAR SKIES" },
+ { mfr: "120/6020.CS.0", dw_base: "DWKK-134113", pid: "7421501800499", map: 843, color: "JADE / SAGE" },
+ { mfr: "120/6020M.CS.0", dw_base: "DWKK-134114", pid: "7421501833267", map: 843, color: "JADE / SAGE PEARL" },
+ { mfr: "120/6021.CS.0", dw_base: "DWKK-134116", pid: "7421502029875", map: 843, color: "OLIVE" },
+ { mfr: "120/6022M.CS.0", dw_base: "DWKK-134118", pid: "7421502193715", map: 843, color: "ROSE" },
+ { mfr: "120/6023.CS.0", dw_base: "DWKK-134120", pid: "7421502324787", map: 843, color: "PLATINUM" },
+ { mfr: "120/6023M.CS.0", dw_base: "DWKK-134121", pid: "7421502423091", map: 843, color: "PLATINUM" },
+ { mfr: "120/6024M.CS.0", dw_base: "DWKK-134123", pid: "7421502521395", map: 843, color: "GOLD PEARL" },
+ { mfr: "120/6025.CS.0", dw_base: "DWKK-134124", pid: "7421502586931", map: 843, color: "MIDNIGHT" },
+];
+
+async function gql(q, v = {}) {
+ for (let i = 0; i < 3; i++) {
+ const r = await fetch(ENDPOINT, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query: q, variables: v }) });
+ const j = await r.json();
+ if (!j.errors) return j;
+ if (i === 2) throw new Error(JSON.stringify(j.errors).slice(0, 400));
+ await new Promise(s => setTimeout(s, 600 * (i + 1)));
+ }
+}
+
+const INSPECT_Q = `query($id:ID!){product(id:$id){id title options{id name values} variants(first:20){edges{node{title sku}}}}}`;
+const ADD = `mutation($pid:ID!,$vs:[ProductVariantsBulkInput!]!){productVariantsBulkCreate(productId:$pid,variants:$vs,strategy:REMOVE_STANDALONE_VARIANT){productVariants{id sku price inventoryItem{measurement{weight{value unit}}}} userErrors{field message code}}}`;
+const CREATE_OPTION = `mutation($pid:ID!,$values:[OptionValueCreateInput!]!){productOptionsCreate(productId:$pid, options:[{name:"Size", position:1, values:$values}]){product{id} userErrors{field message}}}`;
+
+async function processOne(row) {
+ const productId = `gid://shopify/Product/${row.pid}`;
+ const { data } = await gql(INSPECT_Q, { id: productId });
+ if (!data?.product) return { row, status: 'missing-product' };
+ const p = data.product;
+ const variants = p.variants.edges.map(e => e.node);
+ const hasNonSample = variants.some(v => v.title !== 'Sample' && !(v.sku || '').endsWith('-Sample'));
+ if (hasNonSample) return { row, status: 'skip-has-variant' };
+ const sample = variants.find(v => v.title === 'Sample' || (v.sku || '').endsWith('-Sample'));
+ const baseSku = sample ? sample.sku.replace(/-Sample$/, '') : row.dw_base;
+ const newSku = `${baseSku}-${PANEL_LABEL}`;
+ const sizeOption = p.options.find(o => o.name === OPTION_NAME);
+ if (!LIVE) return { row, status: 'DRY', baseSku, newSku, price: row.map.toFixed(2), hasSize: !!sizeOption };
+ if (!sizeOption) {
+ const r = await gql(CREATE_OPTION, { pid: productId, values: [{ name: 'Sample' }, { name: PANEL_LABEL }] });
+ const e = r.data.productOptionsCreate.userErrors;
+ if (e?.length) {
+ const r2 = await gql(CREATE_OPTION, { pid: productId, values: [{ name: PANEL_LABEL }] });
+ const e2 = r2.data.productOptionsCreate.userErrors;
+ if (e2?.length) return { row, status: 'option-create-failed', errors: e2 };
+ }
+ }
+ const variantInput = {
+ optionValues: [{ optionName: OPTION_NAME, name: PANEL_LABEL }],
+ price: row.map.toFixed(2),
+ inventoryItem: { sku: newSku, measurement: { weight: { value: PANEL_WEIGHT_LB, unit: 'POUNDS' } }, tracked: false },
+ };
+ const { data: d2 } = await gql(ADD, { pid: productId, vs: [variantInput] });
+ const errs = d2.productVariantsBulkCreate.userErrors;
+ if (errs?.length) return { row, status: 'error', errors: errs };
+ const created = d2.productVariantsBulkCreate.productVariants[0];
+ return { row, status: 'ok', sku: created.sku, price: created.price, weight: created.inventoryItem?.measurement?.weight };
+}
+
+(async () => {
+ console.log(`mode=${LIVE ? 'LIVE' : 'DRY-RUN'} rows=${SEASONAL_WOODS.length}`);
+ const results = [];
+ for (let i = 0; i < SEASONAL_WOODS.length; i++) {
+ try {
+ const r = await processOne(SEASONAL_WOODS[i]);
+ results.push(r);
+ console.log(` [${i+1}/${SEASONAL_WOODS.length}] ${r.row.mfr} ${r.row.color} → ${r.status} ${r.sku || ''} ${r.price ? '$'+r.price : ''}`);
+ } catch (e) {
+ results.push({ row: SEASONAL_WOODS[i], status: 'exception', error: String(e).slice(0,400) });
+ console.log(` [${i+1}/${SEASONAL_WOODS.length}] ${SEASONAL_WOODS[i].mfr}: EXCEPTION ${String(e).slice(0,160)}`);
+ }
+ if (LIVE) await new Promise(s => setTimeout(s, 200));
+ }
+ const stamp = new Date().toISOString().replace(/[:.]/g,'-');
+ fs.writeFileSync(`/tmp/cole-son-pricing/seasonal-woods-${LIVE?'live':'dry'}-${stamp}.json`, JSON.stringify(results, null, 2));
+ console.log('\nStatus:', JSON.stringify(results.reduce((a, r) => (a[r.status] = (a[r.status] || 0) + 1, a), {})));
+})();
← a20c8005 security: strip hardcoded dw_admin DSN password -> env-first
·
back to Designer Wallcoverings
·
security: pickup DW-Agents strip of hardcoded DW2025secure D e9a4650a →