diff --git a/backend/app/api/v1/analytics.py b/backend/app/api/v1/analytics.py index dcdd79a5..3cf87165 100644 --- a/backend/app/api/v1/analytics.py +++ b/backend/app/api/v1/analytics.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from app.core.db import get_db +from app.schemas.recommend import RecommendMixInput, RecommendMixOutput from app.services import analytics_queries as q router = APIRouter() @@ -180,3 +181,26 @@ def prinzip_objects(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, A # Funnel-эндпойнты (CRM-данные) перенесены в backend/app/api/v1/admin_leads.py: # /api/v1/admin/leads/funnel/{monthly,by-source,by-object} с X-Admin-Token. + + +# ---- Recommender (Уровень 1, rule-based) ----------------------------------- + + +@router.post("/recommend/mix", response_model=RecommendMixOutput) +def recommend_mix( + payload: RecommendMixInput, + db: Annotated[Session, Depends(get_db)], +) -> dict[str, Any]: + """Rule-based квартирография для участка в указанном районе ЕКБ. + + На вход — district_name (8 районов ЕКБ), опционально area_total_m2 и + target_class. На выход — распределение по 5 бакетам комнатности с + ожидаемыми ценами/площадями/выручкой и список comparable ЖК. + """ + return q.recommend_mix( + db, + district_name=payload.district_name, + area_total_m2=payload.area_total_m2, + target_class=payload.target_class, + months_window=payload.months_window, + ) diff --git a/backend/app/schemas/recommend.py b/backend/app/schemas/recommend.py new file mode 100644 index 00000000..223bbcac --- /dev/null +++ b/backend/app/schemas/recommend.py @@ -0,0 +1,46 @@ +"""IO contracts for the rule-based квартирография recommender. + +POST /api/v1/analytics/recommend/mix +""" + +from typing import Any, Literal + +from pydantic import BaseModel, Field + +ClassLiteral = Literal["Comfort", "Comfort+", "Business", "Elite"] + + +class RecommendMixInput(BaseModel): + district_name: str = Field(..., min_length=2, max_length=80) + area_total_m2: float | None = Field(default=None, ge=100, le=500_000) + target_class: ClassLiteral | None = None + months_window: int = Field(default=12, ge=3, le=36) + + +class RecommendBucket(BaseModel): + bucket: str + share_pct: float + deal_count: int + area_avg_m2: float + area_median_m2: float + price_median_per_m2: float + price_p25_per_m2: float + price_p75_per_m2: float + units_planned: int | None = None + revenue_planned_rub: float | None = None + + +class RecommendComparable(BaseModel): + obj_id: int + comm_name: str | None = None + dev_name: str | None = None + obj_class: str | None = None + flat_count: int | None = None + sold_perc: float | None = None + + +class RecommendMixOutput(BaseModel): + scope: dict[str, Any] + buckets: list[RecommendBucket] + summary: dict[str, Any] + comparables: list[RecommendComparable] diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 2ceaabb2..636bde83 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -955,3 +955,293 @@ def prinzip_objects_with_velocity(db: Session) -> list[dict[str, Any]]: } for r in rows ] + + +# ── Rule-based recommender (Уровень 1) ──────────────────────────────────────── + +# Pretty-name map shared with quartirography_deals(). Keep IDs sortable so +# bucket ordering is deterministic in the response. +_BUCKET_PRETTY: dict[str, str] = { + "1-Студия": "Студии 15-30", + "2-1-к": "1-к 30-45", + "3-2-к": "2-к 45-60", + "4-3-к": "3-к 60-80", + "5-80+ м²": "80+ м²", +} + + +def recommend_mix( + db: Session, + *, + district_name: str, + area_total_m2: float | None = None, + target_class: str | None = None, + months_window: int = 12, + region_code: int = 66, +) -> dict[str, Any]: + """Rule-based квартирография recommender. + + City-wide bucket distribution from rosreestr_deals (последние N месяцев), + скорректированная на район (через ekb_districts.median_price_per_m2) и + класс (через yandex_realty_zk price-агрегаты per-class). + + See plan: C:/Users/user/.claude/plans/crispy-swinging-gadget.md + """ + warnings: list[str] = [] + + # 1) District lookup + district_row = ( + db.execute( + text( + """ + SELECT district_name, zk_count, flat_count, + median_price_per_m2, mean_price_per_m2 + FROM ekb_districts + WHERE district_name ILIKE :dn + LIMIT 1 + """ + ), + {"dn": district_name.strip()}, + ) + .mappings() + .first() + ) + if not district_row: + return { + "scope": {"district": district_name, "error": "district unknown"}, + "buckets": [], + "summary": { + "total_revenue_rub": None, + "weighted_avg_price_per_m2": None, + "warnings": [f"Район '{district_name}' не найден в ekb_districts"], + }, + "comparables": [], + } + + district_median = _f(district_row["median_price_per_m2"]) + if district_median is None: + warnings.append( + f"В ekb_districts нет median_price_per_m2 для '{district_row['district_name']}'," + " коэффициент района = 1.0" + ) + + # 2) City-wide median baseline + city_median = _f( + db.execute( + text( + """ + SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY median_price_per_m2) + FROM ekb_districts + WHERE median_price_per_m2 IS NOT NULL + """ + ) + ).scalar() + ) + + district_factor = ( + district_median / city_median + if (district_median and city_median and city_median > 0) + else 1.0 + ) + + # 3) Class multiplier from yandex_realty_zk price ranges (price_from) + class_multiplier = 1.0 + if target_class: + cls_row = ( + db.execute( + text( + """ + SELECT + AVG(price_from) FILTER (WHERE obj_class = :cls) AS class_avg, + AVG(price_from) AS overall_avg + FROM yandex_realty_zk + WHERE price_from IS NOT NULL AND price_from > 0 + """ + ), + {"cls": target_class}, + ) + .mappings() + .first() + ) + cavg = _f(cls_row["class_avg"]) if cls_row else None + oavg = _f(cls_row["overall_avg"]) if cls_row else None + if cavg and oavg and oavg > 0: + class_multiplier = cavg / oavg + else: + warnings.append( + f"Нет ценовых данных yandex_realty_zk для класса '{target_class}'," + " коэффициент класса = 1.0" + ) + + # 4) Bucket distribution from rosreestr_deals — city-wide, last N months + bucket_rows = ( + db.execute( + text( + """ + WITH bucketed AS ( + SELECT CASE + WHEN area < 30 THEN '1-Студия' + WHEN area < 45 THEN '2-1-к' + WHEN area < 60 THEN '3-2-к' + WHEN area < 80 THEN '4-3-к' + ELSE '5-80+ м²' + END AS bucket, + area, + price_per_sqm + FROM rosreestr_deals + WHERE region_code = :rc + AND doc_type = 'ДДУ' + -- realestate_type_code 002001003000 = квартиры (жилые помещения). + -- 001 = земельные участки, 002 = нежилые помещения. + AND realestate_type_code = '002001003000' + AND area > 10 + AND area <= 200 -- отсечь выбросы (коммерческие площади) + AND price_per_sqm BETWEEN 30000 AND 1000000 + AND period_start_date >= NOW() + - (:months_window || ' months')::INTERVAL + ) + SELECT bucket, + COUNT(*)::bigint AS deals, + AVG(area) AS area_avg, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area) AS area_median, + PERCENTILE_CONT(0.5) WITHIN GROUP + (ORDER BY price_per_sqm) AS price_median, + PERCENTILE_CONT(0.25) WITHIN GROUP + (ORDER BY price_per_sqm) AS price_p25, + PERCENTILE_CONT(0.75) WITHIN GROUP + (ORDER BY price_per_sqm) AS price_p75 + FROM bucketed + GROUP BY bucket + ORDER BY bucket + """ + ), + {"rc": region_code, "months_window": months_window}, + ) + .mappings() + .all() + ) + total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1 + + # 5) Build buckets with adjusted prices + optional allocation + buckets: list[dict[str, Any]] = [] + weighted_num = 0.0 # Σ area_avg × share × price + weighted_den = 0.0 # Σ area_avg × share + total_revenue = 0.0 + have_revenue = False + for r in bucket_rows: + bid = r["bucket"] + deals = int(r["deals"] or 0) + share = round(deals * 100 / total_deals, 1) + area_avg = _f(r["area_avg"]) or 0.0 + area_med = _f(r["area_median"]) or 0.0 + p_med_city = _f(r["price_median"]) or 0.0 + p25_city = _f(r["price_p25"]) or 0.0 + p75_city = _f(r["price_p75"]) or 0.0 + + adj = district_factor * class_multiplier + p_med = round(p_med_city * adj, 2) + p25 = round(p25_city * adj, 2) + p75 = round(p75_city * adj, 2) + + units_planned: int | None = None + revenue_planned: float | None = None + if area_total_m2 and area_avg > 0: + allocated = area_total_m2 * (share / 100.0) + units_planned = max(1, round(allocated / area_avg)) + revenue_planned = round(units_planned * area_avg * p_med, 2) + total_revenue += revenue_planned + have_revenue = True + + weighted_num += area_avg * share * p_med + weighted_den += area_avg * share + + if deals < 30: + warnings.append( + f"Бакет '{_BUCKET_PRETTY.get(bid, bid)}': только {deals} сделок" + f" за {months_window} мес — оценка с большой погрешностью" + ) + + buckets.append( + { + "bucket": _BUCKET_PRETTY.get(bid, bid), + "share_pct": share, + "deal_count": deals, + "area_avg_m2": round(area_avg, 1), + "area_median_m2": round(area_med, 1), + "price_median_per_m2": p_med, + "price_p25_per_m2": p25, + "price_p75_per_m2": p75, + "units_planned": units_planned, + "revenue_planned_rub": revenue_planned, + } + ) + + weighted_avg_price = round(weighted_num / weighted_den, 2) if weighted_den > 0 else None + + # 6) Comparable ЖК — same district (parsed from addr) and class + cmp_rows = ( + db.execute( + text( + """ + WITH latest_agg AS ( + SELECT obj_id, MAX(snapshot_date) AS snap + FROM domrf_kn_sales_agg + WHERE type = 'apartments' + GROUP BY obj_id + ) + SELECT o.obj_id, o.comm_name, o.dev_name, o.obj_class, o.flat_count, + a.perc AS sold_perc + FROM domrf_kn_objects o + LEFT JOIN latest_agg la ON la.obj_id = o.obj_id + LEFT JOIN domrf_kn_sales_agg a + ON a.obj_id = la.obj_id + AND a.snapshot_date = la.snap + AND a.type = 'apartments' + WHERE o.region_cd = :rc + AND o.addr ILIKE '%' || :dn || '%' + AND (:cls::text IS NULL OR o.obj_class = :cls) + ORDER BY o.flat_count DESC NULLS LAST + LIMIT 5 + """ + ), + {"rc": region_code, "dn": district_row["district_name"], "cls": target_class}, + ) + .mappings() + .all() + ) + + return { + "scope": { + "district": district_row["district_name"], + "district_zk_count": district_row["zk_count"], + "district_median_price_per_m2": district_median, + "district_factor": round(district_factor, 4), + "class_multiplier": round(class_multiplier, 4), + "target_class": target_class, + "months_window": months_window, + "region_code": region_code, + "total_deals": total_deals if bucket_rows else 0, + "data_caveat": ( + "MVP: bucket-распределение город-wide (регион 66). Район влияет" + " только на ценовой коэффициент. v2 добавит per-district demand" + " при заведении PostGIS-полигонов." + ), + }, + "buckets": buckets, + "summary": { + "total_revenue_rub": round(total_revenue, 2) if have_revenue else None, + "weighted_avg_price_per_m2": weighted_avg_price, + "warnings": warnings, + }, + "comparables": [ + { + "obj_id": r["obj_id"], + "comm_name": r["comm_name"], + "dev_name": r["dev_name"], + "obj_class": r["obj_class"], + "flat_count": r["flat_count"], + "sold_perc": _f(r["sold_perc"]), + } + for r in cmp_rows + ], + }