Code-review nits (без изменения поведения):
- quarter_dump_lookup.py: комментарий про дедуп ошибочно утверждал, что зоны без
рег-номера не схлопываются. На деле reg_numb_border — TEXT NOT NULL (0 NULL на
проде), а DISTINCT ON в PG считает NULL равными. Переформулировано на точное.
- frontend/types/nspd.ts: убран устаревший 'cad_zouit' из списка возможных
group_key (бэкенд теперь эмитит только protected/engineering/okn/natural/other).
#1954 — площадь «—»: COALESCE(land_record_area, specified_area, declared_area)
в EGRN-блоке analyze (land_record_area NULL у 12809/42233 участков, но
specified_area заполнена). area_m2 для 66:41:0205010:287 теперь 106378 (был NULL).
#1954 — «Обновлено» сломано: cost_registration_date — мёртвая колонка (0/42233);
репойнт на updated_at (42233/42233 заполнено). Ключ ответа last_egrn_update_date
не меняется (additive value fix).
#1958 — confidence-фактор «Прогноз спрос/предложение» дублировался ×4
(по фактору на горизонт). Сворачиваем в один weakest-link'ом (MIN ранга
по горизонтам) в _component_confidences до confidence-движка (#990).
#1957 — ЗОУИТ backend:
- _get_cad_zouit_overlaps: DISTINCT ON (reg_numb_border) — дедуп дубль-строк
(одна физ. зона 2× с разным category_name). group_key cad_zouit→protected.
- _get_zouit_overlaps (dump path): subcategory→RU-тип карта (26→СЗЗ и др.,
коды сверены кросс-джойном dump↔cad_zouit на проде); type_zone из карты,
reg из props.options.reg_numb_border. Раньше отдавал blank-строки.
- унификация group_key (protected/engineering/okn/natural/other) + top-level
reg_numb_border в обоих путях.
UP038-модернизация isinstance в report_assembler (pre-commit ruff 0.7.4).
Frontend note (#1957): nspd_zouit_overlaps теперь всегда group_key из набора
{protected,engineering,okn,natural,other} — сырой 'cad_zouit' больше не отдаётся;
оба пути несут type_zone + reg_numb_border.
Tests: +4 _component_confidences collapse, +6 ЗОУИТ (subcategory map, dump
typing, DISTINCT ON), schema-test обновлён на protected. 451 passed targeted.
The data-freshness monitor classified by run RECENCY only, so the domrf_kn
FLATS loader running status=done but extracting 0 flats for ~5 weeks went
undetected — and the kn source watched objects_count (healthy ~1548), not
flats_count (the broken =0 metric).
Add an opt-in zero-output check: an otherwise-fresh run-ledger source (recent
success, would-be fresh by age) that produced 0 work-rows in the 7d window is
downgraded to status="failed" (so scrape_freshness_check alerts), with an
additive "reason". Guards: alert_on_zero_output flag, run-ledger only
(timestamp_col is None), status=="ok" (age-stale/failed already covered), and
upd_7d==0 (SUM of the source's own work_col over done-runs).
Registry: new kn_flats source (kn_scrape_runs, work_col=flats_count, critical,
flag on) — watches the column that was broken; existing kn (objects_count)
unchanged. Flag also enabled on objective (rows_lots, critical). nspd/nspd_geo/
cadastre left unflagged (legitimate-0 / data-table).
JSON additive only (new nullable "reason" key; endpoint is dict[str,Any], no
frontend consumer / no codegen needed). 4 new tests (downgrade, no-false-
positive, age-precedence, registry). code-reviewer APPROVE.
Would have caught #1945 within ~8-14d instead of 5 weeks.
domrf_kn_objects is a snapshot dimension (UNIQUE (obj_id, snapshot_date), ~8
snapshots/obj_id). _SUPPLY_BATCH_SQL joined flats to ALL object-snapshot rows
(no o.snapshot_date filter), counting each flat ~8.5x → supply_units_in_radius
inflated ~8.5x, sold_pct_of_supply deflated ~8.5x, is_oversold under-fired
(all user-facing, best_layouts.py:571-611; sold_pct=deals/supply is a raw
ratio so no canceling).
Fix: dedup objects to one row per obj_id (latest-snapshot coords) via
DISTINCT ON in an objects-first MATERIALIZED CTE, then join domrf_kn_flats via
idx_kn_flats_obj. units now = one count per flat (prod cross-check at radius
1.5km: units == count(*) == count(DISTINCT f.id) == 9612 for 65 objects;
correction factor 8.56x at 1.5km, 9.13x at 1.0km). This also aligns the supply
denominator with the deals numerator (_COMPETITORS_IN_RADIUS_SQL already uses
DISTINCT ON latest snapshot).
Perf bonus: objects-first avoids the parallel seq scan of the ~376k-row flats
snapshot. radius 1.5km / snapshot 2026-05-17: 240ms/~28k buffers/6712 disk
reads -> 49ms/1554 buffers/0 disk reads (~5x).
Tests: add SQL-text fan-out guard (DISTINCT ON + MATERIALIZED, no bare
flats->objects join); update stale EXPLAIN mirror in test_phantom_columns.
USER-FACING: best-layouts supply/sold_pct/is_oversold/sell-out-months shift
~8.5x toward correct (frontend BestLayoutsBlock only; ТЗ recommendation + PDF
unchanged — they derive from sum_deals, not supply). Deep-reviewed (APPROVE).
App-level logs (logging.getLogger("app.*")) had no StreamHandler, so their
INFO was lost — `docker logs gendesign-backend-1` showed only uvicorn loggers.
Useful lines (e.g. "OSRM road-distance applied", RBAC, analyze) were invisible
when debugging on the VPS (only Sentry/GlitchTip received them as breadcrumbs).
main.py now attaches one StreamHandler to the root "app" logger at
APP_LOG_LEVEL (default INFO), idempotent (marker guard), propagate=True so the
Sentry LoggingIntegration on root still gets breadcrumbs/events and there's no
stdout double (root has no stdout handler). API process only — celery worker
(celery_app.py) untouched, since its loaders log per-sync and would be noisy.
Flood-safe on the API: the analyze hot path logs INFO per-request (parcels.py
has 3 logger.info), not per-row.
Adds tests/test_app_logging.py (handler present, level INFO, propagate intact).
Refs #1926
Walk-relevant POIs (school/shop/park/kindergarten/pharmacy/stops) now route
via a FOOT OSRM graph (osrm-walk service), car-relevant POIs (mall/hospital +
unknown) keep the DRIVING graph. Validation showed driving overstated
pedestrian-proximity distance — median 1.6-2.9x straight-line (#39).
- config: osrm_walk_local_url + osrm_walk_categories (frozenset, 9 walk cats)
- osrm_client_local: base_url override on get_road_distances_m (default unchanged)
- _apply_osrm_road_distances: split POIs by category, per-group OSRM call with
independent graceful fallback (one server down -> its group keeps straight-line),
in-place write-back by original index; never raises; flag-OFF byte-identical
- docker-compose: osrm-walk service (foot graph, internal, mem_limit 1.5g)
- build_osrm.sh: CAR=0 gate for foot-only refresh (doesn't touch live car graph)
- tests: per-category split, per-group fallback, asymmetric intra-group write-back
Still flag-gated (use_osrm_distances OFF) — enabling is a product decision.
Refs #39
kn (DOM.РФ ЖК) runs weekly (Mon cron) but thresholds were 2/5 → false "stale" every Wed-Sun. Recalibrate to 8/14 (weekly, mirrors objective) + regression test. Surfaced by the freshness monitor's first prod run. Refs #73
smr/sale_inflation_annual in _build_monthly_cashflow, gated by module constants =0.0 → byte-identical zero-diff (deep-review verified IEEE-754 identity + all 57 prior tests unchanged). Land not escalated; marketing/VAT/tax retimed on escalated revenue (total preserved). Capability wired+tested, OFF until calibrated indices + invariant reframe. No schema/codegen. Refs #1881
Extract compute_freshness(db) from the /admin/scrape/freshness handler (endpoint unchanged) + daily beat task scrape_freshness_check that alerts via sentry_sdk.capture_message when a source is stale/failed (error on critical-failed, else warning). Registered in celery include + beat (09:00 MSK). Refs #73
Cache-miss upsert now runs in a fresh SessionLocal() instead of committing the shared /analyze transaction mid-request. Read-back via shared session unchanged. Refs #1850
New price tier objective_geo_radius: ST_DWithin median of Objective new-build prices (objective_lots ⋈ complexes) within 3km of parcel centroid, between quarter-MV and district_reference. Closes the name-match gap (5 of 9 EKB districts had no Objective name-match). data/sql/168 functional GIST index (prod EXPLAIN 114ms→28ms). Degenerate-centroid guard + honest RU price_source captions. Deep-review ✅.
Refs #1881
VAT charged on full gross margin → only on parking value-added; residential (реализация жилья + услуги застройщика по ДДУ) exempt per пп.22/23.1 п.3 ст. 149 НК; input VAT embedded in gross СМР. Σ cashflow == net_profit invariant preserved. PDF + concept-UI caveats synced. +3 tests, deep-review ✅.
Refs #1881
ZOUIT_OVERLAP_SUB17 (единственный subcategory-blocker = охранная зона
ЛЭП/газа/трубопровода) был ХАРД-блокером на ЛЮБОЕ пересечение → блокировал
434/574 (75.6%) участков как «нельзя строить МКД». Прод-замер: реальное
покрытие участка этими зонами median=6%, p90=47%, только 1/17 >60% — тонкие
инж-коридоры. По РФ охранная зона ограничивает застройку ВНУТРИ полосы, а не
весь участок (МКД сажается на незанятой части). Хард-блок на 6%-покрытии неверен
и (после фикса резолва зон #1891) прятал financial_estimate у ~90% участков.
Фикс: area-gate.
- quarter_dump_lookup._get_zouit_overlaps + _get_cad_zouit_overlaps: добавлен
coverage_pct (доля площади участка под зоной, в geography м², NULLIF-guard).
- gate_verdict.compute_gate_verdict: sub-17 (и cad network/blocker) АГРЕГИРУЮТСЯ
(sum-capped, консервативно к блоку) → блокер ТОЛЬКО если покрытие >
settings.gate_zouit_engineering_blocker_min_coverage (0.6, env-tunable), иначе
WARNING ZOUIT_ENGINEERING_PARTIAL с % покрытия. Больше нет 4× дубль-блокеров.
СЗЗ/OKN/прочие ЗОУИТ — без изменений (warnings). main_vri/резидентность — без
изменений.
- coverage_pct отсутствует (legacy/synthetic) → трактуем как 0 → warning
(документировано: дамп всегда даёт coverage; ложный хард-блок дороже).
Тесты: +помесь gate/aggregation/coverage (131 combined passed); правка
_make_zouit_row (7-я колонка coverage_pct) в tests/test_quarter_dump_lookup.py.
Полный бэкенд локально: 2 failed → fixed → перепроверка. ruff чисто. Схема не
менялась (gate_verdict/overlaps — dict[str,Any], api-types regen не нужен).
Refs #1881
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR-A синтезировал zone_code = geoportal-индекс ("Ж-2") → gate.is_residential_zone
матчил ^Ж и считал ЛЮБУЮ Ж-* зону жилой-МКД. Но по zone_regulation_cache.main_vri
(authoritative ВРИ): Ж-1/Ж-2 = только ИЖС (нет кодов МКД) → ^Ж давал FALSE POSITIVE
("можно МКД" на ИЖС-участке — реальный дефект для девелопера); Ц-2 = разрешает МКД
(коды 2.5/2.6) но без Ж-префикса → FALSE NEGATIVE.
Фикс: is_residential_zone получил приоритет-0 — при наличии main_vri решает по нему
авторитетно (mkd_permitted_from_vri: коды 2.1.1/2.5/2.6 = МКД, точное совпадение
токена, "2.50"/"2.59"/"12.0" не матчат), переопределяя ^Ж/subcategory/keyword.
Эвристики остаются ТОЛЬКО как fallback при main_vri=None (dump-путь без geoportal).
parcels.py прокидывает _regulation.main_vri в nspd_zoning на ОБОИХ путях (synth +
dump-augment) → dump-зоны (Ц-2 и пр.) тоже апгрейдятся на main_vri-детекцию.
Унифицирует PR-A+PR-B. Обратная совместимость: main_vri default None.
Проверено по зонам: Ж-3/4/5 permit_mkd=True, Ж-1/2=False(ИЖС), Ц-2=True,
Ц-1/3/4+ЦС-*=False.
Тесты: +22 (mkd_permitted_from_vri true/false/none + no-false-prefix; override
suppress ИЖС / enable Ц-2 / overrides subcategory; fallback при None unchanged;
synth ИЖС-кейс не даёт residential). Полный бэкенд локально: 3442 passed, 0 failed,
coverage 70.3% (gate 65%). ruff чисто. Схема не менялась. Code-review 2×approve 0 major.
Refs #1881#1891
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
financial_estimate фаерил ~0% в проде (0/542 анализов, 90д): резолв ПЗЗ-зоны на
участок проваливался на 94%. Причина — в analyze ДВА резолвера зоны:
(1) get_quarter_dump_data/_get_zoning (nspd_quarter_dumps, точный ST_Intersects) —
терзоны НЕ замощают квартал плотно, центроид участка падает в 45-86м ЗАЗОР между
валидными зонами → nspd_zoning=None у 512/542; (2) get_or_fetch_zone_regulation →
EKBGeoportalClient.zone_index_at (живой геопортал, cache-first) — РАБОТАЕТ, резолвит
gap-участки. Но (2), дающий max_far, был ЗАГЕЙЧЕН за `_nspd_zoning is not None`, т.е.
зазор дампа глушил рабочий резолвер. Плюс: dump кладёт кадастровый рег-номер
("66:41-7.14") в zone_code, а gate.is_residential_zone матчит ^Ж → не срабатывал.
Фикс (PR-A): убран `_nspd_zoning is not None` из условия — резолвер геопортала
работает при наличии centroid (region-guard: только КН 66:41, геопортал ЕКБ-only).
Когда dump зону не дал — синтезируем минимальный nspd_zoning из geoportal:
zone_code = индекс зоны ("Ж-2") → is_residential_zone ^Ж срабатывает → can_build_mkd
резолвится для Ж-* зон. Запись обратно в nspd_dump_data["nspd_zoning"] доходит до
gate / финмоста / ответа (читают по ключу). Когда dump зону ДАЛ — zone_code не
перетираем (raw_props.subcategory детектит жильё), только добавляем regulation-поля.
Геопортал — authoritative источник (point-in-zone по ПОЛНОМУ слою ПЗЗ); «зазор»
только в нашем кэше-дампе, не в реальности. Хот-path-safe (try/except, timeout 3с,
cache-first). Расширение dump-резидентности на Ц-*/ЦС-* → PR-B.
Тесты: +7 (синтез при dump=None+geoportal; нет синтеза при обоих None; dump zone_code
не перетёрт; геопортал падает→деградация; is_residential_zone Ж-2=True, ПК-1/ЦС-3=False).
Кейс синтеза ассертит РЕАЛЬНЫЙ gate can_build_mkd=True. 47 passed (вкл. pre-existing
zoning+gate), ruff+mypy чисто. Схема не менялась (api-types regen не нужен).
Прод-замер: 66:41:0303006:930 (Ж-5 gap) сейчас zone_code/max_far/financial=None,
can_build=False → после деплоя ожидаем Ж-5/max_far=4/can_build=True/financial=PRESENT.
Refs #1881
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Финмодель брала фиксированное окно продаж 30 мес независимо от рынка
(schedule_is_default всегда true). Теперь окно считается из ЛОКАЛЬНОГО темпа
поглощения, который уже вычисляется в том же /analyze, но финмодель его
игнорировала.
Корректность абсорбции (ключевое): velocity.monthly_velocity_sqm — это СУММА
поглощения ВСЕГО конкурентного набора в радиусе (м²/мес), НЕ темп одного
проекта. Поэтому per-project absorption = monthly_velocity / max(n_with_sales,1)
(темп одного типичного локального продавца) — иначе модель считала бы, что новый
проект забирает весь рыночный темп (дико оптимистично). Поле
project_absorption_sqm_per_month добавлено в VelocityResult (objective-путь);
rosreestr-fallback и вырожденные пути → None (поквартальный count без
по-проектной декомпозиции не может задавать график).
financial.py: окно = clamp(ceil(residential/velocity), MIN=6, MAX=120) при
конечной velocity>0; иначе дефолт 30. Эскроу-инвариант сохранён:
sales_end=max(sales_start+base, constr_end). Инвариант Σ cashflow == net_profit
держится (перенос выручки во времени не меняет сумму). schedule_is_default
флипается в false когда график рыночный; новое поле sales_duration_months
(реализованное окно) для UI/PDF.
Wiring: parcels.py → synthesize_parcel_financial(velocity_sqm_per_month) →
compute_financial(market_velocity_sqm_per_month). Generative §1c путь пока
передаёт None (out of scope, follow-up).
Тесты: +13 (None→дефолт+инвариант; рыночная velocity; клампы MIN/MAX; эскроу;
non-finite→fallback; rosreestr→None; инвариант по размерам окна; регресс PR-3 —
ровно одна смена знака на коротком окне). Полный бэкенд: 3414 passed, 0 failed.
ruff+mypy(strict financial.py) чисто. api-types перегенерены.
Code-review: 2× approve, 0 majors (adversarial correctness-lens подтвердил
семантику абсорбции, инвариант, не-proxy IRR, клампы, rosreestr-None).
Refs #1881
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
financial_estimate (эпик #1881) фаерит только при разрезолвленном НСПД-регламенте
(max_far), а он резолвится ЛЕНИВО при анализе участка → zone_regulation_cache всего
33 зоны → у большинства участков financial_estimate=None («—» в кокпите). Backfill
проактивно кэширует ВСЕ террзоны ЕКБ (~100-120) → ЛЮБОй участок в известной зоне
сразу получает регламент и финмодель.
- backfill_ekb_zone_regulations (zone_regulation.py): WFS-перечисление террзон ЕКБ
(features_in_bbox 'territorial_zone') → dedup по urban_index (предпочёт фичу с
геометрией) → representative_point (внутри полигона, не centroid) → zone_regulation_at
→ upsert. ИДЕМПОТЕНТНО (cached skip перед fetch), per-zone try/except (один сбой не
рушит batch), rate_delay вежливость к геопорталу, limit для частичного прогона.
- Celery task backfill_zone_regulations + регистрация в celery_app include +
admin POST /api/v1/admin/scrape/zone-regulations/backfill (паттерн objective sync-our).
- Ленивый refresh_zone_regulations / get_or_fetch / upsert НЕ тронуты (additive путь).
- +9 тестов (dedup, idempotency cached-skip, per-zone error isolation, centroid-inside,
limit). mypy/ruff clean.
Прод-прогон backfill — отдельный шаг после deploy (как Objective sync), не на deploy.
Code-review поймал незарегистрированный task (был бы NotRegistered) — исправлено.
Refs #1881
Когда demand_normalization honesty-gate отбивает β rate-sensitivity
(`sensitivity.confidence=='low'`, ЕКБ-регрессия не проходит n≥30/R²≥0.1/slope<0),
все 393 прод-отчёта получают coefficient=1.0 для всех трёх сценариев
conservative/base/aggressive. По дизайну backend честно деградирует, но фронт
рисует 3 разноцветные карточки/линии одинаковых чисел без caveat.
Backend (scenarios.py, report.py, report_assembler.py, orchestrator.py):
- compute_scenarios теперь возвращает (list, collapsed: bool, reason: str|None)
- _detect_collapsed(): math.isclose(rel_tol=1e-9, abs_tol=1e-6) сравнивает
projected_demand_units И deficit_index на всех горизонтах между
conservative и aggressive — расхождение в любой метрике на любом h → False
- _COLLAPSE_REASON_LOW_BETA — единственный источник истины каноничного
русского предложения
- ReportScenarios получает поля scenarios_collapsed + scenarios_collapse_reason
- demand_normalization.py НЕ ТРОНУТ — поведение корректно по контракту
Backend экспортёры (report_pdf.py, excel.py):
- PDF: при collapse — тёмная плашка-параграф вместо таблицы трёх столбцов
- Excel: ОДНА строка «base» + caveat-ячейка вместо трёх идентичных столбцов
Frontend (forecast.ts, ScenariosBlock, ScenarioCards, ScenarioCompareTable,
ForecastChart, Section6Forecast, ptica.module.css):
- Тип ReportScenarios расширен двумя optional-полями
- ScenariosBlock (light): headline-bar + одна grid-карточка base + caveat
- ScenarioCards (ptica dark): sub-component ScenarioBaseCard, одна карточка
+ collapse-note, ScenarioCompareTable return null
- ForecastChart: effectiveScenarios filter — одна линия viz-1 «Базовый»
вместо трёх перекрывающихся
- Section6Forecast: caveat выше графика, читается ТОЛЬКО из
scenarios_collapse_reason (источник истины — backend constant)
Тесты: +18 новых (TestMetricsEqual×6, TestDetectCollapsed×7,
TestComputeScenariosCollapseDetection×4 + 1 проверка graceful + corner).
102 forecasting passed, 88 cross-module passed.
Refs #1871
Resolve numeric ПЗЗ limits (max_far/КСИТ, max_height_m, max_floors,
max_building_pct, min_parcel_area_m2) by parcel centroid via the coordinate
resolver get_or_fetch_zone_regulation (cache-first; bounded 3s live geoportal)
and merge into the /analyze nspd_zoning object. Powers the ПТИЦА cockpit's
real КСИТ/height/density (was placeholder). Flag-gated
(enable_zoning_regulation_in_analyze, default on) and hot-path-safe: any
exception/timeout → null fields, analyze never 500s or slow-fails (3s cap).
Additive — existing nspd_zoning keys untouched. 198 tests pass.
Note: zone_index_at is a live WFS call per analyze even on warm cache
(~0.3-0.5s healthy / 3s cap degraded) — optimization tracked as follow-up.
_get_zoning() read non-existent props (top-level reg_numb_border / zone_code /
type_zone / name) so nspd_zoning.zone_code & zone_name were NULL on every parcel.
Real dump props (verified prod, 796 features) carry only the registration number,
duplicated across descr / label / externalKey / options.reg_numb_border (no human
zone name exists in the NSPD dump). Map both fields to that reg-number with safe
nested narrowing (isinstance options dict) + legacy fallbacks; honest — no
fabricated name. Surfacing reg_numb_border also unblocks the КСИТ crosswalk (#1843).
Regression tests use the real prop shape (full / nested-only / opaque→None).
Optional query param exclude_dev (CSV, case-insensitive) drops competitor
rows by dev_name from the «Конкуренты» block of the parcel snapshot PDF.
Client case: generate a report without a specific developer's offer.
Without the param behavior is unchanged (zero behavior change).
- #1736 MiniMap: проброс useConnectionPoints → точки подключения на карте analyze (были только в /legacy)
- #1737 confidence: пронесено имя сервиса → RU-ярлык (Рынок/Будущее предложение/…) вместо «Компонент вкладывающий сервис»
- #1738 pipeline: self-exclusion субъекта (ST_DWithin 80м) — проект не считает сам себя будущим конкурентом
- #1739 PDF: snapshot_pdf обёрнут в try/except+logger.exception (причина 500 видна) + format=pdf в forecast export + font_url fallback
- #1740 gate↔recommendation: при can_build_mkd=False — gate_caveat на обоих рекомендаторах (противоречие явное, не молчит)
Verify: py_compile 5/5, tsc 0, ruff clean, pytest confidence/forecast 95 passed.
Closes#1736Closes#1737Closes#1738Closes#1739Closes#1740