← back to Carnegie Reprice
Carnegie Phase 2 PoC: Acapella de-fragment tool (14 per-color standalone products, dry-run + gated apply, reversible)
4a8631ca2ce00b4b1a873d1f54f14c17b25eb103 · 2026-08-18 12:55:08 -0700 · Steve Abrams
Files touched
A phase2-poc-acapella.mjs
Diff
commit 4a8631ca2ce00b4b1a873d1f54f14c17b25eb103
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 18 12:55:08 2026 -0700
Carnegie Phase 2 PoC: Acapella de-fragment tool (14 per-color standalone products, dry-run + gated apply, reversible)
---
phase2-poc-acapella.mjs | 231 ++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 231 insertions(+)
diff --git a/phase2-poc-acapella.mjs b/phase2-poc-acapella.mjs
new file mode 100644
index 0000000..c0699a6
--- /dev/null
+++ b/phase2-poc-acapella.mjs
@@ -0,0 +1,231 @@
+#!/usr/bin/env node
+// TK-10671 Phase 2 PoC — Acapella ONLY.
+// De-fragment Acapella: 2 bundled products (generic "Color N") -> 14 standalone
+// per-color-SKU products (house structure), then ARCHIVE the 2 old bundles.
+// Reversible: writes phase2-poc-acapella.json with every created + archived id.
+//
+// Usage: node phase2-poc-acapella.mjs (DRY RUN — prints plan only)
+// node phase2-poc-acapella.mjs --apply (FIRE live, gated by Steve)
+//
+// Data source of truth: dw_unified.carnegie_catalog (ACAPELLA rows only).
+
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+
+const APPLY = process.argv.includes('--apply');
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const API = '2024-10';
+const HERE = path.dirname(new URL(import.meta.url).pathname);
+
+// --- token ---
+const envTxt = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (envTxt.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m) || [])[1]?.trim().replace(/^["']|["']$/g, '');
+if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+const OLD_PRODUCT_IDS = ['7896342495283', '7896453939251']; // carnegie-acapella (panels) + -1 (upholstery)
+
+// Return rows as JS objects via JSON (no delimiter ambiguity, multiline-safe).
+function psqlJson(sql) {
+ const wrapped = `SELECT COALESCE(json_agg(t), '[]') FROM (${sql}) t`;
+ const out = execFileSync('psql', ['host=/tmp dbname=dw_unified', '-At', '-c', wrapped], { encoding: 'utf8' });
+ return JSON.parse(out.trim() || '[]');
+}
+
+async function shopify(method, endpoint, body) {
+ const res = await fetch(`https://${DOMAIN}/admin/api/${API}/${endpoint}`, {
+ method,
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const txt = await res.text();
+ let json; try { json = JSON.parse(txt); } catch { json = { raw: txt }; }
+ if (!res.ok) throw new Error(`${method} ${endpoint} -> ${res.status}: ${txt.slice(0, 300)}`);
+ return json;
+}
+
+const titleCase = s => s.replace(/\w\S*/g, t => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
+
+// --- load Acapella color-SKUs ---
+const COLS = ['dw_sku','mfr_sku','pattern_name','color_number','product_type','price',
+ 'width','content','durability_wyzenbeek','repeat_h','repeat_v','finish','backing',
+ 'cleaning_code','flammability','origin','description_text','swatch_image_url','local_image',
+ 'collection_tag','color_bucket'];
+const rawItems = psqlJson(`SELECT ${COLS.join(',')}, color_tags, style_tags FROM carnegie_catalog WHERE pattern_name ILIKE '%acapella%' ORDER BY mfr_sku`);
+const items = rawItems.map(r => {
+ const o = {};
+ for (const c of COLS) {
+ o[c] = (r[c] === '' ? null : r[c]);
+ if (c === 'description_text' && o[c]) o[c] = String(o[c]).replace(/[\r\n]+/g, ' ').trim();
+ }
+ return o;
+});
+const tagMap = Object.fromEntries(rawItems.map(r => [r.dw_sku, {
+ color_tags: r.color_tags || [], style_tags: r.style_tags || [] }]));
+
+if (items.length !== 14) { console.error(`expected 14 Acapella SKUs, got ${items.length} — aborting`); process.exit(1); }
+
+const useLabel = it => /panel/i.test(it.product_type) ? 'Panels' : 'Upholstery';
+
+function buildTitle(it) {
+ // Never "Color N" alone, never "Unknown". Carnegie names colorways by NUMBER.
+ const cn = it.color_number || it.mfr_sku; // fallback per rails: mfr sku, then color, then skip
+ const use = useLabel(it);
+ return titleCase(`Acapella Color ${cn} ${use}`) + ' | Carnegie';
+}
+
+const RETAIL = 76; // round(42/0.65/0.85)
+
+function specMetafields(it) {
+ // house specs live in global.* (copied from Kravet reference)
+ const mf = [];
+ const push = (ns, key, val, type = 'single_line_text_field') => {
+ if (val == null || val === '') return;
+ mf.push({ namespace: ns, key, type, value: String(val) });
+ };
+ // identity — global + custom + dwc (house triple-write)
+ for (const ns of ['global', 'custom', 'dwc']) {
+ push(ns, ns === 'global' ? 'Brand' : 'brand', 'Carnegie');
+ push(ns, 'pattern_name', it.pattern_name);
+ push(ns, ns === 'global' ? 'manufacturer_sku' : 'manufacturer_sku', it.mfr_sku);
+ }
+ push('global', 'dw_sku', it.dw_sku);
+ push('custom', 'dw_sku', it.dw_sku);
+ push('dwc', 'dw_sku', it.dw_sku);
+ push('custom', 'color', it.color_number);
+ push('global', 'color', it.color_number);
+ push('custom', 'product_class', 'Fabric');
+ push('custom', 'vendor', 'Carnegie');
+ push('custom', 'collection_name', 'Carnegie Textiles');
+ push('dwc', 'collection', 'Carnegie Textiles');
+ // specs
+ push('global', 'Width', it.width);
+ push('global', 'Content', it.content);
+ push('global', 'Durability', it.durability_wyzenbeek);
+ push('global', 'Horz. Repeat', it.repeat_h);
+ push('global', 'Vert. Repeat', it.repeat_v);
+ push('global', 'Finish', it.finish);
+ push('global', 'Backing', it.backing);
+ push('global', 'Cleaning', it.cleaning_code);
+ push('global', 'Flammability', it.flammability);
+ push('global', 'Country of Origin', it.origin);
+ push('global', 'Use', useLabel(it) === 'Panels' ? 'Upholstered Walls/Panels' : 'Upholstery');
+ return mf;
+}
+
+function buildTags(it) {
+ const t = new Set(['Carnegie', 'Carnegie Textiles', 'Fabric', 'Acapella']);
+ t.add(useLabel(it) === 'Panels' ? 'Upholstered Walls/Panels' : 'Upholstery');
+ if (it.color_bucket) t.add(it.color_bucket);
+ (tagMap[it.dw_sku]?.color_tags || []).forEach(x => t.add(x));
+ (tagMap[it.dw_sku]?.style_tags || []).forEach(x => t.add(x));
+ return [...t].join(', ');
+}
+
+function productPayload(it) {
+ const dw = it.dw_sku;
+ const title = buildTitle(it);
+ // image: swatch_image_url (strip Magento resize query so we get full res), fallback local
+ let imgSrc = it.swatch_image_url ? it.swatch_image_url.split('?')[0] : null;
+ const localPath = it.local_image ? `/Users/macstudio3/Projects/carnegie-reprice/images/${it.local_image}` : null;
+ const p = {
+ title,
+ body_html: it.description_text || '',
+ vendor: 'Carnegie',
+ product_type: 'Fabric',
+ tags: buildTags(it),
+ status: 'draft', // flip to active after gate check below
+ options: [{ name: 'Size', values: ['Memo Sample', 'Sold Per Yard'] }],
+ variants: [
+ { option1: 'Memo Sample', sku: `${dw}-Sample`, price: '4.25',
+ inventory_management: null, inventory_policy: 'continue', taxable: true },
+ { option1: 'Sold Per Yard', sku: dw, price: String(RETAIL),
+ inventory_management: 'shopify', inventory_policy: 'continue', taxable: true },
+ ],
+ };
+ if (imgSrc) p.images = [{ src: imgSrc }];
+ return { payload: p, imgSrc, localPath, dw, title };
+}
+
+// 5-field + image gate: catalog is complete, but verify per SKU
+function gatePass(it, imgSrc) {
+ const reasons = [];
+ if (!imgSrc && !it.local_image) reasons.push('no-image');
+ if (!it.width) reasons.push('no-width');
+ if (!it.description_text) reasons.push('no-desc');
+ return { pass: reasons.length === 0, reasons };
+}
+
+async function main() {
+ console.log(`\n=== Acapella PoC — ${APPLY ? 'APPLY (LIVE)' : 'DRY RUN'} — 14 color-SKUs ===\n`);
+ const plan = items.map(it => {
+ const { payload, imgSrc, localPath } = productPayload(it);
+ const g = gatePass(it, imgSrc);
+ return { it, payload, imgSrc, localPath, gate: g };
+ });
+
+ for (const { it, payload, imgSrc, gate } of plan) {
+ console.log(`• ${payload.title}`);
+ console.log(` dw=${it.dw_sku} mfr=${it.mfr_sku} color=${it.color_number} use=${useLabel(it)}`);
+ console.log(` variants: Memo Sample ${it.dw_sku}-Sample $4.25 | Sold Per Yard ${it.dw_sku} $${RETAIL} (cost $${it.price})`);
+ console.log(` image: ${imgSrc || '(local ' + it.local_image + ')'}`);
+ console.log(` specs: width=${it.width} content=${(it.content||'').slice(0,30)} wyz=${it.durability_wyzenbeek} finish=${it.finish} flam=${it.flammability} origin=${it.origin}`);
+ console.log(` gate: ${gate.pass ? 'ACTIVE-ELIGIBLE' : 'DRAFT (' + gate.reasons.join(',') + ')'}`);
+ }
+
+ console.log(`\nARCHIVE (old bundled): ${OLD_PRODUCT_IDS.join(', ')}\n`);
+
+ if (!APPLY) {
+ console.log('DRY RUN only. Re-run with --apply to fire live.');
+ return;
+ }
+
+ // ---- APPLY ----
+ const reversal = { ticket: 'TK-10671', pattern: 'Acapella', ranAt: new Date().toISOString(),
+ created: [], archived: [], retail: RETAIL };
+
+ let i = 0;
+ for (const { it, payload, imgSrc, gate } of plan) {
+ i++;
+ // create product
+ const res = await shopify('POST', 'products.json', { product: payload });
+ const pid = res.product.id;
+ const created = { product_id: String(pid), handle: res.product.handle, title: payload.title,
+ dw_sku: it.dw_sku, mfr_sku: it.mfr_sku, color_number: it.color_number, use: useLabel(it),
+ variant_ids: res.product.variants.map(v => ({ sku: v.sku, id: v.id, price: v.price })),
+ image_ok: (res.product.images || []).length > 0 };
+ // metafields
+ for (const m of specMetafields(it)) {
+ try { await shopify('POST', `products/${pid}/metafields.json`, { metafield: m }); }
+ catch (e) { console.warn(` mf ${m.namespace}.${m.key} failed: ${e.message.slice(0,80)}`); }
+ }
+ // activate if gate passes AND image landed
+ const imgLanded = (res.product.images || []).length > 0;
+ if (gate.pass && imgLanded) {
+ await shopify('PUT', `products/${pid}.json`, { product: { id: pid, status: 'active' } });
+ created.status = 'active';
+ } else {
+ created.status = 'draft';
+ created.draft_reason = !imgLanded ? 'image-failed-to-attach' : gate.reasons.join(',');
+ }
+ reversal.created.push(created);
+ console.log(`[${i}/14] created ${pid} ${payload.title} -> ${created.status}`);
+ await new Promise(r => setTimeout(r, 700)); // gentle pacing
+ }
+
+ // archive old bundles
+ for (const oid of OLD_PRODUCT_IDS) {
+ const before = await shopify('GET', `products/${oid}.json?fields=id,handle,title,status`);
+ await shopify('PUT', `products/${oid}.json`, { product: { id: oid, status: 'archived' } });
+ reversal.archived.push({ product_id: oid, handle: before.product.handle, title: before.product.title,
+ prior_status: before.product.status });
+ console.log(`archived ${oid} (${before.product.handle})`);
+ await new Promise(r => setTimeout(r, 700));
+ }
+
+ fs.writeFileSync(path.join(HERE, 'phase2-poc-acapella.json'), JSON.stringify(reversal, null, 2));
+ console.log(`\nReversal file written: ${path.join(HERE, 'phase2-poc-acapella.json')}`);
+ console.log(`Created ${reversal.created.length} products, archived ${reversal.archived.length}.`);
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
← f5f4a83 Carnegie Phase 1 complete: specs+swatch images applied to 59
·
back to Carnegie Reprice
·
Carnegie Phase 3 full-line rollout: resumable builder + fact af22a7d →