← 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/ /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); });