← back to Nationalrealestate

src/ingest/parcels/fulton_ga.ts

184 lines

/**
 * Fulton County GA (Atlanta, FIPS 13121) parcel ingest — dedicated full-universe
 * adapter (applies the Wake/Miami-Dade/Tarrant/Bexar DTD verdict B precedent: a
 * rich full-universe keyless ArcGIS county gets a dedicated adapter, not the
 * sale-cursor generic one, so the real OWNER + physical-detail fields are
 * preserved).
 *
 * Source: the Fulton County Dept. of IT / GIS Division hosted "Tax Parcels"
 * FeatureServer (built from the Fulton County Board of Assessors tax digest;
 * keyless, maxRecordCount 1000, supportsPagination, $0):
 *   https://services1.arcgis.com/AQDHTHDrZzfsFsB5/arcgis/rest/services/Tax_Parcels/FeatureServer/0
 * The serviceDescription self-identifies as "the Fulton County Board of
 * Assessor's ... annual tax digest." The layer serves outSR=4326 server-side, so
 * parcel centroids come back as clean WGS84 lat/lng.
 *
 * Coverage (373,004-parcel universe, TaxYear 2026 — current): Owner (99.98%,
 * 372,938), Address (100% — assembled from the structured AddrNumber/PreDir/
 * Street/Suffix/PosDir/UnitType/Unit components, which is cleaner than the flat
 * `Address` string), LUCode (land-use code, 100%), ClassCode, LivUnits
 * (living/dwelling units, 86%), LandAcres, Neighborhood.
 *
 * This Fulton assessor open view carries NO market/assessed value, NO
 * year-built, NO building sqft, NO beds/baths, and NO sale/deed feed. Because
 * there is no priced-sale feed here, this county is parcel-DETAIL-only (like
 * Bexar TX / Salt Lake UT): zero parcel_event rows are written — never a
 * fabricated sale.
 *
 * source_id = ParcelID (the Fulton Board of Assessors parcel account id, e.g.
 * "07 410001590187"). We keep the raw ParcelID including its embedded space (it
 * is the canonical key on the Fulton digest); rows with a blank ParcelID are
 * skipped.
 *
 * Run: NODE_OPTIONS=--max-old-space-size=4096 npm run ingest:parcels fulton
 */
import { pool } from '../../../db/pool.ts';
import { openRun, closeRun } from '../run.ts';
import { upsertParcels, registerParcelSource, registerSourceFieldMap, normAddress, type ParcelUpsertRow } from './upsert.ts';

const FIPS = '13121';
const SOURCE_KEY = 'fulton_ga';
const LAYER = 'https://services1.arcgis.com/AQDHTHDrZzfsFsB5/arcgis/rest/services/Tax_Parcels/FeatureServer/0';
const WHERE = "ParcelID IS NOT NULL AND ParcelID <> ''";
const PAGE = 1000; // layer maxRecordCount is 1000
// TK-50 field-level provenance: our parcel field → the exact source attribute it
// maps from. address is assembled from the structured Addr* components (falling
// back to the flat Address). No value/year/sqft/beds/baths/sale in this view.
const FIELD_MAP: Record<string, string> = {
  source_id: 'ParcelID',
  address: 'AddrNumber+AddrPreDir+AddrStreet+AddrSuffix+AddrPosDir+AddrUntTyp+AddrUnit|Address',
  units: 'LivUnits', use_desc: 'LUCode', tax_year: 'TaxYear', owner_name: 'Owner',
};

const s = (v: unknown): string | null => { const t = v == null ? '' : String(v).trim(); return t ? t : null; };
const num = (v: unknown): number | null => { const x = Number(v); return Number.isFinite(x) && x !== 0 ? x : null; };

/**
 * Assemble a clean street line from the structured Fulton address components,
 * in USPS-ish order: "<num> <predir> <street> <suffix> <posdir> <unittype> <unit>".
 * We prefer the components over the flat `Address` field because they are
 * separately reliable; fall back to the flat `Address` if no components exist.
 */
function buildStreet(a: any): string | null {
  const parts = [a.AddrNumber, a.AddrPreDir, a.AddrStreet, a.AddrSuffix, a.AddrPosDir, a.AddrUntTyp, a.AddrUnit]
    .map(s).filter(Boolean);
  const assembled = parts.join(' ').replace(/\s+/g, ' ').trim();
  // Fulton uses a literal "0" AddrNumber for unaddressed land parcels; when the
  // number is "0" and there's a real street, drop the leading 0 so the address
  // isn't "0 GULLATT RD" (keep the street though — it's still location info).
  const cleaned = assembled.replace(/^0\s+/, '').trim();
  return cleaned || s(a.Address);
}

/** plain vertex-average of ring[0] — NOT a true polygon centroid (fine for a map
 *  pin; multipart parcels only get the first ring). Geometry is already WGS84
 *  because we request outSR=4326. */
function centroid(geom: any): [number | null, number | null] {
  const ring = geom?.rings?.[0]; if (!Array.isArray(ring) || !ring.length) return [null, null];
  let sx = 0, sy = 0; for (const [x, y] of ring) { sx += x; sy += y; }
  return [+(sx / ring.length).toFixed(6), +(sy / ring.length).toFixed(6)];
}

const OUT = ['ParcelID', 'TaxYear', 'Address', 'AddrNumber', 'AddrPreDir', 'AddrStreet',
  'AddrSuffix', 'AddrPosDir', 'AddrUntTyp', 'AddrUnit', 'Owner', 'LUCode', 'ClassCode',
  'ExCode', 'LivUnits', 'LandAcres', 'NbrHood', 'Subdiv'].join(',');

async function fetchPage(offset: number): Promise<any[]> {
  const u = new URL(LAYER + '/query');
  u.searchParams.set('where', WHERE);
  u.searchParams.set('outFields', OUT);
  u.searchParams.set('orderByFields', 'OBJECTID ASC');
  u.searchParams.set('resultOffset', String(offset));
  u.searchParams.set('resultRecordCount', String(PAGE));
  u.searchParams.set('returnGeometry', 'true');
  u.searchParams.set('outSR', '4326');
  u.searchParams.set('f', 'json');
  let lastErr: any;
  for (let a = 0; a < 3; a++) {
    try {
      const res = await fetch(u, { headers: { 'User-Agent': 'Mozilla/5.0 (usre-parcel-ingest)' }, signal: AbortSignal.timeout(120_000) });
      if (!res.ok) throw new Error(`fulton ${res.status}: ${(await res.text()).slice(0, 140)}`);
      const j: any = await res.json();
      if (j.error) throw new Error(`fulton error: ${JSON.stringify(j.error).slice(0, 140)}`);
      return j.features || [];
    } catch (e) { lastErr = e; if (a < 2) await new Promise(r => setTimeout(r, 2000 * (a + 1))); }
  }
  throw lastErr;
}

export async function ingestFulton(): Promise<{ upserted: number }> {
  const runId = await openRun('parcel_fulton_ga', LAYER);
  // TK-50: run start time — the fetched_at stamped on every row this run touches.
  const fetchedAt = new Date().toISOString();
  try {
    await registerSourceFieldMap(SOURCE_KEY, FIELD_MAP, 'Fulton County GA (Board of Assessors tax digest) via Fulton DoIT GIS Tax_Parcels view');
    let offset = 0, seen = 0, skipped = 0, upserted = 0;
    for (;;) {
      const feats = await fetchPage(offset);
      if (!feats.length) break;
      const batch: ParcelUpsertRow[] = [];
      for (const f of feats) {
        const a = f.attributes || {};
        const pid = s(a.ParcelID);
        if (!pid) { skipped++; continue; }
        const owner = s(a.Owner);
        const street = buildStreet(a);
        // skip pure ghost polygons (no owner AND no street) so we don't store bare geometry rows
        if (!owner && !street) { skipped++; continue; }
        seen++;
        const [lng, lat] = centroid(f.geometry);
        const norm = street ? normAddress(street) : null;
        batch.push({
          county_fips: FIPS, source_id: pid,
          address: norm, norm_address: norm,
          // Fulton's tax-parcel view has no separate city column (the county
          // spans Atlanta + ~14 other municipalities); leave city null rather
          // than guess it from the neighborhood code.
          city: null, zip: null,
          lat, lng,
          year_built: null, sqft: null, beds: null, baths: null,
          units: num(a.LivUnits),
          use_desc: s(a.LUCode),
          land_value: null, improvement_value: null, total_value: null,
          tax_year: s(a.TaxYear), owner_name: owner, zoning: null,
          // no priced-sale / deed feed in this Fulton assessor open view — leave
          // both sale fields NULL (never fabricate a sale date or price).
          last_sale_date: null, last_sale_price: null,
          extra: JSON.stringify({
            class_code: s(a.ClassCode) || undefined,
            ex_code: s(a.ExCode) || undefined,
            land_acres: num(a.LandAcres) || undefined,
            neighborhood: s(a.NbrHood) || undefined,
            subdivision: s(a.Subdiv) || undefined,
            note: 'Fulton County GA (Board of Assessors tax digest) via Fulton DoIT GIS Tax_Parcels view — owner + structured address + land-use code + living units + land acres; no market value / year-built / building sqft / beds-baths / sale-or-deed feed in this open view',
          }),
          // ── TK-50 field-level provenance (record-level: one feature → this row) ──
          // ParcelID contains an embedded space — encodeURIComponent handles it.
          sourceKey: SOURCE_KEY,
          sourceUrl: `${LAYER}/query?where=${encodeURIComponent(`ParcelID='${pid}'`)}&outFields=*&f=html`,
          fetchedAt,
          rawSource: JSON.stringify(a),
        });
      }
      upserted += await upsertParcels(batch);
      offset += feats.length;
      if (seen % 100000 < feats.length) console.log(`[fulton] ${upserted} upserted, ${skipped} stubs skipped`);
      if (feats.length < PAGE) break;
    }
    if (upserted < 340000) throw new Error(`only ${upserted} Fulton parcels (expected ~373k) — layer/paging/filter drift?`);

    const n = await registerParcelSource(FIPS, LAYER,
      `Fulton County GA (Atlanta) Board of Assessors tax digest via Fulton DoIT GIS Tax_Parcels view — ${upserted} parcels: owner (99.98%), property address assembled from structured components (100%), land-use code (LUCode, 100%), class code, living/dwelling units (LivUnits, 86%), land acres, neighborhood, Map (polygon centroid, WGS84 via outSR). TaxYear 2026 (current). No market/assessed value, year-built, building sqft, beds/baths, or sale/deed feed in this open assessor view (parcel-detail-only, like Bexar/Salt Lake — zero fabricated events). ${skipped} placeholder/geometry-only stubs skipped.`);
    await closeRun(runId, 'ok', { upserted, skipped, notes: `Fulton GA: ${upserted} parcels, ${skipped} stubs skipped` });
    console.log(`[fulton] ok: ${upserted} parcels, ${skipped} stubs (registry ${n})`);
    return { upserted };
  } catch (e: any) {
    await closeRun(runId, 'failed', { notes: String(e.message || e).slice(0, 500) });
    throw e;
  }
}

if (import.meta.url === `file://${process.argv[1]}`) {
  ingestFulton().then(() => pool.end()).catch(e => { console.error(e); process.exit(1); });
}