feat(parcels): best-layouts endpoint + service (#113 PR C) #196
No reviewers
Labels
No labels
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
Fable 5 ревью
feedback/max
generative
GG-форсайт
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
вторичка
ИРД
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#196
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "feat/113-best-layouts-endpoint"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
PR C (3 из 4) для Issue #113 Layout Analysis. Builds on PR #193 (schemas/helpers) + PR #194 (mv_layout_velocity).
Что в PR
POST /api/v1/parcels/{cad_num}/best-layouts— top layout patterns у конкурентов в радиусеbackend/app/services/site_finder/best_layouts.py(NEW, 563 LOC) — service layerbackend/app/api/v1/parcels.py(+24 LOC) — endpoint после/competitorsАлгоритм (4 SQL queries)
cad_parcels_geom→ fallbackcad_quarters_geom) — pattern из quarter_dump_lookup.pyST_DWithin+ optionalobj_class_filtermv_layout_velocityperroom_bucket(sum_deals, avg_area, avg_price)domrf_kn_flatsс CASE для(rb, ab)mapping — один query вместо NRecommendation logic
target_total_flats(optional) →abs_unitsper bucketconfidence∈ {high/medium/low} per coverage % (≥50/≥20/<20)rationale_text— human-readable descriptionPhase 2.1 limitation
layout_type(евро/классика) иbalcony_count— отсутствуют вdomrf_kn_flatsroom_bucket × area_bin= 30 классов (достаточно для unit-mix ТЗ)layout_type— follow-upCarryover bug flag (НЕ fixed в этом PR)
competitors.py:143WHERE f.status='sold'всегда 0 (поле 99.8% NULL на проде). PR C обходит — используетobjective_corpus_room_month(через MV). Follow-up issue для #112 рекомендуется.Test plan
pytest tests/api/v1/test_parcel_best_layouts.py -v— 10/10 passpytest tests/api/v1/test_parcel_{competitors,connection_points}.py tests/test_layout_signature.py -q— 43 pass, no regressionruff check + formatcleanmypy strictcleancurl POST /best-layoutsна test cad с реальным radius → проверить shape + non-empty top_layouts на high-density cad'еKnown minor follow-ups (после merge)
MAX(snapshot_date)subquery — pre-compute как bind paramconfidencethresholds — config-drivenCloses part of #113 (full close after PR D PDF+UI).
POST /api/v1/parcels/{cad_num}/best-layouts — top layout patterns (room_bucket × area_bin) у конкурентов в радиусе с ranking by velocity из mv_layout_velocity + unit-mix recommendation для архитектурного ТЗ. Service (backend/app/services/site_finder/best_layouts.py, 563 LOC): - centroid lookup с fallback (cad_parcels → cad_quarters) - competitors-in-radius через ST_DWithin - aggregate по mv_layout_velocity per room_bucket - supply batch query (один SQL вместо N) для sold_pct_of_supply - Hamilton largest-remainder normalizer (sum_pct=100 invariant) Endpoint (backend/app/api/v1/parcels.py, +24 LOC) после /competitors. Tests (backend/tests/api/v1/test_parcel_best_layouts.py, 10 mock tests): 404, empty competitors, MV empty, exclude/filter, min_velocity, time_window scaling, obj_class_filter, target_total_flats, 3-bucket ranking. Phase 2.1 (без layout_type / balcony_count — отсутствуют в БД, ждут B2B Объектив #52). Reuses PR A schemas/helpers, PR B MV. NOTE: competitors.py:143 status='sold' carryover bug НЕ fixed — отдельный follow-up issue. PR C использует MV (через objective_corpus_room_month) что работает корректно.Deep Code Review — verdict
Summary
services/site_finder/best_layouts.py,api/v1/parcels.py; P3: 1 — test file)fdc6456· base6aa22ef8(mergeable=true, no conflicts, no prior reviews)🔴 Critical (BLOCK merge)
backend/app/services/site_finder/best_layouts.py:64,82,131— columndomrf_kn_objects.geom_3857НЕ существует на проде → endpoint вернёт 500 на любой вызовVerified via
mcp__postgres-gendesign:competitors.py:106(рабочий код в проде) корректно вычисляет геометрию on-the-fly:Но
best_layouts.pyиспользует несуществующий column в трёх местах:_COMPETITORS_IN_RADIUS_SQL(lines 64, 67) —WHERE geom_3857 IS NOT NULL AND ST_DWithin(geom_3857, ...)_SUPPLY_BATCH_SQL(lines 105, 107) —JOIN domrf_kn_objects o ... WHERE o.geom_3857 IS NOT NULL AND ST_DWithin(o.geom_3857, ...)Почему тесты прошли: все 10 тестов — mock-based,
db.execute.side_effect = [...]возвращает MagicMock'и без выполнения SQL. DB-схема не проверяется → false-positive green.Дополнительно: docstring класса утверждает
Геометрия domrf_kn_objects.geom_3857 уже в EPSG:3857(line 60) — это неверное утверждение про схему, не описка.Fix (выровнять с
competitors.py):И в
get_best_layouts(): вместоx_3857/y_3857извлекатьcenter_lon/center_latизcad_*_geom(тем же паттерном чтоcompetitors.pyиспользует в_PARCEL_CENTROID_SQL). После fix также нужен integration test (хотя бы один) который реально гоняет SQL — иначе схема-mismatch снова проскочит.🟠 High (должен быть пофиксен до merge)
Integration test отсутствует, баг прошёл 10/10 unit tests
Все тесты —
MagicMockнаdb.execute.side_effect. Это пропустило column-mismatch и пропустит любой будущий schema drift. Минимум: 1 smoke-тест черезhttpx.AsyncClient + test DBлибо pytest-fixture с реальным connection к локальной prod-snapshot БД на одном cad_num с непустым результатом.Reference из vault:
fixes/Fix_Competitors_Schema_Mismatch_May26.md— точно такой же кейс с #112 (тоже 4 critical schema mismatch'а, пойманы reviewer'ом до merge, не тестами). Повторяющийся pattern → нужна доска "integration-test gate" для site_finder services.🟡 Medium (желательно fix)
best_layouts.py:182—_round_to_5dead codeФункция определена, никем не вызывается (
grep -r _round_to_5 backend/→ один файл). Либо удалить, либо использовать в_normalize_pctесли задумано округление до 5%. Сейчас просто dead.best_layouts.py:411— coverage % считает по post-exclude/filter denominatorobjects_total_in_radiusснимок ДОexclude/filter,obj_ids_with_dataограниченset(all_obj_ids)(после фильтрации). Если user вызвал сfilter_competitor_obj_ids=[X], coverage будет искусственно занижен (1 obj с данными / 30 total = 3%, "low confidence"), хотя в filtered set может быть 100%. PR описание явно упоминает этот minor — flag для follow-up.best_layouts.py:135—MAX(snapshot_date)subquery в WHERE без bindSubquery re-executes на каждый запрос. Лучше pre-fetch в Python (один SELECT на старте) + bind param. На
domrf_kn_flats(407k rows, 96 days истории) MAX поднимет индексidx_kn_flats_region_snap— не катастрофа, но лишние ~20-50ms на запрос.best_layouts.py:417-422— hardcoded thresholds 50/20 для confidenceСогласно PR описанию — config-driven follow-up. Для V1 OK, отметить как TODO с issue link.
🟢 Low / nits
best_layouts.py:182-184—_round_to_5без unit-test — добавить или удалитьbest_layouts.py:25-29—_VELOCITY_DIVISORSliteral —last_year=2.0: 24mo / 12mo = 2 ОК, но если MV окно когда-нибудь поменяется (e.g. 36mo), эта const рассинхронится с94_mv_layout_velocity.sql:51 INTERVAL '24 months'. Magic number — комментарий "matches NN_mv_layout_velocity.sql window" поможетCross-file impact analysis
grep "best-layouts|best_layouts|BestLayouts" frontend/→ 0 matches. Нет client-side caller ещё → API contract change не сломает UI (это PR D будет добавлять).best_layouts: только новый endpointparcels.py:2114. Чистый new code.BestLayoutsRequest/Response,LayoutDataQuality,LayoutTzMixRow,LayoutTzRecommendation,TopLayoutRow— все определены вschemas/parcel.py:151-215(PR A). Импорты ОК.mv_layout_velocityопределён вdata/sql/94_mv_layout_velocity.sql(#194 merged). UNIQUE index(obj_id, room_bucket)+(obj_id)индекс есть —WHERE obj_id = ANY(:obj_ids) GROUP BY room_bucketбудет index-only при <100 obj_id. ✅competitors.py:143carryover (status='sold'): подтверждено в проде —status92% NULL (378578 / 407k rows). PR C правильно обходит — берётtotal_deals_24moизmv_layout_velocity(черезobjective_corpus_room_month, неkn_flats.status). ✅ workaround корректен, в PR описании flagged как known carryover bug.Vault cross-check
code/modules/best_layouts.md— vault entry уже создан worker'ом, корректно описывает алгоритм и carryoverfixes/Fix_Competitors_Schema_Mismatch_May26.md— точно такой же класс bug'а (column не существует) уже был в #112. Сейчас повторяется в #113 PR C → паттерн: "site_finder services пишутся без проверки реальной схемы". Нужно либо integration-test gate, либо CI-проверка schema-references в SQL (grep + DESCRIBE).domains/api/endpoints/Endpoint_Parcel_Competitors.md— описывает рабочий паттернST_MakePoint(longitude, latitude), 4326)::geography. Worker должен был свериться. После fix — обновить vault entrybest_layouts.md(поменять "EPSG:3857 уже в БД" → "computed on-the-fly from lat/lng").Performance findings
После fix column-mismatch:
_COMPETITORS_IN_RADIUS_SQL:ST_DWithinнаgeography→ seq scan поdomrf_kn_objects(4548 rows). Acceptable для 4k rows, но если БД вырастет — добавить GIST index на функциональный(ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)). Сейчас НЕ блокирующее._SUPPLY_BATCH_SQL: JOIN с MAX-subquery — на 407k flats × 4.5k objects → нуженEXPLAIN ANALYZEпосле fix. Pre-computemax_snap→ bind как:max_snapснимет O(N) subquery overhead.mv_layout_velocity_pk (obj_id, room_bucket) UNIQUE+mv_layout_velocity_obj_idx (obj_id)— оба покрываютWHERE obj_id = ANY(:obj_ids) GROUP BY room_bucket.Positive observations
CAST(:x AS float),CAST(:obj_class_filter AS text)(psycopg v3 + SQLAlchemy)text(...)parametrized, никаких f-string в SQLsum=100гарантирован_normalize_pctобработкаtotal<=0edge case естьtry/exceptper-query сlogger.exception— debug-friendly:x::type(psycopg trap), нетprint(), нетrequestsasync defhandler,Depends(get_db)через Annotated — modern FastAPI patterncode/modules/best_layouts.mdсинхронно с кодом (per CLAUDE.md rule #6)feat/..., нет --force/--no-verify в историиcompetitors.py:143 status='sold'обоснованно flagged but not fixed — scope disciplineRecommended next steps
geom_3857наST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geographyв обоих SQL + переделать coord_row наlat/lngвместоx_3857/y_3857_round_to_5code/modules/best_layouts.md(vault) — убрать упоминание "EPSG:3857 в БД"Complexity / blast radius
Auto-merge decision
Scope BLOCKED for auto-merge per
.claude/rules/git-pr.md:backend/app/api/v1/**← touchedbackend/app/services/**← touchedДаже при APPROVE этот PR требует human merge. С текущим 🔴 — main session/user решит сначала пинговать worker для fixup, не мержить.
Action taken: posted verdict comment, НЕ мержил (scope blocked + 🔴 critical).
Deep Code Review — re-review verdict (SHA
a88917f)Summary
fdc6456)fdc6456→ New SHA:a88917f🔴 Critical
Все resolved.
🟠 High
Все resolved. (Integration test gap downgraded to 🟡 — schema bug fixed, runtime risk сильно снижен.)
🟡 Medium (carryover из предыдущего ревью — не блокирующее, можно follow-up issue)
best_layouts.py:382-388— coverage % всё ещё mix pre/post-filter denominators:При aggressive
exclude_competitor_obj_idscoverage будет artificially low → confidence ниже чем должно. Fix: либоobjects_total_in_radius = len(all_obj_ids)после фильтров, либо считать coverage % по pre-filter set целиком.best_layouts.py:112—MAX(snapshot_date)subquery в_SUPPLY_BATCH_SQLre-executes per scan:Pre-compute как bind param:
latest_snap = db.scalar(text("SELECT MAX(snapshot_date) FROM domrf_kn_flats"))→ pass via:latest_snap. Один extra round-trip vs повторение в spatial scan.best_layouts.py:389-393— hardcoded confidence thresholds (50.0/20.0). Move вsettings.py(LAYOUT_CONFIDENCE_HIGH_PCT/LAYOUT_CONFIDENCE_MEDIUM_PCT) или в module-level constants для easier tuning.best_layouts.py— no real-DB integration test. Mock tests passed на сломаннойgeom_3857версии (paтterned afterfixes/Fix_Competitors_Schema_Mismatch_May26.md). Severity downgraded (схема fixed, та же on-the-fly pattern что и workingcompetitors.py:106), но добавить smoke test с реальной БД для регрессионного покрытия желательно — можно в PR D / отдельном test-only PR.🟡 New observation
best_layouts.py:103-117(_SUPPLY_BATCH_SQL) —ST_DWithinна on-the-fly geography без spatial index (поскольку нетgeom_3857column → нет GIST). Для prod radii 1-3 km в EKB likely OK (filter by spatial bbox черезST_DWithincheap geography). Тот же trade-off что вcompetitors.pyaccepts — noted, not blocking.🟢 Resolved since
fdc6456best_layouts.py:64,82,131—geom_3857references gone — заменены наST_SetSRID(ST_MakePoint(longitude, latitude), 4326)::geographyconsistently в обоих SQL (_COMPETITORS_IN_RADIUS_SQL+_SUPPLY_BATCH_SQL). Schema verified через postgres MCP:domrf_kn_objectsимеетlatitude/longitude NUMERIC(нетgeom_3857),domrf_kn_flatsditto.x_3857/y_3857→center_lon/center_lat(consistency с reference implcompetitors.py)._round_to_5dead code removed.Cross-file impact
best_layouts.py— следует established pattern изcompetitors.py:106(geography on-the-fly). Other callers не затронуты.parcels.py— endpoint только добавлен, существующие handlers не задеты.mv_layout_velocity(PR B #194) — usage в_VELOCITY_BY_ROOM_SQLкорректное, columns matches schema PR.Vault cross-check
fixes/Fix_Competitors_Schema_Mismatch_May26.mdpattern (та же проблема — column non-existent — здесь предотвращена fixup'ом).fixes/Fix_BestLayouts_geom_3857_Phantom_May26.mddocumenting:geom_3857column наdomrf_kn_objects, которого никогда не существовало\d domrf_kn_objectsPerformance findings
_COMPETITORS_IN_RADIUS_SQL+_SUPPLY_BATCH_SQL: on-the-fly geography без spatial index. Acceptable для EKB radii. Worth EXPLAIN на проде через qa-tester post-deploy.Positive observations
Auto-merge decision
Scope BLOCKED для auto-merge — PR touches:
backend/app/api/v1/**(parcels.py)backend/app/services/**(best_layouts.py)Per Auto-merge policy в
.claude/agents/deep-code-reviewer.mdсекция "Auto-merge BLOCKED" — даже при verdict без 🔴/🟠 reviewer не мержит сам. Main session / user принимают merge decision.Recommended next steps
qa-testerpost-deploy smoke —curl POST /best-layoutsна high-density cad'е (e.g.66:41:0303161:...) → verify shape + non-empty top_layouts + EXPLAIN на проде для confirm spatial scan не Seq Scan'ит всюdomrf_kn_flats(~2M rows).fixes/Fix_BestLayouts_geom_3857_Phantom_May26.mdper CLAUDE.md rule #6.Complexity / blast radius
Re-review by
deep-code-reviewersubagent. Posted aslekss361. Scope blocked → main session / user merge decision.Deep Code Review — verdict (iteration 3, SHA
8488012f)Summary
backend/app/api/v1/parcels.py,backend/app/services/site_finder/best_layouts.py; P3: 1 —backend/tests/api/v1/test_parcel_best_layouts.py)a88917f: +46 / −21 across 2 files)fdc6456🔴 BLOCK ·a88917f⚠️ MINOR ·8488012f✅ APPROVECarryover items — status
_round_to_5dead codea88917f)best_layouts.py:389-395—objects_total_after_filter = len(all_obj_ids),objects_with_data = len(obj_ids_with_data & set(all_obj_ids))(intersection с post-filter)MAX(snapshot_date)re-executed subquerybest_layouts.py:315— pre-computed viadb.scalar(...), bound as:latest_snap(CAST AS date) in_SUPPLY_BATCH_SQLbest_layouts.py:64-66—LAYOUT_CONFIDENCE_HIGH_PCT = 50.0,LAYOUT_CONFIDENCE_MEDIUM_PCT = 20.0module constants (not fullsettings.*config-driven, но acceptable v1 + есть комментарий "Tune via PR")_SUPPLY_BATCH_SQLon-the-fly geography (no GIST)competitors.pypattern. Schema fixgeom_3857→ST_MakePoint(longitude, latitude)::geographypreserved froma88917f✅. Optimization (generated column + GIST) — separate infra PR.Schema verification (live DB via MCP)
domrf_kn_objects:latitude/longitudeNUMERIC (nogeom_3857) — matches PR usagedomrf_kn_flats:total_area NUMERIC,rooms INT,is_studio BOOL NULL,flat_type TEXT,snapshot_date DATE NOT NULL,obj_id BIGINT— все referenced колонки на месте;f.is_studio = TRUEкорректно обрабатывает NULL (falls through наflat_typecheck)mv_layout_velocity: PK(obj_id, room_bucket)→WHERE obj_id = ANY(:obj_ids)использует index;GROUP BY room_bucketfinebackend/app/schemas/parcel.py):BestLayoutsRequest.time_window: Literal["last_month", "last_quarter", "last_year"]→_VELOCITY_DIVISORS[request.time_window]не может бросить KeyError на провалидированном inputNew observations (+46 lines)
db.scalar(text("SELECT MAX(snapshot_date) FROM domrf_kn_flats"))— full-table aggregate; на партиционированной таблице планировщик берёт MAX-per-partition (cheap), один-shot. Acceptable v1.LayoutDataQuality.objects_total_in_radiusтеперь семантически означает "post-filter count" (после exclude/filter), не "raw radius count". Поле новое — нет backward-compat issue, но рекомендуется уточнить в docstring schema. Cosmetic._make_db.db.scalar.return_value = latest_snap if latest_snap is not None else _TODAY— корректно покрывает новыйdb.scalarcall.Remaining 🟡 (non-blocking)
pytest -m integrationс фикстурой geom + supply data. Worker открыл follow-up issue. Не блокирует merge — 10/10 mock tests + ручной qa-tester smoke post-deploy достаточно для ship.Positive observations
geom_3857→ on-the-fly geography) preserved без regressionCAST(:x AS type)per.claude/rules/backend.md), no SQL injection vectorpsycopg v3(через SQLAlchemy text + session.execute) — никакогоpsycopg2logger.*неprint(),httpxнеrequests_VELOCITY_DIVISORSlookup type-safe (Literal-constrained input)test_three_obj_ids_ranking_and_pct_sum_100)quarter_dump_lookup.pypatternCross-file impact analysis
backend/app/schemas/parcel.py— schemasBestLayoutsRequest/Response,TopLayoutRow,LayoutTzMixRow/Recommendation,LayoutDataQualityимпортируются только из этого PR (verified — no other consumers in this repo state)backend/app/services/site_finder/layout_signature.py(из PR A #193) —area_bin(),layout_signature()reused correctlymv_layout_velocity(PR B #194) — query patternobj_id = ANY(:obj_ids)использует PK index, no perf concernRisk / merge
Final action
backend/app/api/v1/**+backend/app/services/**→ ❌ Auto-merge BLOCKED per.claude/rules/git-pr.mdpolicyqa-testerдляcurl POST /api/v1/parcels/<test_cad>/best-layoutsна high-density cad (ЕКБ центр) + проверка response shape + non-empty top_layouts