Merge branch 'main' into feat/cadastral-geo-match
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

This commit is contained in:
lekss361 2026-06-18 08:44:06 +00:00
commit 48bedb5f65
31 changed files with 2138 additions and 350 deletions

View file

@ -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_<cad>_<YYYY-MM-DD>.<ext>`); для 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(":", "-")

View file

@ -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():

View file

@ -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)

View file

@ -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,
}
)

View file

@ -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)

View file

@ -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,
)

View file

@ -4,10 +4,12 @@
<meta charset="UTF-8" />
<title>Карточка участка {{ cad_num }}</title>
<style>
{% if font_url %}
@font-face {
font-family: 'DejaVu Sans';
src: url('{{ font_url }}') format('truetype');
}
{% endif %}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'DejaVu Sans', Arial, sans-serif;

View file

@ -22,10 +22,33 @@ import datetime as dt
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
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"
@ -289,7 +312,11 @@ def test_get_forecast_ready_when_run_present() -> None:
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:
# 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)
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
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["run_id"] == 66
assert body["report"] == report_dict
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam).
_, kwargs = lrf.call_args
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam) — первый вызов.
_, kwargs = lrf.call_args_list[0]
assert kwargs.get("schema_version") == "1.0"
finally:
app.dependency_overrides.clear()
@ -590,8 +617,54 @@ def test_export_forecast_no_run_returns_404() -> None:
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:
"""format вне {md,json,tg,docx,pptx} → 422 (Literal-валидация ДО работы с БД)."""
"""format вне {md,json,tg,docx,pptx,pdf} → 422 (Literal-валидация ДО работы с БД)."""
from app.core.db import get_db
db = MagicMock()
@ -599,7 +672,7 @@ def test_export_forecast_invalid_format_returns_422() -> None:
try:
with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf:
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
lrf.assert_not_called()
finally:

View file

@ -5,6 +5,7 @@ import Link from "next/link";
import { useQueryClient } from "@tanstack/react-query";
import { ChatPanel } from "@/components/site-finder/ChatPanel";
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
@ -160,22 +161,48 @@ export function AnalysisPageContent({ cad }: Props) {
{/* ── Main scroll area ──────────────────────────────────────────── */}
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
{/* Section 1 — IMPLEMENTED in A5 */}
{/* Вердикт-баннер участка — выше всех секций (#1741). */}
<GateVerdictBanner verdict={analysis.gate_verdict} />
{/* ── Группа «Участок» ──────────────────────────────────────── */}
<GroupDivider label="Участок" />
{/* 1. Информация — IMPLEMENTED in A5 */}
<Section1ParcelInfo cad={cad} />
{/* Section 2 — IMPLEMENTED in A6 */}
<Section2NetworksUtilities cad={cad} />
{/* Section 3 — IMPLEMENTED in A7 */}
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
{/* Section 4 — IMPLEMENTED in A10 */}
{/* 2. Оценка участка — IMPLEMENTED in A10 */}
<Section4Estimate cad={cad} />
{/* Section 5 — IMPLEMENTED in A11 */}
{/* 3. Сети и точки подключения — IMPLEMENTED in A6 */}
<Section2NetworksUtilities cad={cad} />
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
<GroupDivider label="Стройка и рынок" />
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
<Section5Atmosphere cad={cad} />
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */}
{/* Статус пересчёта прогноза при смене горизонта (#1744)
нейтральная серая строка вместо молчаливого disabled. */}
{isFetching && (
<div
role="status"
aria-live="polite"
style={{
fontSize: 13,
lineHeight: "20px",
color: "var(--fg-tertiary, #73767E)",
transition: "opacity 0.2s",
}}
>
Пересчитываем прогноз на {horizon} мес
</div>
)}
{/* 6. Прогноз и рекомендация — IMPLEMENTED in 958-B3 (§22 forecast) */}
<Section6Forecast cad={cad} selectedHorizon={horizon} />
{/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
@ -245,32 +272,85 @@ function Breadcrumb({
);
}
// ── Sidebar nav ────────────────────────────────────────────────────────────────
// ── Group divider ────────────────────────────────────────────────────────────
//
// Плашка-разделитель между смысловыми группами секций (#1741): тёмный фон
// --bg-headline + светлый uppercase-label --fg-on-dark, паттерн headline-bar.
const NAV_ITEMS = [
{ id: "section-1", label: "1. Информация" },
{ id: "section-2", label: "2. Сети" },
function GroupDivider({ label }: { label: string }) {
return (
<div
style={{
background: "var(--bg-headline, #0F172A)",
borderRadius: 12,
padding: "10px 18px",
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--fg-on-dark, #E2E8F0)",
}}
>
{label}
</div>
</div>
);
}
// ── Sidebar nav ────────────────────────────────────────────────────────────────
//
// Две группы с групп-хедерами (#1741). Нумерация секций сквозная 16 под новый
// порядок: «Участок» → 1-3, «Стройка и рынок» → 4-6.
interface NavItem {
id: string;
label: string;
children?: { id: string; label: string }[];
}
interface NavGroup {
label: string;
items: NavItem[];
}
const NAV_GROUPS: NavGroup[] = [
{
id: "section-3",
label: "3. Продажи конкурентов",
children: [
{ id: "section-3-1", label: "3.1 Настройки выборки" },
{ id: "section-3-2", label: "3.2 Планировки" },
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
label: "Участок",
items: [
{ id: "section-1", label: "1. Информация" },
{ id: "section-4", label: "2. Оценка участка" },
{ id: "section-2", label: "3. Сети и точки подключения" },
],
},
{ id: "section-4", label: "4. Оценка" },
{ id: "section-5", label: "5. Атмосфера" },
{
id: "section-6",
label: "6. Прогноз",
children: [
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
{ id: "section-6-2", label: "6.2 Сценарии" },
{ id: "section-6-3", label: "6.3 Уверенность" },
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
label: "Стройка и рынок",
items: [
{
id: "section-3",
label: "4. Рынок и конкуренты",
children: [
{ id: "section-3-1", label: "4.1 Настройки выборки" },
{ id: "section-3-2", label: "4.2 Планировки" },
{ id: "section-3-3", label: "4.3 Остатки и скорость" },
],
},
{ id: "section-5", label: "5. Атмосфера" },
{
id: "section-6",
label: "6. Прогноз и рекомендация",
children: [
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
{ id: "section-6-2", label: "6.2 Сценарии" },
{ id: "section-6-3", label: "6.3 Уверенность" },
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
],
},
],
},
];
@ -293,49 +373,36 @@ function AnalysisSidebarNav() {
flexDirection: "column",
}}
>
{NAV_ITEMS.map((item) => (
<div key={item.id}>
<button
type="button"
onClick={() => scrollTo(item.id)}
{NAV_GROUPS.map((group, gi) => (
<div key={group.label}>
<div
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "8px 16px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary, #111111)",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--bg-card-alt, #FAFBFC)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "none";
padding: gi === 0 ? "0 16px 6px" : "12px 16px 6px",
fontSize: 11,
fontWeight: 600,
letterSpacing: "0.06em",
textTransform: "uppercase",
color: "var(--fg-tertiary, #73767E)",
}}
>
{item.label}
</button>
{"children" in item &&
item.children?.map((child) => (
{group.label}
</div>
{group.items.map((item) => (
<div key={item.id}>
<button
key={child.id}
type="button"
onClick={() => scrollTo(child.id)}
onClick={() => scrollTo(item.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "6px 16px 6px 28px",
padding: "8px 16px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 12,
color: "var(--fg-secondary, #5B6066)",
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary, #111111)",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
@ -346,9 +413,38 @@ function AnalysisSidebarNav() {
e.currentTarget.style.background = "none";
}}
>
{child.label}
{item.label}
</button>
))}
{item.children?.map((child) => (
<button
key={child.id}
type="button"
onClick={() => scrollTo(child.id)}
style={{
display: "block",
width: "100%",
textAlign: "left",
padding: "6px 16px 6px 28px",
background: "none",
border: "none",
cursor: "pointer",
fontSize: 12,
color: "var(--fg-secondary, #5B6066)",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
"var(--bg-card-alt, #FAFBFC)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "none";
}}
>
{child.label}
</button>
))}
</div>
))}
</div>
))}
</nav>

View file

@ -22,64 +22,88 @@ interface Props {
disabled?: boolean;
}
// Подсказка на сегментах — поясняет, что даёт ближний / дальний горизонт.
const HORIZON_TOOLTIP =
"6 мес — ближний спрос, точнее; 24 мес — стратегия, шире интервал";
export function HorizonSelector({ value, onChange, disabled = false }: Props) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: 6,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span
style={{
fontSize: 12,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
whiteSpace: "nowrap",
}}
>
Горизонт прогноза
</span>
<div
role="radiogroup"
aria-label="Горизонт прогноза"
style={{
display: "inline-flex",
borderRadius: 8,
border: "1px solid var(--border-card)",
background: "var(--bg-card)",
padding: 2,
gap: 2,
opacity: disabled ? 0.6 : 1,
}}
>
{HORIZONS.map((h) => {
const selected = h === value;
return (
<button
key={h}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
title={HORIZON_TOOLTIP}
onClick={() => onChange(h)}
style={{
appearance: "none",
cursor: disabled ? "not-allowed" : "pointer",
padding: "6px 16px",
borderRadius: 6,
border: "none",
background: selected ? "var(--accent)" : "transparent",
color: selected ? "var(--fg-on-dark)" : "var(--fg-secondary)",
fontSize: 13,
fontWeight: selected ? 600 : 500,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
transition: "background 0.12s, color 0.12s",
}}
>
{h} мес
</button>
);
})}
</div>
</div>
<span
style={{
fontSize: 12,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
whiteSpace: "nowrap",
lineHeight: "16px",
color: "var(--fg-tertiary)",
textAlign: "right",
}}
>
Горизонт прогноза
Смена горизонта пересчитывает прогноз заново (~1030 сек)
</span>
<div
role="radiogroup"
aria-label="Горизонт прогноза"
style={{
display: "inline-flex",
borderRadius: 8,
border: "1px solid var(--border-card)",
background: "var(--bg-card)",
padding: 2,
gap: 2,
opacity: disabled ? 0.6 : 1,
}}
>
{HORIZONS.map((h) => {
const selected = h === value;
return (
<button
key={h}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={() => onChange(h)}
style={{
appearance: "none",
cursor: disabled ? "not-allowed" : "pointer",
padding: "6px 16px",
borderRadius: 6,
border: "none",
background: selected ? "var(--accent)" : "transparent",
color: selected ? "var(--fg-on-dark)" : "var(--fg-secondary)",
fontSize: 13,
fontWeight: selected ? 600 : 500,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
transition: "background 0.12s, color 0.12s",
}}
>
{h} мес
</button>
);
})}
</div>
</div>
);
}

View file

@ -80,15 +80,33 @@ export function Pipeline24moBlock({ data }: Props) {
>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 11,
fontWeight: 700,
color: "#6b7280",
color: "var(--fg-secondary, #5B6066)",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
title="Pipeline 24 месяца — ЖК, которые сдаются в окне 24мес в радиусе 5км"
title="Прогноз будущего предложения: ЖК-конкуренты со сроком сдачи в ближайшие 24 месяца в радиусе 5 км. Это оценка, а не факт продаж."
>
Конкуренты на 24 мес (5 км)
Будущее предложение конкурентов (24 мес)
<span
style={{
padding: "1px 6px",
borderRadius: 6,
fontSize: 10,
fontWeight: 600,
letterSpacing: "0.02em",
color: "var(--fg-tertiary, #73767E)",
background: "var(--bg-card-alt, #FAFBFC)",
border: "1px solid var(--border-card, #E6E8EC)",
textTransform: "none",
}}
>
прогноз
</span>
</span>
<span
style={{
@ -102,13 +120,14 @@ export function Pipeline24moBlock({ data }: Props) {
}}
>
{data.severity_label ?? data.severity} ·{" "}
{data.flats_total.toLocaleString("ru-RU")} квартир
{data.flats_total.toLocaleString("ru")} лотов
</span>
</div>
{data.objects_count === 0 ? (
<div style={{ fontSize: 13, color: "#9ca3af" }}>
Нет ЖК-конкурентов с planned_commissioning в окне 24мес в радиусе 5км.
<div style={{ fontSize: 13, color: "var(--fg-tertiary, #73767E)" }}>
Нет ЖК-конкурентов со сроком сдачи в ближайшие 24 месяца в радиусе 5
км.
</div>
) : (
<>
@ -123,20 +142,10 @@ export function Pipeline24moBlock({ data }: Props) {
}}
>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>Объектов</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
style={{ color: "var(--fg-tertiary, #73767E)", fontSize: 11 }}
>
{data.objects_count}
</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>
Квартир суммарно
ЖК
</div>
<div
style={{
@ -145,11 +154,31 @@ export function Pipeline24moBlock({ data }: Props) {
fontVariantNumeric: "tabular-nums",
}}
>
{data.flats_total.toLocaleString("ru-RU")}
{data.objects_count.toLocaleString("ru")} ЖК
</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>Горизонт</div>
<div
style={{ color: "var(--fg-tertiary, #73767E)", fontSize: 11 }}
>
Лотов суммарно
</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{data.flats_total.toLocaleString("ru")} лотов
</div>
</div>
<div>
<div
style={{ color: "var(--fg-tertiary, #73767E)", fontSize: 11 }}
>
Горизонт
</div>
<div
style={{
fontWeight: 600,
@ -186,7 +215,7 @@ export function Pipeline24moBlock({ data }: Props) {
>
{fmtClass(cls)}:{" "}
<strong style={{ fontVariantNumeric: "tabular-nums" }}>
{flats.toLocaleString("ru-RU")}
{flats.toLocaleString("ru")} лотов
</strong>
</div>
))}
@ -200,11 +229,11 @@ export function Pipeline24moBlock({ data }: Props) {
style={{
fontSize: 11,
fontWeight: 600,
color: "#6b7280",
color: "var(--fg-secondary, #5B6066)",
marginBottom: 6,
}}
>
По кварталам сдачи
По кварталам сдачи (прогноз)
</div>
<div
style={{
@ -248,7 +277,7 @@ export function Pipeline24moBlock({ data }: Props) {
opacity: 0.5,
borderRadius: "3px 3px 0 0",
}}
title={`${q.quarter}: ${q.objects} ЖК / ${q.flats} квартир`}
title={`${q.quarter}: ${q.objects.toLocaleString("ru")} ЖК / ${q.flats.toLocaleString("ru")} лотов (прогноз)`}
/>
<div
style={{
@ -278,7 +307,7 @@ export function Pipeline24moBlock({ data }: Props) {
background: "none",
border: "none",
padding: 0,
color: "#1d4ed8",
color: "var(--accent, #1D4ED8)",
fontSize: 13,
cursor: "pointer",
fontWeight: 500,
@ -286,7 +315,7 @@ export function Pipeline24moBlock({ data }: Props) {
>
{expanded
? "Скрыть список"
: `Топ-${data.top_objects.length} ЖК pipeline`}
: `Топ-${data.top_objects.length} ЖК в прогнозе`}
</button>
{expanded && (
<div
@ -375,7 +404,9 @@ export function Pipeline24moBlock({ data }: Props) {
color: "#374151",
}}
>
{obj.flat_count?.toLocaleString("ru-RU") ?? "—"}
{obj.flat_count != null
? `${obj.flat_count.toLocaleString("ru")} лотов`
: "—"}
</td>
<td
style={{

View file

@ -38,6 +38,14 @@ function formatPercent(score: number): string {
return `${Math.round(score * 100)}%`;
}
/** Словесный диапазон темпа: 033 «медленно» / 3366 «средне» / 66100 «быстро». */
function paceBand(score: number): { label: string; color: string } {
if (score >= 0.66)
return { label: "быстро", color: "var(--success, #0A7A3A)" };
if (score >= 0.33) return { label: "средне", color: "var(--warn, #9A6700)" };
return { label: "медленно", color: "var(--danger, #B3261E)" };
}
export function VelocityBlock({ velocity }: VelocityBlockProps) {
if (!velocity) {
return (
@ -55,6 +63,16 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
const confColor = CONFIDENCE_COLOR[velocity.confidence];
const scorePct = formatPercent(velocity.velocity_score);
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
const band = paceBand(velocity.velocity_score);
// Вердикт: «×1.4 к медиане ЕКБ — выше рынка» (краткое словесное «быстрее/
// медленнее рынка» по соотношению, отдельно от шкалы 0100%).
const aboveMarket = ratio >= 1;
const verdict = `×${ratio.toFixed(1)} к медиане ЕКБ — ${
aboveMarket ? "выше рынка" : "ниже рынка"
}`;
const verdictColor = aboveMarket
? "var(--success, #0A7A3A)"
: "var(--danger, #B3261E)";
return (
<div>
@ -97,23 +115,46 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
{/* Score gauge — показываем только если данные есть */}
{dataAvailable && (
<div style={{ marginBottom: 12 }}>
{/* Крупный вердикт — главное число секции */}
<div
style={{
fontSize: 18,
fontWeight: 600,
color: verdictColor,
lineHeight: 1.2,
marginBottom: 8,
}}
title="Соотношение темпа продаж конкурентов к медианному темпу по ЕКБ (м² в месяц)"
>
{verdict}
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
fontSize: 12,
color: "#6b7280",
color: "var(--fg-secondary, #5B6066)",
marginBottom: 4,
}}
>
<span>Velocity-score</span>
<span style={{ fontWeight: 600, color: "#111827" }}>
<span title="Шкала 0100%: 033% медленно, 3366% средне, 66100% быстро">
Темп продаж
</span>
<span
style={{
fontWeight: 600,
color: "var(--fg-primary, #111111)",
fontVariantNumeric: "tabular-nums",
}}
>
{scorePct}
</span>
</div>
<div
style={{
background: "#e5e7eb",
background: "var(--border-card, #E6E8EC)",
borderRadius: 4,
height: 8,
overflow: "hidden",
@ -121,23 +162,40 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
>
<div
style={{
background:
velocity.velocity_score >= 0.66
? "#10b981"
: velocity.velocity_score >= 0.33
? "#f59e0b"
: "#ef4444",
background: band.color,
width: `${velocity.velocity_score * 100}%`,
height: "100%",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
{Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
{Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) &middot;{" "}
{ratio >= 1
? `x${ratio.toFixed(1)} выше`
: `${formatPercent(ratio)} от среднего`}
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "var(--fg-tertiary, #73767E)",
marginTop: 4,
}}
>
<span>выше продаётся быстрее</span>
<span style={{ fontWeight: 600, color: band.color }}>
{band.label}
</span>
</div>
{/* Сами м²/мес — мелкий caption под вердиктом */}
<div
style={{
fontSize: 11,
color: "var(--fg-tertiary, #73767E)",
marginTop: 6,
fontVariantNumeric: "tabular-nums",
}}
>
{Math.round(velocity.monthly_velocity_sqm).toLocaleString("ru")}{" "}
м²/мес против{" "}
{Math.round(velocity.ekb_median_sqm).toLocaleString("ru")} м²/мес
(медиана ЕКБ)
</div>
</div>
)}

View file

@ -0,0 +1,311 @@
"use client";
/**
* 6.4 Прогноз по всем форматам таблица ассортимента (#1745, фаза 1).
*
* Статическая таблица под рекомендацией продукта: строки = форматы (студия / 1к /
* 2к / 3к / 4-5к) РЕКОМЕНДОВАННОГО класса, колонки = Формат · Индекс дефицита ·
* Прогноз спроса (квартир) · Сигнал. Цель показать продуктовому директору весь
* срез дефицита по форматам одним взглядом (а не только рекомендованный формат).
*
* Источник: `product_tz.mix` (квартирография ячейки формат×класс с per-ячейкой
* deficit_index + #1745-проброшенными projected_demand_units/signal). Фильтруем к
* форматам рекомендованного класса, упорядочиваем по канонической комнатной сетке.
*
* СЕМАНТИКА (зеркало §22 deficit): deficit_index >0 недонасыщенность (--success,
* повод строить), <0 затоварка (--danger), 0 баланс. Рекомендованный формат
* (сильнейший дефицит класса) строка --accent-soft. deficit_index=null «тонкие
* данные» серым (--fg-tertiary), НЕ 0 (честность: отсутствие сигнала нулевой
* сигнал). Прогноз спроса null тоже «тонкие данные».
*
* БЕЗ слайдеров и без рублей это фаза 1 (фаза 2 с экономикой/сценариями придёт
* после бэктеста движка #951). Блок помечен «Советующий расчёт, не основание для
* инвест-решения».
*
* GRACEFUL: returns null когда у рекомендованного класса нет ни одной форматной
* ячейки (нечего показывать).
*/
import type { ProductMixEntry } from "@/types/forecast";
import { Badge } from "@/components/ui/Badge";
import type { BadgeVariant } from "@/components/ui/Badge";
import {
DEFICIT_BALANCE_EPS,
deficitSignalWord,
deficitVariant,
fmtNum,
} from "./forecast-helpers";
interface Props {
/** Рекомендованный класс (§10.2) — фильтр строк таблицы. */
objClass: string | null;
/** Квартирография — ячейки формат×класс с deficit_index/прогнозом/сигналом. */
mix: ProductMixEntry[];
}
// Каноническая комнатная сетка для строк (порядок ОБЯЗАТЕЛЕН: от меньшего к
// большему). Ключ — live bucket-id (вокабуляр overlay), значение — короткая
// RU-метка строки. Неизвестные bucket'ы рисуем как есть в конце.
const ROOM_ROW_ORDER: { bucket: string; label: string }[] = [
{ bucket: "1-Студия", label: "Студия" },
{ bucket: "2-1-к", label: "1-к" },
{ bucket: "3-2-к", label: "2-к" },
{ bucket: "4-3-к", label: "3-к" },
{ bucket: "5-80+ м²", label: "4-5-к" },
];
const ROOM_INDEX: Map<string, number> = new Map(
ROOM_ROW_ORDER.map((r, i) => [r.bucket, i]),
);
const ROOM_LABEL: Map<string, string> = new Map(
ROOM_ROW_ORDER.map((r) => [r.bucket, r.label]),
);
// Сигнал-бейдж: вариант по знаку дефицита (строить success / баланс neutral /
// избегать danger). Зеркало deficitVariant, но neutral вместо нейтрального.
function signalVariant(deficitIndex: number): BadgeVariant {
const v = deficitVariant(deficitIndex);
if (v === "success") return "success";
if (v === "danger") return "danger";
return "neutral";
}
// Цвет ячейки индекса дефицита (только токены): >0 --success, <0 --danger,
// ≈0 нейтрально (--fg-secondary). Зеркало deficitVariant.
function deficitColor(deficitIndex: number): string {
const v = deficitVariant(deficitIndex);
if (v === "success") return "var(--success)";
if (v === "danger") return "var(--danger)";
return "var(--fg-secondary)";
}
interface AssortmentRow {
bucket: string;
label: string;
deficit_index: number | null;
projected_demand_units: number | null;
signal: string | null;
}
function buildRows(
objClass: string | null,
mix: ProductMixEntry[],
): AssortmentRow[] {
// Ячейки рекомендованного класса (или все, если класс не задан — деградация).
const cells = mix.filter(
(m) => m.bucket != null && (objClass == null || m.obj_class === objClass),
);
// По одной строке на bucket (если дубликаты — берём первую встреченную).
const byBucket = new Map<string, ProductMixEntry>();
for (const m of cells) {
if (m.bucket != null && !byBucket.has(m.bucket)) byBucket.set(m.bucket, m);
}
const rows: AssortmentRow[] = [];
for (const [bucket, m] of byBucket) {
rows.push({
bucket,
label: ROOM_LABEL.get(bucket) ?? bucket,
deficit_index: m.deficit_index ?? null,
projected_demand_units: m.projected_demand_units ?? null,
// Готовый backend-signal в приоритете; иначе деривируем из индекса (fallback).
signal:
m.signal ??
(m.deficit_index != null ? deficitSignalWord(m.deficit_index) : null),
});
}
// Сортируем по канонической сетке; неизвестные bucket'ы — в конец (стабильно).
rows.sort((a, b) => {
const ai = ROOM_INDEX.get(a.bucket) ?? ROOM_ROW_ORDER.length;
const bi = ROOM_INDEX.get(b.bucket) ?? ROOM_ROW_ORDER.length;
if (ai !== bi) return ai - bi;
return a.bucket.localeCompare(b.bucket);
});
return rows;
}
// Рекомендованный формат = сильнейший дефицит класса (как в ранкинге). null —
// нет ни одной ячейки с измеримым индексом → нечего подсвечивать.
function recommendedBucket(rows: AssortmentRow[]): string | null {
let best: AssortmentRow | null = null;
for (const r of rows) {
if (r.deficit_index == null) continue;
if (best == null || r.deficit_index > (best.deficit_index ?? -Infinity)) {
best = r;
}
}
return best?.bucket ?? null;
}
const TH_STYLE: React.CSSProperties = {
fontSize: 11,
fontWeight: 500,
letterSpacing: "0.04em",
textTransform: "uppercase",
color: "var(--fg-secondary)",
padding: "8px 12px",
whiteSpace: "nowrap",
};
const TD_STYLE: React.CSSProperties = {
fontSize: 13,
color: "var(--fg-primary)",
padding: "9px 12px",
fontVariantNumeric: "tabular-nums",
};
export function ForecastAssortmentBlock({ objClass, mix }: Props) {
const rows = buildRows(objClass, mix);
if (rows.length === 0) return null;
const recoBucket = recommendedBucket(rows);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{/* Headline-bar: один-предложение caveat (--bg-headline + --fg-on-dark) */}
<div
style={{
background: "var(--bg-headline)",
color: "var(--fg-on-dark)",
borderRadius: 12,
padding: "10px 14px",
fontSize: 12,
lineHeight: 1.5,
}}
>
Советующий расчёт, не основание для инвест-решения.
</div>
{/* Таблица прогноза по всем форматам */}
<div
style={{
border: "1px solid var(--border-card)",
borderRadius: 12,
overflowX: "auto",
}}
>
<table
style={{
width: "100%",
borderCollapse: "collapse",
minWidth: 480,
}}
>
<thead>
<tr style={{ background: "var(--bg-card-alt)" }}>
<th style={{ ...TH_STYLE, textAlign: "left" }}>Формат</th>
<th
style={{ ...TH_STYLE, textAlign: "right" }}
title="Индекс дефицита ∈ [1, +1]: >0 — недонасыщенность (повод строить), <0 — затоварка."
>
Индекс дефицита
</th>
<th
style={{ ...TH_STYLE, textAlign: "right" }}
title="Прогноз спроса на целевом горизонте — сколько квартир рынок поглотит в этом формате."
>
Прогноз спроса, квартир
</th>
<th style={{ ...TH_STYLE, textAlign: "left" }}>Сигнал</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const isReco = recoBucket != null && r.bucket === recoBucket;
const di = r.deficit_index;
const demand = r.projected_demand_units;
return (
<tr
key={r.bucket}
style={{
background: isReco
? "var(--accent-soft)"
: "var(--bg-card)",
borderTop:
i === 0 ? "none" : "1px solid var(--border-soft)",
}}
>
{/* Формат */}
<td
style={{
...TD_STYLE,
textAlign: "left",
fontWeight: isReco ? 600 : 400,
}}
>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
}}
>
{r.label}
{isReco && (
<Badge variant="info" size="sm">
рекомендован
</Badge>
)}
</span>
</td>
{/* Индекс дефицита */}
<td style={{ ...TD_STYLE, textAlign: "right" }}>
{di != null ? (
<span
style={{ fontWeight: 600, color: deficitColor(di) }}
>
{di > 0 ? "+" : ""}
{fmtNum(di, 2)}
</span>
) : (
<span style={{ color: "var(--fg-tertiary)" }}>
тонкие данные
</span>
)}
</td>
{/* Прогноз спроса, квартир */}
<td style={{ ...TD_STYLE, textAlign: "right" }}>
{demand != null ? (
<>
{fmtNum(demand, 0)}{" "}
<span style={{ color: "var(--fg-tertiary)" }}>кв.</span>
</>
) : (
<span style={{ color: "var(--fg-tertiary)" }}>
тонкие данные
</span>
)}
</td>
{/* Сигнал */}
<td style={{ ...TD_STYLE, textAlign: "left" }}>
{di != null ? (
<Badge variant={signalVariant(di)} size="sm">
{r.signal ??
(Math.abs(di) < DEFICIT_BALANCE_EPS
? "баланс"
: deficitSignalWord(di))}
</Badge>
) : (
<span
style={{ color: "var(--fg-tertiary)", fontSize: 12 }}
>
тонкие данные
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -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];
// component_2..N → «Компонент N» (note уже несёт «вкладывающий сервис: …»)
const m = key.match(/^component_(\d+)$/);
if (m) return `Компонент ${m[1]}`;
// Fallback для старых конвертов без `label`: component / component_2..N → «Компонент N».
const m = key.match(/^component(?:_(\d+))?$/);
if (m) return m[1] ? `Компонент ${m[1]}` : "Компонент";
return key;
}
@ -142,7 +146,7 @@ export function ForecastConfidenceBlock({ confidence }: Props) {
color: "var(--fg-primary)",
}}
>
{factorLabel(key)}
{factorLabel(key, factor)}
{valueStr != null && (
<span
style={{

View file

@ -77,12 +77,17 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
<tr>
<th style={TH_STYLE}>Горизонт</th>
<th style={TH_STYLE}>Индекс дефицита</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}>
<th
style={{ ...TH_STYLE, textAlign: "right" }}
title="Во сколько месяцев распродаётся текущий объём при базовом темпе"
>
Мес. предложения
</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}>Прогноз спроса</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}>
Прогноз предложения
Прогноз спроса, квартир
</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}>
Прогноз предложения, квартир
</th>
<th style={{ ...TH_STYLE, textAlign: "right" }}>Ставка</th>
</tr>
@ -154,6 +159,19 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
</table>
</div>
{/* Caveat — единицы и статус оценки под таблицей (advisory, не решение). */}
<p
style={{
margin: 0,
fontSize: 12,
lineHeight: "16px",
color: "var(--fg-tertiary)",
}}
>
Спрос/предложение прогнозные квартиры за горизонт; оценка advisory,
уверенность средней.
</p>
{/* Rate-sensitivity phrase shared across horizons in current data; show
the distinct phrases once each to avoid noisy repetition. */}
<RateSensitivityNotes forecasts={rows} />

View file

@ -20,6 +20,7 @@ import type {
ProductCommercial,
ProductMixEntry,
ProductReason,
ProductReasonRejected,
ProductUsp,
ReportProductTz,
} from "@/types/forecast";
@ -27,12 +28,14 @@ import type {
import { Badge } from "@/components/ui/Badge";
import type { BadgeVariant } from "@/components/ui/Badge";
import {
aggregateClassDeficits,
DEFICIT_BALANCE_EPS,
deficitBarWidthPct,
deficitVariant,
deficitWord,
fmtNum,
} from "./forecast-helpers";
import type { ClassDeficit } from "./forecast-helpers";
interface Props {
/** §13.4 section; the whole block is skipped when absent (see Section6Forecast). */
@ -74,14 +77,21 @@ export function ForecastProductTzBlock({ productTz }: Props) {
// Graceful: nothing meaningful to show → render nothing.
if (!isNonEmpty(productTz)) return null;
// #1742 — объяснение выбора класса: per-класс дефицит (агрегат mix по классам).
const classDeficits = aggregateClassDeficits(productTz.mix);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* Recommended class — prominent pill + summary line */}
{/* Recommended class — prominent pill + автоген-фраза про дефицит + 3 класса */}
<ClassHeadline
obj_class={productTz.obj_class}
summary={productTz.summary}
classDeficits={classDeficits}
/>
{/* #1742 — отвергнутые альтернативы подняты из-под ката (выше обоснования) */}
<RejectedAlternatives reasons={productTz.reasons} />
{/* Квартирография — deficit/share bars */}
<MixBars mix={productTz.mix} />
@ -91,7 +101,7 @@ export function ForecastProductTzBlock({ productTz }: Props) {
{/* USP-ниши */}
<UspList usp={productTz.usp} />
{/* §16 обоснование (раскрываемое) */}
{/* §16 обоснование (раскрываемое) — без «отвергнутых» (подняты выше) */}
<ReasonsDisclosure reasons={productTz.reasons} />
</div>
);
@ -102,21 +112,68 @@ export function ForecastProductTzBlock({ productTz }: Props) {
function ClassHeadline({
obj_class,
summary,
classDeficits,
}: {
obj_class: string | null;
summary: string | null;
classDeficits: ClassDeficit[];
}) {
if (obj_class == null && !summary) return null;
// #1742 — дефицит рекомендованного класса (для автоген-фразы). Ищем его агрегат
// в per-класс таблице; null → у класса нет измеримого индекса (тонкие данные).
const recoDeficit =
obj_class != null
? (classDeficits.find((c) => c.obj_class === obj_class)
?.meanDeficitIndex ?? null)
: null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{obj_class != null && (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={SUBHEAD_STYLE}>Рекомендованный класс</span>
<Badge variant="info" size="md">
{obj_class}
{capitalize(obj_class)}
</Badge>
</div>
)}
{/* Автоген-фраза: почему именно этот класс (#1742) */}
{obj_class != null && recoDeficit != null && (
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.5,
color: "var(--fg-primary)",
}}
>
<strong style={{ fontWeight: 600 }}>{capitalize(obj_class)}</strong>
здесь сильнее всего недозакрыт спрос: индекс дефицита{" "}
<span
style={{
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
color: `var(--${deficitVariant(recoDeficit) === "danger" ? "danger" : deficitVariant(recoDeficit) === "success" ? "success" : "fg-secondary"})`,
}}
title="Индекс дефицита ∈ [1, +1]: +1 — острый дефицит (недонасыщенность), 1 — затоварка."
>
{recoDeficit > 0 ? "+" : ""}
{fmtNum(recoDeficit, 2)}
</span>{" "}
<span style={{ color: "var(--fg-tertiary)" }}>
(где +1 острый дефицит, 1 затоварка).
</span>
</p>
)}
{/* Мини-таблица 3 классов с их индексом дефицита (контраст) (#1742) */}
<ClassDeficitTable
classDeficits={classDeficits}
recommended={obj_class}
/>
{summary && (
<p
style={{
@ -133,6 +190,159 @@ function ClassHeadline({
);
}
// ── Мини-таблица per-класс дефицита (#1742 — контраст 3 классов) ───────────────
function ClassDeficitTable({
classDeficits,
recommended,
}: {
classDeficits: ClassDeficit[];
recommended: string | null;
}) {
if (classDeficits.length === 0) return null;
return (
<div
style={{
display: "flex",
flexDirection: "column",
border: "1px solid var(--border-card)",
borderRadius: 8,
overflow: "hidden",
}}
>
{/* header */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr auto",
gap: 12,
padding: "6px 12px",
background: "var(--bg-card-alt)",
borderBottom: "1px solid var(--border-soft)",
}}
>
<span style={SUBHEAD_STYLE}>Класс</span>
<span style={{ ...SUBHEAD_STYLE, textAlign: "right" }}>
Индекс дефицита
</span>
</div>
{classDeficits.map((c, i) => {
const isReco = recommended != null && c.obj_class === recommended;
const di = c.meanDeficitIndex;
return (
<div
key={c.obj_class}
style={{
display: "grid",
gridTemplateColumns: "1fr auto",
gap: 12,
alignItems: "center",
padding: "7px 12px",
background: isReco ? "var(--accent-soft)" : "var(--bg-card)",
borderTop: i === 0 ? "none" : "1px solid var(--border-soft)",
}}
>
<span
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "var(--fg-primary)",
fontWeight: isReco ? 600 : 400,
}}
>
{capitalize(c.obj_class)}
{isReco && (
<Badge variant="info" size="sm">
рекомендован
</Badge>
)}
</span>
{di != null ? (
<span
style={{
fontSize: 13,
fontVariantNumeric: "tabular-nums",
fontWeight: 600,
textAlign: "right",
color: `var(--${deficitVariant(di) === "danger" ? "danger" : deficitVariant(di) === "success" ? "success" : "fg-secondary"})`,
}}
>
{di > 0 ? "+" : ""}
{fmtNum(di, 2)}
</span>
) : (
<span
style={{
fontSize: 12,
textAlign: "right",
color: "var(--fg-tertiary)",
}}
>
тонкие данные
</span>
)}
</div>
);
})}
</div>
);
}
// ── Отвергнутые альтернативы (#1742 — поднято из-под ката) ─────────────────────
function RejectedAlternatives({ reasons }: { reasons: ProductReason[] }) {
// Собираем уникальные отвергнутые альтернативы из всех §16-причин (де-дуп по
// alternative — одна и та же альтернатива может встречаться в нескольких reason).
const seen = new Set<string>();
const rejected: ProductReasonRejected[] = [];
for (const r of reasons) {
for (const rej of r.rejected ?? []) {
if (!rej.alternative || seen.has(rej.alternative)) continue;
seen.add(rej.alternative);
rejected.push(rej);
}
}
if (rejected.length === 0) return null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={SUBHEAD_STYLE}>Отвергнутые альтернативы</span>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{rejected.map((rej, i) => (
<span
key={`${rej.alternative}-${i}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
color: "var(--fg-secondary)",
background: "var(--bg-card-alt)",
border: "1px solid var(--border-soft)",
borderRadius: 6,
padding: "3px 8px",
}}
>
<span style={{ color: "var(--fg-primary)" }}>
{capitalize(rej.alternative)}
</span>
{rej.deficit_index != null && (
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{rej.deficit_index > 0 ? "+" : ""}
{fmtNum(rej.deficit_index, 2)}
</span>
)}
<span style={{ color: "var(--fg-tertiary)" }}>· {rej.reason}</span>
</span>
))}
</div>
</div>
);
}
// ── Квартирография (mix bars) ─────────────────────────────────────────────────
function mixLabel(m: ProductMixEntry): string {
@ -446,42 +656,8 @@ function ReasonCard({ reason }: { reason: ProductReason }) {
</p>
)}
{reason.rejected && reason.rejected.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={SUBHEAD_STYLE}>Отвергнутые альтернативы</span>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{reason.rejected.map((rej, i) => (
<span
key={`${rej.alternative}-${i}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
color: "var(--fg-secondary)",
background: "var(--bg-card-alt)",
border: "1px solid var(--border-soft)",
borderRadius: 6,
padding: "3px 8px",
}}
>
<span style={{ color: "var(--fg-primary)" }}>
{rej.alternative}
</span>
{rej.deficit_index != null && (
<span style={{ fontVariantNumeric: "tabular-nums" }}>
{rej.deficit_index > 0 ? "+" : ""}
{fmtNum(rej.deficit_index, 2)}
</span>
)}
<span style={{ color: "var(--fg-tertiary)" }}>
· {rej.reason}
</span>
</span>
))}
</div>
</div>
)}
{/* «Отвергнутые альтернативы» подняты на уровень блока (#1742)
здесь не дублируем. */}
{reason.what_would_change && reason.what_would_change.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>

View file

@ -16,6 +16,7 @@ import dynamic from "next/dynamic";
import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { Geometry } from "geojson";
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
// MiniMap высота тайла карты — компромисс между плотностью Section 1 и
// читаемостью. Layer-controls/легенда вне этого размера (flow ниже).
@ -87,10 +88,18 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
export function MiniMap({ data }: Props) {
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 сам
// ограничивает высоту карты-тайла (mapHeight), а легенда + layer-controls
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) лежат
// под картой потоком и должны быть видны/кликабельны.
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) + панель
// точек подключения (CpLayerControlPanel) лежат под картой потоком и должны
// быть видны/кликабельны. MINIMAP_HEIGHT ограничивает ТОЛЬКО тайл карты, не
// клипает CP-панель.
return (
<div
style={{
@ -101,6 +110,7 @@ export function MiniMap({ data }: Props) {
>
<SiteMap
data={adapted}
connectionPoints={connectionPoints}
competitors={data.competitors ?? []}
pipelineObjects={data.pipeline_24mo?.top_objects ?? []}
riskZones={data.nspd_risk_zones ?? []}

View file

@ -1,7 +1,7 @@
"use client";
/**
* Section2NetworksUtilities "2. Сети и точки подключения"
* Section2NetworksUtilities "3. Сети и точки подключения"
*
* Layout:
* HeadlineBar (title «Сети» + subtitle с расстояниями)

View file

@ -126,7 +126,7 @@ function Section31Settings({
margin: 0,
}}
>
3.1 Настройки выборки
4.1 Настройки выборки
</h3>
<p
style={{
@ -281,7 +281,7 @@ function Section32Layouts({ cad }: { cad: string }) {
margin: 0,
}}
>
3.2 Планировки
4.2 Планировки
</h3>
<p
style={{
@ -299,14 +299,95 @@ function Section32Layouts({ cad }: { cad: string }) {
);
}
// ── Section 3.3 «Остатки и скорость» ──────────────────────────────────────────
// ── Section 3.3 «Как продаётся рынок рядом» ───────────────────────────────────
function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
/** Small uppercase label-заголовок над каждым подблоком 3.3. */
function SubBlockLabel({ children }: { children: ReactNode }) {
return (
<div
style={{
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary, #5B6066)",
textTransform: "uppercase",
letterSpacing: "0.04em",
marginBottom: 8,
}}
>
{children}
</div>
);
}
/**
* Собирает одно предложение-вердикт для headline-bar из уже доступных данных
* velocity / pipeline / market_trend. Возвращает null если данных совсем нет.
*
* - ratio = темп продаж конкурентов / медиана ЕКБ (м²/мес).
* - months_of_inventory за сколько месяцев при текущем темпе разойдётся
* будущее предложение конкурентов (24 мес). Темп в лотах/мес выводим из
* velocity.by_room_bucket (units за months_observed); без него клаузу
* пропускаем, чтобы не выдумывать цифру.
* - price change market_trend.delta_6m_pct в процентных пунктах.
*/
function buildVerdict(data: ParcelAnalysis): string | null {
const v = data.velocity;
const pipeline = data.pipeline_24mo;
const trend = data.market_trend;
const parts: string[] = [];
if (v && v.ekb_median_sqm > 0 && v.velocity_data_available !== false) {
const ratio = v.monthly_velocity_sqm / v.ekb_median_sqm;
const pace = ratio >= 1 ? "быстрее рынка" : "медленнее рынка";
parts.push(
`Конкуренты продают ×${ratio.toFixed(1)} к медиане ЕКБ (${pace})`,
);
// Темп в лотах/мес из наблюдённых сделок по комнатности.
if (
pipeline &&
pipeline.flats_total > 0 &&
v.by_room_bucket &&
v.months_observed > 0
) {
const unitsSold = Object.values(v.by_room_bucket).reduce(
(sum, b) => sum + b.units,
0,
);
const lotsPerMonth = unitsSold / v.months_observed;
if (lotsPerMonth > 0) {
const moi = pipeline.flats_total / lotsPerMonth;
parts.push(
`при текущем темпе остаток разойдётся за ~${Math.round(
moi,
).toLocaleString("ru")} мес`,
);
}
}
}
if (trend) {
const d = trend.delta_6m_pct;
const sign = d > 0 ? "+" : d < 0 ? "" : "";
const abs = Math.abs(d).toLocaleString("ru", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
parts.push(`цены за период ${sign}${abs} п.п.`);
}
if (parts.length === 0) return null;
return `${parts.join("; ")}.`;
}
function Section33MarketSales({ data }: { data: ParcelAnalysis }) {
const hasPipeline = data.pipeline_24mo != null;
const hasVelocity = data.velocity != null;
const hasTrend = data.market_trend != null;
const isEmpty = !hasPipeline && !hasVelocity && !hasTrend;
const verdict = buildVerdict(data);
return (
<div id="section-3-3" style={{ scrollMarginTop: 72 }}>
@ -319,7 +400,7 @@ function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
margin: 0,
}}
>
3.3 Остатки и скорость
4.3 Как продаётся рынок рядом
</h3>
<p
style={{
@ -328,7 +409,7 @@ function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
margin: "4px 0 0",
}}
>
Pipeline конкурентов на 24 мес · темп продаж · тренд цен
Остатки конкурентов · темп продаж · динамика цен
</p>
</div>
@ -343,31 +424,65 @@ function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
fontSize: 13,
}}
>
Данные по остаткам и скорости продаж отсутствуют для этого участка
Данные по продажам рынка рядом отсутствуют для этого участка
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: 16,
}}
>
{hasPipeline && <Pipeline24moBlock data={data.pipeline_24mo!} />}
{hasVelocity && (
<>
{/* Headline-bar — один вердикт-предложение */}
{verdict && (
<div
style={{
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 10,
background: "var(--bg-headline, #0F172A)",
borderRadius: 12,
padding: "14px 18px",
marginBottom: 16,
fontSize: 14,
lineHeight: 1.4,
color: "var(--fg-on-dark, #E2E8F0)",
}}
>
<VelocityBlock velocity={data.velocity} />
{verdict}
</div>
)}
{hasTrend && <MarketTrendBlock trend={data.market_trend} />}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
gap: 16,
}}
>
{hasVelocity && (
<div>
<SubBlockLabel>Темп продаж</SubBlockLabel>
<div
style={{
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",
borderRadius: 10,
padding: "14px 18px",
}}
>
<VelocityBlock velocity={data.velocity} />
</div>
</div>
)}
{hasPipeline && (
<div>
<SubBlockLabel>
Будущее предложение конкурентов (24 мес)
</SubBlockLabel>
<Pipeline24moBlock data={data.pipeline_24mo!} />
</div>
)}
{hasTrend && (
<div>
<SubBlockLabel>Динамика цен</SubBlockLabel>
<MarketTrendBlock trend={data.market_trend} />
</div>
)}
</div>
</>
)}
</div>
);
@ -684,7 +799,7 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
)}
<Section32Layouts cad={cad} />
<Section33RemainVelocity data={data} />
<Section33MarketSales data={data} />
</div>
{/* Filtered competitors count hint */}

View file

@ -1,7 +1,7 @@
"use client";
/**
* Section4Estimate "4. Оценка участка"
* Section4Estimate "2. Оценка участка"
*
* Layout:
* HeadlineBar (score + verdict label as title)
@ -18,7 +18,6 @@
*/
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
import { ScoreBreakdownPanel } from "@/components/site-finder/ScoreBreakdownPanel";
import { ScoreBreakdownStackedBar } from "@/components/site-finder/ScoreBreakdownStackedBar";
import { GeologyBlock } from "@/components/site-finder/GeologyBlock";
@ -166,12 +165,7 @@ export function Section4Estimate({ cad }: Props) {
<HeadlineBar title={headlineTitle} subtitle={headlineSubtitle} />
</div>
{/* ── Gate verdict banner ──────────────────────────────────────────── */}
{analysis.gate_verdict && (
<div style={{ marginBottom: 12 }}>
<GateVerdictBanner verdict={analysis.gate_verdict} />
</div>
)}
{/* Gate verdict banner перенесён НАД группой «Участок» (AnalysisPageContent, #1741) */}
{/* ── Score breakdown: 2-col on desktop ───────────────────────────── */}
{hasBreakdown && (

View file

@ -33,6 +33,7 @@ import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
import { ScenariosBlock } from "./ScenariosBlock";
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock";
import { ForecastScoringBlock } from "./ForecastScoringBlock";
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
import { ForecastMetaLine } from "./ForecastMetaLine";
@ -217,6 +218,17 @@ function ForecastReady({
(pt.commercial != null &&
(pt.commercial.available != null || !!pt.commercial.caveat)));
// 6.4a — прогноз по всем форматам (#1745). Показываем, когда квартирография
// несёт хотя бы одну форматную ячейку (с bucket) рекомендованного класса —
// иначе таблица пуста (объект класс не задан → деградируем к любым ячейкам).
const hasAssortment =
pt != null &&
pt.mix.some(
(m) =>
m.bucket != null &&
(pt.obj_class == null || m.obj_class === pt.obj_class),
);
// 6.5 — прозрачность скоринга (§13.6). scoring is optional on the partial
// ForecastReport type; populated when product_scores / special_indices exist.
const sc = report.scoring;
@ -240,6 +252,7 @@ function ForecastReady({
hasScenarios ||
hasConfidence ||
hasProductTz ||
hasAssortment ||
hasScoring ||
hasFutureSupplyDetail;
@ -379,6 +392,15 @@ function ForecastReady({
</SubBlock>
)}
{/* 6.4a Прогноз по всем форматам таблица ассортимента (#1745, фаза 1).
Под рекомендацией: весь срез дефицита по форматам рекомендованного
класса. Показываем только когда есть форматные ячейки квартирографии. */}
{hasAssortment && pt && (
<SubBlock id="section-6-4a" title="6.4 Прогноз по всем форматам">
<ForecastAssortmentBlock objClass={pt.obj_class} mix={pt.mix} />
</SubBlock>
)}
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
{hasScoring && sc && (
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">

View file

@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
@ -39,9 +40,23 @@ const FIXTURE: ParcelAnalyzeResponse = {
nspd_red_lines: [],
};
// MiniMap calls useConnectionPoints (useQuery) since #1736 — needs a QueryClient
// in context. retry:false so the (unmocked) query fails fast into idle/error
// state without affecting these layout-only assertions.
function renderMiniMap() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<MiniMap data={FIXTURE} />
</QueryClientProvider>,
);
}
describe("MiniMap (#1218)", () => {
it("passes mapHeight=280 to SiteMap (compact tile, controls flow below)", async () => {
render(<MiniMap data={FIXTURE} />);
renderMiniMap();
// SiteMap is dynamically imported (ssr:false) — wait until our stub renders.
await waitFor(() =>
expect(screen.getByTestId("sitemap-stub")).toBeInTheDocument(),
@ -50,7 +65,7 @@ describe("MiniMap (#1218)", () => {
});
it("wrapper does NOT clip overflow — layer controls must remain visible", async () => {
const { container } = render(<MiniMap data={FIXTURE} />);
const { container } = renderMiniMap();
await waitFor(() =>
expect(screen.getByTestId("sitemap-stub")).toBeInTheDocument(),
);

View file

@ -5,7 +5,11 @@
*/
import type { BadgeVariant } from "@/components/ui/Badge";
import type { ConfidenceLevel, FutureSupply } from "@/types/forecast";
import type {
ConfidenceLevel,
FutureSupply,
ProductMixEntry,
} from "@/types/forecast";
/** Below this |deficit_index| we treat the market as balanced (neutral). */
export const DEFICIT_BALANCE_EPS = 0.05;
@ -26,6 +30,70 @@ export function deficitWord(deficitIndex: number): string {
return "баланс";
}
/**
* Сигнал-вердикт по deficit_index для таблицы форматов (#1745): «строить»
* (недонасыщенность) / «баланс» / «избегать» (затоварка). Зеркало backend
* `report_assembler._build_signal` фронт-fallback, когда mix-ячейка не несёт
* готового `signal` (старый прогон). Семантика та же, что у `deficitWord`.
*/
export function deficitSignalWord(deficitIndex: number): string {
if (deficitIndex > DEFICIT_BALANCE_EPS) return "строить";
if (deficitIndex < -DEFICIT_BALANCE_EPS) return "избегать";
return "баланс";
}
// ── Class-level deficit aggregation (#1742 — объяснение выбора класса) ──────────
/** Агрегат дефицита одного класса: среднее deficit_index по его форматам. */
export interface ClassDeficit {
obj_class: string;
/** Среднее deficit_index по форматам класса (null — у всех форматов нет индекса). */
meanDeficitIndex: number | null;
/** Сколько форматов класса несут не-null deficit_index (для honest-агрегата). */
nWithIndex: number;
}
/**
* Агрегировать `mix` (квартирография ячейки формат×класс) в per-класс дефицит:
* среднее deficit_index по форматам каждого класса. Зеркало backend
* `recommendation._recommend_class` (среднее по room-bucket'ам класса). Это даёт
* мини-таблицу 3 классов (#1742) из уже-доступного `mix` без backend-расширения
* `mix` несёт все 15 ячеек (5 форматов × 3 класса) с per-ячейкой deficit_index.
*
* Сортировка DESC по meanDeficitIndex (сильнейший дефицит сверху, как в ранкинге);
* классы без obj_class игнорируются (нечего агрегировать НЕ фабрикуем класс).
* Класс, у которого ни у одной ячейки нет индекса meanDeficitIndex=null (фронт
* рисует «тонкие данные», НЕ 0).
*/
export function aggregateClassDeficits(mix: ProductMixEntry[]): ClassDeficit[] {
const byClass = new Map<string, number[]>();
for (const m of mix) {
if (m.obj_class == null) continue;
const list = byClass.get(m.obj_class) ?? [];
if (m.deficit_index != null) list.push(m.deficit_index);
byClass.set(m.obj_class, list);
}
const out: ClassDeficit[] = [];
for (const [obj_class, values] of byClass) {
const meanDeficitIndex =
values.length > 0
? values.reduce((a, b) => a + b, 0) / values.length
: null;
out.push({ obj_class, meanDeficitIndex, nWithIndex: values.length });
}
// DESC по среднему дефициту; null-индексы — в конец (стабильно). tie-break — класс ASC.
out.sort((a, b) => {
const av = a.meanDeficitIndex;
const bv = b.meanDeficitIndex;
if (av == null && bv == null) return a.obj_class.localeCompare(b.obj_class);
if (av == null) return 1;
if (bv == null) return -1;
if (bv !== av) return bv - av;
return a.obj_class.localeCompare(b.obj_class);
});
return out;
}
/**
* Bar width (%) for a signed deficit_index ( 1..1) on a 0..100 scale. Uses the
* magnitude so both недонасыщенность (+) and затоварка () read as a filled bar

View file

@ -233,7 +233,7 @@ export interface paths {
};
/**
* Export Parcel Forecast
* @description Экспорт §22-форсайта Markdown / JSON / DOCX / PPTX (файл) или TG-сводка (#959).
* @description Экспорт §22-форсайта Markdown / JSON / DOCX / PPTX / PDF (файл) или TG-сводка (#959).
*
* Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
* /{cad}/forecast inline) и отдаёт его в нужной форме.
@ -243,6 +243,8 @@ export interface paths {
* зеркало содержания 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).
@ -254,10 +256,10 @@ export interface paths {
*
* 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_<cad>_<YYYY-MM-DD>.<ext>`); для tg inline text/plain сводка.
*/
get: operations["export_parcel_forecast_api_v1_parcels__cad_num__forecast_export_get"];
@ -4915,8 +4917,8 @@ export interface operations {
export_parcel_forecast_api_v1_parcels__cad_num__forecast_export_get: {
parameters: {
query?: {
/** @description Формат выгрузки: md (Markdown) | json (сырой отчёт) | tg (сводка) | docx (Word-документ) | pptx (презентация) */
format?: "md" | "json" | "tg" | "docx" | "pptx";
/** @description Формат выгрузки: md (Markdown) | json (сырой отчёт) | tg (сводка) | docx (Word-документ) | pptx (презентация) | pdf (PDF-отчёт) */
format?: "md" | "json" | "tg" | "docx" | "pptx" | "pdf";
};
header?: never;
path: {

View file

@ -158,6 +158,13 @@ export interface ConfidenceFactor {
note: string;
level: ConfidenceLevel;
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 {
@ -210,6 +217,19 @@ export interface ProductMixEntry {
deficit_index: number | null;
/** Доля формата, % (0..100) — присутствует только в явном mix. */
pct?: number | null;
/**
* #1745 прогноз спроса по формату (квартир) на целевом горизонте. Проброшен из
* ranked_segments overlay (`projected_demand_units`). null при тонких данных или в
* demand_only-режиме (без геометрии участка спрос-проекция неизмерима) рендерится
* «тонкие данные», НЕ 0.
*/
projected_demand_units?: number | null;
/**
* #1745 деривированный сигнал из deficit_index: «строить» (недонасыщенность) /
* «баланс» / «избегать» (затоварка). null при deficit_index=null (тонкие данные).
* Дублирует frontend-семантику deficitWord фронт может пересчитать сам как fallback.
*/
signal?: string | null;
confidence?: ConfidenceLevel | null;
}

View file

@ -9,6 +9,8 @@
Sources:
- avito_city_sweep run_avito_city_sweep (scrape_pipeline.py)
- avito_newbuilding_sweep run_avito_newbuilding_sweep (scrape_pipeline.py; dedicated
novostroyka-filtered citywide SERP save, 100% new-build cards)
- yandex_city_sweep run_yandex_city_sweep (scrape_pipeline.py, #561; shipped DORMANT)
- cian_history_backfill run_cian_backfill (this module, #560)
- rosreestr_dkp_import import_rosreestr_dkp (this module, #563)
@ -95,6 +97,7 @@ from app.core.db import SessionLocal
from app.services import scrape_runs as runs_mod
from app.services.scrape_pipeline import (
run_avito_city_sweep,
run_avito_newbuilding_sweep,
run_cian_city_sweep,
run_domclick_city_sweep,
run_yandex_city_sweep,
@ -309,6 +312,44 @@ async def trigger_avito_city_sweep_run(db: Session, schedule_row: dict[str, Any]
return run_id
async def trigger_avito_newbuilding_sweep_run(
db: Session, schedule_row: dict[str, Any]
) -> int | None:
"""Создать новый scrape_runs + launch run_avito_newbuilding_sweep в asyncio.create_task.
Зеркало trigger_avito_city_sweep_run, но citywide novostroyka-обход (без anchor/
houses/detail-параметров): dedicated novostroyka-SERP save.
Returns run_id (или None если skip есть running run).
"""
run_id = _claim_run(db, schedule_row)
if run_id is None:
return None
params = schedule_row.get("default_params") or {}
# Spawn asyncio task — pass NEW session (avoid sharing with this scheduler tick)
async def _run() -> None:
run_db = SessionLocal()
try:
await run_avito_newbuilding_sweep(
run_db,
run_id=run_id,
pages=int(params.get("pages", 20)),
request_delay_sec=float(params.get("request_delay_sec", 7.0)),
)
except Exception:
logger.exception("scheduler: run_avito_newbuilding_sweep crashed run_id=%d", run_id)
finally:
run_db.close()
task = asyncio.create_task(_run())
# Keep reference to avoid GC before task completes (RUF006)
task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
logger.info("scheduler: triggered newbuilding_sweep run_id=%d", run_id)
return run_id
async def trigger_yandex_city_sweep_run(db: Session, schedule_row: dict[str, Any]) -> int | None:
"""Создать новый scrape_runs + launch run_yandex_city_sweep в asyncio.create_task.
@ -1402,6 +1443,8 @@ async def scheduler_loop() -> None:
source = sch["source"]
if source == "avito_city_sweep":
await trigger_avito_city_sweep_run(db, sch)
elif source == "avito_newbuilding_sweep":
await trigger_avito_newbuilding_sweep_run(db, sch)
elif source == "yandex_city_sweep":
await trigger_yandex_city_sweep_run(db, sch)
elif source == "cian_history_backfill":

View file

@ -990,6 +990,114 @@ async def run_avito_city_sweep(
raise
# ── Avito newbuilding (novostroyka) citywide sweep ────────────────
@dataclass
class NewbuildingSweepCounters:
"""Aggregate counters для Avito novostroyka citywide sweep run."""
lots_fetched: int = 0
lots_inserted: int = 0
lots_updated: int = 0
errors_count: int = 0
def to_dict(self) -> dict[str, int]:
return {f.name: getattr(self, f.name) for f in fields(self)}
async def run_avito_newbuilding_sweep(
db: Session,
*,
run_id: int,
pages: int = 20,
request_delay_sec: float = 7.0,
) -> NewbuildingSweepCounters:
"""Citywide-обход ЕКБ-выборки только новостроек (novostroyka-filter) → save.
В отличие от run_avito_city_sweep (anchor-based fetch_around + houses/detail/IMV),
это простой citywide paginated-обход через AvitoScraper.fetch_newbuildings:
dedicated novostroyka-SERP отдаёт 100% new-build карточки (listing_segment=
"novostroyki", newbuilding_id/newbuilding_url заполнены парсером). Сохраняем через
стандартный save_listings, ведём SERP-счётчики через scrape_runs.
- Single shared AsyncSession/BrowserFetcher на весь sweep (один TLS fingerprint)
- AvitoBlockedError/AvitoRateLimitedError mark_banned (status='banned')
- is_cancelled-чек до старта SERP-фазы
"""
counters = NewbuildingSweepCounters()
browser_mode = settings.scraper_fetch_mode == "browser"
async with AsyncExitStack() as stack:
session: AsyncSession | None = None
shared_bf: BrowserFetcher | None = None
if browser_mode:
shared_bf = await stack.enter_async_context(BrowserFetcher(source="avito"))
else:
session = await stack.enter_async_context(
AsyncSession(
impersonate="chrome120",
timeout=25,
headers=_CHROME_HEADERS,
proxies=_avito_proxies(),
)
)
try:
if scrape_runs.is_cancelled(db, run_id):
logger.info("nb-sweep run_id=%d: cancelled before SERP phase", run_id)
scrape_runs.mark_done(db, run_id, counters.to_dict())
return counters
scraper = AvitoScraper()
if browser_mode:
scraper._browser = shared_bf
else:
scraper._cffi = session
try:
lots: list[ScrapedLot] = await scraper.fetch_newbuildings(
pages=pages,
delay_override_sec=request_delay_sec,
)
except (AvitoBlockedError, AvitoRateLimitedError) as e:
logger.error("nb-sweep run_id=%d: SERP BLOCKED — %s", run_id, e)
scrape_runs.mark_banned(db, run_id, str(e), counters.to_dict())
return counters
counters.lots_fetched += len(lots)
if lots:
try:
ins, upd = save_listings(db, lots)
counters.lots_inserted += ins
counters.lots_updated += upd
except Exception as save_exc:
logger.exception(
"nb-sweep run_id=%d: save_listings failed: %s", run_id, save_exc
)
counters.errors_count += 1
try:
db.rollback()
except Exception:
pass
scrape_runs.update_heartbeat(db, run_id, counters.to_dict())
scrape_runs.mark_done(db, run_id, counters.to_dict())
logger.info(
"nb-sweep run_id=%d done: lots=%d (ins=%d/upd=%d) errors=%d",
run_id,
counters.lots_fetched,
counters.lots_inserted,
counters.lots_updated,
counters.errors_count,
)
return counters
except Exception as exc:
logger.exception("nb-sweep run_id=%d: fatal error", run_id)
scrape_runs.mark_failed(db, run_id, str(exc), counters.to_dict())
raise
# ── Yandex city sweep ───────────────────────────────────────────
@ -2266,8 +2374,7 @@ async def run_domclick_city_sweep(
return counters
logger.info(
"domclick-sweep run_id=%d: citywide SERP city_id=%d rooms=%s pages=%d "
"(watchdog %ds)",
"domclick-sweep run_id=%d: citywide SERP city_id=%d rooms=%s pages=%d (watchdog %ds)",
run_id,
city_id,
_rooms,
@ -2333,11 +2440,13 @@ __all__ = [
"CianFullLoadCounters",
"CitySweepCounters",
"DomClickCitySweepCounters",
"NewbuildingSweepCounters",
"PipelineCounters",
"PipelineResult",
"YandexCitySweepCounters",
"YandexFullLoadCounters",
"run_avito_city_sweep",
"run_avito_newbuilding_sweep",
"run_avito_pipeline",
"run_cian_city_sweep",
"run_cian_full_load",

View file

@ -26,6 +26,7 @@ from __future__ import annotations
import asyncio
import logging
import re
from collections.abc import Callable
from datetime import date, datetime, timedelta, timezone
from typing import Any
from urllib.parse import urlencode, urljoin, urlparse, urlunparse
@ -216,6 +217,12 @@ def _build_sort_timestamp_map(html: str) -> dict[str, date]:
# маркерам в начале документа (firewall-страница объёмна, не сканируем целиком).
_FIREWALL_MARKERS = ("доступ ограничен", "проблема с ip", "firewall-container")
# EKB-фильтр «Новостройка» (slug категории kvartiry/prodam). Кодирует
# ASgB-параметры выборки только новостроек — SERP возвращает 100% new-build
# карточки (с data-marker="item-development-name"). Извлечён из реального
# search-URL: /ekaterinburg/kvartiry/prodam/novostroyka-ASgBAgICAkSSA8YQ5geOUg
NOVOSTROYKA_SLUG = "novostroyka-ASgBAgICAkSSA8YQ5geOUg"
def _is_firewall_page(html: str) -> bool:
"""True если Avito вернул firewall-страницу IP-блока (на HTTP 200)."""
@ -500,6 +507,16 @@ class AvitoScraper(BaseScraper):
params = {"s": 104, "p": page}
return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}"
def _build_newbuilding_url(self, page: int = 1) -> str:
"""URL ЕКБ-выборки только новостроек (novostroyka-filter), сортировка по дате.
Тот же shape параметров, что _build_citywide_url (s=104, p=page), но slug
категории NOVOSTROYKA_SLUG отдаёт исключительно new-build карточки.
"""
params = {"s": 104, "p": page}
path = f"/ekaterinburg/kvartiry/prodam/{NOVOSTROYKA_SLUG}"
return f"{self.base_url}{path}?{urlencode(params)}"
def _build_rooms_url(self, room_slug: str, page: int = 1) -> str:
"""T6: URL с фильтром по комнатности для всего ЕКБ (no geo).
@ -532,6 +549,30 @@ class AvitoScraper(BaseScraper):
Returns:
Список ScrapedLot (все страницы, дедуп по source_id).
"""
all_lots = await self._paginate_sweep(
pages,
self._build_citywide_url,
label="citywide",
delay_override_sec=delay_override_sec,
)
logger.info("avito fetch_city_wide pages=%d total_lots=%d", pages, len(all_lots))
return all_lots
async def _paginate_sweep(
self,
pages: int,
url_builder: Callable[[int], str],
*,
label: str,
delay_override_sec: float | None = None,
) -> list[ScrapedLot]:
"""Общий paginated-обход ЕКБ (citywide / novostroyka) с break-on-empty.
url_builder(page) функция построения URL страницы (citywide или
novostroyka). Сохраняет anti-block pipeline (_fetch_serp_html: firewall,
IP rotation, ретраи), дедуп по source_id, break-on-empty. label только
для логов. Не меняет наблюдаемое поведение fetch_city_wide.
"""
if delay_override_sec is not None:
self.request_delay_sec = delay_override_sec
@ -539,21 +580,21 @@ class AvitoScraper(BaseScraper):
seen_ids: set[str] = set()
for page in range(1, pages + 1):
url = self._build_citywide_url(page)
url = url_builder(page)
try:
html = await self._fetch_serp_html(url, page)
except (AvitoBlockedError, AvitoRateLimitedError):
raise
except Exception:
logger.exception("avito citywide page=%d fetch failed url=%s", page, url)
logger.exception("avito %s page=%d fetch failed url=%s", label, page, url)
break
if html is None:
logger.info("avito citywide page=%d: non-200 — end of pagination", page)
logger.info("avito %s page=%d: non-200 — end of pagination", label, page)
break
lots = self._parse_html(html, source_url_base=url)
if not lots:
logger.info("avito citywide page=%d: 0 lots — end of pagination", page)
logger.info("avito %s page=%d: 0 lots — end of pagination", label, page)
break
new_lots = [lot for lot in lots if lot.source_id not in seen_ids]
@ -562,7 +603,8 @@ class AvitoScraper(BaseScraper):
seen_ids.add(lot.source_id)
all_lots.extend(new_lots)
logger.info(
"avito citywide page=%d: %d lots (%d new, %d dedup'd, total=%d)",
"avito %s page=%d: %d lots (%d new, %d dedup'd, total=%d)",
label,
page,
len(lots),
len(new_lots),
@ -571,8 +613,37 @@ class AvitoScraper(BaseScraper):
)
if page < pages:
await self.sleep_between_requests()
return all_lots
logger.info("avito fetch_city_wide pages=%d total_lots=%d", pages, len(all_lots))
async def fetch_newbuildings(
self,
pages: int = 30,
*,
delay_override_sec: float | None = None,
) -> list[ScrapedLot]:
"""Обход ЕКБ-выборки только новостроек (novostroyka-filter), paginated.
Avito отдаёт dedicated SERP с фильтром «Новостройка» 100% new-build
карточек (с маркером застройщика listing_segment="novostroyki",
newbuilding_id/newbuilding_url заполнены). Citywide (без geo/anchor).
Возвращает дедуплицированный список (по source_id), break-on-empty.
Сохраняет весь anti-block pipeline. Не трогает fetch_city_wide/fetch_around.
Args:
pages: максимальное число страниц (default 30).
delay_override_sec: если задан переопределяет request_delay_sec для
этого вызова.
Returns:
Список ScrapedLot новостроек (все страницы, дедуп по source_id).
"""
all_lots = await self._paginate_sweep(
pages,
self._build_newbuilding_url,
label="newbuilding",
delay_override_sec=delay_override_sec,
)
logger.info("avito fetch_newbuildings pages=%d total_lots=%d", pages, len(all_lots))
return all_lots
async def fetch_by_rooms(

View file

@ -0,0 +1,41 @@
-- 123_avito_newbuilding_sweep_schedule.sql
-- Seed row для Avito newbuilding (novostroyka) citywide sweep.
--
-- Avito отдаёт dedicated novostroyka-filtered SERP (slug
-- novostroyka-ASgBAgICAkSSA8YQ5geOUg) — 100% new-build карточки с маркером
-- застройщика (listing_segment='novostroyki', newbuilding_id заполнен парсером).
-- Текущий general sweep ловит новостройки лишь инцидентно (~20 строк); этот
-- dedicated обход даёт сотни. Citywide (без anchor/geo), save через стандартный путь.
--
-- default_params:
-- pages — макс. число страниц SERP за прогон.
-- request_delay_sec — пауза между страницами (anti-block).
--
-- next_run_at = завтра 02:00 UTC — чтобы НЕ выстрелить мгновенно на деплое (enabled
-- row с NULL next_run_at трактуется due немедленно); первый прогон в окне 02:00-05:00.
--
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
-- Idempotent: ON CONFLICT (source) DO NOTHING — безопасно запускать повторно.
BEGIN;
INSERT INTO scrape_schedules (
source,
enabled,
window_start_hour,
window_end_hour,
next_run_at,
default_params
)
VALUES
(
'avito_newbuilding_sweep',
true,
2,
5,
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 2)) AT TIME ZONE 'UTC',
'{"pages": 20, "request_delay_sec": 8}'::jsonb
)
ON CONFLICT (source) DO NOTHING;
COMMIT;

View file

@ -0,0 +1,35 @@
<!doctype html>
<!--
Minimal Avito novostroyka (new-build) SERP fixture, distilled from the dedicated
novostroyka-filtered SERP (/ekaterinburg/kvartiry/prodam/novostroyka-...). Каждая
карточка несёт data-marker="item-development-name" (название ЖК/застройщика) +
якорь на ЖК-сабдомен https://zhk-<slug>-ekaterinburg.avito.ru → avito.py::_parse_html
классифицирует их как listing_segment='novostroyki' и заполняет newbuilding_id/url.
-->
<html lang="ru"><head><meta charset="utf-8"><title>Avito novostroyka SERP fixture</title></head>
<body>
<div class="items-items">
<div data-marker="item" data-item-id="9100000001" id="i9100000001">
<a data-marker="item-title" href="/ekaterinburg/kvartiry/1-k._kvartira_40m_510et._9100000001">1-к. квартира, 40&nbsp;м², 5/10&nbsp;эт.</a>
<meta itemprop="price" content="6800000">
<div data-marker="item-line"><div data-marker="item-address"><p>ул. Меридиан, 1</p></div></div>
<div data-marker="item-development-name">ЖК «Меридиан»</div>
<a href="https://zhk-meridian-ekaterinburg.avito.ru?context=abc">ЖК «Меридиан»</a>
<p data-marker="item-date">2 часа назад</p>
</div>
<div data-marker="item" data-item-id="9100000002" id="i9100000002">
<a data-marker="item-title" href="/ekaterinburg/kvartiry/2-k._kvartira_62m_812et._9100000002">2-к. квартира, 62&nbsp;м², 8/12&nbsp;эт.</a>
<meta itemprop="price" content="9100000">
<div data-marker="item-line"><div data-marker="item-address"><p>ул. Северная, 7</p></div></div>
<div data-marker="item-development-name">ЖК «Северный квартал»</div>
<a href="https://zhk-severnyy-kvartal-ekaterinburg.avito.ru?context=def">ЖК «Северный квартал»</a>
<p data-marker="item-date">3 часа назад</p>
</div>
</div>
<script>
window.__avito_state_fragment = {"catalog":{"items":[
{"id":9100000001,"category":{"id":24,"slug":"kvartiry"},"addressDetailed":{"locationName":"Екатеринбург"},"sortTimeStamp":1700000000000,"allowTimeStamp":1700000000000},
{"id":9100000002,"category":{"id":24,"slug":"kvartiry"},"addressDetailed":{"locationName":"Екатеринбург"},"sortTimeStamp":1700086400000,"allowTimeStamp":1700086400000}
]}};
</script>
</body></html>

View file

@ -0,0 +1,245 @@
"""Avito newbuilding (novostroyka) citywide sweep.
Тесты (без сети, без реального DB):
1. _build_newbuilding_url novostroyka-slug в пути, s=104, page param, без geo.
2. _parse_html на novostroyka-SERP fixture listing_segment='novostroyki' +
newbuilding_id заполнен (DOM-маркер застройщика + zhk-якорь).
3. fetch_newbuildings break-on-empty + dedup (mirror fetch_city_wide).
4. NewbuildingSweepCounters defaults + to_dict.
5. run_avito_newbuilding_sweep wiring мокаем fetch_newbuildings + save_listings,
assert save вызван и SERP-счётчики записаны (mark_done).
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import scrape_pipeline
from app.services.scrapers.avito import NOVOSTROYKA_SLUG, AvitoScraper
from app.services.scrapers.base import ScrapedLot
FIXTURE = Path(__file__).parent / "fixtures" / "avito_serp_novostroyka.html"
def _make_lot(source_id: str) -> ScrapedLot:
return ScrapedLot(
source="avito",
source_url=f"https://www.avito.ru/ekaterinburg/kvartiry/{source_id}",
source_id=source_id,
price_rub=6_000_000,
listing_segment="novostroyki",
)
# ── 1. _build_newbuilding_url ───────────────────────────────────────────────
def test_build_newbuilding_url_contains_slug_and_sort() -> None:
s = AvitoScraper()
url = s._build_newbuilding_url(page=1)
assert NOVOSTROYKA_SLUG in url
assert "novostroyka-" in url
assert "geoCoords" not in url
assert "radius" not in url
assert "s=104" in url
assert "p=1" in url
assert "ekaterinburg/kvartiry/prodam/" in url
def test_build_newbuilding_url_page_param() -> None:
s = AvitoScraper()
url = s._build_newbuilding_url(page=2)
assert "novostroyka-" in url
assert "p=2" in url
def test_build_newbuilding_url_differs_from_citywide() -> None:
s = AvitoScraper()
assert s._build_newbuilding_url(page=1) != s._build_citywide_url(page=1)
# ── 2. _parse_html classifies novostroyka cards ─────────────────────────────
def test_parse_novostroyka_fixture_segments_and_newbuilding_id() -> None:
html = FIXTURE.read_text(encoding="utf-8")
s = AvitoScraper()
lots = s._parse_html(html, source_url_base=s._build_newbuilding_url(page=1))
assert len(lots) == 2
for lot in lots:
assert lot.listing_segment == "novostroyki"
assert lot.newbuilding_id is not None
assert lot.newbuilding_url is not None
ids = {lot.newbuilding_id for lot in lots}
assert "meridian-ekaterinburg" in ids
assert "severnyy-kvartal-ekaterinburg" in ids
# ── 3. fetch_newbuildings break-on-empty + dedup ────────────────────────────
@pytest.mark.asyncio
async def test_fetch_newbuildings_break_on_empty() -> None:
s = AvitoScraper()
pages_data: list[list[ScrapedLot]] = [[_make_lot("A"), _make_lot("B")], []]
call_n = 0
async def mock_html(url: str, page: int) -> str:
assert "novostroyka-" in url
return f"<html>{page}</html>"
def mock_parse(html: str, source_url_base: str) -> list[ScrapedLot]:
nonlocal call_n
idx = call_n
call_n += 1
return pages_data[idx] if idx < len(pages_data) else []
with patch.object(s, "_fetch_serp_html", side_effect=mock_html):
with patch.object(s, "_parse_html", side_effect=mock_parse):
with patch.object(s, "sleep_between_requests", new=AsyncMock()):
result = await s.fetch_newbuildings(pages=10, delay_override_sec=0)
assert len(result) == 2
assert call_n == 2 # page1 + page2(empty) → stop
@pytest.mark.asyncio
async def test_fetch_newbuildings_dedup_by_source_id() -> None:
s = AvitoScraper()
dup = _make_lot("SAME")
pages_data: list[list[ScrapedLot]] = [[dup], [dup], []]
call_n = 0
async def mock_html(url: str, page: int) -> str:
return f"<html>{page}</html>"
def mock_parse(html: str, source_url_base: str) -> list[ScrapedLot]:
nonlocal call_n
idx = call_n
call_n += 1
return pages_data[idx] if idx < len(pages_data) else []
with patch.object(s, "_fetch_serp_html", side_effect=mock_html):
with patch.object(s, "_parse_html", side_effect=mock_parse):
with patch.object(s, "sleep_between_requests", new=AsyncMock()):
result = await s.fetch_newbuildings(pages=10, delay_override_sec=0)
assert len(result) == 1
assert result[0].source_id == "SAME"
def test_fetch_city_wide_url_unchanged() -> None:
"""fetch_city_wide остаётся citywide-URL — рефактор не сменил его поведение."""
s = AvitoScraper()
assert "novostroyka-" not in s._build_citywide_url(page=1)
assert "prodam-ASgBAgICAUSSA8YQ" in s._build_citywide_url(page=1)
# ── 4. NewbuildingSweepCounters ─────────────────────────────────────────────
def test_newbuilding_counters_defaults_and_to_dict() -> None:
from app.services.scrape_pipeline import NewbuildingSweepCounters
c = NewbuildingSweepCounters()
assert c.lots_fetched == 0
assert c.lots_inserted == 0
assert c.lots_updated == 0
assert c.errors_count == 0
d = c.to_dict()
assert set(d.keys()) == {"lots_fetched", "lots_inserted", "lots_updated", "errors_count"}
# ── 5. run_avito_newbuilding_sweep wiring ───────────────────────────────────
class _FakeRuns:
def __init__(self) -> None:
self.heartbeats: list[dict[str, int]] = []
self.done: dict[str, int] | None = None
self.failed: tuple[str, dict[str, int]] | None = None
self.banned: tuple[str, dict[str, int]] | None = None
def is_cancelled(self, _db: Any, _run_id: int) -> bool:
return False
def update_heartbeat(self, _db: Any, _run_id: int, counters: dict[str, int]) -> None:
self.heartbeats.append(dict(counters))
def mark_done(self, _db: Any, _run_id: int, counters: dict[str, int]) -> None:
self.done = dict(counters)
def mark_failed(self, _db: Any, _run_id: int, error: str, counters: dict[str, int]) -> None:
self.failed = (error, dict(counters))
def mark_banned(self, _db: Any, _run_id: int, error: str, counters: dict[str, int]) -> None:
self.banned = (error, dict(counters))
def _install_runs(monkeypatch: pytest.MonkeyPatch, fake: _FakeRuns) -> None:
monkeypatch.setattr(scrape_pipeline.scrape_runs, "is_cancelled", fake.is_cancelled)
monkeypatch.setattr(scrape_pipeline.scrape_runs, "update_heartbeat", fake.update_heartbeat)
monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_done", fake.mark_done)
monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_failed", fake.mark_failed)
monkeypatch.setattr(scrape_pipeline.scrape_runs, "mark_banned", fake.mark_banned)
@pytest.mark.asyncio
async def test_run_avito_newbuilding_sweep_saves_and_counts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_runs = _FakeRuns()
_install_runs(monkeypatch, fake_runs)
fake_lots = [_make_lot("N1"), _make_lot("N2"), _make_lot("N3")]
async def fake_fetch_newbuildings(
self: AvitoScraper,
pages: int = 30,
*,
delay_override_sec: float | None = None,
) -> list[ScrapedLot]:
return list(fake_lots)
monkeypatch.setattr(AvitoScraper, "fetch_newbuildings", fake_fetch_newbuildings)
save_calls: list[int] = []
def fake_save_listings(_db: Any, lots: Any, **_kw: Any) -> tuple[int, int]:
save_calls.append(len(lots))
return (len(lots), 0)
monkeypatch.setattr(scrape_pipeline, "save_listings", fake_save_listings)
# Force cffi-mode (no browser) so we don't spin up BrowserFetcher.
monkeypatch.setattr(scrape_pipeline.settings, "scraper_fetch_mode", "cffi")
# Stub shared AsyncSession (used by the sweep in cffi-mode) — no real session.
fake_session = AsyncMock()
fake_session.__aenter__ = AsyncMock(return_value=fake_session)
fake_session.__aexit__ = AsyncMock(return_value=None)
monkeypatch.setattr(scrape_pipeline, "AsyncSession", lambda **_kw: fake_session)
counters = await scrape_pipeline.run_avito_newbuilding_sweep(
MagicMock(),
run_id=42,
pages=2,
request_delay_sec=0.0,
)
assert save_calls == [3]
assert counters.lots_fetched == 3
assert counters.lots_inserted == 3
assert counters.lots_updated == 0
assert fake_runs.done is not None
assert fake_runs.done["lots_fetched"] == 3
assert fake_runs.failed is None
assert fake_runs.banned is None