gendesign/data/sql/178b_load_cadastral_value.py
bot-backend 4d063d6827
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 14m30s
feat(sitefinder): rosreestr cadastral-value loader + 0.7x denoise of quarter price index
Quarterly Rosreestr dataset_КАДАСТРСТОИМОСТЬ (cadastral value) gives a
per-cad-quarter cadastral ₽/m². Registered deals priced below 0.7x that
quarter's cadastral median are dropped from mv_quarter_price_per_m2 as
noise (data-entry junk / non-arm's-length deals that drag medians down).

- 178_schema_cadastral_value.sql: rosreestr_cadastral_value (+ staging),
  region-66-scoped, idempotent.
- 178b_load_cadastral_value.py: psycopg v3 staging COPY -> typed region-66
  INSERT, idempotent skip-if-loaded. Run MANUALLY by operator after deploy.
- 179_mv_quarter_price_cadastral_floor.sql: recreates BOTH MVs (CASCADE
  drops the dependent index MV). Adds cad_floor CTE + LEFT JOIN guard to
  mv_quarter_price_per_m2; mv_quarter_price_index recreated verbatim.

Safe to auto-apply on prod before any cadastral data exists: empty
rosreestr_cadastral_value -> cad_floor has 0 rows -> floor_ppm2 IS NULL for
every quarter -> predicate keeps ALL deals -> MV byte-identical to the
pre-floor (95) definition. Proven by a rolled-back dry-run (66:% EXCEPT
both directions = 0). With the experiment cad source loaded the floor
moves 480 of 1972 quarters (median 66788 -> 67350).

NN bumped from spec's 172/173 to 178/178b/179 (172-177 already on main).
2026-06-28 19:45:35 +03:00

157 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Загрузчик кварталов dataset_КАДАСТРСТОИМОСТЬ (кадастровая стоимость) в gendesign.
Region-66-scoped: при INSERT фильтруем region_code='66' (Свердловская обл.), хотя
исходный CSV покрывает регионы 60-92. Источник — открытый квартальный датасет
Росреестра. Заливается через psycopg v3 (staging COPY → typed INSERT).
Назначение: cost_per_area (кадастровая ₽/m²) даёт «пол» цены на квартал, по которому
denoise-миграция 179 отбрасывает зашумлённые сделки rosreestr_deals дешевле
0.7x кадастрового медианного ₽/m².
Idempotent: пропускает source_quarter, уже залитый в rosreestr_cadastral_value.
Запускается ВРУЧНУЮ оператором после деплоя; затем REFRESH двух MV (см. 179).
Usage:
cd backend
uv run python ../data/sql/178b_load_cadastral_value.py 2026Q1
uv run python ../data/sql/178b_load_cadastral_value.py # все из JOBS
"""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
import psycopg
PG_DSN = os.environ.get(
"TUNNEL_DSN",
"postgresql://gendesign:2J2SBPMKuS998fiwhtQqDhMI@localhost:15432/gendesign",
)
RAW = Path(__file__).resolve().parent.parent / "raw"
# Датасет всегда UTF-8, delimiter '~', header columns в порядке staging-таблицы.
DELIMITER = "~"
# (source_quarter, csv_filename)
JOBS: list[tuple[str, str]] = [
("2026Q1", "dataset_КАДАСТРСТОИМОСТЬ_r-r_60-92_y_2026_q_1.csv"),
]
# Region-66-scoped typed INSERT. Staging — всё TEXT, поэтому каждое числовое поле
# проходит regex-guard + NULLIF перед ::cast (мусорные строки → NULL, не падаем).
INSERT_SQL = """
INSERT INTO rosreestr_cadastral_value (
source_quarter,
number, realestate_type_code, purpose_code, area, region_code,
district, city, quarter_cad_number, street,
registration_quarter, application_quarter, cost, cost_per_area, period
)
SELECT
%s,
CASE WHEN number ~ '^[0-9]+$' THEN number::INTEGER ELSE NULL END,
NULLIF(realestate_type_code, ''),
NULLIF(purpose_code, ''),
CASE WHEN area ~ '^[0-9]+(\\.[0-9]+)?$' AND area::NUMERIC > 0
THEN area::NUMERIC ELSE NULL END,
region_code::SMALLINT,
NULLIF(district, ''),
NULLIF(city, ''),
NULLIF(quarter_cad_number, ''),
NULLIF(street, ''),
NULLIF(registration_quarter, ''),
NULLIF(application_quarter, ''),
CASE WHEN cost ~ '^[0-9]+(\\.[0-9]+)?$' AND cost::NUMERIC > 0
THEN cost::NUMERIC ELSE NULL END,
CASE WHEN cost_per_area ~ '^[0-9]+(\\.[0-9]+)?$' AND cost_per_area::NUMERIC > 0
THEN cost_per_area::NUMERIC ELSE NULL END,
NULLIF(period, '')
FROM rosreestr_cadastral_value_staging
WHERE region_code = '66'
AND NULLIF(quarter_cad_number, '') IS NOT NULL
"""
def main() -> int:
targets = sys.argv[1:] or [j[0] for j in JOBS]
selected = [j for j in JOBS if j[0] in targets]
if not selected:
print(f"Нет matching кварталов в JOBS. Targets: {targets}")
return 1
conn = psycopg.connect(PG_DSN, connect_timeout=15)
try:
for sq, fname in selected:
with conn.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM rosreestr_cadastral_value "
"WHERE source_quarter = %s",
(sq,),
)
already = cur.fetchone()[0]
if already:
print(
f"=== {sq} SKIP — уже {already} строк "
f"(DELETE FROM rosreestr_cadastral_value "
f"WHERE source_quarter='{sq}' чтобы перезалить)"
)
continue
csv_path = RAW / fname
if not csv_path.exists():
print(f"=== {sq} SKIP — нет файла {csv_path}")
continue
size_mb = csv_path.stat().st_size / 1e6
print(f"=== {sq} ({fname}, sep='{DELIMITER}', {size_mb:.1f} MB) ===")
t0 = time.time()
with conn.cursor() as cur:
cur.execute("TRUNCATE rosreestr_cadastral_value_staging")
copy_sql = (
f"COPY rosreestr_cadastral_value_staging FROM STDIN "
f"(FORMAT csv, HEADER true, DELIMITER '{DELIMITER}', "
f"QUOTE '\"', NULL '')"
)
with cur.copy(copy_sql) as copy:
with open(csv_path, "rb") as f:
while True:
chunk = f.read(8 * 1024 * 1024)
if not chunk:
break
copy.write(chunk)
t_copy = time.time() - t0
cur.execute(INSERT_SQL, (sq,))
inserted = cur.rowcount
conn.commit()
t_total = time.time() - t0
print(
f" COPY {t_copy:.0f}s, INSERT (region 66) {inserted} rows, "
f"total {t_total:.0f}s"
)
# Final summary
with conn.cursor() as cur:
cur.execute(
"""
SELECT source_quarter,
COUNT(*) AS rows,
COUNT(*) FILTER (
WHERE realestate_type_code = '002001003000'
) AS apt_rows
FROM rosreestr_cadastral_value
GROUP BY source_quarter
ORDER BY source_quarter
"""
)
print("\n=== Итоги по rosreestr_cadastral_value (region 66) ===")
for sq, rows, apt_rows in cur.fetchall():
print(f" {sq} rows={rows:>9} apartments={apt_rows:>9}")
finally:
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())