← back to Nationalrealestate

src/ingest/commercial/from_parcel.ts

113 lines

/**
 * Materialize commercial_parcel from the EXISTING parcel table — $0, NO new source.
 * For counties whose parcel.use_desc is already a plain description (King County WA:
 * 'Office Building', 'Warehouse', 'Retail Store', …). A SQL pre-filter narrows to
 * commercial-keyword candidates (so we don't load the whole county), then
 * classifyCommercialByDesc types each precisely; non-commercial rows are skipped.
 * APN normalized to digits. Idempotent upsert on (county_fips, ain).
 *
 *   npm run ingest:commercial -- king   # King County WA (53033) from loaded parcels
 */
import { query } from '../../../db/pool.ts';
import { openRun, closeRun } from '../run.ts';
import { classifyCommercialByDesc, type CommercialType } from '../../lib/commercial_types.ts';

const digits = (v: unknown): string | null => { const t = (v == null ? '' : String(v)).replace(/\D/g, ''); return t || null; };

// Broad SQL pre-filter covering the classifier's keywords — precise typing happens in JS.
const KEYWORDS = 'commercial|office|ofc|professional|bank|savings|warehous|warehse|whse|distribution|storage|manufactur|industrial|store|shop|supermarket|department|restaurant|cocktail|hotel|motel|hospitality|lodging|parking|studio|motion picture|television|radio|retail|wholesale|outlet|nursery|greenhouse|kennel|laundry|repair|service station|auto|recreation';

const COLS = ['county_fips', 'ain', 'address', 'city', 'zip', 'ctype', 'use_desc', 'use_class',
  'assessed_total', 'assessed_land', 'assessed_imp', 'roll_year', 'recording_date', 'sqft', 'year_built', 'units'];

export function ingestCommercialFromParcel(fips: string, label: string) {
  return async (): Promise<{ upserted: number }> => {
    const runId = await openRun(`commercial_${fips}_fromparcel`, 'parcel-table');
    try {
      const r = await query<any>(
        `SELECT county_fips, source_id, address, city, zip, use_desc, total_value, sqft, year_built, units
           FROM parcel WHERE county_fips = $1 AND use_desc ~* $2`, [fips, KEYWORDS]);
      const byAin = new Map<string, any>();
      for (const p of r.rows) {
        const ctype = classifyCommercialByDesc(p.use_desc);
        if (!ctype) continue;
        const ain = digits(p.source_id); if (!ain) continue;
        byAin.set(ain, {
          county_fips: p.county_fips, ain, address: p.address, city: p.city, zip: p.zip,
          ctype, use_desc: p.use_desc, use_class: null,
          assessed_total: p.total_value, assessed_land: null, assessed_imp: null,
          roll_year: null, recording_date: null, sqft: p.sqft, year_built: p.year_built, units: p.units,
        });
      }
      const rows = [...byAin.values()];
      let up = 0;
      for (let i = 0; i < rows.length; i += 1000) {
        const chunk = rows.slice(i, i + 1000);
        const params: unknown[] = [];
        const vals = chunk.map((row, j) => { const b = j * COLS.length; COLS.forEach(c => params.push(row[c] ?? null)); return '(' + COLS.map((_, k) => `$${b + k + 1}`).join(',') + ')'; });
        await query(
          `INSERT INTO commercial_parcel (${COLS.join(',')}) VALUES ${vals.join(',')}
           ON CONFLICT (county_fips, ain) DO UPDATE SET ${COLS.filter(c => c !== 'county_fips' && c !== 'ain').map(c => `${c}=EXCLUDED.${c}`).join(', ')}, updated_at=NOW()`,
          params);
        up += chunk.length;
      }
      await closeRun(runId, 'ok', { upserted: up, notes: `${label}: ${up} commercial parcels materialized from parcel table (${r.rows.length} candidates scanned)` });
      console.log(`[commercial:${label}] done: ${up} commercial parcels from ${r.rows.length} candidates`);
      return { upserted: up };
    } catch (e: any) {
      await closeRun(runId, 'failed', { notes: e.message });
      throw e;
    }
  };
}

/**
 * Coded-county variant: for counties whose parcel.use_desc is a CODE (e.g. NYC
 * 'Class O6', building-class letter O = Office). Pass an authoritative `classify`
 * (code string -> CommercialType|null) and a SQL `filter` regex that narrows to the
 * commercial-relevant codes so we don't scan the whole county. No guessing — the
 * classify fn must come from the county's published code table (Cook lesson).
 */
export function ingestCommercialFromParcelCoded(
  fips: string, label: string, filter: string, classify: (useDesc: string) => CommercialType | null,
) {
  return async (): Promise<{ upserted: number }> => {
    const runId = await openRun(`commercial_${fips}_coded`, 'parcel-table');
    try {
      const r = await query<any>(
        `SELECT county_fips, source_id, address, city, zip, use_desc, total_value, sqft, year_built, units
           FROM parcel WHERE county_fips = $1 AND use_desc ~* $2`, [fips, filter]);
      const byAin = new Map<string, any>();
      for (const p of r.rows) {
        const ctype = classify(p.use_desc || '');
        if (!ctype) continue;
        const ain = digits(p.source_id); if (!ain) continue;
        byAin.set(ain, {
          county_fips: p.county_fips, ain, address: p.address, city: p.city, zip: p.zip,
          ctype, use_desc: p.use_desc, use_class: null,
          assessed_total: p.total_value, assessed_land: null, assessed_imp: null,
          roll_year: null, recording_date: null, sqft: p.sqft, year_built: p.year_built, units: p.units,
        });
      }
      const rows = [...byAin.values()];
      let up = 0;
      for (let i = 0; i < rows.length; i += 1000) {
        const chunk = rows.slice(i, i + 1000);
        const params: unknown[] = [];
        const vals = chunk.map((row, j) => { const b = j * COLS.length; COLS.forEach(c => params.push(row[c] ?? null)); return '(' + COLS.map((_, k) => `$${b + k + 1}`).join(',') + ')'; });
        await query(
          `INSERT INTO commercial_parcel (${COLS.join(',')}) VALUES ${vals.join(',')}
           ON CONFLICT (county_fips, ain) DO UPDATE SET ${COLS.filter(c => c !== 'county_fips' && c !== 'ain').map(c => `${c}=EXCLUDED.${c}`).join(', ')}, updated_at=NOW()`,
          params);
        up += chunk.length;
      }
      await closeRun(runId, 'ok', { upserted: up, notes: `${label}: ${up} commercial parcels (coded, ${r.rows.length} candidates)` });
      console.log(`[commercial:${label}] done: ${up} commercial parcels from ${r.rows.length} coded candidates`);
      return { upserted: up };
    } catch (e: any) {
      await closeRun(runId, 'failed', { notes: e.message });
      throw e;
    }
  };
}