fix(site-finder): 5 дефектов из боевого фидбека analyze (#1736 #1737 #1738 #1739 #1740)
Some checks failed
CI / changes (push) Successful in 9s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Failing after 1m50s
CI / frontend-tests (pull_request) Failing after 1m48s
CI / openapi-codegen-check (pull_request) Failing after 2m39s
CI / openapi-codegen-check (push) Failing after 2m46s
CI / backend-tests (pull_request) Successful in 9m25s
CI / backend-tests (push) Successful in 9m30s
Some checks failed
CI / changes (push) Successful in 9s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Failing after 1m50s
CI / frontend-tests (pull_request) Failing after 1m48s
CI / openapi-codegen-check (pull_request) Failing after 2m39s
CI / openapi-codegen-check (push) Failing after 2m46s
CI / backend-tests (pull_request) Successful in 9m25s
CI / backend-tests (push) Successful in 9m30s
- #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 #1736 Closes #1737 Closes #1738 Closes #1739 Closes #1740
This commit is contained in:
parent
e7901bc1e8
commit
b1c0ea1268
9 changed files with 349 additions and 100 deletions
|
|
@ -49,6 +49,7 @@ from app.services.exporters.report_md import (
|
||||||
render_report_markdown,
|
render_report_markdown,
|
||||||
render_report_telegram_summary,
|
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.report_pptx import render_report_pptx
|
||||||
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
|
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
|
||||||
from app.services.site_finder.best_layouts import get_best_layouts
|
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_MEDIUM_THRESHOLD = 500 # flats_total < это → low
|
||||||
PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high
|
PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high
|
||||||
PIPELINE_TOP_OBJECTS_LIMIT = 10
|
PIPELINE_TOP_OBJECTS_LIMIT = 10
|
||||||
|
# #1738: самоисключение СУБЪЕКТА анализа из pipeline. Если анализируемый участок
|
||||||
|
# уже застраивается (его ЖК есть в domrf_kn_objects), он не должен считаться
|
||||||
|
# собственным «конкурентом». Отсекаем объекты, чья точка попадает в геометрию
|
||||||
|
# участка или в ~80м от неё (домрф-координата ставится на корпус/въезд, может
|
||||||
|
# не совпадать с центроидом ЗУ → буфер ловит «свой» объект).
|
||||||
|
PIPELINE_SELF_EXCLUDE_M = 80
|
||||||
|
|
||||||
|
|
||||||
def _coord_round(value: Any) -> float | None:
|
def _coord_round(value: Any) -> float | None:
|
||||||
|
|
@ -1163,11 +1170,35 @@ def get_parcel_forecast(
|
||||||
return {"status": "pending"}
|
return {"status": "pending"}
|
||||||
|
|
||||||
if run is not None:
|
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 {
|
return {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"run_id": run.id,
|
"run_id": run.id,
|
||||||
"created_at": run.created_at,
|
"created_at": run.created_at,
|
||||||
"report": run.result,
|
"report": report,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Форсайт ещё не посчитан (таска в работе или /analyze не вызывали) — клиент поллит.
|
# Форсайт ещё не посчитан (таска в работе или /analyze не вызывали) — клиент поллит.
|
||||||
|
|
@ -1180,14 +1211,14 @@ def export_parcel_forecast(
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
format: Annotated[
|
format: Annotated[
|
||||||
Literal["md", "json", "tg", "docx", "pptx"],
|
Literal["md", "json", "tg", "docx", "pptx", "pdf"],
|
||||||
Query(
|
Query(
|
||||||
description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | "
|
description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | "
|
||||||
"tg (сводка) | docx (Word-документ) | pptx (презентация)"
|
"tg (сводка) | docx (Word-документ) | pptx (презентация) | pdf (PDF-отчёт)"
|
||||||
),
|
),
|
||||||
] = "md",
|
] = "md",
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX (файл) или TG-сводка (#959).
|
"""Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX / PDF (файл) или TG-сводка (#959).
|
||||||
|
|
||||||
Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
|
Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
|
||||||
/{cad}/forecast inline) и отдаёт его в нужной форме.
|
/{cad}/forecast inline) и отдаёт его в нужной форме.
|
||||||
|
|
@ -1197,6 +1228,8 @@ def export_parcel_forecast(
|
||||||
зеркало содержания md/pdf; python-docx).
|
зеркало содержания md/pdf; python-docx).
|
||||||
• format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация,
|
• format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация,
|
||||||
сжатое зеркало содержания docx; python-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 сводка
|
• format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка
|
||||||
для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
|
для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
|
||||||
и отправить, а не файл-download).
|
и отправить, а не файл-download).
|
||||||
|
|
@ -1208,10 +1241,10 @@ def export_parcel_forecast(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
||||||
format: "md" (default) | "json" | "tg" | "docx" | "pptx".
|
format: "md" (default) | "json" | "tg" | "docx" | "pptx" | "pdf".
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response — для md/json/docx/pptx attachment с Content-Disposition (имя
|
Response — для md/json/docx/pptx/pdf attachment с Content-Disposition (имя
|
||||||
`gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
|
`gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
|
||||||
"""
|
"""
|
||||||
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
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"'},
|
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":
|
if format == "json":
|
||||||
payload = json.dumps(run.result, ensure_ascii=False, default=str)
|
payload = json.dumps(run.result, ensure_ascii=False, default=str)
|
||||||
media_type = "application/json"
|
media_type = "application/json"
|
||||||
|
|
@ -1705,6 +1747,13 @@ def analyze_parcel(
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
:radius_m
|
: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
|
||||||
AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval)
|
AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval)
|
||||||
ORDER BY ready_dt ASC
|
ORDER BY ready_dt ASC
|
||||||
|
|
@ -1712,6 +1761,7 @@ def analyze_parcel(
|
||||||
{
|
{
|
||||||
"wkt": geom_wkt,
|
"wkt": geom_wkt,
|
||||||
"radius_m": PIPELINE_RADIUS_M,
|
"radius_m": PIPELINE_RADIUS_M,
|
||||||
|
"self_exclude_m": PIPELINE_SELF_EXCLUDE_M,
|
||||||
"horizon_months": str(PIPELINE_HORIZON_MONTHS),
|
"horizon_months": str(PIPELINE_HORIZON_MONTHS),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -2731,6 +2781,26 @@ def analyze_parcel(
|
||||||
e,
|
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] = {
|
result_payload: dict[str, Any] = {
|
||||||
"cad_num": cad_num,
|
"cad_num": cad_num,
|
||||||
"source": source,
|
"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_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])],
|
||||||
"nspd_dump": nspd_dump_data["nspd_dump"],
|
"nspd_dump": nspd_dump_data["nspd_dump"],
|
||||||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
||||||
"gate_verdict": compute_gate_verdict(
|
# (вычислен выше, до сборки payload — нужен для gate_caveat #1740).
|
||||||
nspd_zoning=nspd_dump_data["nspd_zoning"],
|
"gate_verdict": gate_verdict,
|
||||||
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"],
|
|
||||||
),
|
|
||||||
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
||||||
"weights_profile": {
|
"weights_profile": {
|
||||||
"source": _weights_source,
|
"source": _weights_source,
|
||||||
|
|
@ -3301,52 +3367,70 @@ def parcel_snapshot_pdf(
|
||||||
Не является официальной выпиской ЕГРН — только аналитические данные НСПД.
|
Не является официальной выпиской ЕГРН — только аналитические данные НСПД.
|
||||||
Генерация <2 сек. Открывается в Adobe Reader / Chrome.
|
Генерация <2 сек. Открывается в Adobe Reader / Chrome.
|
||||||
"""
|
"""
|
||||||
# 1) Получить метаданные участка из cad_parcels
|
# 1-6) Сбор данных участка из БД (метаданные + геометрия + POI + конкуренты +
|
||||||
parcel_row = (
|
# district + last_update). Обёрнуто в try/except (#1739): иначе сырая DB/PostGIS-
|
||||||
db.execute(
|
# ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет
|
||||||
text("""
|
# traceback; HTTPException(404) при отсутствии участка и (422) при отсутствии
|
||||||
SELECT readable_address AS address,
|
# геометрии — re-raise НЕ оборачиваем (это валидные клиентские ответы, а не 500).
|
||||||
land_record_area AS area_m2,
|
try:
|
||||||
land_record_category_type AS land_category,
|
# 1) Получить метаданные участка из cad_parcels
|
||||||
permitted_use_established_by_document AS vri,
|
parcel_row = (
|
||||||
cost_value AS cadastral_cost,
|
db.execute(
|
||||||
updated_at AS last_update
|
text("""
|
||||||
FROM cad_parcels
|
SELECT readable_address AS address,
|
||||||
WHERE cad_num = CAST(:c AS text)
|
land_record_area AS area_m2,
|
||||||
LIMIT 1
|
land_record_category_type AS land_category,
|
||||||
"""),
|
permitted_use_established_by_document AS vri,
|
||||||
{"c": cad_num},
|
cost_value AS cadastral_cost,
|
||||||
)
|
updated_at AS last_update
|
||||||
.mappings()
|
FROM cad_parcels
|
||||||
.first()
|
WHERE cad_num = CAST(:c AS text)
|
||||||
)
|
LIMIT 1
|
||||||
|
"""),
|
||||||
if not parcel_row:
|
{"c": cad_num},
|
||||||
raise HTTPException(
|
)
|
||||||
status_code=404,
|
.mappings()
|
||||||
detail=f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки.",
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2) Получить геометрию (WKT) для POI / competitor queries
|
if not parcel_row:
|
||||||
geom_row = (
|
raise HTTPException(
|
||||||
db.execute(
|
status_code=404,
|
||||||
text("""
|
detail=(
|
||||||
SELECT ST_AsText(COALESCE(
|
f"Участок {cad_num} не найден в БД. "
|
||||||
(SELECT geom FROM cad_parcels_geom WHERE cad_num = CAST(:c AS text) LIMIT 1),
|
"Используйте POST /analyze для загрузки."
|
||||||
(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
|
|
||||||
|
|
||||||
# 3) POI в радиусе 1 км (только если есть геометрия)
|
# 2) Получить геометрию (WKT) для POI / competitor queries
|
||||||
poi_rows: list[dict[str, Any]] = []
|
geom_row = (
|
||||||
if geom_wkt:
|
db.execute(
|
||||||
poi_rows = [
|
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)
|
dict(r)
|
||||||
for r in db.execute(
|
for r in db.execute(
|
||||||
text("""
|
text("""
|
||||||
|
|
@ -3371,10 +3455,8 @@ def parcel_snapshot_pdf(
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
# 4) Конкуренты в радиусе 3 км (только если есть геометрия)
|
# 4) Конкуренты в радиусе 3 км
|
||||||
competitor_rows: list[dict[str, Any]] = []
|
competitor_rows: list[dict[str, Any]] = [
|
||||||
if geom_wkt:
|
|
||||||
competitor_rows = [
|
|
||||||
dict(r)
|
dict(r)
|
||||||
for r in db.execute(
|
for r in db.execute(
|
||||||
text("""
|
text("""
|
||||||
|
|
@ -3408,9 +3490,8 @@ def parcel_snapshot_pdf(
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
# 5) Получить district (через пересечение с ekb_districts если есть геом)
|
# 5) Получить district (через пересечение с ekb_districts)
|
||||||
district: str | None = None
|
district: str | None = None
|
||||||
if geom_wkt:
|
|
||||||
district_row = (
|
district_row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text("""
|
text("""
|
||||||
|
|
@ -3427,16 +3508,25 @@ def parcel_snapshot_pdf(
|
||||||
if district_row:
|
if district_row:
|
||||||
district = district_row["district_name"]
|
district = district_row["district_name"]
|
||||||
|
|
||||||
# 6) Форматировать last_update
|
# 6) Форматировать last_update
|
||||||
raw_update = parcel_row["last_update"]
|
raw_update = parcel_row["last_update"]
|
||||||
last_update_str: str | None = None
|
last_update_str: str | None = None
|
||||||
if raw_update is not None:
|
if raw_update is not None:
|
||||||
try:
|
try:
|
||||||
last_update_str = raw_update.strftime("%d.%m.%Y")
|
last_update_str = raw_update.strftime("%d.%m.%Y")
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
last_update_str = str(raw_update)[:10]
|
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:
|
try:
|
||||||
pdf_bytes = generate_snapshot_pdf(
|
pdf_bytes = generate_snapshot_pdf(
|
||||||
cad_num=cad_num,
|
cad_num=cad_num,
|
||||||
|
|
@ -3456,7 +3546,7 @@ def parcel_snapshot_pdf(
|
||||||
competitors_limit=5,
|
competitors_limit=5,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
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
|
raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc
|
||||||
|
|
||||||
cad_safe = cad_num.replace(":", "-")
|
cad_safe = cad_num.replace(":", "-")
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,11 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Путь к директории шаблонов (относительно этого файла — 2 уровня вверх, затем templates)
|
# Путь к директории шаблонов: app/templates/parcel_snapshot.html (#1739).
|
||||||
_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent / "templates"
|
# Файл лежит в 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 Sans (Ubuntu/Debian Docker-образ + Alpine резерв)
|
||||||
_DEJAVU_CANDIDATES: list[str] = [
|
_DEJAVU_CANDIDATES: list[str] = [
|
||||||
|
|
@ -61,6 +64,10 @@ def _find_font_url() -> str:
|
||||||
|
|
||||||
WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig,
|
WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig,
|
||||||
поэтому пустая строка допустима — шрифт тогда подбирается CSS generic.
|
поэтому пустая строка допустима — шрифт тогда подбирается 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:
|
for path in _DEJAVU_CANDIDATES:
|
||||||
if pathlib.Path(path).exists():
|
if pathlib.Path(path).exists():
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,16 @@ JSON-safe и ложится в слот ReportConfidence (#987): {level, rationa
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
Confidence = Literal["high", "medium", "low"]
|
Confidence = Literal["high", "medium", "low"]
|
||||||
|
|
||||||
|
# Вход component_confidences может быть плоским уровнем (legacy) ИЛИ парой
|
||||||
|
# (имя-сервиса, уровень) — чтобы пронести ИМЯ сервиса сквозь стек (#1737).
|
||||||
|
ComponentConfidence = Confidence | tuple[str, Confidence]
|
||||||
|
|
||||||
# ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab
|
# ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab
|
||||||
# future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ───────────────
|
# future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ───────────────
|
||||||
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
_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_ADVISORY_CAP: str = "advisory_cap"
|
||||||
_F_COMPONENT: str = "component" # один вкладывающий per-service confidence
|
_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) ──────
|
# ── Пороги счётчиков (align с per-service gate'ами, прочитанными в #949/#951) ──────
|
||||||
|
|
||||||
# deal_count: число сделок (продаж) за окно. high — длинная плотная выборка,
|
# deal_count: число сделок (продаж) за окно. high — длинная плотная выборка,
|
||||||
|
|
@ -97,20 +114,29 @@ class ConfidenceFactor:
|
||||||
(число сделок / ЖК / покрытие / месяцы / bool шок-окна — или None); `level` —
|
(число сделок / ЖК / покрытие / месяцы / bool шок-окна — или None); `level` —
|
||||||
его собственный вклад high/medium/low; `note` — короткая RU-фраза с числом
|
его собственный вклад high/medium/low; `note` — короткая RU-фраза с числом
|
||||||
(«7 сделок за 6 мес — мало»), из которой собирается структурная причина §15.
|
(«7 сделок за 6 мес — мало»), из которой собирается структурная причина §15.
|
||||||
|
`label` — человекочитаемое RU-имя фактора (#1737: для component-факторов это ИМЯ
|
||||||
|
под-сервиса — «Рынок» / «Будущее предложение» …, чтобы UI не показывал сырой
|
||||||
|
«Компонент N»); пусто для счётчиков (у них своя RU-метка на стороне UI).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
value: Any # число/доля/флаг, обосновавшие level (или None)
|
value: Any # число/доля/флаг, обосновавшие level (или None)
|
||||||
level: Confidence
|
level: Confidence
|
||||||
note: str # RU, с реальным числом
|
note: str # RU, с реальным числом
|
||||||
|
label: str = "" # RU-имя фактора (для component — имя сервиса, #1737); опционально
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
# `label` кладём ТОЛЬКО когда задан — схема обратно-совместима (старые
|
||||||
|
# потребители без поля `label` продолжают читать name/value/level/note).
|
||||||
|
out: dict[str, Any] = {
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"value": self.value,
|
"value": self.value,
|
||||||
"level": self.level,
|
"level": self.level,
|
||||||
"note": self.note,
|
"note": self.note,
|
||||||
}
|
}
|
||||||
|
if self.label:
|
||||||
|
out["label"] = self.label
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -145,7 +171,12 @@ class ReportConfidenceResult:
|
||||||
while f"{key}_{i}" in factors:
|
while f"{key}_{i}" in factors:
|
||||||
i += 1
|
i += 1
|
||||||
key = f"{key}_{i}"
|
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
|
factors["advisory_capped"] = self.advisory_capped
|
||||||
return {
|
return {
|
||||||
"level": self.level,
|
"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 confidence → ConfidenceFactor. PURE.
|
||||||
|
|
||||||
Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт,
|
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(
|
return ConfidenceFactor(
|
||||||
name=_F_COMPONENT,
|
name=_F_COMPONENT,
|
||||||
value=None,
|
value=None,
|
||||||
level=level,
|
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(
|
def compute_report_confidence(
|
||||||
*,
|
*,
|
||||||
component_confidences: list[Confidence] | None = None,
|
component_confidences: Sequence[ComponentConfidence] | None = None,
|
||||||
deal_count: int | None = None,
|
deal_count: int | None = None,
|
||||||
analog_count: int | None = None,
|
analog_count: int | None = None,
|
||||||
domrf_coverage: float | None = None,
|
domrf_coverage: float | None = None,
|
||||||
|
|
@ -398,7 +435,9 @@ def compute_report_confidence(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→
|
component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→
|
||||||
нет вкладывающих компонентов.
|
нет вкладывающих компонентов. Каждый элемент — ЛИБО плоский уровень
|
||||||
|
'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
|
||||||
|
имя сервиса проносится сквозь стек в RU-метку фактора).
|
||||||
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
||||||
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
|
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
|
||||||
domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||||
|
|
@ -451,9 +490,16 @@ def compute_report_confidence(
|
||||||
factors.append(_confounded_factor(True))
|
factors.append(_confounded_factor(True))
|
||||||
|
|
||||||
# ── 2. Вкладывающие per-service confidence → факторы ──────────────────────
|
# ── 2. Вкладывающие per-service confidence → факторы ──────────────────────
|
||||||
|
# Элемент — ЛИБО плоский уровень (legacy), ЛИБО пара (имя-сервиса, уровень)
|
||||||
|
# (#1737: проносим имя сервиса в RU-метку фактора). Мусор/неизвестный уровень
|
||||||
|
# отбрасываем (whitelist), как и раньше.
|
||||||
for c in component_confidences or []:
|
for c in component_confidences or []:
|
||||||
if c in _CONFIDENCE_RANK:
|
if isinstance(c, tuple):
|
||||||
factors.append(_component_factor(c))
|
service, level = c
|
||||||
|
else:
|
||||||
|
service, level = None, c
|
||||||
|
if level in _CONFIDENCE_RANK:
|
||||||
|
factors.append(_component_factor(level, service))
|
||||||
|
|
||||||
# ── 3. weakest-link агрегат (худший тянет вниз) ───────────────────────────
|
# ── 3. weakest-link агрегат (худший тянет вниз) ───────────────────────────
|
||||||
raw_level = _aggregate(factors)
|
raw_level = _aggregate(factors)
|
||||||
|
|
|
||||||
|
|
@ -246,23 +246,33 @@ def _component_confidences(
|
||||||
forecasts: Sequence[dict[str, Any]],
|
forecasts: Sequence[dict[str, Any]],
|
||||||
product_scores: dict[str, Any] | None,
|
product_scores: dict[str, Any] | None,
|
||||||
special_indices: dict[str, Any] | None,
|
special_indices: dict[str, Any] | None,
|
||||||
) -> list[Confidence]:
|
) -> list[tuple[str, Confidence]]:
|
||||||
"""Собрать per-service confidence вкладывающих под-сервисов — для #990. PURE.
|
"""Собрать per-service confidence вкладывающих под-сервисов — для #990. PURE.
|
||||||
|
|
||||||
#990 свернёт их weakest-link (MIN) вместе с сырыми счётчиками. Берём confidence-
|
#990 свернёт их weakest-link (MIN) вместе с сырыми счётчиками. Берём confidence-
|
||||||
метку каждого доступного под-вывода (market_metrics §9.2 / future_supply §9.3 /
|
метку каждого доступного под-вывода (market_metrics §9.2 / future_supply §9.3 /
|
||||||
каждый per-горизонт forecast #952 / product_scores #985 / special_indices #986).
|
каждый per-горизонт forecast #952 / product_scores #985 / special_indices #986).
|
||||||
Только whitelisted 'high|medium|low' (мусор/None отбрасываем).
|
Только whitelisted 'high|medium|low' (мусор/None отбрасываем).
|
||||||
|
|
||||||
|
#1737: возвращаем ПАРЫ (имя-сервиса, уровень) — машинное имя под-сервиса
|
||||||
|
(«market_metrics» / «future_supply» / «product_scores» / «special_indices» /
|
||||||
|
«forecasts»), чтобы #990 пронёс ИМЯ сервиса в RU-метку фактора и UI не показывал
|
||||||
|
безличный «Компонент N».
|
||||||
"""
|
"""
|
||||||
out: list[Confidence] = []
|
out: list[tuple[str, Confidence]] = []
|
||||||
for source in (market_metrics, future_supply, product_scores, special_indices):
|
for name, source in (
|
||||||
|
("market_metrics", market_metrics),
|
||||||
|
("future_supply", future_supply),
|
||||||
|
("product_scores", product_scores),
|
||||||
|
("special_indices", special_indices),
|
||||||
|
):
|
||||||
conf = _confidence_of(source)
|
conf = _confidence_of(source)
|
||||||
if conf is not None:
|
if conf is not None:
|
||||||
out.append(conf)
|
out.append((name, conf))
|
||||||
for f in forecasts:
|
for f in forecasts:
|
||||||
conf = _confidence_of(f)
|
conf = _confidence_of(f)
|
||||||
if conf is not None:
|
if conf is not None:
|
||||||
out.append(conf)
|
out.append(("forecasts", conf))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Карточка участка {{ cad_num }}</title>
|
<title>Карточка участка {{ cad_num }}</title>
|
||||||
<style>
|
<style>
|
||||||
|
{% if font_url %}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'DejaVu Sans';
|
font-family: 'DejaVu Sans';
|
||||||
src: url('{{ font_url }}') format('truetype');
|
src: url('{{ font_url }}') format('truetype');
|
||||||
}
|
}
|
||||||
|
{% endif %}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
font-family: 'DejaVu Sans', Arial, sans-serif;
|
font-family: 'DejaVu Sans', Arial, sans-serif;
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,33 @@ import datetime as dt
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def _weasyprint_native_libs_available() -> tuple[bool, str]:
|
||||||
|
"""Probe whether WeasyPrint can render (native GTK/Pango/GObject libs present).
|
||||||
|
|
||||||
|
libpango/libgobject отсутствуют на macOS dev, присутствуют в Docker/CI-образе.
|
||||||
|
Зеркало гварда из tests/services/exporters/test_report_pdf.py — пропускаем
|
||||||
|
PDF-рендер на dev, но прогоняем на CI (#1739).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
|
HTML(string="<p>x</p>").write_pdf()
|
||||||
|
except (OSError, ImportError) as e:
|
||||||
|
return False, str(e)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
|
||||||
|
_skip_if_no_weasyprint = pytest.mark.skipif(
|
||||||
|
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
|
||||||
|
)
|
||||||
|
|
||||||
# ── Константы ─────────────────────────────────────────────────────────────────
|
# ── Константы ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_CAD = "66:41:0204016:10"
|
_CAD = "66:41:0204016:10"
|
||||||
|
|
@ -289,7 +312,11 @@ def test_get_forecast_ready_when_run_present() -> None:
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
app.dependency_overrides[get_db] = _override_db(db)
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
try:
|
try:
|
||||||
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
|
# 1-й вызов — §22-ран ("1.0"); 2-й — analyze-ран для gate_caveat (#1740),
|
||||||
|
# здесь None (gate не применяется).
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]
|
||||||
|
) as lrf:
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
|
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
@ -297,8 +324,8 @@ def test_get_forecast_ready_when_run_present() -> None:
|
||||||
assert body["status"] == "ready"
|
assert body["status"] == "ready"
|
||||||
assert body["run_id"] == 66
|
assert body["run_id"] == 66
|
||||||
assert body["report"] == report_dict
|
assert body["report"] == report_dict
|
||||||
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam).
|
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam) — первый вызов.
|
||||||
_, kwargs = lrf.call_args
|
_, kwargs = lrf.call_args_list[0]
|
||||||
assert kwargs.get("schema_version") == "1.0"
|
assert kwargs.get("schema_version") == "1.0"
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
@ -590,8 +617,54 @@ def test_export_forecast_no_run_returns_404() -> None:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@_skip_if_no_weasyprint
|
||||||
|
def test_export_forecast_pdf_returns_pdf() -> None:
|
||||||
|
"""format=pdf → 200 application/pdf + attachment .pdf, тело — PDF-байты (%PDF) (#1739)."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
fake_run = MagicMock()
|
||||||
|
fake_run.result = _REPORT_FIXTURE
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.headers["content-type"] == "application/pdf"
|
||||||
|
cd = resp.headers["content-disposition"]
|
||||||
|
assert "attachment;" in cd
|
||||||
|
assert cd.endswith('.pdf"')
|
||||||
|
# cad-двоеточия санитизированы в имени файла.
|
||||||
|
assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
|
||||||
|
# WeasyPrint PDF начинается с маркера %PDF.
|
||||||
|
assert resp.content[:4] == b"%PDF"
|
||||||
|
# Читаем именно §22-схему "1.0".
|
||||||
|
_, kwargs = lrf.call_args
|
||||||
|
assert kwargs.get("schema_version") == "1.0"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_forecast_pdf_no_run_returns_404() -> None:
|
||||||
|
"""format=pdf + рана нет → 404 (как md/json/docx/pptx export-вариант, НЕ 202/500)."""
|
||||||
|
from app.core.db import get_db
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
|
try:
|
||||||
|
with patch("app.api.v1.parcels.latest_run_for", return_value=None):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
|
||||||
|
assert resp.status_code == 404, resp.text
|
||||||
|
assert "посчитан" in resp.json()["detail"]
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_export_forecast_invalid_format_returns_422() -> None:
|
def test_export_forecast_invalid_format_returns_422() -> None:
|
||||||
"""format вне {md,json,tg,docx,pptx} → 422 (Literal-валидация ДО работы с БД)."""
|
"""format вне {md,json,tg,docx,pptx,pdf} → 422 (Literal-валидация ДО работы с БД)."""
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -599,7 +672,7 @@ def test_export_forecast_invalid_format_returns_422() -> None:
|
||||||
try:
|
try:
|
||||||
with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf:
|
with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf:
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
|
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=xml")
|
||||||
assert resp.status_code == 422, resp.text
|
assert resp.status_code == 422, resp.text
|
||||||
lrf.assert_not_called()
|
lrf.assert_not_called()
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,15 @@ function isConfidenceFactor(v: unknown): v is ConfidenceFactor {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function factorLabel(key: string): string {
|
function factorLabel(key: string, factor: ConfidenceFactor): string {
|
||||||
|
// #1737: backend проносит RU-имя сервиса в `label` для component-факторов
|
||||||
|
// («Рынок» / «Будущее предложение» / …) — предпочитаем его, чтобы не показывать
|
||||||
|
// безличный «Компонент N».
|
||||||
|
if (factor.label) return factor.label;
|
||||||
if (FACTOR_RU[key]) return FACTOR_RU[key];
|
if (FACTOR_RU[key]) return FACTOR_RU[key];
|
||||||
// component_2..N → «Компонент N» (note уже несёт «вкладывающий сервис: …»)
|
// Fallback для старых конвертов без `label`: component / component_2..N → «Компонент N».
|
||||||
const m = key.match(/^component_(\d+)$/);
|
const m = key.match(/^component(?:_(\d+))?$/);
|
||||||
if (m) return `Компонент ${m[1]}`;
|
if (m) return m[1] ? `Компонент ${m[1]}` : "Компонент";
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,7 +146,7 @@ export function ForecastConfidenceBlock({ confidence }: Props) {
|
||||||
color: "var(--fg-primary)",
|
color: "var(--fg-primary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{factorLabel(key)}
|
{factorLabel(key, factor)}
|
||||||
{valueStr != null && (
|
{valueStr != null && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import dynamic from "next/dynamic";
|
||||||
import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
|
import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
import type { Geometry } from "geojson";
|
import type { Geometry } from "geojson";
|
||||||
|
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||||
|
|
||||||
// MiniMap высота тайла карты — компромисс между плотностью Section 1 и
|
// MiniMap высота тайла карты — компромисс между плотностью Section 1 и
|
||||||
// читаемостью. Layer-controls/легенда вне этого размера (flow ниже).
|
// читаемостью. Layer-controls/легенда вне этого размера (flow ниже).
|
||||||
|
|
@ -87,10 +88,18 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
|
||||||
export function MiniMap({ data }: Props) {
|
export function MiniMap({ data }: Props) {
|
||||||
const adapted = toSiteMapData(data);
|
const adapted = toSiteMapData(data);
|
||||||
|
|
||||||
|
// #1736: точки подключения ресурсов (электро/газ/вода/тепло). Зеркалим
|
||||||
|
// обвязку legacy /site-finder (page.tsx:109,568) — тянем CP-данные по
|
||||||
|
// data.cad_num и пробрасываем prop `connectionPoints` в <SiteMap>, который
|
||||||
|
// рисует ConnectionPointsLayer + CpLayerControlPanel при truthy prop.
|
||||||
|
const { data: connectionPoints } = useConnectionPoints(data.cad_num);
|
||||||
|
|
||||||
// #1218: НЕ задаём height + overflow:hidden на обёртке. SiteMap сам
|
// #1218: НЕ задаём height + overflow:hidden на обёртке. SiteMap сам
|
||||||
// ограничивает высоту карты-тайла (mapHeight), а легенда + layer-controls
|
// ограничивает высоту карты-тайла (mapHeight), а легенда + layer-controls
|
||||||
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) лежат
|
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) + панель
|
||||||
// под картой потоком и должны быть видны/кликабельны.
|
// точек подключения (CpLayerControlPanel) лежат под картой потоком и должны
|
||||||
|
// быть видны/кликабельны. MINIMAP_HEIGHT ограничивает ТОЛЬКО тайл карты, не
|
||||||
|
// клипает CP-панель.
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -101,6 +110,7 @@ export function MiniMap({ data }: Props) {
|
||||||
>
|
>
|
||||||
<SiteMap
|
<SiteMap
|
||||||
data={adapted}
|
data={adapted}
|
||||||
|
connectionPoints={connectionPoints}
|
||||||
competitors={data.competitors ?? []}
|
competitors={data.competitors ?? []}
|
||||||
pipelineObjects={data.pipeline_24mo?.top_objects ?? []}
|
pipelineObjects={data.pipeline_24mo?.top_objects ?? []}
|
||||||
riskZones={data.nspd_risk_zones ?? []}
|
riskZones={data.nspd_risk_zones ?? []}
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,13 @@ export interface ConfidenceFactor {
|
||||||
note: string;
|
note: string;
|
||||||
level: ConfidenceLevel;
|
level: ConfidenceLevel;
|
||||||
value: number | null;
|
value: number | null;
|
||||||
|
/**
|
||||||
|
* Human-readable RU label (#1737). For per-service «component» factors this is
|
||||||
|
* the service name («Рынок» / «Будущее предложение» / …) so the UI never shows a
|
||||||
|
* bare «Компонент N». Optional — older envelopes omit it; the UI falls back to
|
||||||
|
* the factor key.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportConfidence {
|
export interface ReportConfidence {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue