fix(analytics): mortgage rate sort + recommend last_updated + drop dead route (#236 #237 #251) #1312

Merged
bot-backend merged 1 commit from fix/analytics-mortgage-recommend-cleanup into main 2026-06-13 15:22:23 +00:00
6 changed files with 53 additions and 19 deletions

View file

@ -27,7 +27,6 @@ from app.schemas.parcel import (
ConnectionPointsResponse,
OpportunityParcel,
ParcelBboxResponse,
ParcelDetail,
ParcelMapMarker,
ParcelMeta,
ParcelSearchRequest,
@ -1047,12 +1046,10 @@ async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
# ── #994 (961-C3, EPIC 961): run-history read endpoints ───────────────────────
#
# ВАЖНО про порядок маршрутов: `/runs/{run_id}` объявлен ВЫШЕ `/{parcel_id}`.
# FastAPI матчит роуты в порядке регистрации — если бы `/{parcel_id}` шёл первым,
# запрос `GET /runs/123` ушёл бы в get_parcel с parcel_id="runs" (литерал "runs"
# съелся бы как path-param). Объявление здесь, до `/{parcel_id}`, гарантирует, что
# `/runs/{run_id}` резолвится корректно. `/{cad_num}/runs` (2 сегмента) с
# `/{parcel_id}` (1 сегмент) по числу сегментов не конфликтует, но держим рядом.
# #251: мёртвый 1-сегментный catch-all `GET /{parcel_id}` (501 Not implemented,
# Stage 2b так и не наступил, фронт его не звал) удалён. Поэтому 1-сегментного
# конфликта с `/runs/{run_id}` больше нет. Catch-all на этом router снова заводить
# НЕ стоит без явной нужды — он перехватывал бы любой 1-сегментный путь.
@router.get("/runs/{run_id}", response_model=AnalysisRunDetail)
@ -1094,12 +1091,6 @@ def list_analysis_runs(
return AnalysisRunListResponse(runs=runs)
@router.get("/{parcel_id}", response_model=ParcelDetail)
async def get_parcel(parcel_id: str) -> ParcelDetail:
"""TODO Stage 2b: fetch parcel by id from DB."""
raise HTTPException(status_code=501, detail="Not implemented yet")
@router.get("/{cad_num}/fetch-status")
def get_fetch_status(
cad_num: str,

View file

@ -165,11 +165,6 @@ class ParcelSearchResponse(BaseModel):
total: int
class ParcelDetail(ParcelSummary):
geometry_geojson: dict[str, Any]
enrichment: dict[str, Any]
# ── Competitors endpoint schemas ──────────────────────────────────────────────
TimeWindow = Literal["last_month", "last_quarter", "last_year"]

View file

@ -1651,6 +1651,13 @@ def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]:
Старый ILIKE '%ипотечн%жилищн%' случайно матчил «долю ипотечных кредитов
на ИЖС» (5.57% на ИЖС НЕ ставка). Теперь строго matchим
'Средневзвешенная ставка по ипотечным жилищным' + 'в рублях, %'.
#236: period — ТЕКСТ вида 'Февраль 2026', поэтому `ORDER BY period DESC`
сортировал лексикографически по названию месяца ('Январь' > 'Февраль',
т.к. «Я» > «Ф») и возвращал устаревшую точку. Сортируем по разобранной
дате (год DESC, затем порядковый номер месяца DESC). Regex-guard
`'^[А-Яа-я]+ [0-9]{4}$'` отсекает мусорные строки, где в period лежит
число (доля ИЖС вида '5.57'), иначе split_part(...)::int упал бы.
"""
row = (
db.execute(
@ -1662,7 +1669,15 @@ def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]:
AND title ILIKE '%в рублях, %'
AND value IS NOT NULL
AND value BETWEEN 1 AND 30 -- защита от мусорных
ORDER BY period DESC
AND period ~ '^[А-Яа-я]+ [0-9]{4}$' -- 'Месяц YYYY', иначе skip
ORDER BY
split_part(period, ' ', 2)::int DESC, -- год
CASE split_part(period, ' ', 1) -- порядковый месяца
WHEN 'Январь' THEN 1 WHEN 'Февраль' THEN 2 WHEN 'Март' THEN 3
WHEN 'Апрель' THEN 4 WHEN 'Май' THEN 5 WHEN 'Июнь' THEN 6
WHEN 'Июль' THEN 7 WHEN 'Август' THEN 8 WHEN 'Сентябрь' THEN 9
WHEN 'Октябрь' THEN 10 WHEN 'Ноябрь' THEN 11 WHEN 'Декабрь' THEN 12
END DESC
LIMIT 1
"""
)
@ -1675,6 +1690,27 @@ def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]:
return _f(row["value"]), row["period"]
def _recommend_data_last_updated(db: Session, *, region_code: int) -> str | None:
"""#237: дата актуальности данных recommend — MAX(snapshot_date) по основному
источнику (domrf_kn_objects, ЖК региона). ISO 'YYYY-MM-DD' для UI-индикатора
свежести; None если в регионе нет объектов с непустым snapshot_date.
"""
val = db.execute(
text(
"""
SELECT MAX(snapshot_date)
FROM domrf_kn_objects
WHERE region_cd = :rc
AND snapshot_date IS NOT NULL
"""
),
{"rc": region_code},
).scalar()
if val is None:
return None
return val.isoformat()
def _active_competitors_count(
db: Session,
*,
@ -2611,6 +2647,9 @@ def recommend_mix(
mortgage_rate, mortgage_period = _current_mortgage_rate(db)
# #237: индикатор актуальности данных — MAX(snapshot_date) основного источника
data_last_updated = _recommend_data_last_updated(db, region_code=region_code)
# #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены
noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"])
@ -2892,6 +2931,8 @@ def recommend_mix(
"poi_factor": round(poi_factor, 4),
"mortgage_rate_pct": mortgage_rate,
"mortgage_rate_period": mortgage_period,
# #237: дата актуальности данных recommend (ISO 'YYYY-MM-DD' | null)
"last_updated": data_last_updated,
"elasticity": elasticity,
"elasticity_r2": elast["r2"],
"elasticity_n": elast["n"],

View file

@ -322,6 +322,7 @@ def _run_recommend_mix_full(
patch(f"{_MOD}._current_mortgage_rate", return_value=(None, None)),
patch(f"{_MOD}._noise_penalty_factor", return_value=(1.0, [])),
patch(f"{_MOD}._bucket_success_ranking", return_value=[]),
patch(f"{_MOD}._recommend_data_last_updated", return_value=None),
]
with (
@ -339,6 +340,7 @@ def _run_recommend_mix_full(
patches[11],
patches[12],
patches[13],
patches[14],
):
return recommend_mix(
db,

View file

@ -193,6 +193,9 @@ export default function RecommendPage() {
data.scope.poi_score_city_avg != null
? ` · POI ${data.scope.poi_score.toFixed(0)}/${data.scope.poi_score_city_avg.toFixed(0)} (${data.scope.poi_score > data.scope.poi_score_city_avg ? "выше" : "ниже"} среднего)`
: ""}
{data.scope.last_updated
? ` · данные на ${new Date(data.scope.last_updated).toLocaleDateString("ru")}`
: ""}
</div>
</div>
) : null}

View file

@ -395,6 +395,8 @@ export interface RecommendMixOutput {
poi_factor: number;
mortgage_rate_pct: number | null;
mortgage_rate_period: string | null;
/** #237 — дата актуальности данных (ISO 'YYYY-MM-DD') | null */
last_updated: string | null;
elasticity: number;
elasticity_r2: number;
elasticity_n: number;