← back to Carnegie Reprice

phase2-poc-acapella.mjs

265 lines

#!/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';
// TK-10792: canonical mfr_sku gate
// TK-10820: also import bodyHtmlValid so a blank rendered body can never go ACTIVE via this path
import { mfrSkuValid, bodyHtmlValid, SKIP_TAG as NEEDS_MFR_TAG } from './carnegie-mfr-gate.mjs';

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, tries = 0) {
  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 }; }
  // Retry transient throttle/5xx with backoff so metafield writes aren't silently
  // dropped when Shopify's REST bucket is momentarily exhausted.
  if ((res.status === 429 || res.status >= 500) && tries < 6) {
    await new Promise(r => setTimeout(r, 1500 * (tries + 1)));
    return shopify(method, endpoint, body, tries + 1);
  }
  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)
// RETAIL is hardcoded from cost=$42. If this PoC is copied to a Carnegie pattern
// with a different cost, that assumption ships a silently-wrong price — so assert
// every item really is cost $42 before using the hardcoded retail.
const offCost = items.filter(it => Number(it.price) !== 42);
if (offCost.length) { console.error(`RETAIL=${RETAIL} assumes cost $42, but ${offCost.length} item(s) differ (e.g. ${offCost[0].dw_sku}=$${offCost[0].price}) — aborting; recompute RETAIL per item`); process.exit(1); }

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
// TK-10792: mfr_sku gate added — DWAG-* or null/empty → no activation
function gatePass(it, imgSrc) {
  const reasons = [];
  if (!imgSrc && !it.local_image) reasons.push('no-image');
  if (!it.width) reasons.push('no-width');
  // TK-10820: gate the RENDERED body (payload body_html = description_text || ''),
  // stripping tags/&nbsp;/whitespace — matches the create-path gate in rollout.mjs so an
  // empty <p></p> / whitespace-only description can never slip through as ACTIVE.
  if (!bodyHtmlValid(it.description_text)) reasons.push('no-desc');
  if (!mfrSkuValid(it.mfr_sku)) reasons.push(`no-real-mfr-sku(${JSON.stringify(it.mfr_sku)})`);
  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
    const mfFailed = [];
    for (const m of specMetafields(it)) {
      try { await shopify('POST', `products/${pid}/metafields.json`, { metafield: m }); }
      catch (e) { mfFailed.push({ ns: m.namespace, key: m.key, err: e.message.slice(0,120) }); console.warn(`  mf ${m.namespace}.${m.key} failed: ${e.message.slice(0,80)}`); }
    }
    // Surface dropped metafields in the run record instead of only console.warn.
    if (mfFailed.length) { created.mf_failed = mfFailed; console.warn(`  ⚠ ${mfFailed.length} metafield(s) failed for ${it.dw_sku} — recorded in summary`); }
    // 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';
      const hasMfrIssue = gate.reasons.some(r => r.startsWith('no-real-mfr-sku'));
      created.draft_reason = !imgLanded ? 'image-failed-to-attach' : gate.reasons.join(',');
      // TK-10792: tag products blocked by mfr_sku gate so they surface in the Needs-Mfr-SKU filter
      if (hasMfrIssue) {
        try {
          const t = await shopify('GET', `products/${pid}.json?fields=id,tags`);
          const existingTags = (t.product.tags || '').split(',').map(s=>s.trim()).filter(Boolean);
          if (!existingTags.includes(NEEDS_MFR_TAG)) {
            await shopify('PUT', `products/${pid}.json`, { product: { id: pid, tags: [...existingTags, NEEDS_MFR_TAG].join(', ') } });
          }
        } catch(e) { console.warn(`  could not add ${NEEDS_MFR_TAG} tag to ${pid}: ${e.message.slice(0,80)}`); }
      }
    }
    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); });