Extend the concept generator so ConceptInput can carry an optional
building_program (list of typed houses from a catalog). When present,
placement lays out EXACTLY that program — for each item, place `count`
sections of the catalog footprint at the item's floors — instead of the
greedy max-FAR coverage-cap sweep. When absent, the existing greedy
behavior is unchanged (byte-for-byte backward-compatible).
- catalog.py: hardcoded HOUSE_TYPES (panel_econom, monolith_comfort,
tower_business, lowrise_comfort, townhouse) — sane-default catalog,
promote to DB later; get_house_type / available_section_types lookups.
- schema: additive BuildingProgramItem {section_type, floors, count} and
ConceptInput.building_program (default None -> greedy). ConceptVariant
gains optional placed_count / requested_count (partial-fit signal).
- placement: shared _Placer (collision/STRtree/setback machine extracted
from greedy sweep, reused — no duplication); place_program +
place_program_variant; branch in place_all_strategies on
building_program. Mixed-floor TEAP via exact per-floor-group aggregation
(GFA = sum(area_i * floors_i), no rounding drift).
- partial fit: when the parcel can't fit all sections, place as many as
fit and report placed_count < requested_count (no hard-422); zero-fit
still raises ParcelGeometryError (-> 422).
- API: validate program section_type keys against the catalog (unknown ->
422) before placement.
- tests: catalog integrity, greedy backward-compat, exact 2-item program +
TEAP reflection, over-packed partial placement, API program path.
- regenerate frontend api-types.ts (OpenAPI codegen gate stays green).
Stage 2a of epic #1953: backend service + endpoint for live economic recompute
driven by the interactive 3D massing (Stage 2b debounced sliders).
- teap.py: add pure synthesize_teap_from_program(total_footprint_sqm, floors,
site_area_sqm, housing_class, sections) — builds a TEAP from the SCALAR
aggregate footprint × floors, mirroring synthesize_teap_from_buildability and
reusing the same shared norm constants (_OFFICE_SHARE_OF_GFA / _EFFICIENCY_BY_CLASS
/ _AVG_APARTMENT_SQM / _PARKING_PER_APARTMENT) — single source of truth.
- schemas/concept.py: add MassingProgram (program contract, optional pre-resolved
market_price_per_sqm + parcel_centroid_wkt) and MassingRecomputeOutput (teap + financial).
- api/v1/concepts.py: add POST /api/v1/concepts/recompute — synthesize TEAP → run
the existing pure compute_financial. FAST path uses body market_price_per_sqm
(no DB); else _lookup_market_price by centroid via run_in_threadpool; else class norm.
- tests: synthesize_teap_from_program (gfa math, parity with compute_teap, class
efficiency, sections no-op) + endpoint (200, coherent output, price passthrough
skips DB, DB fallback, class-norm default, floors validation).
_SUPPLY_BATCH_SQL джойнил domrf_kn_flats по ОДНОЙ глобальной дате
(f.snapshot_date = MAX(snapshot_date) по всей таблице). Но domrf_kn_flats —
ПО-ОБЪЕКТНЫЙ time-series: каждый ЖК скрейпится в свой день. На единственной
глобал-max дате присутствует обычно 1 объект → у остальных 0 квартир →
supply_units_in_radius=0 для всех строк 4.2 Планировки → frontend показывал
«Срок продажи 0 мес» и «% продано —». Регрессия от #1944 (objects-first
дедуп snapshot'ов объектов, который сам по себе корректен).
Фикс: flats_latest CTE (DISTINCT ON (obj_id) ... ORDER BY obj_id,
snapshot_date DESC, id DESC) берёт для КАЖДОГО obj_id его собственный
последний снимок и джойнится к nearby. objects-first MATERIALIZED дедуп
(#1944) сохранён → fan-out по снимкам не возвращается. Глобальный
db.scalar(MAX(snapshot_date)) + :latest_snap bind удалены.
Прод (66:41:0205010:287, r=1км, 9 объектов): supply 0 (global-max) → 2675
(per-object, 4 объекта имеют flats на разных датах 2026-05-17/05-05; ни один
не на глобал-max 2026-06-22). Данные flats частично сломаны (#1945, отдельно),
но фикс корректно двигает supply с 0 к реальным per-object числам.
Тесты: новый guard test_supply_joins_flats_per_object_latest_snapshot;
обновлены mock-фабрики (db.scalar больше не вызывается).
Корень «−1.00 везде» (эпик #1953): compute_demand_supply_forecast брал
district-wide unit_velocity (847.5/мес, ВСЕ классы/комнаты) как спрос и
весь district-сток (~63k доступных) как предложение для КАЖДОЙ ячейки
what_to_build → один и тот же ratio во всех ячейках → все deficit_index
прижаты к −1.0. Плюс objective_lots — append-per-snapshot (~2.9× инфляция
строк), что симметрично раздувало обе базы → даже сегментация без дедупа
осталась бы вырожденной.
Фикс (blast radius — ТОЛЬКО forecast/deficit calc; platform-wide dedup = #1964):
- market_metrics.compute_market_metrics: +obj_class/+room_bucket (+cache key).
_STOCK_SQL и _SALES_WINDOW_SQL дедуплят до ПОСЛЕДНЕГО снапшота на физлот
(DISTINCT ON project_name,corpus_name,section,floor,lot_number ORDER BY …
snapshot_date DESC,id DESC), затем агрегируют. Class-фильтр (LOWER=LOWER,
class lowercase) + room-bucket (Source-B room_area-вокабуляр, зеркало
sales_series.room_area_bucket_of → what_to_build фильтрует без перевода).
ROLLUP/GROUPING сохранён; confidence считается на дедуплицированных counts.
- demand_supply_forecast: base_pace и open-сток теперь ПОСЕГМЕНТНЫЕ
(market_metrics(obj_class,room_bucket)). При заданном сегменте L2/L3
(hidden/future) ИСКЛЮЧЕНЫ из баланса — они класс/формат-агностичны, иначе
двоились бы по всем ячейкам. +_market_room_bucket VOCAB-мост (валидирующий
pass-through Source-B меток; неизвестное → None = без фильтра, не тихий 0-rows).
- what_to_build/_DEFAULT_CLASSES и recommendation Economy-маппинг: «эконом»→
«стандарт» (в objective_lots эконома НЕТ, стандарт=483k → раньше ячейка
матчила 0 строк и молча выпадала).
- report_assembler honesty-guard: если ВСЯ сетка прижата к ±1.0
(degenerate-fallback) — не эмитим «строить»/«избегать», показываем
«недостаточно гранулярных данных для посегментного вывода».
- data/sql/173_objective_lots_physflat_idx.sql: partial index под DISTINCT ON
(Index Only Scan + Unique, без Sort на 1.75M строк; idempotent, BEGIN/COMMIT).
Prod-verify (parcel 66:41:0205010:287, Железнодорожный, h=24): ячейки
ДИФФЕРЕНЦИРУЮТ (12 measured, 7 distinct) вместо all −1.0; MOI комфорт/студия
38.5 vs стандарт/студия 244.3 (точное совпадение с ожидаемым).
Тесты: регрессия «ячейки различаются (не all −1.0)» + vocab-translation +
honesty-guard + посегментное предложение. ruff clean; no :name::type.
Replace dev-jargon with plain RU across the §22 forecast report (6.2/6.3/6.5
+ deficit/затоварка legend). Source of truth = backend reason strings + the
frontend RU maps; text/render-only, no scoring/forecast math touched.
Backend (source of truth):
- scenarios.py _COLLAPSE_REASON_LOW_BETA: «β rate-sensitivity не прошёл gate …»
→ «чувствительность к ставке не оценена на коротком ряде ЕКБ → один базовый
сценарий вместо трёх».
- confidence_engine.py _coverage_factor: drop «domrf↔objective» jargon, say it
affects будущее предложение/конкуренцию. New _history_factor: «глубина истории
N мес» + на что влияет + связь с 6.2 (короткий ряд → один сценарий).
Frontend (both Section-6 families — live analysis page + ptica cockpit):
- Deficit legend: −1 затоварка / 0 баланс / +1 острый дефицит + actionable
трактовка; MOI tied to «сколько месяцев район распродаёт предложение».
- 6.2 heading «Почему один сценарий, а не три» over the collapse reason.
- 6.3 render confidence.rationale + weakest-link rule («скорее завысим
недоверие, чем недооценим риск»); FACTOR_RU gains confounded_window/
advisory_cap; factor notes shown.
- 6.5 «Вес»→«Оценка»; overall verdict vs 0.5; «Риск избытка предложения»→
«Запас по предложению»; §-refs moved from reason into tooltip; 6 special-
index 1-line «что это + куда лучше» descriptions; 0.00-score reasons shown.
Tests: confidence_engine (history/coverage notes), stripSectionRefs vitest.
Refs #1963
Миграция 172 убрала english 'Comfort' из v_bucket_success_score (NULL-class
→ 'не указан'), но _bucket_success_ranking в analytics_queries.py матчил
COALESCE(:cls, 'Comfort') — после 172 ни одна строка не равна 'Comfort' →
recommend_mix success-boost (#25, /analytics/recommend квартирография) тихо
возвращал [] (silent degradation). LIVE на проде с момента ручного применения
172 (graceful empty, без краша).
Fix:
- call site (recommend_mix): передаём target_class_db (уже переведённый через
_class_to_db_vocab english→русский), а не сырой english target_class.
Чинит и латентный pre-existing баг: Business/Elite никогда не матчили
русский источник.
- SQL default 'Comfort' → 'Комфорт' (массовый класс ЕКБ, 723 объекта).
NULL-class ('не указан') в default-путь намеренно не попадают
(документировано в docstring).
Prod-verified: OLD 'Comfort' default → 0 строк; NEW 'Комфорт' default → 5.
Tests:
- _bucket_success_ranking: реальный запрос (не замокан) с русским default —
SQL содержит 'Комфорт', не 'Comfort'; ranking непустой.
- recommend_mix: english 'Business' переводится в 'Бизнес' перед ranking
(раньше тест патчил _bucket_success_ranking→[] и баг не ловил;
helper теперь умеет patch_success_ranking=False).
- #1960: позитивный тест выбора quarter_rosreestr basis (deals≥5, все
higher fallback'и None) → median_price_basis='quarter_rosreestr'.
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.
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).
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
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
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>
Финмодель брала фиксированное окно продаж 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
_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).
- #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