feat(analyze): add market_price из mv_quarter_price_per_m2 (#33 PR B) #210

Merged
lekss361 merged 1 commit from feat/33-analyze-quarter-price into main 2026-05-16 11:28:17 +00:00
Owner

Summary

Issue #33 PR B — analyze integration с mv_quarter_price_per_m2 (созданным в PR #209).

Backend changes

  • POST /analyze response расширен market_price:
    {
      "p25": 142000.0, "median": 158500.0, "p75": 175000.0, "mean": 159823.5,
      "deals_count": 87,
      "median_6m": 161000.0, "median_12m": 156000.0, "median_24m": 150000.0,
      "last_deal_date": "2026-04-12",
      "source": "quarter_mv"
    }
    
    Или fallback при отсутствии данных:
    {"deals_count": 0, "source": "no_data"}
    
  • Schema MarketPrice(BaseModel) в schemas/parcel.py для документации типов
  • district.median_price_per_m2 kept as-is для backward-compat — frontend Phase B переключится отдельным PR

MV stats (verified live на проде после PR #209 deploy)

  • 52 492 quarters в mv_quarter_price_per_m2
  • Sample queries работают через quarter_cad_number lookup

Tests

backend/tests/api/v1/test_analyze_market_price.py (NEW, 3 mock tests):

  1. known quarter в MV → market_price filled
  2. quarter не в MV → deals_count=0, source=no_data
  3. invalid cad → 404 (no regression)

Test plan

  • Schema verified
  • backend: ruff check + ruff format + mypy clean
  • backend tests: 3 mock tests (Linux Docker — Windows skip per WeasyPrint deps)
  • no regression на adjacent endpoints (inline_weights / competitors / best_layouts / connection_points)
  • qa-tester post-deploy: curl POST /analyze на test cad → market_price.median finite + source=quarter_mv

Closes #33 (full close — Phase A merged in #209, Phase B here completes acceptance "/analyze возвращает quarter price").

Phase C (Celery beat weekly refresh) — отдельный issue в follow-up.

## Summary **Issue #33 PR B** — analyze integration с `mv_quarter_price_per_m2` (созданным в PR #209). ### Backend changes - `POST /analyze` response расширен `market_price`: ```json { "p25": 142000.0, "median": 158500.0, "p75": 175000.0, "mean": 159823.5, "deals_count": 87, "median_6m": 161000.0, "median_12m": 156000.0, "median_24m": 150000.0, "last_deal_date": "2026-04-12", "source": "quarter_mv" } ``` Или fallback при отсутствии данных: ```json {"deals_count": 0, "source": "no_data"} ``` - Schema `MarketPrice(BaseModel)` в `schemas/parcel.py` для документации типов - `district.median_price_per_m2` **kept as-is** для backward-compat — frontend Phase B переключится отдельным PR ### MV stats (verified live на проде после PR #209 deploy) - **52 492 quarters** в `mv_quarter_price_per_m2` ✅ - Sample queries работают через `quarter_cad_number` lookup ### Tests `backend/tests/api/v1/test_analyze_market_price.py` (NEW, 3 mock tests): 1. known quarter в MV → market_price filled 2. quarter не в MV → `deals_count=0, source=no_data` 3. invalid cad → 404 (no regression) ## Test plan - [x] Schema verified - [x] backend: ruff check + ruff format + mypy clean - [ ] backend tests: 3 mock tests (Linux Docker — Windows skip per WeasyPrint deps) - [x] no regression на adjacent endpoints (inline_weights / competitors / best_layouts / connection_points) - [ ] **qa-tester post-deploy**: `curl POST /analyze` на test cad → `market_price.median` finite + `source=quarter_mv` Closes #33 (full close — Phase A merged in #209, Phase B here completes acceptance "/analyze возвращает quarter price"). Phase C (Celery beat weekly refresh) — отдельный issue в follow-up.
lekss361 added 1 commit 2026-05-16 11:19:02 +00:00
POST /analyze returns market_price из mv_quarter_price_per_m2 (52K quarters).
Fields: p25/median/p75/mean/median_{6,12,24}m/deals_count/last_deal_date.
source='quarter_mv' либо 'no_data'. district.median_price_per_m2 kept для backward-compat.

MarketPrice BaseModel в schemas. 3 mock tests.
Author
Owner

Deep Code Review — verdict

Summary

  • Status: APPROVE (medium-nits only, no blocking issues)
  • Files reviewed: 3 (P1: parcels.py+51, schemas/parcel.py+22 · P3 test: +263)
  • Lines: +336 / -0
  • PR: #210 · head 614c951 · base efa66a9f · mergeable=true
  • Auto-merge: BLOCKED per scope policy (backend/app/api/v1/** + backend/app/schemas/**) — needs human merge.

🔴 Critical

None.

🟠 High

None.

🟡 Medium (желательно fix — отдельным follow-up PR можно)

  • DRY: дубль _derive_quarter_cadparcels.py:1804-1806 inline реализует

    _cad_parts = cad_num.split(":")
    _quarter_cad = ":".join(_cad_parts[:3]) if len(_cad_parts) >= 3 else cad_num
    

    Идентичная функция уже существует: backend/app/services/cadastre/bulk_harvest.py:1182-1187 (_derive_quarter_cad(cad_num: str) -> str) и используется в 5+ местах bulk_harvest. Дополнительно services/site_finder/cadastre_fetch.py:45,61 дублирует тот же split-by-colon. Fix: либо from app.services.cadastre.bulk_harvest import _derive_quarter_cad (но _-prefix намекает на private), либо вынести в app/services/utils/cad.py единым хелпером (рекомендую) — отдельным refactor PR после merge.

  • Test coverage пропускает NULL sub-windowtest_market_price_found_in_mv всегда даёт median_6m=Decimal(...). На проде verified что median_6m/median_12m часто = NULL (нет сделок в 6m окне) — этот ровно тот edge-case что PR обрабатывает (if mp_row["median_6m"] is not None else None), и он не покрыт тестом. Добавь 4-й тест с mv_data["median_6m"] = None, mv_data["median_12m"] = None → assert mp["median_6m"] is None.

🟢 Low / nits (можно проигнорировать)

  • MarketPrice(BaseModel) объявлена в schemas/parcel.py но не используется как response_model (endpoint возвращает untyped dict[str, Any]). Per PR body это intentional — schema живёт для документации. OK для текущего scope, но при codegen OpenAPI типы не появятся у фронта автоматически → frontend Phase C прийдётся прописать TS-тип руками.
  • test_market_price_invalid_cad_returns_404 docstring "404 expected" но assert status_code in (202, 404) — расхождение между описанием и проверкой. Нит.
  • _cad_parts / _quarter_cad локальные _-prefixed имена внутри функции — style nit, обычно _ для module-private. Не блокер.

Cross-file impact analysis

  • district.median_price_per_m2 оставлен intact (parcels.py:1846) → frontend LandTab / MarketTab не сломается; Phase C переключение отдельным PR (declared в body).
  • Все 17 db.execute(...) call-sites в analyze_parcel пересчитаны — тестовый responses[] индекс 13 (market_price после success_recommendation) совпадает с фактическим порядком в коде.
  • Никакой другой caller mv_quarter_price_per_m2 пока нет (grep по backend/ чист) — single-consumer, blast radius минимален.
  • Frontend ещё не читает market_price (grep frontend/ чист) — добавление поля aдитивно, никакого TS regen не нужно для этого PR.

Vault cross-check

  • domains/rosreestr/MV_Quarter_Price_Per_M2_May16.md — schema vault entry создана в PR #209, актуальна.
  • domains/api/endpoints/Endpoint_Parcel_Analyze.md — упоминание market_price уже добавлено (verified via simple_search).
  • limitations/Limit_District_Median_Null.md + limitations/Limit_Trend_Always_Capped_OPEN.md — обе ссылаются на #33 как решение, consistent с этим PR.

Performance findings

Live EXPLAIN на MV (через MCP postgres):

Index Scan using mv_quarter_price_pk on mv_quarter_price_per_m2  (cost=0.29..8.31 rows=1 width=68)
  Index Cond: ((quarter_cad_number)::text = '66:41:0204016'::text)
  • Unique btree на quarter_cad_number (PK от data/sql/95_mv_quarter_price.sql)
  • Sub-ms lookup на 52 492-row MV — добавляет ~1ms к /analyze (negligible относительно остальных query).
  • Live verified: MV содержит 52K строк all-Russia, sample rows показывают median_6m=NULL legitimately — fallback логика правильная.

Conventions

  • psycopg v3 only (no psycopg2 import).
  • CAST trap check passed: grep -nE ':[a-z_]+::[a-z]' parcels.py → 0 hits.
  • Parameterized SQL (:q), no f-string injection.
  • logger.warning(...) не print(...).
  • Literal import present в schemas/parcel.py:2 (from typing import Any, Literal).
  • Any import present в parcels.py:6.
  • SAVEPOINT pattern (with db.begin_nested():) защищает outer tx — matches Bug_Pzz_Loader_Missing_Savepoint lesson.
  • Literal["quarter_mv", "no_data"] для source — type-safe enum.
  • Линии ≤100 (multi-line ternary для median_6m/12m/24m корректно разбит).

Positive observations

  • Try/except + fallback {"deals_count": 0, "source": "no_data"} гарантирует no-500 при missing data — graceful degradation.
  • int(mp_row["deals_count"]) без if not None else — корректно, MV всегда возвращает deals_count >= 3 (HAVING фильтр), NULL невозможен.
  • last_deal_date.isoformat() правильно сериализует dt.date для JSON.
  • Decimal → float cast в endpoint — test проверяет через pytest.approx.
  • Test docstring явно перечисляет 17 db.execute call positions — высокий уровень documentation discipline.
  • PR scope строго ограничен (3 файла, 0 deletions, backward-compat поле district.median_price_per_m2 сохранено).
  1. Merge as-is — все medium nits можно адресовать в follow-up.
  2. (Optional follow-up) Extract _derive_quarter_cadapp/services/utils/cad.py и dedupe 3 call-sites (bulk_harvest.py, cadastre_fetch.py, нового — parcels.py).
  3. Post-deploy QA (per Test plan): curl POST /analyze на cad из 66:41:* → проверить market_price.source value. На проде EKB-quarter может legitimately дать no_data если в нём нет 3+ ДДУ за 24mo, это OK.
  4. Phase C (frontend): добавить TS тип MarketPrice руками (codegen не подхватит — schema не привязана к response_model) + рендер в MarketTab / OverviewTab.

Complexity / blast radius score

  • Risk: Low — additive поле, no breaking change, fallback на no_data при любой ошибке.
  • Reversibility: Easy revert — single commit, no DB migration in this PR (MV уже в проде через #209).
  • Recommended merge window: anytime — нет caller'ов, frontend не сломается.

Auto-merge decision

PR diff touches backend/app/api/v1/parcels.py + backend/app/schemas/parcel.pyscope BLOCKED per .claude/agents/deep-code-reviewer.md Auto-merge policy. Approved but needs human merge.

## Deep Code Review — verdict <!-- gendesign-deep-reviewer: sha=614c951 verdict=approve scope=blocked --> ### Summary - **Status**: ✅ APPROVE (medium-nits only, no blocking issues) - **Files reviewed**: 3 (P1: `parcels.py`+51, `schemas/parcel.py`+22 · P3 test: +263) - **Lines**: +336 / -0 - **PR**: #210 · head `614c951` · base `efa66a9f` · mergeable=true - **Auto-merge**: BLOCKED per scope policy (`backend/app/api/v1/**` + `backend/app/schemas/**`) — needs human merge. ### 🔴 Critical _None._ ### 🟠 High _None._ ### 🟡 Medium (желательно fix — отдельным follow-up PR можно) - [ ] **DRY: дубль `_derive_quarter_cad`** — `parcels.py:1804-1806` inline реализует ```python _cad_parts = cad_num.split(":") _quarter_cad = ":".join(_cad_parts[:3]) if len(_cad_parts) >= 3 else cad_num ``` Идентичная функция уже существует: `backend/app/services/cadastre/bulk_harvest.py:1182-1187` (`_derive_quarter_cad(cad_num: str) -> str`) и используется в 5+ местах bulk_harvest. Дополнительно `services/site_finder/cadastre_fetch.py:45,61` дублирует тот же split-by-colon. Fix: либо `from app.services.cadastre.bulk_harvest import _derive_quarter_cad` (но `_`-prefix намекает на private), либо вынести в `app/services/utils/cad.py` единым хелпером (рекомендую) — отдельным refactor PR после merge. - [ ] **Test coverage пропускает NULL sub-window** — `test_market_price_found_in_mv` всегда даёт `median_6m=Decimal(...)`. На проде verified что `median_6m`/`median_12m` часто = NULL (нет сделок в 6m окне) — этот ровно тот edge-case что PR обрабатывает (`if mp_row["median_6m"] is not None else None`), и он не покрыт тестом. Добавь 4-й тест с `mv_data["median_6m"] = None, mv_data["median_12m"] = None` → assert `mp["median_6m"] is None`. ### 🟢 Low / nits (можно проигнорировать) - [ ] `MarketPrice(BaseModel)` объявлена в `schemas/parcel.py` но не используется как `response_model` (endpoint возвращает untyped `dict[str, Any]`). Per PR body это intentional — schema живёт для документации. OK для текущего scope, но при codegen OpenAPI типы не появятся у фронта автоматически → frontend Phase C прийдётся прописать TS-тип руками. - [ ] `test_market_price_invalid_cad_returns_404` docstring "404 expected" но assert `status_code in (202, 404)` — расхождение между описанием и проверкой. Нит. - [ ] `_cad_parts` / `_quarter_cad` локальные `_`-prefixed имена внутри функции — style nit, обычно `_` для module-private. Не блокер. ### Cross-file impact analysis - ✅ `district.median_price_per_m2` оставлен intact (`parcels.py:1846`) → frontend `LandTab` / `MarketTab` не сломается; Phase C переключение отдельным PR (declared в body). - ✅ Все 17 `db.execute(...)` call-sites в `analyze_parcel` пересчитаны — тестовый `responses[]` индекс 13 (market_price после success_recommendation) совпадает с фактическим порядком в коде. - ✅ Никакой другой caller `mv_quarter_price_per_m2` пока нет (`grep` по `backend/` чист) — single-consumer, blast radius минимален. - ✅ Frontend ещё не читает `market_price` (grep `frontend/` чист) — добавление поля aдитивно, никакого TS regen не нужно для этого PR. ### Vault cross-check - ✅ `domains/rosreestr/MV_Quarter_Price_Per_M2_May16.md` — schema vault entry создана в PR #209, актуальна. - ✅ `domains/api/endpoints/Endpoint_Parcel_Analyze.md` — упоминание `market_price` уже добавлено (verified via simple_search). - ✅ `limitations/Limit_District_Median_Null.md` + `limitations/Limit_Trend_Always_Capped_OPEN.md` — обе ссылаются на #33 как решение, consistent с этим PR. ### Performance findings Live `EXPLAIN` на MV (через MCP postgres): ``` Index Scan using mv_quarter_price_pk on mv_quarter_price_per_m2 (cost=0.29..8.31 rows=1 width=68) Index Cond: ((quarter_cad_number)::text = '66:41:0204016'::text) ``` - ✅ Unique btree на `quarter_cad_number` (PK от `data/sql/95_mv_quarter_price.sql`) - ✅ Sub-ms lookup на 52 492-row MV — добавляет ~1ms к `/analyze` (negligible относительно остальных query). - ✅ Live verified: MV содержит 52K строк all-Russia, sample rows показывают `median_6m=NULL` legitimately — fallback логика правильная. ### Conventions ✅ - psycopg v3 only (no `psycopg2` import). - **CAST trap check passed**: `grep -nE ':[a-z_]+::[a-z]' parcels.py` → 0 hits. - Parameterized SQL (`:q`), no f-string injection. - `logger.warning(...)` не `print(...)`. - `Literal` import present в `schemas/parcel.py:2` (`from typing import Any, Literal`). - `Any` import present в `parcels.py:6`. - SAVEPOINT pattern (`with db.begin_nested():`) защищает outer tx — matches `Bug_Pzz_Loader_Missing_Savepoint` lesson. - `Literal["quarter_mv", "no_data"]` для `source` — type-safe enum. - Линии ≤100 (multi-line ternary для `median_6m/12m/24m` корректно разбит). ### Positive observations - ✅ Try/except + fallback `{"deals_count": 0, "source": "no_data"}` гарантирует no-500 при missing data — graceful degradation. - ✅ `int(mp_row["deals_count"])` без `if not None else` — корректно, MV всегда возвращает `deals_count >= 3` (HAVING фильтр), NULL невозможен. - ✅ `last_deal_date.isoformat()` правильно сериализует `dt.date` для JSON. - ✅ `Decimal → float` cast в endpoint — test проверяет через `pytest.approx`. - ✅ Test docstring явно перечисляет 17 db.execute call positions — высокий уровень documentation discipline. - ✅ PR scope строго ограничен (3 файла, 0 deletions, backward-compat поле `district.median_price_per_m2` сохранено). ### Recommended next steps 1. **Merge as-is** — все medium nits можно адресовать в follow-up. 2. (Optional follow-up) Extract `_derive_quarter_cad` → `app/services/utils/cad.py` и dedupe 3 call-sites (`bulk_harvest.py`, `cadastre_fetch.py`, нового — `parcels.py`). 3. **Post-deploy QA** (per Test plan): `curl POST /analyze` на cad из `66:41:*` → проверить `market_price.source` value. На проде EKB-quarter может legitimately дать `no_data` если в нём нет 3+ ДДУ за 24mo, это OK. 4. Phase C (frontend): добавить TS тип `MarketPrice` руками (codegen не подхватит — schema не привязана к response_model) + рендер в `MarketTab` / `OverviewTab`. ### Complexity / blast radius score - **Risk**: Low — additive поле, no breaking change, fallback на no_data при любой ошибке. - **Reversibility**: Easy revert — single commit, no DB migration in this PR (MV уже в проде через #209). - **Recommended merge window**: anytime — нет caller'ов, frontend не сломается. ### Auto-merge decision PR diff touches `backend/app/api/v1/parcels.py` + `backend/app/schemas/parcel.py` → **scope BLOCKED** per `.claude/agents/deep-code-reviewer.md` Auto-merge policy. Approved but **needs human merge**.
lekss361 merged commit 27a0957bb5 into main 2026-05-16 11:28:17 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#210
No description provided.