feat(sf-b5): extend /parcels/{cad}/analyze with 15 fields (EGRN/encumbrance/red_lines/risks/district prices) (#332)
Some checks failed
Deploy / changes (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled

This commit is contained in:
lekss361 2026-05-17 21:11:18 +00:00
parent 4040cba4c0
commit 493238447a

View file

@ -1702,6 +1702,217 @@ def analyze_parcel(
except Exception as e:
logger.warning("parcel_meta query failed for %s: %s", cad_num, e)
# B5-1) EGRN block — расширенные данные из cad_parcels (SF-B5)
egrn_block: dict[str, Any] = {}
try:
egrn_row = (
db.execute(
text("""
SELECT cost_value AS cadastral_value_rub,
cost_index AS cost_index_per_m2,
land_record_category_type AS land_category,
permitted_use_established_by_document AS permitted_use_text,
cost_registration_date AS last_egrn_update_date,
land_record_area AS area_m2,
ownership_type,
right_type,
status,
readable_address,
registration_date
FROM cad_parcels
WHERE cad_num = CAST(:c AS text)
LIMIT 1
"""),
{"c": cad_num},
)
.mappings()
.first()
)
if egrn_row:
_cad_val = (
float(egrn_row["cadastral_value_rub"])
if egrn_row["cadastral_value_rub"] is not None
else None
)
_area_m2 = float(egrn_row["area_m2"]) if egrn_row["area_m2"] is not None else None
_idx = egrn_row["cost_index_per_m2"]
_cad_per_m2: float | None = None
if _idx is not None:
_cad_per_m2 = float(_idx)
elif _cad_val is not None and _area_m2 and _area_m2 > 0:
_cad_per_m2 = round(_cad_val / _area_m2, 2)
egrn_block = {
"cadastral_value_rub": _cad_val,
"cadastral_value_per_m2": _cad_per_m2,
"land_category": egrn_row["land_category"],
"permitted_use_text": egrn_row["permitted_use_text"],
"last_egrn_update_date": (
egrn_row["last_egrn_update_date"].isoformat()
if egrn_row["last_egrn_update_date"] is not None
else None
),
"area_m2": _area_m2,
"ownership_type": egrn_row["ownership_type"],
"right_type": egrn_row["right_type"],
"parcel_status": egrn_row["status"],
"address": egrn_row["readable_address"],
"registration_date": (
egrn_row["registration_date"].isoformat()
if egrn_row["registration_date"] is not None
else None
),
}
except Exception as e:
logger.warning("egrn_block query failed for %s: %s", cad_num, e)
# B5-2) Encumbrance block — ЗОУИТ из cad_zouit (SF-B5)
encumbrance_block: dict[str, Any] = {
"has_zouit": False,
"zouit_types": [],
"zouit_count": 0,
}
try:
zouit_rows = (
db.execute(
text("""
SELECT type_zone, name_by_doc
FROM cad_zouit
WHERE ST_Intersects(geom, ST_GeomFromText(:wkt, 4326))
ORDER BY id
"""),
{"wkt": geom_wkt},
)
.mappings()
.all()
)
if zouit_rows:
_zouit_types = list({r["type_zone"] for r in zouit_rows if r["type_zone"]})
encumbrance_block = {
"has_zouit": True,
"zouit_types": _zouit_types,
"zouit_count": len(zouit_rows),
}
except Exception as e:
logger.warning("encumbrance_block query failed for %s: %s", cad_num, e)
# B5-3) Red lines block — пересечение с cad_red_lines (SF-B5)
red_lines_block: dict[str, Any] = {"intersects": False, "count": 0}
try:
rl_row = (
db.execute(
text("""
SELECT COUNT(*) AS cnt
FROM cad_red_lines
WHERE ST_Intersects(
geom::geometry,
ST_GeomFromText(:wkt, 4326)
)
"""),
{"wkt": geom_wkt},
)
.mappings()
.first()
)
if rl_row:
_rl_cnt = int(rl_row["cnt"])
red_lines_block = {
"intersects": _rl_cnt > 0,
"count": _rl_cnt,
}
except Exception as e:
logger.warning("red_lines_block query failed for %s: %s", cad_num, e)
# B5-4) Metro placeholder — заполнится после merge 22h metro scraper
metro_block: dict[str, Any] = {"nearest_top3": None}
# B5-5) District price ranges из objective_lots (SF-B5)
district_price_block: dict[str, Any] = {
"district_price_per_m2_min": None,
"district_price_per_m2_max": None,
"district_price_per_m2_median": None,
"district_price_sample_size": None,
}
if district_row and district_row["district_name"]:
try:
dp_row = (
db.execute(
text("""
SELECT
MIN(price_per_m2_rub) AS price_min,
MAX(price_per_m2_rub) AS price_max,
PERCENTILE_CONT(0.5) WITHIN GROUP (
ORDER BY price_per_m2_rub
) AS price_median,
COUNT(*) AS sample_size
FROM objective_lots
WHERE district = CAST(:dn AS text)
AND price_per_m2_rub IS NOT NULL
AND price_per_m2_rub BETWEEN 30000 AND 600000
"""),
{"dn": district_row["district_name"]},
)
.mappings()
.first()
)
if dp_row and dp_row["sample_size"] and int(dp_row["sample_size"]) > 0:
district_price_block = {
"district_price_per_m2_min": (
round(float(dp_row["price_min"])) if dp_row["price_min"] else None
),
"district_price_per_m2_max": (
round(float(dp_row["price_max"])) if dp_row["price_max"] else None
),
"district_price_per_m2_median": (
round(float(dp_row["price_median"])) if dp_row["price_median"] else None
),
"district_price_sample_size": int(dp_row["sample_size"]),
}
except Exception as e:
logger.warning("district_price_block query failed for %s: %s", cad_num, e)
# B5-6) Risk indicators — flood_zone из cad_risk_zones + noise_score + geology proxy (SF-B5)
risks_block: dict[str, Any] = {
"flood_zone": False,
"noise_score": round(noise_score, 2),
"geology_risk_label": None,
}
try:
flood_row = (
db.execute(
text("""
SELECT COUNT(*) AS cnt
FROM cad_risk_zones
WHERE ST_Intersects(
geom::geometry,
ST_GeomFromText(:wkt, 4326)
)
AND (risk_type ILIKE '%flood%' OR risk_type ILIKE '%подтоп%'
OR risk_type ILIKE '%затоп%')
"""),
{"wkt": geom_wkt},
)
.mappings()
.first()
)
_flood = bool(flood_row and int(flood_row["cnt"]) > 0)
# Geology proxy через hydrology flood_risk_flag (уже посчитан выше)
_geo_flood = hydrology.get("flood_risk_flag", False) if hydrology else False
_has_flood = _flood or _geo_flood
# geology_risk_label: high если flooding, medium если шум > 65дБ, иначе low
if _has_flood:
_geo_label: str | None = "high"
elif noise_db_max >= 65.0:
_geo_label = "medium"
else:
_geo_label = "low"
risks_block = {
"flood_zone": _has_flood,
"noise_score": round(noise_score, 2),
"geology_risk_label": _geo_label,
}
except Exception as e:
logger.warning("risks_block query failed for %s: %s", cad_num, e)
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
market_trend: dict[str, Any] | None = None
try:
@ -2300,6 +2511,16 @@ def analyze_parcel(
},
# #254: custom POI scoring — user-defined points (via X-Session-Id header).
"custom_poi_score_items": custom_poi_items,
# SF-B5: EGRN + encumbrance + red_lines + metro + district prices + risks
"egrn": egrn_block,
"encumbrance": encumbrance_block,
"red_lines": red_lines_block,
"metro": metro_block,
"district_price_per_m2_min": district_price_block["district_price_per_m2_min"],
"district_price_per_m2_max": district_price_block["district_price_per_m2_max"],
"district_price_per_m2_median": district_price_block["district_price_per_m2_median"],
"district_price_sample_size": district_price_block["district_price_sample_size"],
"risks": risks_block,
}