← back to Dw Activation Calendar

scripts/generate-schedule.js.bak

164 lines

'use strict';
/*
 * generate-schedule.js — assign a DURABLE date + time (go-live) to EVERY staged
 * SKU waiting to go active, and write it to data/activation-schedule.json.
 *
 * WHY a JSON file (not a PG table): dw_unified's publication `dw_unified_pub` is
 * FOR ALL TABLES, so ANY new table auto-joins logical replication to Kamatera and
 * could stall the subscriber's apply worker. A JSON file keeps the schedule
 * local-only, reversible, and non-canonical — exactly the DTD-AX intent — with
 * zero replication blast-radius. (dw_unified is still READ, never written.)
 *
 * FAITHFUL TO REALITY: the order and cadence mirror the real executor
 * (dw-five-field-step0/bulk-fivefield-exec.py + drain.sh):
 *   - order  = the executor's live vendor round-robin `rr`
 *              (samples-first within each vendor, breadth-first across vendors),
 *              computed over the SAME pending set, excluding reprice-zero (held).
 *   - cadence = HARD 500/day (budget.cjs backlog cap), spread across the 24 hourly
 *               drain slots that fire at :10, ~30/slot (150 in the 21-23 reclaim
 *               slots). Under a 500/day cap a day fills hours 00:10..~16:10.
 * So each SKU's go_live_at is the concrete date + HH:10 slot the drain will
 * actually activate it in — not an invented time.
 *
 * Re-run any time to re-project against the current worklist (100% coverage,
 * deterministic). Every staged SKU gets exactly one (date, time).
 */
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');

const PER_DAY = parseInt(process.env.SCHED_PER_DAY, 10) || 500;   // budget.cjs backlog hard cap
const SLOT_MIN = 10;                                              // drain fires at HH:10
const hourCap = (h) => (h >= 21 ? 150 : 30);                      // drain.sh SLOT_MAX (reclaim 150 in 21-23)

const pool = process.env.DATABASE_URL
  ? new Pool({ connectionString: process.env.DATABASE_URL, max: 4 })
  : new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'dw_unified',
      user: process.env.PGUSER || process.env.USER || 'stevestudio2', max: 4 });

// The executor's EXACT rr ordering (bulk-fivefield-exec.py), minus reprice-zero
// (held-no-cost — the drain's TOTAL and the calendar both exclude it).
// NOTE: dw_sku is NOT the row key — ~3.3k build-roll rows carry a blank dw_sku but
// always have a shopify_id, so shopify_id is the durable per-item identity (and the
// right join key for images/titles). Each ROW is one drain unit → one slot.
// EXCLUDE already-live products (Steve's rule, 2026-07-15): if a product is
// already ACTIVE on Shopify, it has already "gone active" — it must NOT be staged
// for redeployment on the activation calendar. We gate on the shopify_products
// mirror's status. LEFT JOIN so a worklist row with no mirror row is KEPT (can't
// prove it's live). The window functions compute over the FILTERED set so rr stays
// contiguous. (dw_unified still READ-only; nothing is written back to it.)
const RR_SQL = `
  SELECT rr, shopify_id, dw_sku, mfr_sku, vendor, fix_type FROM (
    SELECT (row_number() OVER (PARTITION BY w.vendor ORDER BY (w.fix_type='add-sample') DESC, w.dw_sku))*100000
           + dense_rank() OVER (ORDER BY w.vendor) AS rr,
           w.shopify_id, w.dw_sku, w.mfr_sku, w.vendor, w.fix_type
      FROM bulk_fivefield_worklist w
      LEFT JOIN shopify_products p ON p.shopify_id = w.shopify_id
     WHERE w.status='pending' AND w.fix_type <> 'reprice-zero'
       AND COALESCE(upper(p.status), '') <> 'ACTIVE'   -- already-live products are not re-staged
       AND w.vendor NOT ILIKE '%schumacher%'           -- Schumacher is INTERNAL-ONLY (Steve HARD RULE 2026-07-16): never drain to the live store; internal.schumacher.designerwallcoverings.com only
  ) t
  ORDER BY rr, vendor, dw_sku`;

// which hourly :10 slot a within-day position lands in (0-based pos within the day)
function slotHourForPos(pos) {
  let cum = 0;
  for (let h = 0; h < 24; h++) {
    const cap = hourCap(h);
    if (pos < cum + cap) return h;
    cum += cap;
  }
  return 23; // unreachable under a 500/day cap, but safe
}

async function main() {
  const c = await pool.connect();
  let rows;
  try {
    rows = (await c.query(RR_SQL)).rows;
  } finally { c.release(); await pool.end(); }

  const total = rows.length;
  if (total === 0) { console.error('No pending schedulable SKUs — nothing to schedule.'); process.exit(2); }

  // Start at the next full drain day (tomorrow 00:00 local) — matches the calendar's
  // existing convention and avoids over-fitting today's partially-spent budget.
  const start = new Date(); start.setHours(0, 0, 0, 0); start.setDate(start.getDate() + 1);

  const pad = (n) => String(n).padStart(2, '0');
  const out = new Array(total);
  for (let seq = 0; seq < total; seq++) {
    const r = rows[seq];
    const day = Math.floor(seq / PER_DAY);
    const pos = seq % PER_DAY;
    const hour = slotHourForPos(pos);
    // concrete local wall-clock instant: (start + day) at HH:10
    const dt = new Date(start.getFullYear(), start.getMonth(), start.getDate() + day, hour, SLOT_MIN, 0, 0);
    const go_live_date = `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`;
    out[seq] = {
      shopify_id: r.shopify_id,          // durable per-item identity + image/title join key
      dw_sku: r.dw_sku || '',            // may be blank on build-roll rows
      mfr_sku: r.mfr_sku || '',          // label fallback when dw_sku is blank
      rr: Number(r.rr),
      seq,
      vendor: r.vendor,
      fix_type: r.fix_type,
      slot_hour: hour,
      go_live_date,
      go_live_at: dt.toISOString(),      // exact instant (UTC); UI renders back to local
    };
  }

  // Apply MANUAL MOVES (drag-to-reschedule) so a regenerate keeps the user's
  // hand-placed dates instead of overwriting them with the computed cadence.
  try {
    const ov = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'schedule-overrides.json'), 'utf8'));
    let applied = 0;
    for (const s of out) {
      const o = ov[s.shopify_id] || ov[s.dw_sku];
      if (o && o.go_live_date) {
        s.go_live_date = o.go_live_date;
        if (o.slot_hour != null) s.slot_hour = o.slot_hour;
        const [Y, M, D] = o.go_live_date.split('-').map(Number);
        s.go_live_at = new Date(Y, M - 1, D, s.slot_hour, 10, 0, 0).toISOString();
        s.moved = true; applied++;
      }
    }
    if (applied) console.log(`  applied ${applied} manual move override(s)`);
  } catch { /* no overrides file — nothing to apply */ }

  // Coverage assertion — EVERY staged item MUST have a date+time (Steve's requirement).
  // Keyed on the row (seq), not dw_sku: dw_sku is not unique and can be blank.
  const withDT = out.filter(s => s.go_live_at && s.go_live_date).length;
  const uniqueSeq = new Set(out.map(s => s.seq)).size;
  if (withDT !== total || uniqueSeq !== total) {
    console.error(`COVERAGE FAIL: total=${total} withDateTime=${withDT} uniqueSeq=${uniqueSeq}`);
    process.exit(3);
  }

  const distinctProducts = new Set(out.map(s => s.shopify_id)).size;
  const blankSkuItems = out.filter(s => !s.dw_sku).length;
  const meta = {
    generated_at: new Date().toISOString(),
    total_items: total,               // worklist rows = drain units (each gets one slot)
    distinct_products: distinctProducts,
    blank_sku_items: blankSkuItems,   // build-roll rows without a dw_sku (keyed by shopify_id)
    per_day: PER_DAY,
    slot_minute: SLOT_MIN,
    start_date: `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}`,
    end_date: out[total - 1].go_live_date,
    days_to_clear: Math.ceil(total / PER_DAY),
    coverage: `${withDT}/${total}`,
    source: 'bulk_fivefield_worklist (rr order, reprice-zero excluded) @ 500/day hourly :10 slots',
  };

  const dataDir = path.join(__dirname, '..', 'data');
  fs.mkdirSync(dataDir, { recursive: true });
  const file = path.join(dataDir, 'activation-schedule.json');
  fs.writeFileSync(file, JSON.stringify({ meta, skus: out }, null, 0));
  console.log(`✓ wrote ${file}`);
  console.log(`  ${meta.coverage} SKUs stamped with date+time · ${meta.days_to_clear} days · ${meta.start_date} → ${meta.end_date}`);
}

main().catch(e => { console.error('generate-schedule failed:', e.message); process.exit(1); });