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).
157 lines
6.1 KiB
Python
157 lines
6.1 KiB
Python
"""Загрузчик кварталов 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())
|