← back to Dwha Harlequin Onboard

scripts/check-coverage.mjs

184 lines

#!/usr/bin/env node
/**
 * DWHA Harlequin Check Coverage — check-coverage.mjs
 * TK-10882 — VP DW Commerce
 *
 * Dry-run audit: shows exactly which harlequin_catalog rows are onboard-ready
 * vs skipped, and why. No writes. No Shopify calls. No image downloads.
 *
 * Usage:
 *   node scripts/check-coverage.mjs              # summary
 *   node scripts/check-coverage.mjs --verbose    # full row list
 *   node scripts/check-coverage.mjs --json       # machine-readable JSON
 */

import pg from 'pg';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = path.resolve(__dirname, '..');
const RESTORE_DIR  = path.join(PROJECT_ROOT, 'restore-maps');

const IS_VERBOSE = process.argv.includes('--verbose');
const IS_JSON    = process.argv.includes('--json');

function computeRetailPrice(price_trade) {
  const raw = parseFloat(price_trade) / 0.65 / 0.85;
  return Math.round(raw * 100) / 100;
}

// Settlement-risk keywords (floral/bird/tropical require settlement gate before ACTIVE)
const SETTLEMENT_KEYWORDS = [
  'floral', 'flower', 'botanical', 'bird', 'tropical', 'leaf', 'leaves',
  'fauna', 'flora', 'butterfly', 'dragonfly', 'peacock', 'palm', 'fern',
  'above and below', 'marine', 'seascape',
];

function hasSettlementRisk(row) {
  const text = [row.pattern_name, row.design, row.features, row.color_name, row.collection]
    .filter(Boolean).join(' ').toLowerCase();
  return SETTLEMENT_KEYWORDS.some(kw => text.includes(kw));
}

async function main() {
  const pool = new pg.Pool({ host: '/tmp', database: 'dw_unified' });

  let allRows;
  try {
    const result = await pool.query(`
      SELECT
        id, dw_sku, mfr_sku, pattern_name, color_name, collection,
        price_trade, price_retail, image_url, product_url,
        width, material, discontinued, on_shopify, shopify_product_id,
        image_rejected, image_rejection_reason, design, features
      FROM harlequin_catalog
      WHERE dw_sku LIKE 'DWHA-%' OR dw_sku IS NULL
      ORDER BY id ASC
    `);
    allRows = result.rows;
  } catch (err) {
    console.error('DB error:', err.message);
    await pool.end();
    process.exit(1);
  }

  await pool.end();

  // Already rolled out (on_shopify = true)
  const alreadyOnShopify = allRows.filter(r => r.on_shopify === true);

  // Classify each row
  const ready   = [];
  const skipped = [];

  for (const row of allRows) {
    if (row.on_shopify) {
      skipped.push({ ...row, skip_reason: 'already-on-shopify' });
      continue;
    }
    const missing = [];
    if (!row.dw_sku)       missing.push('dw_sku');
    if (!row.price_trade)  missing.push('price_trade');
    if (!row.image_url)    missing.push('image_url');
    if (row.discontinued)  missing.push('discontinued');
    if (row.image_rejected) missing.push('image_rejected');

    if (missing.length) {
      skipped.push({ ...row, skip_reason: missing.join(',') });
    } else {
      ready.push({
        ...row,
        retail_price: computeRetailPrice(row.price_trade),
        settlement_risk: hasSettlementRisk(row),
      });
    }
  }

  const settlementFlagged = ready.filter(r => r.settlement_risk);

  // Restore-map count (already created this run)
  let restoreMaps = 0;
  try {
    restoreMaps = fs.readdirSync(RESTORE_DIR).filter(f => f.endsWith('.json') && !f.includes('.rolled-back')).length;
  } catch {}

  // Skip reason breakdown
  const skipReasons = {};
  skipped.forEach(r => {
    const reasons = r.skip_reason.split(',');
    reasons.forEach(reason => { skipReasons[reason] = (skipReasons[reason] || 0) + 1; });
  });

  if (IS_JSON) {
    console.log(JSON.stringify({
      generated: new Date().toISOString(),
      total: allRows.length,
      ready: ready.length,
      skipped: skipped.length,
      already_on_shopify: alreadyOnShopify.length,
      settlement_flagged: settlementFlagged.length,
      restore_maps: restoreMaps,
      skip_reasons: skipReasons,
      ready_rows: IS_VERBOSE ? ready : ready.slice(0, 20),
      skip_rows: IS_VERBOSE ? skipped : skipped.slice(0, 10),
    }, null, 2));
    return;
  }

  // Human-readable output
  console.log('\n=== DWHA Harlequin Coverage Check ===');
  console.log(`Generated:          ${new Date().toLocaleString()}`);
  console.log(`Total rows in DB:   ${allRows.length}`);
  console.log(`─────────────────────────────────────`);
  console.log(`READY to onboard:   ${ready.length}  (have dw_sku + price_trade + image_url, not discontinued, not rejected)`);
  console.log(`Already on Shopify: ${alreadyOnShopify.length}`);
  console.log(`Skipped (issues):   ${skipped.length - alreadyOnShopify.length}`);
  console.log(`─────────────────────────────────────`);
  console.log(`Settlement-flagged: ${settlementFlagged.length}  (ready but require settlement gate before ACTIVE publish)`);
  console.log(`Clean (no risk):    ${ready.length - settlementFlagged.length}`);
  console.log(`─────────────────────────────────────`);
  console.log(`Restore-maps today: ${restoreMaps}  (DRAFT products created this session)`);

  console.log('\nSkip reason breakdown:');
  Object.entries(skipReasons).sort((a,b) => b[1]-a[1]).forEach(([reason, count]) => {
    console.log(`  ${reason.padEnd(22)} ${count}`);
  });

  console.log('\nPrice range (retail):');
  const prices = ready.map(r => r.retail_price).sort((a, b) => a - b);
  if (prices.length) {
    console.log(`  Min: $${prices[0]}   Max: $${prices[prices.length - 1]}   Avg: $${(prices.reduce((s,p)=>s+p,0)/prices.length).toFixed(2)}`);
  }

  console.log('\nCollections in ready set:');
  const colls = {};
  ready.forEach(r => { if (r.collection) colls[r.collection] = (colls[r.collection]||0) + 1; });
  Object.entries(colls).sort((a,b)=>b[1]-a[1]).slice(0, 10).forEach(([c, n]) => {
    console.log(`  ${c.padEnd(30)} ${n}`);
  });

  if (IS_VERBOSE) {
    console.log('\n--- READY rows (first 50) ---');
    ready.slice(0, 50).forEach((r, i) => {
      const colorDisplay = r.color_name && !r.color_name.toLowerCase().includes('curation')
        ? r.color_name : r.mfr_sku;
      console.log(`  ${String(i+1).padStart(3)}. ${r.dw_sku}  "${r.pattern_name} ${colorDisplay}"  trade:$${r.price_trade}  retail:$${r.retail_price}${r.settlement_risk ? '  [SETTLEMENT-RISK]' : ''}`);
    });
    if (ready.length > 50) console.log(`  ... and ${ready.length - 50} more`);

    console.log('\n--- Settlement-risk rows ---');
    settlementFlagged.slice(0, 20).forEach(r => {
      console.log(`  ${r.dw_sku}  "${r.pattern_name}"  — will need settlement gate before ACTIVE`);
    });
  }

  console.log('\n=== Cadence plan ===');
  console.log(`  25/day × ${Math.ceil(ready.length/25)} days = ~${Math.ceil(ready.length/25)} days to fully onboard`);
  console.log(`  All created as DRAFT — active publish is a separate Steve-gated step.`);
  console.log('');
}

main().catch(err => { console.error('Fatal:', err); process.exit(1); });