Постраничная вычитка PDF многоконтурного участка 66:41:0702017:131:
1. MultiPolygon роняет concept-стек через geometry._parse_polygon
(expected Polygon) ДО буфер-стадии, которая мультиконтур уже умеет →
§7 без концепции, §5 деградирует, market-price падает. Новый
_largest_polygon_geojson нормализует к крупнейшему контуру (паттерн
geometry.py:263-265) до ConceptInput; Polygon проходит identity,
битая геометрия — вход как есть. Интерактивный POST /concepts на
мультиконтурах по-прежнему 422 — вынесено в #2295.
2. Сиротские заголовки: page-break-after avoid для h2/h3.
3. Тепло/вода §3: агрегат «Суммарно (ЕКБ)», фильтр областного шума
(Регионгаз по Ирбиту/Тавде и т.п.), кап топ-15/25 + «и ещё K»,
усечение имён 120 симв; дефицитные ЕКБ-резервы гарантированно
видимы (bucket до капа, тест-лок).
4. Конкуренты §4: скип безымянных с 0 лотов, группировка корпусов
(имя+девелопер → min дистанция, сумма лотов, «(K корпусов)»).
5. ЗОУИТ-противоречие: overlaps непусты при has_zouit=false →
«да (по данным НСПД)»; обратное направление не тронуто.
6. Призрачные НСПД-строки, дубль-колонка confidence, absorption → %,
средний чек без «+» (unsigned-форматтер).
Refs #2259, #2295
excel.py рендерил _scenario_deficit_index (скаляр) в таблицу сценариев под
шапкой «(12 мес)» — при fallback на другой горизонт подпись лгала (то же что
docx/pptx до 764db617). Добавлены _scenario_deficit_horizon и
_scenario_deficit_cell (зеркало report_pdf.py/764db617): при fallback ячейка
несёт «(гор. N мес)». _build_scenarios_sheet переключён на _scenario_deficit_cell.
Тесты: два новых кейса — fallback (6 мес → аннотация) и primary (12 мес → скаляр).
#1244 (security): внешние/скрейпинг-строки (comm_name из DOM.РФ, headline/usp_text)
с ведущим = + - @ \t \r писались как есть → openpyxl сохранял как формулы
(data_type='f'), исполнялись при открытии в Excel/LibreOffice. _sanitize_formula
префиксует такие строки апострофом (OWASP CSV-injection escape); числа/даты/bool
не трогаются. _write_kv labels тоже санитизируются. Подтверждено на openpyxl 3.1.5.
#1245 (concurrency): async ask() вызывал sync get_report_for_chat() (sync SQLAlchemy
тянет крупный JSONB §22-отчёт) напрямую — блокировал event loop, в отличие от
LLM-ветки (run_in_threadpool). Обёрнуто в run_in_threadpool.
Closes#1244Closes#1245
render_report_docx (python-docx) mirrors report_md/report_pdf section order &
content, reuses report_pdf pure helpers (DRY), graceful on thin/empty report.
Widen /forecast/export format Literal to include docx → Word attachment.
Add python-docx dep + regenerate uv.lock (uv sync --frozen passes). Part of #959.
render_report_telegram_summary (pure, no new deps, DRY-reuses report_pdf str
helpers) + `tg` format on GET /{cad}/forecast/export → inline text/plain snippet
(no attachment, copy-paste-ready). md/json unchanged; no-run 404, bad format 422.
Graceful on thin/empty reports. Part of EPIC #959.
render_report_markdown (pure, no new runtime deps) reuses report_pdf's str
helpers (DRY), + GET /{cad_num}/forecast/export?format=md|json. No forecast run
→ 404; graceful on thin/empty reports; GFM-safe table escaping. PDF/XLSX already
existed; this adds the cheapest no-dep formats. Part of EPIC #959.
deficit_index pins to -1.0 for every ЕКБ segment (12mo demand flow vs multi-year
supply stock → log-ratio clamps) → zero discriminating power, though the oversupply
is partly real. Add MOI (gross competing supply / demand_per_mo), the real-estate
absorption standard, as an additive non-saturating companion that DISCRIMINATES
(Уралмаш 42mo … Чермет 109mo) where deficit cannot. deficit_index math kept exactly
as-is (honest absolute: -1 = genuinely oversupplied); docstrings clarify -1 is common
and MOI is the discriminating companion (no recalibration). _gross_supply extract-method
(single source of truth; _project_supply behavior byte-identical, code-review-verified).
Surface MOI in §22 future_market (passthrough) + exec_summary key_numbers/verdict.
Guards: no demand → None, no supply → 0. Prod: MOI varies 42→109mo, deficit stays -1.
Discrimination test pins MOI separating two segments both at deficit -1. Refs #952.
REOPENED. PDF + Excel exporters read non-existent dict keys, so demand/supply/
scenario columns silently rendered "—". Tests passed only because the fixtures
were stale (hand-typed the same wrong keys → fixture agreed with buggy exporter).
- future_market: demand/supply → projected_demand_units/projected_supply_units
- scenarios: drop non-existent per-scenario "overall"; show primary-horizon
deficit_index from ScenarioForecast.forecasts (scoring.overall was NOT broken)
- Excel #991: add missing future_supply (index + breakdown) + confidence.factors
sections; add future_supply to PDF for parity
- tests: rebuild forecast/scenario fixtures from real DemandSupplyForecast /
ScenarioForecast as_dict(); contract-key regression guards fail on key-drift
(verified: reintroducing old keys fails the new tests). 28 passed.
Refs #989#991