gendesign/backend/app/services/site_finder/quarter_price_refresh.py
lekss361 e1a2a779e4 feat(analytics): mv_quarter_price_per_m2 MV + refresh helper (#33 D1 PR A)
Per-cad_quarter price aggregation из 6.83M rosreestr_deals.

Source verification (postgres MCP):
- rosreestr_deals total: 6 830 001
- price_per_sqm filled 99.9% (fallback deal_price/area)
- quarter_cad_number (NOT cad_quarter), period_start_date, realestate_type_code

SQL (95_mv_quarter_price.sql, ~124 LOC):
- Filter realestate_type_code='002001003000' (новостройки) + outlier 30K..800K
- Percentile p25/median/p75 + mean + temporal slices (6m/12m/24m)
- 24-month window, HAVING COUNT(*)>=3
- WITH NO DATA + REFRESH after INDEX (#194 pattern)
- UNIQUE INDEX (quarter_cad_number) для CONCURRENTLY
- ~52 492 quarters в MV (all-Russia; EKB subset ~9700)

Python helper (quarter_price_refresh.py, ~65 LOC):
- refresh_quarter_price(db, concurrently=True) с try/except fallback

Phase B (analyze integration — quarter median вместо district) — отдельный PR.
2026-05-16 14:06:57 +03:00

63 lines
2.3 KiB
Python

"""Refresh helper for mv_quarter_price_per_m2 (Issue #33 D1).
Not scheduled automatically in this PR — intended for manual invocation or
a Celery beat task in a follow-up issue (Issue #33 PR C).
Usage example (manual, via psql-connected shell or admin endpoint):
from sqlalchemy.orm import Session
from app.services.site_finder.quarter_price_refresh import refresh_quarter_price
count = refresh_quarter_price(db)
# logs: "mv_quarter_price_per_m2 refreshed: 52492 rows"
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def refresh_quarter_price(db: Session, *, concurrently: bool = True) -> int:
"""REFRESH MATERIALIZED VIEW mv_quarter_price_per_m2.
Args:
db: SQLAlchemy Session (sync).
concurrently: When True, uses REFRESH CONCURRENTLY — non-blocking but
requires the unique index mv_quarter_price_pk to exist (created by
95_mv_quarter_price.sql) and the MV to be already populated.
Pass False only for the very first populate or after MV recreation.
Returns:
Row count in the MV after refresh (for observability / alerting).
Raises:
OperationalError: Re-raised if the error is not the known
"cannot refresh materialized view concurrently" case.
"""
try:
if concurrently:
db.execute(text("REFRESH MATERIALIZED VIEW CONCURRENTLY mv_quarter_price_per_m2"))
else:
db.execute(text("REFRESH MATERIALIZED VIEW mv_quarter_price_per_m2"))
db.commit()
except OperationalError as e:
if concurrently and "cannot refresh materialized view" in str(e).lower():
logger.warning(
"CONCURRENTLY failed (MV likely not populated), falling back to"
" non-concurrent refresh"
)
db.rollback()
db.execute(text("REFRESH MATERIALIZED VIEW mv_quarter_price_per_m2"))
db.commit()
else:
raise
row = db.execute(text("SELECT COUNT(*) FROM mv_quarter_price_per_m2")).first()
count = int(row[0]) if row else 0
logger.info("mv_quarter_price_per_m2 refreshed: %d rows", count)
return count