diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index a12e1946..faf49018 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -49,6 +49,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 @@ -244,6 +245,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: @@ -1163,11 +1170,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 не вызывали) — клиент поллит. @@ -1180,14 +1211,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) и отдаёт его в нужной форме. @@ -1197,6 +1228,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). @@ -1208,10 +1241,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) @@ -1249,6 +1282,15 @@ 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-ветки остаются байт-в-байт прежними. + if format == "pdf": + return Response( + content=export_report_pdf(run.result), + 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" @@ -1705,6 +1747,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 @@ -1712,6 +1761,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), }, ) @@ -2731,6 +2781,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, @@ -2827,12 +2897,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, @@ -3301,52 +3367,70 @@ 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(""" @@ -3371,10 +3455,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(""" @@ -3408,9 +3490,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(""" @@ -3427,16 +3508,25 @@ 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, @@ -3456,7 +3546,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 756e25a6..894e4e6c 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 6ae2a02e..ceed14a6 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, ) @@ -370,7 +407,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, analog_count: int | None = None, domrf_coverage: float | None = None, @@ -398,7 +435,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). analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count). domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта). @@ -451,9 +490,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/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index cd45e8f1..5fab475f 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -246,23 +246,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 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 }}