← back to Dw Five Field Step0

scripts/resolve-priceability.js

128 lines

#!/usr/bin/env node
/**
 * Step-0 priceability resolver (DTD verdict B). READ-ONLY.
 *
 * For every ACTIVE product that needs a fix (missing a sellable product variant
 * OR missing a sample OR has a $0 variant), determine whether it is AUTO-PRICEABLE
 * — i.e. a REAL roll cost/price exists in a trusted source — vs NO-COST HOLD.
 *
 * Trusted cost sources, in priority (placeholder product_pricing stubs EXCLUDED):
 *  Kravet family: kravet_dwkk_variant_map.price(dw_sku) -> product_map.map_price(dw_sku)
 *                 -> kravet_master_price.map_price(mfr_sku) -> kravet_catalog dw_sell_price(mfr_sku)
 *  All others:    the vendor's cadence costExpr (vendors.js) against its catalog
 *                 table, joined by mfr_sku then dw_sku, value > 0.
 *
 * Emits per-vendor + grand-total split and writes JSON + a PG audit table.
 */
const fs = require('fs');
const { execSync } = require('child_process');
delete process.env.PGPASSWORD; delete process.env.DATABASE_URL;
const VENDORS = require(process.env.HOME + '/Projects/Designer-Wallcoverings/shopify/scripts/cadence/vendors.js');

const psql = sql => execSync(`psql -d dw_unified -tAF '\t' -f -`, { input: sql, encoding: 'utf8', maxBuffer: 1 << 28 });

const KRAVET_FAM = new Set([
  'Kravet','Kravet Couture','Kravet Design','Kravet Contract','Kravet Basics',
  'Lee Jofa','Lee Jofa Modern','Groundworks','Brunschwig & Fils','Cole & Son',
  'GP & J Baker','Colefax And Fowler','Colefax and Fowler','Clarke And Clarke',
  'Clarke & Clarke','Mulberry','Mulberry Home','Threads','Baker Lifestyle',
  'Andrew Martin','Nicolette Mayer','Aerin','Barclay Butera','Thom Filicia',
  'Gaston Y Daniela','Gaston y Daniela','Donghia',
]);

// Map a Shopify vendor name -> the cadence vendors.js config (table + costExpr).
// vendors.js is keyed by its own label; match loosely on table presence.
function cfgForVendor(vendor) {
  for (const [k, c] of Object.entries(VENDORS)) {
    if (k.toLowerCase() === vendor.toLowerCase()) return c;
  }
  // a few known aliases (Shopify vendor name -> vendors.js key / catalog table)
  return null;
}

// Build a per-vendor priceable count for the gap set.
function main() {
  // gap vendors with counts of the fix-needing set
  const gapRows = psql(`
    SELECT vendor,
      count(*) FILTER (WHERE NOT has_product OR NOT has_sample OR any_zero) AS need_fix,
      count(*) FILTER (WHERE NOT has_product) AS miss_product,
      count(*) FILTER (WHERE NOT has_sample)  AS miss_sample,
      count(*) FILTER (WHERE any_zero)        AS zero
    FROM active_five_field_status
    GROUP BY vendor HAVING count(*) FILTER (WHERE NOT has_product OR NOT has_sample OR any_zero) > 0
    ORDER BY need_fix DESC;`).trim().split('\n').filter(Boolean).map(l => {
      const [vendor, need_fix, miss_product, miss_sample, zero] = l.split('\t');
      return { vendor, need_fix:+need_fix, miss_product:+miss_product, miss_sample:+miss_sample, zero:+zero };
    });

  const results = [];
  for (const g of gapRows) {
    const v = g.vendor.replace(/'/g, "''");
    let priceable = 0, source = 'none';

    if (KRAVET_FAM.has(g.vendor)) {
      source = 'kravet-map';
      priceable = +psql(`
        SELECT count(DISTINCT a.shopify_id)
        FROM active_five_field_status a
        JOIN shopify_products sp ON sp.shopify_id=a.shopify_id
        LEFT JOIN kravet_dwkk_variant_map vm ON vm.dw_sku=sp.dw_sku
        LEFT JOIN product_map pm ON pm.dw_sku=sp.dw_sku
        LEFT JOIN kravet_master_price kmp ON upper(kmp.mfr_sku)=upper(sp.mfr_sku)
        LEFT JOIN kravet_catalog kc ON upper(kc.mfr_sku)=upper(sp.mfr_sku)
        WHERE a.vendor='${v}' AND (NOT a.has_product OR NOT a.has_sample OR a.any_zero)
          AND (vm.price>0 OR pm.map_price>0 OR kmp.map_price>0 OR kc.dw_sell_price>0);`).trim();
    } else {
      // sample-only gap needs NO new cost (sample is flat $4.25); only roll-build needs cost.
      // Priceability here = "can we build the roll" = real catalog cost exists.
      const cfg = cfgForVendor(g.vendor);
      if (cfg && cfg.table) {
        source = `${cfg.table}:${cfg.costExpr}`;
        // try mfr_sku then dw_sku join; costExpr references catalog columns
        const q = key => `
          SELECT count(DISTINCT a.shopify_id)
          FROM active_five_field_status a
          JOIN shopify_products sp ON sp.shopify_id=a.shopify_id
          JOIN ${cfg.table} c ON ${key}
          WHERE a.vendor='${v}' AND (NOT a.has_product OR NOT a.has_sample OR a.any_zero)
            AND (${cfg.costExpr})::numeric > 0;`;
        let byMfr = 0, byDw = 0;
        try { byMfr = +psql(q(`upper(c.mfr_sku)=upper(sp.mfr_sku)`)).trim() || 0; } catch {}
        try { byDw  = +psql(q(`c.dw_sku=sp.dw_sku`)).trim() || 0; } catch {}
        priceable = Math.max(byMfr, byDw);
      } else {
        // no cadence config -> probe product_map MAP (some non-kravet have real MAP).
        // DTD-C 2026-06-21: join product_map by dw_sku OR by mfr_sku so dw_sku-less
        // private-label rows (Justin-David: dw_sku NULL everywhere, keyed by mfr_sku)
        // resolve to their durable map_price after a full audit rebuild.
        source = 'product_map(dw_sku|mfr_sku)';
        priceable = +psql(`
          SELECT count(DISTINCT a.shopify_id)
          FROM active_five_field_status a
          JOIN shopify_products sp ON sp.shopify_id=a.shopify_id
          LEFT JOIN product_map pm  ON pm.dw_sku=sp.dw_sku
          LEFT JOIN product_map pmm ON pmm.mfr_sku IS NOT NULL
                                   AND upper(pmm.mfr_sku)=upper(sp.mfr_sku)
          WHERE a.vendor='${v}' AND (NOT a.has_product OR NOT a.has_sample OR a.any_zero)
            AND (pm.map_price>0 OR pmm.map_price>0);`).trim() || 0;
      }
    }
    // sample-only-gap SKUs are always "fixable" (flat $4.25) even with no cost,
    // so report both: roll-priceable vs sample-only(no-cost-needed) vs true-hold.
    results.push({ ...g, priceable, source, hold: Math.max(0, g.need_fix - priceable) });
  }

  const tot = results.reduce((a, r) => ({
    need_fix: a.need_fix + r.need_fix, miss_product: a.miss_product + r.miss_product,
    miss_sample: a.miss_sample + r.miss_sample, priceable: a.priceable + r.priceable,
    hold: a.hold + r.hold,
  }), { need_fix:0, miss_product:0, miss_sample:0, priceable:0, hold:0 });

  const out = { generatedAt: new Date().toISOString(), totals: tot, vendors: results };
  fs.writeFileSync(__dirname + '/../out/priceability.json', JSON.stringify(out, null, 2));
  console.log('TOTALS', JSON.stringify(tot));
  console.table(results.slice(0, 40).map(r => ({ vendor: r.vendor, need_fix: r.need_fix, miss_product: r.miss_product, miss_sample: r.miss_sample, priceable: r.priceable, hold: r.hold, source: r.source.slice(0, 28) })));
}
main();