[object Object]

← 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

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 →