diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 35976dc1..74ae4a21 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -50,6 +50,7 @@ from app.services.exporters.report_md import ( render_report_markdown, render_report_telegram_summary, ) +from app.services.exporters.report_pdf import export_report_pdf from app.services.exporters.report_pptx import render_report_pptx from app.services.exporters.snapshot_pdf import generate_snapshot_pdf from app.services.site_finder.best_layouts import get_best_layouts @@ -253,6 +254,12 @@ PIPELINE_HORIZON_MONTHS = 24 PIPELINE_SEVERITY_MEDIUM_THRESHOLD = 500 # flats_total < это → low PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high PIPELINE_TOP_OBJECTS_LIMIT = 10 +# #1738: самоисключение СУБЪЕКТА анализа из pipeline. Если анализируемый участок +# уже застраивается (его ЖК есть в domrf_kn_objects), он не должен считаться +# собственным «конкурентом». Отсекаем объекты, чья точка попадает в геометрию +# участка или в ~80м от неё (домрф-координата ставится на корпус/въезд, может +# не совпадать с центроидом ЗУ → буфер ловит «свой» объект). +PIPELINE_SELF_EXCLUDE_M = 80 def _coord_round(value: Any) -> float | None: @@ -1181,11 +1188,35 @@ def get_parcel_forecast( return {"status": "pending"} if run is not None: + report = run.result + # #1740: gate↔recommendation — если последний analyze-ран дал gate-вердикт + # «Нельзя строить МКД», помечаем forecast-рекомендатор (product_tz) caveat-флагом. + # Рекомендацию НЕ удаляем (строгость = продуктовое решение), только делаем + # противоречие явным. Best-effort: сбой чтения analyze-рана не валит выдачу форсайта. + try: + analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION) + gate = (analyze_run.result or {}).get("gate_verdict") if analyze_run else None + if ( + isinstance(gate, dict) + and gate.get("can_build_mkd") is False + and isinstance(report, dict) + and isinstance(report.get("product_tz"), dict) + ): + report["product_tz"]["gate_caveat"] = ( + "Строительство МКД под вопросом (gate: Нельзя) — " + "рекомендация носит условный характер" + ) + except Exception: + logger.warning( + "gate_caveat enrich failed for forecast cad=%s — report unaffected", + cad_num, + exc_info=True, + ) return { "status": "ready", "run_id": run.id, "created_at": run.created_at, - "report": run.result, + "report": report, } # Форсайт ещё не посчитан (таска в работе или /analyze не вызывали) — клиент поллит. @@ -1198,14 +1229,14 @@ def export_parcel_forecast( cad_num: str, db: Annotated[Session, Depends(get_db)], format: Annotated[ - Literal["md", "json", "tg", "docx", "pptx"], + Literal["md", "json", "tg", "docx", "pptx", "pdf"], Query( description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | " - "tg (сводка) | docx (Word-документ) | pptx (презентация)" + "tg (сводка) | docx (Word-документ) | pptx (презентация) | pdf (PDF-отчёт)" ), ] = "md", ) -> Response: - """Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX (файл) или TG-сводка (#959). + """Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX / PDF (файл) или TG-сводка (#959). Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET /{cad}/forecast inline) и отдаёт его в нужной форме. @@ -1215,6 +1246,8 @@ def export_parcel_forecast( зеркало содержания md/pdf; python-docx). • format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация, сжатое зеркало содержания docx; python-pptx). + • format=pdf → `export_report_pdf(run.result)` — attachment (.pdf, §13-отчёт, + зеркало содержания md/docx; WeasyPrint). • format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать и отправить, а не файл-download). @@ -1226,10 +1259,10 @@ def export_parcel_forecast( Args: cad_num: кадастровый номер участка (в имени файла `:` → `_`). - format: "md" (default) | "json" | "tg" | "docx" | "pptx". + format: "md" (default) | "json" | "tg" | "docx" | "pptx" | "pdf". Returns: - Response — для md/json/docx/pptx attachment с Content-Disposition (имя + Response — для md/json/docx/pptx/pdf attachment с Content-Disposition (имя `gendesign_forecast__.`); для tg — inline text/plain сводка. """ run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION) @@ -1267,6 +1300,25 @@ def export_parcel_forecast( headers={"Content-Disposition": f'attachment; filename="{base_name}.pptx"'}, ) + # pdf — байты (WeasyPrint), отдельной веткой (зеркало pptx early-return): §13-отчёт + # из export_report_pdf (#1739). md/json string-ветки остаются байт-в-байт прежними. + # WeasyPrint-рендер — отдельная зона отказа (шрифты, шаблон, движок): без обёртки + # ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет + # traceback причины 500 (#1739), пользователю — безопасное RU-сообщение. + if format == "pdf": + try: + pdf_bytes = export_report_pdf(run.result) + except HTTPException: + raise + except Exception as exc: + logger.exception("forecast PDF render failed for %s", cad_num) + raise HTTPException(status_code=500, detail="Ошибка генерации PDF-форсайта") from exc + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{base_name}.pdf"'}, + ) + if format == "json": payload = json.dumps(run.result, ensure_ascii=False, default=str) media_type = "application/json" @@ -1723,6 +1775,13 @@ def analyze_parcel( ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography, :radius_m ) + -- #1738: исключаем СУБЪЕКТ анализа — объект на самом участке + -- (его точка внутри/в ~80м геометрии ЗУ) не считаем конкурентом. + AND NOT ST_DWithin( + ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography, + ST_GeomFromText(:wkt, 4326)::geography, + :self_exclude_m + ) AND ready_dt >= CURRENT_DATE AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval) ORDER BY ready_dt ASC @@ -1730,6 +1789,7 @@ def analyze_parcel( { "wkt": geom_wkt, "radius_m": PIPELINE_RADIUS_M, + "self_exclude_m": PIPELINE_SELF_EXCLUDE_M, "horizon_months": str(PIPELINE_HORIZON_MONTHS), }, ) @@ -2785,6 +2845,26 @@ def analyze_parcel( e, ) + # #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner. + # Вычисляем ДО сборки result_payload, чтобы при can_build_mkd is False (#1740) + # пометить рекомендации флагом-кейватом (противоречие gate↔рекомендация явное). + gate_verdict = compute_gate_verdict( + nspd_zoning=nspd_dump_data["nspd_zoning"], + nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"], + nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"], + nspd_dump=nspd_dump_data["nspd_dump"], + ) + + # #1740: gate говорит «Нельзя строить МКД», но рекомендатор всё равно выдаёт + # квартирографию — не удаляем рекомендацию (строгость = продуктовое решение), + # а делаем противоречие явным через caveat-флаг. success_recommendation — + # analyze-рекомендатор (топ-квартирография по district). + if gate_verdict.get("can_build_mkd") is False and success_recommendation is not None: + success_recommendation["gate_caveat"] = ( + "Строительство МКД под вопросом (gate: Нельзя) — " + "рекомендация носит условный характер" + ) + result_payload: dict[str, Any] = { "cad_num": cad_num, "source": source, @@ -2881,12 +2961,8 @@ def analyze_parcel( "nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])], "nspd_dump": nspd_dump_data["nspd_dump"], # #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner - "gate_verdict": compute_gate_verdict( - nspd_zoning=nspd_dump_data["nspd_zoning"], - nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"], - nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"], - nspd_dump=nspd_dump_data["nspd_dump"], - ), + # (вычислен выше, до сборки payload — нужен для gate_caveat #1740). + "gate_verdict": gate_verdict, # #114/#201: кастомные веса POI — source + applied dict для прозрачности. "weights_profile": { "source": _weights_source, @@ -3398,52 +3474,69 @@ def parcel_snapshot_pdf( Не является официальной выпиской ЕГРН — только аналитические данные НСПД. Генерация <2 сек. Открывается в Adobe Reader / Chrome. """ - # 1) Получить метаданные участка из cad_parcels - parcel_row = ( - db.execute( - text(""" - SELECT readable_address AS address, - land_record_area AS area_m2, - land_record_category_type AS land_category, - permitted_use_established_by_document AS vri, - cost_value AS cadastral_cost, - updated_at AS last_update - FROM cad_parcels - WHERE cad_num = CAST(:c AS text) - LIMIT 1 - """), - {"c": cad_num}, - ) - .mappings() - .first() - ) - - if not parcel_row: - raise HTTPException( - status_code=404, - detail=f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки.", + # 1-6) Сбор данных участка из БД (метаданные + геометрия + POI + конкуренты + + # district + last_update). Обёрнуто в try/except (#1739): иначе сырая DB/PostGIS- + # ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет + # traceback; HTTPException(404) при отсутствии участка и (422) при отсутствии + # геометрии — re-raise НЕ оборачиваем (это валидные клиентские ответы, а не 500). + try: + # 1) Получить метаданные участка из cad_parcels + parcel_row = ( + db.execute( + text(""" + SELECT readable_address AS address, + land_record_area AS area_m2, + land_record_category_type AS land_category, + permitted_use_established_by_document AS vri, + cost_value AS cadastral_cost, + updated_at AS last_update + FROM cad_parcels + WHERE cad_num = CAST(:c AS text) + LIMIT 1 + """), + {"c": cad_num}, + ) + .mappings() + .first() ) - # 2) Получить геометрию (WKT) для POI / competitor queries - geom_row = ( - db.execute( - text(""" - SELECT ST_AsText(COALESCE( - (SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1), - (SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1) - )) AS wkt - """), - {"c": cad_num}, - ) - .mappings() - .first() - ) - geom_wkt: str | None = geom_row["wkt"] if geom_row else None + if not parcel_row: + raise HTTPException( + status_code=404, + detail=( + f"Участок {cad_num} не найден в БД. " "Используйте POST /analyze для загрузки." + ), + ) - # 3) POI в радиусе 1 км (только если есть геометрия) - poi_rows: list[dict[str, Any]] = [] - if geom_wkt: - poi_rows = [ + # 2) Получить геометрию (WKT) для POI / competitor queries + geom_row = ( + db.execute( + text(""" + SELECT ST_AsText(COALESCE( + (SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1), + (SELECT geom FROM cad_parcels WHERE cad_num = CAST(:c AS text) LIMIT 1) + )) AS wkt + """), + {"c": cad_num}, + ) + .mappings() + .first() + ) + geom_wkt: str | None = geom_row["wkt"] if geom_row else None + + # #1739: без геометрии участка PDF-снимок бессмыслен (нет POI/конкурентов/ + # района). 422 — клиентский кейс «данные неполны», не серверная 500. + if not geom_wkt: + raise HTTPException( + status_code=422, + detail=( + f"Геометрия участка {cad_num} отсутствует в БД — " + "PDF-снимок недоступен. Запустите POST /analyze для дозагрузки." + ), + ) + + # 3) POI в радиусе 1 км + poi_rows: list[dict[str, Any]] = [ dict(r) for r in db.execute( text(""" @@ -3468,10 +3561,8 @@ def parcel_snapshot_pdf( .all() ] - # 4) Конкуренты в радиусе 3 км (только если есть геометрия) - competitor_rows: list[dict[str, Any]] = [] - if geom_wkt: - competitor_rows = [ + # 4) Конкуренты в радиусе 3 км + competitor_rows: list[dict[str, Any]] = [ dict(r) for r in db.execute( text(""" @@ -3505,9 +3596,8 @@ def parcel_snapshot_pdf( .all() ] - # 5) Получить district (через пересечение с ekb_districts если есть геом) - district: str | None = None - if geom_wkt: + # 5) Получить district (через пересечение с ekb_districts) + district: str | None = None district_row = ( db.execute( text(""" @@ -3524,16 +3614,23 @@ def parcel_snapshot_pdf( if district_row: district = district_row["district_name"] - # 6) Форматировать last_update - raw_update = parcel_row["last_update"] - last_update_str: str | None = None - if raw_update is not None: - try: - last_update_str = raw_update.strftime("%d.%m.%Y") - except AttributeError: - last_update_str = str(raw_update)[:10] + # 6) Форматировать last_update + raw_update = parcel_row["last_update"] + last_update_str: str | None = None + if raw_update is not None: + try: + last_update_str = raw_update.strftime("%d.%m.%Y") + except AttributeError: + last_update_str = str(raw_update)[:10] + except HTTPException: + # 404/422 — валидные клиентские ответы, пропускаем без обёртки в 500. + raise + except Exception as exc: + logger.exception("snapshot data fetch failed for %s", cad_num) + raise HTTPException(status_code=500, detail="Ошибка сбора данных для PDF-снимка") from exc - # 7) Сгенерировать PDF + # 7) Сгенерировать PDF (WeasyPrint-рендер — отдельная зона отказа: шрифты, + # шаблон, движок). logger.exception сохраняет traceback причины 500 (#1739). try: pdf_bytes = generate_snapshot_pdf( cad_num=cad_num, @@ -3553,7 +3650,7 @@ def parcel_snapshot_pdf( competitors_limit=5, ) except Exception as exc: - logger.error("snapshot PDF generation failed for %s: %s", cad_num, exc) + logger.exception("snapshot PDF render failed for %s", cad_num) raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc cad_safe = cad_num.replace(":", "-") diff --git a/backend/app/services/exporters/snapshot_pdf.py b/backend/app/services/exporters/snapshot_pdf.py index 3f1db81a..80b706b4 100644 --- a/backend/app/services/exporters/snapshot_pdf.py +++ b/backend/app/services/exporters/snapshot_pdf.py @@ -13,8 +13,11 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape logger = logging.getLogger(__name__) -# Путь к директории шаблонов (относительно этого файла — 2 уровня вверх, затем templates) -_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent / "templates" +# Путь к директории шаблонов: app/templates/parcel_snapshot.html (#1739). +# Файл лежит в app/services/exporters/, поэтому до app/ — 3 уровня вверх +# (exporters → services → app), затем templates. parent.parent давал +# несуществующий app/services/templates → TemplateNotFound → 500 на snapshot. +_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent.parent / "templates" # Системные пути DejaVu Sans (Ubuntu/Debian Docker-образ + Alpine резерв) _DEJAVU_CANDIDATES: list[str] = [ @@ -61,6 +64,10 @@ def _find_font_url() -> str: WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig, поэтому пустая строка допустима — шрифт тогда подбирается CSS generic. + + #1739: при пустой строке шаблон НЕ эмитит `@font-face { src: url('') }` + (обёрнут в `{% if font_url %}`). Иначе WeasyPrint пытался резолвить пустой + URL → 'failed to load font' ошибка вместо тихого fallback на Arial/sans-serif. """ for path in _DEJAVU_CANDIDATES: if pathlib.Path(path).exists(): diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py index 64192458..82c2052f 100644 --- a/backend/app/services/forecasting/confidence_engine.py +++ b/backend/app/services/forecasting/confidence_engine.py @@ -42,11 +42,16 @@ JSON-safe и ложится в слот ReportConfidence (#987): {level, rationa from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass, field from typing import Any, Literal Confidence = Literal["high", "medium", "low"] +# Вход component_confidences может быть плоским уровнем (legacy) ИЛИ парой +# (имя-сервиса, уровень) — чтобы пронести ИМЯ сервиса сквозь стек (#1737). +ComponentConfidence = Confidence | tuple[str, Confidence] + # ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab # future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ─────────────── _CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2} @@ -61,6 +66,18 @@ _F_CONFOUNDED_WINDOW: str = "confounded_window" _F_ADVISORY_CAP: str = "advisory_cap" _F_COMPONENT: str = "component" # один вкладывающий per-service confidence +# ── RU-имена вкладывающих под-сервисов (#1737). Машинное имя сервиса (как его кормит +# сборщик #988) → человекочитаемое RU-имя для отчёта/UI. Неизвестный/пустой ключ → +# дефолт «Компонент» (graceful — не теряем фактор, просто без точечного имени). ────── +_SERVICE_RU: dict[str, str] = { + "market_metrics": "Рынок", + "future_supply": "Будущее предложение", + "product_scores": "Скоринг продукта", + "special_indices": "Спец-индексы", + "forecasts": "Прогноз спрос/предложение", +} +_SERVICE_RU_DEFAULT: str = "Компонент" + # ── Пороги счётчиков (align с per-service gate'ами, прочитанными в #949/#951) ────── # deal_count: число сделок (продаж) за окно. high — длинная плотная выборка, @@ -97,20 +114,29 @@ class ConfidenceFactor: (число сделок / ЖК / покрытие / месяцы / bool шок-окна — или None); `level` — его собственный вклад high/medium/low; `note` — короткая RU-фраза с числом («7 сделок за 6 мес — мало»), из которой собирается структурная причина §15. + `label` — человекочитаемое RU-имя фактора (#1737: для component-факторов это ИМЯ + под-сервиса — «Рынок» / «Будущее предложение» …, чтобы UI не показывал сырой + «Компонент N»); пусто для счётчиков (у них своя RU-метка на стороне UI). """ name: str value: Any # число/доля/флаг, обосновавшие level (или None) level: Confidence note: str # RU, с реальным числом + label: str = "" # RU-имя фактора (для component — имя сервиса, #1737); опционально def as_dict(self) -> dict[str, Any]: - return { + # `label` кладём ТОЛЬКО когда задан — схема обратно-совместима (старые + # потребители без поля `label` продолжают читать name/value/level/note). + out: dict[str, Any] = { "name": self.name, "value": self.value, "level": self.level, "note": self.note, } + if self.label: + out["label"] = self.label + return out @dataclass(frozen=True) @@ -145,7 +171,12 @@ class ReportConfidenceResult: while f"{key}_{i}" in factors: i += 1 key = f"{key}_{i}" - factors[key] = {"value": f.value, "level": f.level, "note": f.note} + entry: dict[str, Any] = {"value": f.value, "level": f.level, "note": f.note} + # RU-имя фактора (для component — имя сервиса, #1737) кладём ТОЛЬКО когда + # задано — плоский dict остаётся обратно-совместимым по форме. + if f.label: + entry["label"] = f.label + factors[key] = entry factors["advisory_capped"] = self.advisory_capped return { "level": self.level, @@ -256,17 +287,23 @@ def _confounded_factor(confounded: bool) -> ConfidenceFactor: ) -def _component_factor(level: Confidence) -> ConfidenceFactor: +def _component_factor(level: Confidence, service: str | None = None) -> ConfidenceFactor: """Один вкладывающий per-service confidence → ConfidenceFactor. PURE. Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт, - нота поясняет вклад. value=None (число — внутри самого под-сервиса). + нота поясняет вклад. value=None (число — внутри самого под-сервиса). `service` — + машинное имя под-сервиса (#1737): из него берём человекочитаемое RU-имя + («Рынок» / «Будущее предложение» / «Скоринг продукта» / «Спец-индексы»…) в + `label` и НАЗЫВАЕМ сервис в `note` (вместо безличного «вкладывающий сервис: high»). + Неизвестное/None имя → дефолт «Компонент» (graceful — фактор не теряется). """ + ru_name = _SERVICE_RU.get(service or "", _SERVICE_RU_DEFAULT) return ConfidenceFactor( name=_F_COMPONENT, value=None, level=level, - note=f"вкладывающий сервис: {level}", + note=f"{ru_name}: {level}", + label=ru_name, ) @@ -373,7 +410,7 @@ def _join_notes(notes: list[str], *, limit: int = 3) -> str: def compute_report_confidence( *, - component_confidences: list[Confidence] | None = None, + component_confidences: Sequence[ComponentConfidence] | None = None, deal_count: int | None = None, deal_count_months: int | None = None, analog_count: int | None = None, @@ -402,7 +439,9 @@ def compute_report_confidence( Args: component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→ - нет вкладывающих компонентов. + нет вкладывающих компонентов. Каждый элемент — ЛИБО плоский уровень + 'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737: + имя сервиса проносится сквозь стек в RU-метку фактора). deal_count: число сделок за окно (None → нет данных, тянет в low). deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес» в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода. @@ -459,9 +498,16 @@ def compute_report_confidence( factors.append(_confounded_factor(True)) # ── 2. Вкладывающие per-service confidence → факторы ────────────────────── + # Элемент — ЛИБО плоский уровень (legacy), ЛИБО пара (имя-сервиса, уровень) + # (#1737: проносим имя сервиса в RU-метку фактора). Мусор/неизвестный уровень + # отбрасываем (whitelist), как и раньше. for c in component_confidences or []: - if c in _CONFIDENCE_RANK: - factors.append(_component_factor(c)) + if isinstance(c, tuple): + service, level = c + else: + service, level = None, c + if level in _CONFIDENCE_RANK: + factors.append(_component_factor(level, service)) # ── 3. weakest-link агрегат (худший тянет вниз) ─────────────────────────── raw_level = _aggregate(factors) diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py index 418d298e..17e6f967 100644 --- a/backend/app/services/forecasting/recommendation.py +++ b/backend/app/services/forecasting/recommendation.py @@ -544,6 +544,9 @@ def _demand_supply_overlay( "obj_class": seg.segment.get("obj_class"), "deficit_index": seg.deficit_index, "balance_units": seg.balance_units, + # #1745: спрос на горизонт (квартир) — для таблицы прогноза по форматам. + # demand_only его не несёт (без геометрии supply/спрос-проекция неизмеримы). + "projected_demand_units": seg.projected_demand_units, "confidence": seg.confidence, } ) diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index 44ed966e..b9e4d11c 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -84,6 +84,10 @@ _LEVEL_RU: dict[ReportConfidenceLevel, str] = { # special_indices._VOID_THRESHOLD: умеренно-сильный дефицит на лог-шкале #980 [−1,+1]). _STRONG_DEFICIT_THRESHOLD: float = 0.25 +# Нейтральная зона |deficit_index|, внутри которой рынок считаем сбалансированным +# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05. +_SIGNAL_BALANCE_EPS: float = 0.05 + # Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём # 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default). _PRIMARY_HORIZON_MONTHS: int = 12 @@ -259,23 +263,33 @@ def _component_confidences( forecasts: Sequence[dict[str, Any]], product_scores: dict[str, Any] | None, special_indices: dict[str, Any] | None, -) -> list[Confidence]: +) -> list[tuple[str, Confidence]]: """Собрать per-service confidence вкладывающих под-сервисов — для #990. PURE. #990 свернёт их weakest-link (MIN) вместе с сырыми счётчиками. Берём confidence- метку каждого доступного под-вывода (market_metrics §9.2 / future_supply §9.3 / каждый per-горизонт forecast #952 / product_scores #985 / special_indices #986). Только whitelisted 'high|medium|low' (мусор/None отбрасываем). + + #1737: возвращаем ПАРЫ (имя-сервиса, уровень) — машинное имя под-сервиса + («market_metrics» / «future_supply» / «product_scores» / «special_indices» / + «forecasts»), чтобы #990 пронёс ИМЯ сервиса в RU-метку фактора и UI не показывал + безличный «Компонент N». """ - out: list[Confidence] = [] - for source in (market_metrics, future_supply, product_scores, special_indices): + out: list[tuple[str, Confidence]] = [] + for name, source in ( + ("market_metrics", market_metrics), + ("future_supply", future_supply), + ("product_scores", product_scores), + ("special_indices", special_indices), + ): conf = _confidence_of(source) if conf is not None: - out.append(conf) + out.append((name, conf)) for f in forecasts: conf = _confidence_of(f) if conf is not None: - out.append(conf) + out.append(("forecasts", conf)) return out @@ -504,12 +518,16 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]: Overlay #983 не несёт готового `mix`, но его `ranked_segments` (DESC по дефициту) — продуктовый ответ «какой формат строить». Берём `mix` если задан, иначе проецируем - ranked_segments в компактные {bucket, obj_class, deficit_index}. Нет ни того, ни - другого → []. + ranked_segments в компактные {bucket, obj_class, deficit_index, projected_demand_units, + signal} (#1745 — таблица прогноза по форматам). Нет ни того, ни другого → []. + + #1745 ADDITIVE: проброс `projected_demand_units` (прогноз спроса, квартир) и + деривированного `signal` («строить»/«баланс»/«избегать») из deficit_index. Старые + потребители mix читают bucket/obj_class/deficit_index — новые поля их не ломают. """ explicit = product_tz.get("mix") if isinstance(explicit, list): - return [m for m in explicit if isinstance(m, dict)] + return [_enrich_mix_entry(m) for m in explicit if isinstance(m, dict)] ranked = product_tz.get("ranked_segments") if isinstance(ranked, list): return [ @@ -517,6 +535,8 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]: "bucket": seg.get("bucket"), "obj_class": seg.get("obj_class"), "deficit_index": seg.get("deficit_index"), + "projected_demand_units": seg.get("projected_demand_units"), + "signal": _build_signal(seg.get("deficit_index")), } for seg in ranked if isinstance(seg, dict) @@ -524,6 +544,35 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]: return [] +def _enrich_mix_entry(entry: dict[str, Any]) -> dict[str, Any]: + """Дополнить явную mix-ячейку деривированным `signal` (#1745). PURE. + + Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет + `signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует + вход — assembler-чистота). + """ + enriched = dict(entry) + if enriched.get("signal") is None: + enriched["signal"] = _build_signal(enriched.get("deficit_index")) + return enriched + + +def _build_signal(deficit_index: Any) -> str | None: + """Сигнал «строить»/«баланс»/«избегать» из deficit_index ∈ [−1,1] (#1745). PURE. + + Зеркало deficit-семантики §22 (frontend forecast-helpers.deficitWord): >+0.05 + недонасыщенность → «строить», <−0.05 затоварка → «избегать», иначе «баланс». + deficit_index None (тонкие данные) → None (фронт рисует «тонкие данные», НЕ 0/сигнал). + """ + if not isinstance(deficit_index, (int, float)) or isinstance(deficit_index, bool): + return None + if deficit_index > _SIGNAL_BALANCE_EPS: + return "строить" + if deficit_index < -_SIGNAL_BALANCE_EPS: + return "избегать" + return "баланс" + + def _extract_list(source: dict[str, Any], key: str) -> list[dict[str, Any]]: """Достать список dict'ов по ключу (мусор/None → []). PURE.""" value = source.get(key) diff --git a/backend/app/services/forecasting/what_to_build.py b/backend/app/services/forecasting/what_to_build.py index 761838cb..55fc99d0 100644 --- a/backend/app/services/forecasting/what_to_build.py +++ b/backend/app/services/forecasting/what_to_build.py @@ -99,12 +99,14 @@ class RankedSegment: deficit_index: float # не None в ранкинге (None-ячейки отброшены) balance_units: float | None # demand − supply (>0 дефицит / <0 затоварка) confidence: Confidence # ≤ 'medium' (#980 advisory-cap) + projected_demand_units: float | None = None # #1745: спрос на горизонт (квартир) def as_dict(self) -> dict[str, Any]: return { "segment": dict(self.segment), "deficit_index": _round_or_none(self.deficit_index, 3), "balance_units": _round_or_none(self.balance_units, 1), + "projected_demand_units": _round_or_none(self.projected_demand_units, 1), "confidence": self.confidence, } @@ -359,4 +361,5 @@ def _forecast_cell( deficit_index=forecast.deficit_index, balance_units=forecast.balance_units, confidence=forecast.confidence, + projected_demand_units=forecast.projected_demand_units, ) diff --git a/backend/app/templates/parcel_snapshot.html b/backend/app/templates/parcel_snapshot.html index 88a24406..f8cedeb0 100644 --- a/backend/app/templates/parcel_snapshot.html +++ b/backend/app/templates/parcel_snapshot.html @@ -4,10 +4,12 @@ Карточка участка {{ cad_num }}