← back to Sanderson Onboard

tk10873/build_zoffany_optionA_draft.mjs

140 lines

#!/usr/bin/env node
// build_zoffany_optionA_draft.mjs — TK-10873 Stage S3, EPIC TK-10874 (SDG family onboard).
// READ-ONLY; NO SHOPIFY/DB WRITES. Only SELECTs against the local dw_unified mirror
// (host=/tmp socket) + writes LOCAL artifact files under tk10873/. It NEVER issues an
// UPDATE/INSERT/DELETE or any Shopify call. The live-publish is a SEPARATE, Steve-gated step.
//
// Emits the Option-A draft plan for all 331 ACTIVE Zoffany products:
//   REPRICE          — has sellable variant + staged price_retail>0 -> proposed = price_retail
//   SELLABLE_ADD     — missing sellable variant (DWWC-502880 outlier) + usable staged price
//   HELD_NEEDS_PRICE — joins but price null/0, OR no staging join, OR outlier w/o usable price
// Sample variant stays 4.25 on every product (Option-A contract).
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import { buildPlanRow, SAMPLE_PRICE } from './lib.mjs';

const DIR = new URL('.', import.meta.url).pathname;
const JSON_OUT = `${DIR}zoffany_optionA_draft.json`;
const CSV_OUT = `${DIR}zoffany_optionA_draft.csv`;
const SUMMARY_OUT = `${DIR}zoffany_optionA_summary.json`;

// READ-ONLY select. Same psql idiom as scripts/load_feed_pricing.mjs.
// LEFT JOIN keeps all 331 active rows even when staging has no mfr_sku match (join=0).
const SQL = `
COPY (
  SELECT
    l.mfr_sku,
    l.dw_sku            AS live_dw_sku,
    (z.mfr_sku IS NOT NULL) AS joined,
    l.has_product_variant,
    l.has_sample_variant,
    l.variant_count,
    z.dw_sku            AS staged_dw_sku,
    z.price_trade,
    z.price_retail,
    z.our_price,
    z.tariff_amount_per_roll AS tariff
  FROM shopify_products l
  LEFT JOIN zoffany_catalog z ON z.mfr_sku = l.mfr_sku
  WHERE l.vendor ILIKE 'zoffany%' AND l.status = 'ACTIVE'
  ORDER BY l.mfr_sku
) TO STDOUT WITH (FORMAT csv, HEADER true);
`;

function query() {
  const out = execFileSync('psql', ['-h', '/tmp', '-d', 'dw_unified', '-tAc', SQL], { encoding: 'utf8' });
  const lines = out.split('\n').filter(Boolean);
  const header = lines.shift().split(',');
  return lines.map(line => {
    // simple CSV parse — SAFE ONLY because every SELECTed column here is a sku /
    // boolean / numeric with ZERO embedded commas or quotes (verified against the
    // live data). If you ever add a text column (pattern/color/note) to the SELECT,
    // replace this with a real CSV parser — psql COPY quotes such fields and the
    // naive split would misalign.
    const cells = line.split(',');
    const rec = {};
    header.forEach((h, i) => { rec[h] = cells[i] === '' ? null : cells[i]; });
    return {
      mfr_sku: rec.mfr_sku,
      live_dw_sku: rec.live_dw_sku,
      joined: rec.joined === 't' || rec.joined === 'true',
      has_product_variant: rec.has_product_variant === 't' || rec.has_product_variant === 'true',
      has_sample_variant: rec.has_sample_variant === 't' || rec.has_sample_variant === 'true',
      variant_count: rec.variant_count == null ? null : Number(rec.variant_count),
      staged_dw_sku: rec.staged_dw_sku,
      price_trade: rec.price_trade,
      price_retail: rec.price_retail,
      our_price: rec.our_price,
      tariff: rec.tariff,
    };
  });
}

function csvField(v) {
  if (v == null) return '';
  const s = String(v);
  return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

function main() {
  const recs = query();
  const plan = recs.map(buildPlanRow);

  // JSON (full plan)
  fs.writeFileSync(JSON_OUT, JSON.stringify(plan, null, 2));

  // CSV
  const cols = ['mfr_sku', 'dw_sku', 'action', 'live_has_sellable', 'live_has_sample',
    'staged_trade', 'proposed_sellable_price', 'sample_price', 'note'];
  const csvLines = [cols.join(',')];
  for (const p of plan) {
    csvLines.push([
      csvField(p.mfr_sku),
      csvField(p.live_dw_sku || p.staged_dw_sku),
      csvField(p.action),
      csvField(p.has_product_variant),
      csvField(p.has_sample_variant),
      csvField(p.price_trade),
      csvField(p.proposed_sellable_price),
      csvField(p.sample_price),
      csvField(p.note),
    ].join(','));
  }
  fs.writeFileSync(CSV_OUT, csvLines.join('\n') + '\n');

  // Summary
  const counts = { REPRICE: 0, SELLABLE_ADD: 0, HELD_NEEDS_PRICE: 0 };
  let joined = 0, priced = 0, missingSellable = 0;
  for (const p of plan) {
    counts[p.action] = (counts[p.action] || 0) + 1;
    if (p.joined) joined++;
    if (Number(p.price_retail) > 0) priced++;
    if (!p.has_product_variant) missingSellable++;
  }
  const summary = {
    ticket: 'TK-10873',
    epic: 'TK-10874',
    generated_at: new Date().toISOString(),
    read_only: true,
    vendor: 'Zoffany',
    active_total: plan.length,
    joined_staging: joined,
    priced_from_staging: priced,
    missing_sellable_variant: missingSellable,
    sample_price: SAMPLE_PRICE,
    counts,
    expected: { active: 331, joined: 330, priced: 328, missing_sellable: 1 },
  };
  fs.writeFileSync(SUMMARY_OUT, JSON.stringify(summary, null, 2));

  // stdout table
  console.log('=== Zoffany Option-A Draft (READ-ONLY, no writes) ===');
  console.log(`active_total=${plan.length}  joined=${joined}  priced=${priced}  missing_sellable=${missingSellable}`);
  console.log('action              count');
  console.log('------------------  -----');
  for (const [k, v] of Object.entries(counts)) console.log(`${k.padEnd(18)}  ${v}`);
  console.log(`\nartifacts:\n  ${JSON_OUT}\n  ${CSV_OUT}\n  ${SUMMARY_OUT}`);
}

main();