Compare commits
No commits in common. "48bedb5f65eb5d06b7fc3d0474d0c87464777bb9" and "f9769571a339898af490423a9c15fc3e24dc608f" have entirely different histories.
48bedb5f65
...
f9769571a3
31 changed files with 346 additions and 2134 deletions
|
|
@ -50,7 +50,6 @@ from app.services.exporters.report_md import (
|
||||||
render_report_markdown,
|
render_report_markdown,
|
||||||
render_report_telegram_summary,
|
render_report_telegram_summary,
|
||||||
)
|
)
|
||||||
from app.services.exporters.report_pdf import export_report_pdf
|
|
||||||
from app.services.exporters.report_pptx import render_report_pptx
|
from app.services.exporters.report_pptx import render_report_pptx
|
||||||
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
|
from app.services.exporters.snapshot_pdf import generate_snapshot_pdf
|
||||||
from app.services.site_finder.best_layouts import get_best_layouts
|
from app.services.site_finder.best_layouts import get_best_layouts
|
||||||
|
|
@ -254,12 +253,6 @@ PIPELINE_HORIZON_MONTHS = 24
|
||||||
PIPELINE_SEVERITY_MEDIUM_THRESHOLD = 500 # flats_total < это → low
|
PIPELINE_SEVERITY_MEDIUM_THRESHOLD = 500 # flats_total < это → low
|
||||||
PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high
|
PIPELINE_SEVERITY_HIGH_THRESHOLD = 3000 # flats_total >= это → high
|
||||||
PIPELINE_TOP_OBJECTS_LIMIT = 10
|
PIPELINE_TOP_OBJECTS_LIMIT = 10
|
||||||
# #1738: самоисключение СУБЪЕКТА анализа из pipeline. Если анализируемый участок
|
|
||||||
# уже застраивается (его ЖК есть в domrf_kn_objects), он не должен считаться
|
|
||||||
# собственным «конкурентом». Отсекаем объекты, чья точка попадает в геометрию
|
|
||||||
# участка или в ~80м от неё (домрф-координата ставится на корпус/въезд, может
|
|
||||||
# не совпадать с центроидом ЗУ → буфер ловит «свой» объект).
|
|
||||||
PIPELINE_SELF_EXCLUDE_M = 80
|
|
||||||
|
|
||||||
|
|
||||||
def _coord_round(value: Any) -> float | None:
|
def _coord_round(value: Any) -> float | None:
|
||||||
|
|
@ -1188,35 +1181,11 @@ def get_parcel_forecast(
|
||||||
return {"status": "pending"}
|
return {"status": "pending"}
|
||||||
|
|
||||||
if run is not None:
|
if run is not None:
|
||||||
report = run.result
|
|
||||||
# #1740: gate↔recommendation — если последний analyze-ран дал gate-вердикт
|
|
||||||
# «Нельзя строить МКД», помечаем forecast-рекомендатор (product_tz) caveat-флагом.
|
|
||||||
# Рекомендацию НЕ удаляем (строгость = продуктовое решение), только делаем
|
|
||||||
# противоречие явным. Best-effort: сбой чтения analyze-рана не валит выдачу форсайта.
|
|
||||||
try:
|
|
||||||
analyze_run = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
|
|
||||||
gate = (analyze_run.result or {}).get("gate_verdict") if analyze_run else None
|
|
||||||
if (
|
|
||||||
isinstance(gate, dict)
|
|
||||||
and gate.get("can_build_mkd") is False
|
|
||||||
and isinstance(report, dict)
|
|
||||||
and isinstance(report.get("product_tz"), dict)
|
|
||||||
):
|
|
||||||
report["product_tz"]["gate_caveat"] = (
|
|
||||||
"Строительство МКД под вопросом (gate: Нельзя) — "
|
|
||||||
"рекомендация носит условный характер"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
|
||||||
"gate_caveat enrich failed for forecast cad=%s — report unaffected",
|
|
||||||
cad_num,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"run_id": run.id,
|
"run_id": run.id,
|
||||||
"created_at": run.created_at,
|
"created_at": run.created_at,
|
||||||
"report": report,
|
"report": run.result,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Форсайт ещё не посчитан (таска в работе или /analyze не вызывали) — клиент поллит.
|
# Форсайт ещё не посчитан (таска в работе или /analyze не вызывали) — клиент поллит.
|
||||||
|
|
@ -1229,14 +1198,14 @@ def export_parcel_forecast(
|
||||||
cad_num: str,
|
cad_num: str,
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
format: Annotated[
|
format: Annotated[
|
||||||
Literal["md", "json", "tg", "docx", "pptx", "pdf"],
|
Literal["md", "json", "tg", "docx", "pptx"],
|
||||||
Query(
|
Query(
|
||||||
description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | "
|
description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | "
|
||||||
"tg (сводка) | docx (Word-документ) | pptx (презентация) | pdf (PDF-отчёт)"
|
"tg (сводка) | docx (Word-документ) | pptx (презентация)"
|
||||||
),
|
),
|
||||||
] = "md",
|
] = "md",
|
||||||
) -> Response:
|
) -> Response:
|
||||||
"""Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX / PDF (файл) или TG-сводка (#959).
|
"""Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX (файл) или TG-сводка (#959).
|
||||||
|
|
||||||
Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
|
Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
|
||||||
/{cad}/forecast inline) и отдаёт его в нужной форме.
|
/{cad}/forecast inline) и отдаёт его в нужной форме.
|
||||||
|
|
@ -1246,8 +1215,6 @@ def export_parcel_forecast(
|
||||||
зеркало содержания md/pdf; python-docx).
|
зеркало содержания md/pdf; python-docx).
|
||||||
• format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация,
|
• format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация,
|
||||||
сжатое зеркало содержания docx; python-pptx).
|
сжатое зеркало содержания docx; python-pptx).
|
||||||
• format=pdf → `export_report_pdf(run.result)` — attachment (.pdf, §13-отчёт,
|
|
||||||
зеркало содержания md/docx; WeasyPrint).
|
|
||||||
• format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка
|
• format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка
|
||||||
для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
|
для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
|
||||||
и отправить, а не файл-download).
|
и отправить, а не файл-download).
|
||||||
|
|
@ -1259,10 +1226,10 @@ def export_parcel_forecast(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
||||||
format: "md" (default) | "json" | "tg" | "docx" | "pptx" | "pdf".
|
format: "md" (default) | "json" | "tg" | "docx" | "pptx".
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response — для md/json/docx/pptx/pdf attachment с Content-Disposition (имя
|
Response — для md/json/docx/pptx attachment с Content-Disposition (имя
|
||||||
`gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
|
`gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
|
||||||
"""
|
"""
|
||||||
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION)
|
||||||
|
|
@ -1300,25 +1267,6 @@ def export_parcel_forecast(
|
||||||
headers={"Content-Disposition": f'attachment; filename="{base_name}.pptx"'},
|
headers={"Content-Disposition": f'attachment; filename="{base_name}.pptx"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
# pdf — байты (WeasyPrint), отдельной веткой (зеркало pptx early-return): §13-отчёт
|
|
||||||
# из export_report_pdf (#1739). md/json string-ветки остаются байт-в-байт прежними.
|
|
||||||
# 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":
|
if format == "json":
|
||||||
payload = json.dumps(run.result, ensure_ascii=False, default=str)
|
payload = json.dumps(run.result, ensure_ascii=False, default=str)
|
||||||
media_type = "application/json"
|
media_type = "application/json"
|
||||||
|
|
@ -1775,13 +1723,6 @@ def analyze_parcel(
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
:radius_m
|
:radius_m
|
||||||
)
|
)
|
||||||
-- #1738: исключаем СУБЪЕКТ анализа — объект на самом участке
|
|
||||||
-- (его точка внутри/в ~80м геометрии ЗУ) не считаем конкурентом.
|
|
||||||
AND NOT ST_DWithin(
|
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
|
||||||
ST_GeomFromText(:wkt, 4326)::geography,
|
|
||||||
:self_exclude_m
|
|
||||||
)
|
|
||||||
AND ready_dt >= CURRENT_DATE
|
AND ready_dt >= CURRENT_DATE
|
||||||
AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval)
|
AND ready_dt < CURRENT_DATE + cast(:horizon_months || ' months' AS interval)
|
||||||
ORDER BY ready_dt ASC
|
ORDER BY ready_dt ASC
|
||||||
|
|
@ -1789,7 +1730,6 @@ def analyze_parcel(
|
||||||
{
|
{
|
||||||
"wkt": geom_wkt,
|
"wkt": geom_wkt,
|
||||||
"radius_m": PIPELINE_RADIUS_M,
|
"radius_m": PIPELINE_RADIUS_M,
|
||||||
"self_exclude_m": PIPELINE_SELF_EXCLUDE_M,
|
|
||||||
"horizon_months": str(PIPELINE_HORIZON_MONTHS),
|
"horizon_months": str(PIPELINE_HORIZON_MONTHS),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -2845,26 +2785,6 @@ def analyze_parcel(
|
||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
|
|
||||||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner.
|
|
||||||
# Вычисляем ДО сборки result_payload, чтобы при can_build_mkd is False (#1740)
|
|
||||||
# пометить рекомендации флагом-кейватом (противоречие gate↔рекомендация явное).
|
|
||||||
gate_verdict = compute_gate_verdict(
|
|
||||||
nspd_zoning=nspd_dump_data["nspd_zoning"],
|
|
||||||
nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"],
|
|
||||||
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
|
|
||||||
nspd_dump=nspd_dump_data["nspd_dump"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# #1740: gate говорит «Нельзя строить МКД», но рекомендатор всё равно выдаёт
|
|
||||||
# квартирографию — не удаляем рекомендацию (строгость = продуктовое решение),
|
|
||||||
# а делаем противоречие явным через caveat-флаг. success_recommendation —
|
|
||||||
# analyze-рекомендатор (топ-квартирография по district).
|
|
||||||
if gate_verdict.get("can_build_mkd") is False and success_recommendation is not None:
|
|
||||||
success_recommendation["gate_caveat"] = (
|
|
||||||
"Строительство МКД под вопросом (gate: Нельзя) — "
|
|
||||||
"рекомендация носит условный характер"
|
|
||||||
)
|
|
||||||
|
|
||||||
result_payload: dict[str, Any] = {
|
result_payload: dict[str, Any] = {
|
||||||
"cad_num": cad_num,
|
"cad_num": cad_num,
|
||||||
"source": source,
|
"source": source,
|
||||||
|
|
@ -2961,8 +2881,12 @@ def analyze_parcel(
|
||||||
"nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])],
|
"nspd_red_lines": [RedLine(**rl) for rl in nspd_dump_data.get("nspd_red_lines", [])],
|
||||||
"nspd_dump": nspd_dump_data["nspd_dump"],
|
"nspd_dump": nspd_dump_data["nspd_dump"],
|
||||||
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
||||||
# (вычислен выше, до сборки payload — нужен для gate_caveat #1740).
|
"gate_verdict": compute_gate_verdict(
|
||||||
"gate_verdict": 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"],
|
||||||
|
),
|
||||||
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
# #114/#201: кастомные веса POI — source + applied dict для прозрачности.
|
||||||
"weights_profile": {
|
"weights_profile": {
|
||||||
"source": _weights_source,
|
"source": _weights_source,
|
||||||
|
|
@ -3474,69 +3398,52 @@ def parcel_snapshot_pdf(
|
||||||
Не является официальной выпиской ЕГРН — только аналитические данные НСПД.
|
Не является официальной выпиской ЕГРН — только аналитические данные НСПД.
|
||||||
Генерация <2 сек. Открывается в Adobe Reader / Chrome.
|
Генерация <2 сек. Открывается в Adobe Reader / Chrome.
|
||||||
"""
|
"""
|
||||||
# 1-6) Сбор данных участка из БД (метаданные + геометрия + POI + конкуренты +
|
# 1) Получить метаданные участка из cad_parcels
|
||||||
# district + last_update). Обёрнуто в try/except (#1739): иначе сырая DB/PostGIS-
|
parcel_row = (
|
||||||
# ошибка всплывает 500-кой без объяснения причины. logger.exception сохраняет
|
db.execute(
|
||||||
# traceback; HTTPException(404) при отсутствии участка и (422) при отсутствии
|
text("""
|
||||||
# геометрии — re-raise НЕ оборачиваем (это валидные клиентские ответы, а не 500).
|
SELECT readable_address AS address,
|
||||||
try:
|
land_record_area AS area_m2,
|
||||||
# 1) Получить метаданные участка из cad_parcels
|
land_record_category_type AS land_category,
|
||||||
parcel_row = (
|
permitted_use_established_by_document AS vri,
|
||||||
db.execute(
|
cost_value AS cadastral_cost,
|
||||||
text("""
|
updated_at AS last_update
|
||||||
SELECT readable_address AS address,
|
FROM cad_parcels
|
||||||
land_record_area AS area_m2,
|
WHERE cad_num = CAST(:c AS text)
|
||||||
land_record_category_type AS land_category,
|
LIMIT 1
|
||||||
permitted_use_established_by_document AS vri,
|
"""),
|
||||||
cost_value AS cadastral_cost,
|
{"c": cad_num},
|
||||||
updated_at AS last_update
|
)
|
||||||
FROM cad_parcels
|
.mappings()
|
||||||
WHERE cad_num = CAST(:c AS text)
|
.first()
|
||||||
LIMIT 1
|
)
|
||||||
"""),
|
|
||||||
{"c": cad_num},
|
if not parcel_row:
|
||||||
)
|
raise HTTPException(
|
||||||
.mappings()
|
status_code=404,
|
||||||
.first()
|
detail=f"Участок {cad_num} не найден в БД. Используйте POST /analyze для загрузки.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not parcel_row:
|
# 2) Получить геометрию (WKT) для POI / competitor queries
|
||||||
raise HTTPException(
|
geom_row = (
|
||||||
status_code=404,
|
db.execute(
|
||||||
detail=(
|
text("""
|
||||||
f"Участок {cad_num} не найден в БД. " "Используйте POST /analyze для загрузки."
|
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
|
||||||
# 2) Получить геометрию (WKT) для POI / competitor queries
|
"""),
|
||||||
geom_row = (
|
{"c": cad_num},
|
||||||
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
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
geom_wkt: str | None = geom_row["wkt"] if geom_row else None
|
||||||
|
|
||||||
# #1739: без геометрии участка PDF-снимок бессмыслен (нет POI/конкурентов/
|
# 3) POI в радиусе 1 км (только если есть геометрия)
|
||||||
# района). 422 — клиентский кейс «данные неполны», не серверная 500.
|
poi_rows: list[dict[str, Any]] = []
|
||||||
if not geom_wkt:
|
if geom_wkt:
|
||||||
raise HTTPException(
|
poi_rows = [
|
||||||
status_code=422,
|
|
||||||
detail=(
|
|
||||||
f"Геометрия участка {cad_num} отсутствует в БД — "
|
|
||||||
"PDF-снимок недоступен. Запустите POST /analyze для дозагрузки."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3) POI в радиусе 1 км
|
|
||||||
poi_rows: list[dict[str, Any]] = [
|
|
||||||
dict(r)
|
dict(r)
|
||||||
for r in db.execute(
|
for r in db.execute(
|
||||||
text("""
|
text("""
|
||||||
|
|
@ -3561,8 +3468,10 @@ def parcel_snapshot_pdf(
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
# 4) Конкуренты в радиусе 3 км
|
# 4) Конкуренты в радиусе 3 км (только если есть геометрия)
|
||||||
competitor_rows: list[dict[str, Any]] = [
|
competitor_rows: list[dict[str, Any]] = []
|
||||||
|
if geom_wkt:
|
||||||
|
competitor_rows = [
|
||||||
dict(r)
|
dict(r)
|
||||||
for r in db.execute(
|
for r in db.execute(
|
||||||
text("""
|
text("""
|
||||||
|
|
@ -3596,8 +3505,9 @@ def parcel_snapshot_pdf(
|
||||||
.all()
|
.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
# 5) Получить district (через пересечение с ekb_districts)
|
# 5) Получить district (через пересечение с ekb_districts если есть геом)
|
||||||
district: str | None = None
|
district: str | None = None
|
||||||
|
if geom_wkt:
|
||||||
district_row = (
|
district_row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
text("""
|
text("""
|
||||||
|
|
@ -3614,23 +3524,16 @@ def parcel_snapshot_pdf(
|
||||||
if district_row:
|
if district_row:
|
||||||
district = district_row["district_name"]
|
district = district_row["district_name"]
|
||||||
|
|
||||||
# 6) Форматировать last_update
|
# 6) Форматировать last_update
|
||||||
raw_update = parcel_row["last_update"]
|
raw_update = parcel_row["last_update"]
|
||||||
last_update_str: str | None = None
|
last_update_str: str | None = None
|
||||||
if raw_update is not None:
|
if raw_update is not None:
|
||||||
try:
|
try:
|
||||||
last_update_str = raw_update.strftime("%d.%m.%Y")
|
last_update_str = raw_update.strftime("%d.%m.%Y")
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
last_update_str = str(raw_update)[:10]
|
last_update_str = str(raw_update)[:10]
|
||||||
except HTTPException:
|
|
||||||
# 404/422 — валидные клиентские ответы, пропускаем без обёртки в 500.
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("snapshot data fetch failed for %s", cad_num)
|
|
||||||
raise HTTPException(status_code=500, detail="Ошибка сбора данных для PDF-снимка") from exc
|
|
||||||
|
|
||||||
# 7) Сгенерировать PDF (WeasyPrint-рендер — отдельная зона отказа: шрифты,
|
# 7) Сгенерировать PDF
|
||||||
# шаблон, движок). logger.exception сохраняет traceback причины 500 (#1739).
|
|
||||||
try:
|
try:
|
||||||
pdf_bytes = generate_snapshot_pdf(
|
pdf_bytes = generate_snapshot_pdf(
|
||||||
cad_num=cad_num,
|
cad_num=cad_num,
|
||||||
|
|
@ -3650,7 +3553,7 @@ def parcel_snapshot_pdf(
|
||||||
competitors_limit=5,
|
competitors_limit=5,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("snapshot PDF render failed for %s", cad_num)
|
logger.error("snapshot PDF generation failed for %s: %s", cad_num, exc)
|
||||||
raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc
|
raise HTTPException(status_code=500, detail="Ошибка генерации PDF") from exc
|
||||||
|
|
||||||
cad_safe = cad_num.replace(":", "-")
|
cad_safe = cad_num.replace(":", "-")
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,8 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Путь к директории шаблонов: app/templates/parcel_snapshot.html (#1739).
|
# Путь к директории шаблонов (относительно этого файла — 2 уровня вверх, затем templates)
|
||||||
# Файл лежит в app/services/exporters/, поэтому до app/ — 3 уровня вверх
|
_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent / "templates"
|
||||||
# (exporters → services → app), затем templates. parent.parent давал
|
|
||||||
# несуществующий app/services/templates → TemplateNotFound → 500 на snapshot.
|
|
||||||
_TEMPLATE_DIR = pathlib.Path(__file__).parent.parent.parent / "templates"
|
|
||||||
|
|
||||||
# Системные пути DejaVu Sans (Ubuntu/Debian Docker-образ + Alpine резерв)
|
# Системные пути DejaVu Sans (Ubuntu/Debian Docker-образ + Alpine резерв)
|
||||||
_DEJAVU_CANDIDATES: list[str] = [
|
_DEJAVU_CANDIDATES: list[str] = [
|
||||||
|
|
@ -64,10 +61,6 @@ def _find_font_url() -> str:
|
||||||
|
|
||||||
WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig,
|
WeasyPrint умеет сам находить системные шрифты через fonttools/fontconfig,
|
||||||
поэтому пустая строка допустима — шрифт тогда подбирается CSS generic.
|
поэтому пустая строка допустима — шрифт тогда подбирается CSS generic.
|
||||||
|
|
||||||
#1739: при пустой строке шаблон НЕ эмитит `@font-face { src: url('') }`
|
|
||||||
(обёрнут в `{% if font_url %}`). Иначе WeasyPrint пытался резолвить пустой
|
|
||||||
URL → 'failed to load font' ошибка вместо тихого fallback на Arial/sans-serif.
|
|
||||||
"""
|
"""
|
||||||
for path in _DEJAVU_CANDIDATES:
|
for path in _DEJAVU_CANDIDATES:
|
||||||
if pathlib.Path(path).exists():
|
if pathlib.Path(path).exists():
|
||||||
|
|
|
||||||
|
|
@ -42,16 +42,11 @@ JSON-safe и ложится в слот ReportConfidence (#987): {level, rationa
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
Confidence = Literal["high", "medium", "low"]
|
Confidence = Literal["high", "medium", "low"]
|
||||||
|
|
||||||
# Вход component_confidences может быть плоским уровнем (legacy) ИЛИ парой
|
|
||||||
# (имя-сервиса, уровень) — чтобы пронести ИМЯ сервиса сквозь стек (#1737).
|
|
||||||
ComponentConfidence = Confidence | tuple[str, Confidence]
|
|
||||||
|
|
||||||
# ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab
|
# ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab
|
||||||
# future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ───────────────
|
# future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ───────────────
|
||||||
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
||||||
|
|
@ -66,18 +61,6 @@ _F_CONFOUNDED_WINDOW: str = "confounded_window"
|
||||||
_F_ADVISORY_CAP: str = "advisory_cap"
|
_F_ADVISORY_CAP: str = "advisory_cap"
|
||||||
_F_COMPONENT: str = "component" # один вкладывающий per-service confidence
|
_F_COMPONENT: str = "component" # один вкладывающий per-service confidence
|
||||||
|
|
||||||
# ── RU-имена вкладывающих под-сервисов (#1737). Машинное имя сервиса (как его кормит
|
|
||||||
# сборщик #988) → человекочитаемое RU-имя для отчёта/UI. Неизвестный/пустой ключ →
|
|
||||||
# дефолт «Компонент» (graceful — не теряем фактор, просто без точечного имени). ──────
|
|
||||||
_SERVICE_RU: dict[str, str] = {
|
|
||||||
"market_metrics": "Рынок",
|
|
||||||
"future_supply": "Будущее предложение",
|
|
||||||
"product_scores": "Скоринг продукта",
|
|
||||||
"special_indices": "Спец-индексы",
|
|
||||||
"forecasts": "Прогноз спрос/предложение",
|
|
||||||
}
|
|
||||||
_SERVICE_RU_DEFAULT: str = "Компонент"
|
|
||||||
|
|
||||||
# ── Пороги счётчиков (align с per-service gate'ами, прочитанными в #949/#951) ──────
|
# ── Пороги счётчиков (align с per-service gate'ами, прочитанными в #949/#951) ──────
|
||||||
|
|
||||||
# deal_count: число сделок (продаж) за окно. high — длинная плотная выборка,
|
# deal_count: число сделок (продаж) за окно. high — длинная плотная выборка,
|
||||||
|
|
@ -114,29 +97,20 @@ class ConfidenceFactor:
|
||||||
(число сделок / ЖК / покрытие / месяцы / bool шок-окна — или None); `level` —
|
(число сделок / ЖК / покрытие / месяцы / bool шок-окна — или None); `level` —
|
||||||
его собственный вклад high/medium/low; `note` — короткая RU-фраза с числом
|
его собственный вклад high/medium/low; `note` — короткая RU-фраза с числом
|
||||||
(«7 сделок за 6 мес — мало»), из которой собирается структурная причина §15.
|
(«7 сделок за 6 мес — мало»), из которой собирается структурная причина §15.
|
||||||
`label` — человекочитаемое RU-имя фактора (#1737: для component-факторов это ИМЯ
|
|
||||||
под-сервиса — «Рынок» / «Будущее предложение» …, чтобы UI не показывал сырой
|
|
||||||
«Компонент N»); пусто для счётчиков (у них своя RU-метка на стороне UI).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
value: Any # число/доля/флаг, обосновавшие level (или None)
|
value: Any # число/доля/флаг, обосновавшие level (или None)
|
||||||
level: Confidence
|
level: Confidence
|
||||||
note: str # RU, с реальным числом
|
note: str # RU, с реальным числом
|
||||||
label: str = "" # RU-имя фактора (для component — имя сервиса, #1737); опционально
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
# `label` кладём ТОЛЬКО когда задан — схема обратно-совместима (старые
|
return {
|
||||||
# потребители без поля `label` продолжают читать name/value/level/note).
|
|
||||||
out: dict[str, Any] = {
|
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"value": self.value,
|
"value": self.value,
|
||||||
"level": self.level,
|
"level": self.level,
|
||||||
"note": self.note,
|
"note": self.note,
|
||||||
}
|
}
|
||||||
if self.label:
|
|
||||||
out["label"] = self.label
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -171,12 +145,7 @@ class ReportConfidenceResult:
|
||||||
while f"{key}_{i}" in factors:
|
while f"{key}_{i}" in factors:
|
||||||
i += 1
|
i += 1
|
||||||
key = f"{key}_{i}"
|
key = f"{key}_{i}"
|
||||||
entry: dict[str, Any] = {"value": f.value, "level": f.level, "note": f.note}
|
factors[key] = {"value": f.value, "level": f.level, "note": f.note}
|
||||||
# RU-имя фактора (для component — имя сервиса, #1737) кладём ТОЛЬКО когда
|
|
||||||
# задано — плоский dict остаётся обратно-совместимым по форме.
|
|
||||||
if f.label:
|
|
||||||
entry["label"] = f.label
|
|
||||||
factors[key] = entry
|
|
||||||
factors["advisory_capped"] = self.advisory_capped
|
factors["advisory_capped"] = self.advisory_capped
|
||||||
return {
|
return {
|
||||||
"level": self.level,
|
"level": self.level,
|
||||||
|
|
@ -287,23 +256,17 @@ def _confounded_factor(confounded: bool) -> ConfidenceFactor:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _component_factor(level: Confidence, service: str | None = None) -> ConfidenceFactor:
|
def _component_factor(level: Confidence) -> ConfidenceFactor:
|
||||||
"""Один вкладывающий per-service confidence → ConfidenceFactor. PURE.
|
"""Один вкладывающий per-service confidence → ConfidenceFactor. PURE.
|
||||||
|
|
||||||
Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт,
|
Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт,
|
||||||
нота поясняет вклад. value=None (число — внутри самого под-сервиса). `service` —
|
нота поясняет вклад. value=None (число — внутри самого под-сервиса).
|
||||||
машинное имя под-сервиса (#1737): из него берём человекочитаемое RU-имя
|
|
||||||
(«Рынок» / «Будущее предложение» / «Скоринг продукта» / «Спец-индексы»…) в
|
|
||||||
`label` и НАЗЫВАЕМ сервис в `note` (вместо безличного «вкладывающий сервис: high»).
|
|
||||||
Неизвестное/None имя → дефолт «Компонент» (graceful — фактор не теряется).
|
|
||||||
"""
|
"""
|
||||||
ru_name = _SERVICE_RU.get(service or "", _SERVICE_RU_DEFAULT)
|
|
||||||
return ConfidenceFactor(
|
return ConfidenceFactor(
|
||||||
name=_F_COMPONENT,
|
name=_F_COMPONENT,
|
||||||
value=None,
|
value=None,
|
||||||
level=level,
|
level=level,
|
||||||
note=f"{ru_name}: {level}",
|
note=f"вкладывающий сервис: {level}",
|
||||||
label=ru_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -410,7 +373,7 @@ def _join_notes(notes: list[str], *, limit: int = 3) -> str:
|
||||||
|
|
||||||
def compute_report_confidence(
|
def compute_report_confidence(
|
||||||
*,
|
*,
|
||||||
component_confidences: Sequence[ComponentConfidence] | None = None,
|
component_confidences: list[Confidence] | None = None,
|
||||||
deal_count: int | None = None,
|
deal_count: int | None = None,
|
||||||
deal_count_months: int | None = None,
|
deal_count_months: int | None = None,
|
||||||
analog_count: int | None = None,
|
analog_count: int | None = None,
|
||||||
|
|
@ -439,9 +402,7 @@ def compute_report_confidence(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→
|
component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→
|
||||||
нет вкладывающих компонентов. Каждый элемент — ЛИБО плоский уровень
|
нет вкладывающих компонентов.
|
||||||
'high|medium|low' (legacy), ЛИБО пара (имя-сервиса, уровень) (#1737:
|
|
||||||
имя сервиса проносится сквозь стек в RU-метку фактора).
|
|
||||||
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
||||||
deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес»
|
deal_count_months: окно наблюдения для deal_count (мес) — добавляет «за N мес»
|
||||||
в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода.
|
в ноту фактора («7 сделок за 6 мес — мало»). None → нота без периода.
|
||||||
|
|
@ -498,16 +459,9 @@ def compute_report_confidence(
|
||||||
factors.append(_confounded_factor(True))
|
factors.append(_confounded_factor(True))
|
||||||
|
|
||||||
# ── 2. Вкладывающие per-service confidence → факторы ──────────────────────
|
# ── 2. Вкладывающие per-service confidence → факторы ──────────────────────
|
||||||
# Элемент — ЛИБО плоский уровень (legacy), ЛИБО пара (имя-сервиса, уровень)
|
|
||||||
# (#1737: проносим имя сервиса в RU-метку фактора). Мусор/неизвестный уровень
|
|
||||||
# отбрасываем (whitelist), как и раньше.
|
|
||||||
for c in component_confidences or []:
|
for c in component_confidences or []:
|
||||||
if isinstance(c, tuple):
|
if c in _CONFIDENCE_RANK:
|
||||||
service, level = c
|
factors.append(_component_factor(c))
|
||||||
else:
|
|
||||||
service, level = None, c
|
|
||||||
if level in _CONFIDENCE_RANK:
|
|
||||||
factors.append(_component_factor(level, service))
|
|
||||||
|
|
||||||
# ── 3. weakest-link агрегат (худший тянет вниз) ───────────────────────────
|
# ── 3. weakest-link агрегат (худший тянет вниз) ───────────────────────────
|
||||||
raw_level = _aggregate(factors)
|
raw_level = _aggregate(factors)
|
||||||
|
|
|
||||||
|
|
@ -544,9 +544,6 @@ def _demand_supply_overlay(
|
||||||
"obj_class": seg.segment.get("obj_class"),
|
"obj_class": seg.segment.get("obj_class"),
|
||||||
"deficit_index": seg.deficit_index,
|
"deficit_index": seg.deficit_index,
|
||||||
"balance_units": seg.balance_units,
|
"balance_units": seg.balance_units,
|
||||||
# #1745: спрос на горизонт (квартир) — для таблицы прогноза по форматам.
|
|
||||||
# demand_only его не несёт (без геометрии supply/спрос-проекция неизмеримы).
|
|
||||||
"projected_demand_units": seg.projected_demand_units,
|
|
||||||
"confidence": seg.confidence,
|
"confidence": seg.confidence,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,6 @@ _LEVEL_RU: dict[ReportConfidenceLevel, str] = {
|
||||||
# special_indices._VOID_THRESHOLD: умеренно-сильный дефицит на лог-шкале #980 [−1,+1]).
|
# special_indices._VOID_THRESHOLD: умеренно-сильный дефицит на лог-шкале #980 [−1,+1]).
|
||||||
_STRONG_DEFICIT_THRESHOLD: float = 0.25
|
_STRONG_DEFICIT_THRESHOLD: float = 0.25
|
||||||
|
|
||||||
# Нейтральная зона |deficit_index|, внутри которой рынок считаем сбалансированным
|
|
||||||
# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05.
|
|
||||||
_SIGNAL_BALANCE_EPS: float = 0.05
|
|
||||||
|
|
||||||
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
|
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
|
||||||
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
|
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
|
||||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||||
|
|
@ -263,33 +259,23 @@ def _component_confidences(
|
||||||
forecasts: Sequence[dict[str, Any]],
|
forecasts: Sequence[dict[str, Any]],
|
||||||
product_scores: dict[str, Any] | None,
|
product_scores: dict[str, Any] | None,
|
||||||
special_indices: dict[str, Any] | None,
|
special_indices: dict[str, Any] | None,
|
||||||
) -> list[tuple[str, Confidence]]:
|
) -> list[Confidence]:
|
||||||
"""Собрать per-service confidence вкладывающих под-сервисов — для #990. PURE.
|
"""Собрать per-service confidence вкладывающих под-сервисов — для #990. PURE.
|
||||||
|
|
||||||
#990 свернёт их weakest-link (MIN) вместе с сырыми счётчиками. Берём confidence-
|
#990 свернёт их weakest-link (MIN) вместе с сырыми счётчиками. Берём confidence-
|
||||||
метку каждого доступного под-вывода (market_metrics §9.2 / future_supply §9.3 /
|
метку каждого доступного под-вывода (market_metrics §9.2 / future_supply §9.3 /
|
||||||
каждый per-горизонт forecast #952 / product_scores #985 / special_indices #986).
|
каждый per-горизонт forecast #952 / product_scores #985 / special_indices #986).
|
||||||
Только whitelisted 'high|medium|low' (мусор/None отбрасываем).
|
Только whitelisted 'high|medium|low' (мусор/None отбрасываем).
|
||||||
|
|
||||||
#1737: возвращаем ПАРЫ (имя-сервиса, уровень) — машинное имя под-сервиса
|
|
||||||
(«market_metrics» / «future_supply» / «product_scores» / «special_indices» /
|
|
||||||
«forecasts»), чтобы #990 пронёс ИМЯ сервиса в RU-метку фактора и UI не показывал
|
|
||||||
безличный «Компонент N».
|
|
||||||
"""
|
"""
|
||||||
out: list[tuple[str, Confidence]] = []
|
out: list[Confidence] = []
|
||||||
for name, source in (
|
for source in (market_metrics, future_supply, product_scores, special_indices):
|
||||||
("market_metrics", market_metrics),
|
|
||||||
("future_supply", future_supply),
|
|
||||||
("product_scores", product_scores),
|
|
||||||
("special_indices", special_indices),
|
|
||||||
):
|
|
||||||
conf = _confidence_of(source)
|
conf = _confidence_of(source)
|
||||||
if conf is not None:
|
if conf is not None:
|
||||||
out.append((name, conf))
|
out.append(conf)
|
||||||
for f in forecasts:
|
for f in forecasts:
|
||||||
conf = _confidence_of(f)
|
conf = _confidence_of(f)
|
||||||
if conf is not None:
|
if conf is not None:
|
||||||
out.append(("forecasts", conf))
|
out.append(conf)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -518,16 +504,12 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
|
||||||
Overlay #983 не несёт готового `mix`, но его `ranked_segments` (DESC по дефициту) —
|
Overlay #983 не несёт готового `mix`, но его `ranked_segments` (DESC по дефициту) —
|
||||||
продуктовый ответ «какой формат строить». Берём `mix` если задан, иначе проецируем
|
продуктовый ответ «какой формат строить». Берём `mix` если задан, иначе проецируем
|
||||||
ranked_segments в компактные {bucket, obj_class, deficit_index, projected_demand_units,
|
ranked_segments в компактные {bucket, obj_class, deficit_index}. Нет ни того, ни
|
||||||
signal} (#1745 — таблица прогноза по форматам). Нет ни того, ни другого → [].
|
другого → [].
|
||||||
|
|
||||||
#1745 ADDITIVE: проброс `projected_demand_units` (прогноз спроса, квартир) и
|
|
||||||
деривированного `signal` («строить»/«баланс»/«избегать») из deficit_index. Старые
|
|
||||||
потребители mix читают bucket/obj_class/deficit_index — новые поля их не ломают.
|
|
||||||
"""
|
"""
|
||||||
explicit = product_tz.get("mix")
|
explicit = product_tz.get("mix")
|
||||||
if isinstance(explicit, list):
|
if isinstance(explicit, list):
|
||||||
return [_enrich_mix_entry(m) for m in explicit if isinstance(m, dict)]
|
return [m for m in explicit if isinstance(m, dict)]
|
||||||
ranked = product_tz.get("ranked_segments")
|
ranked = product_tz.get("ranked_segments")
|
||||||
if isinstance(ranked, list):
|
if isinstance(ranked, list):
|
||||||
return [
|
return [
|
||||||
|
|
@ -535,8 +517,6 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
"bucket": seg.get("bucket"),
|
"bucket": seg.get("bucket"),
|
||||||
"obj_class": seg.get("obj_class"),
|
"obj_class": seg.get("obj_class"),
|
||||||
"deficit_index": seg.get("deficit_index"),
|
"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
|
for seg in ranked
|
||||||
if isinstance(seg, dict)
|
if isinstance(seg, dict)
|
||||||
|
|
@ -544,35 +524,6 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
return []
|
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]]:
|
def _extract_list(source: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
||||||
"""Достать список dict'ов по ключу (мусор/None → []). PURE."""
|
"""Достать список dict'ов по ключу (мусор/None → []). PURE."""
|
||||||
value = source.get(key)
|
value = source.get(key)
|
||||||
|
|
|
||||||
|
|
@ -99,14 +99,12 @@ class RankedSegment:
|
||||||
deficit_index: float # не None в ранкинге (None-ячейки отброшены)
|
deficit_index: float # не None в ранкинге (None-ячейки отброшены)
|
||||||
balance_units: float | None # demand − supply (>0 дефицит / <0 затоварка)
|
balance_units: float | None # demand − supply (>0 дефицит / <0 затоварка)
|
||||||
confidence: Confidence # ≤ 'medium' (#980 advisory-cap)
|
confidence: Confidence # ≤ 'medium' (#980 advisory-cap)
|
||||||
projected_demand_units: float | None = None # #1745: спрос на горизонт (квартир)
|
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, Any]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"segment": dict(self.segment),
|
"segment": dict(self.segment),
|
||||||
"deficit_index": _round_or_none(self.deficit_index, 3),
|
"deficit_index": _round_or_none(self.deficit_index, 3),
|
||||||
"balance_units": _round_or_none(self.balance_units, 1),
|
"balance_units": _round_or_none(self.balance_units, 1),
|
||||||
"projected_demand_units": _round_or_none(self.projected_demand_units, 1),
|
|
||||||
"confidence": self.confidence,
|
"confidence": self.confidence,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,5 +359,4 @@ def _forecast_cell(
|
||||||
deficit_index=forecast.deficit_index,
|
deficit_index=forecast.deficit_index,
|
||||||
balance_units=forecast.balance_units,
|
balance_units=forecast.balance_units,
|
||||||
confidence=forecast.confidence,
|
confidence=forecast.confidence,
|
||||||
projected_demand_units=forecast.projected_demand_units,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,10 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Карточка участка {{ cad_num }}</title>
|
<title>Карточка участка {{ cad_num }}</title>
|
||||||
<style>
|
<style>
|
||||||
{% if font_url %}
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'DejaVu Sans';
|
font-family: 'DejaVu Sans';
|
||||||
src: url('{{ font_url }}') format('truetype');
|
src: url('{{ font_url }}') format('truetype');
|
||||||
}
|
}
|
||||||
{% endif %}
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
font-family: 'DejaVu Sans', Arial, sans-serif;
|
font-family: 'DejaVu Sans', Arial, sans-serif;
|
||||||
|
|
|
||||||
|
|
@ -22,33 +22,10 @@ import datetime as dt
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
def _weasyprint_native_libs_available() -> tuple[bool, str]:
|
|
||||||
"""Probe whether WeasyPrint can render (native GTK/Pango/GObject libs present).
|
|
||||||
|
|
||||||
libpango/libgobject отсутствуют на macOS dev, присутствуют в Docker/CI-образе.
|
|
||||||
Зеркало гварда из tests/services/exporters/test_report_pdf.py — пропускаем
|
|
||||||
PDF-рендер на dev, но прогоняем на CI (#1739).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from weasyprint import HTML
|
|
||||||
|
|
||||||
HTML(string="<p>x</p>").write_pdf()
|
|
||||||
except (OSError, ImportError) as e:
|
|
||||||
return False, str(e)
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
|
|
||||||
_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
|
|
||||||
_skip_if_no_weasyprint = pytest.mark.skipif(
|
|
||||||
not _WP_OK, reason=f"WeasyPrint native libs missing: {_WP_ERR}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Константы ─────────────────────────────────────────────────────────────────
|
# ── Константы ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_CAD = "66:41:0204016:10"
|
_CAD = "66:41:0204016:10"
|
||||||
|
|
@ -312,11 +289,7 @@ def test_get_forecast_ready_when_run_present() -> None:
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
app.dependency_overrides[get_db] = _override_db(db)
|
app.dependency_overrides[get_db] = _override_db(db)
|
||||||
try:
|
try:
|
||||||
# 1-й вызов — §22-ран ("1.0"); 2-й — analyze-ран для gate_caveat (#1740),
|
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
|
||||||
# здесь None (gate не применяется).
|
|
||||||
with patch(
|
|
||||||
"app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]
|
|
||||||
) as lrf:
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
|
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
@ -324,8 +297,8 @@ def test_get_forecast_ready_when_run_present() -> None:
|
||||||
assert body["status"] == "ready"
|
assert body["status"] == "ready"
|
||||||
assert body["run_id"] == 66
|
assert body["run_id"] == 66
|
||||||
assert body["report"] == report_dict
|
assert body["report"] == report_dict
|
||||||
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam) — первый вызов.
|
# Читаем именно §22-схему "1.0" (3b-i schema-filtered seam).
|
||||||
_, kwargs = lrf.call_args_list[0]
|
_, kwargs = lrf.call_args
|
||||||
assert kwargs.get("schema_version") == "1.0"
|
assert kwargs.get("schema_version") == "1.0"
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
@ -617,54 +590,8 @@ def test_export_forecast_no_run_returns_404() -> None:
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
@_skip_if_no_weasyprint
|
|
||||||
def test_export_forecast_pdf_returns_pdf() -> None:
|
|
||||||
"""format=pdf → 200 application/pdf + attachment .pdf, тело — PDF-байты (%PDF) (#1739)."""
|
|
||||||
from app.core.db import get_db
|
|
||||||
|
|
||||||
fake_run = MagicMock()
|
|
||||||
fake_run.result = _REPORT_FIXTURE
|
|
||||||
|
|
||||||
db = MagicMock()
|
|
||||||
app.dependency_overrides[get_db] = _override_db(db)
|
|
||||||
try:
|
|
||||||
with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf:
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
|
|
||||||
assert resp.status_code == 200, resp.text
|
|
||||||
assert resp.headers["content-type"] == "application/pdf"
|
|
||||||
cd = resp.headers["content-disposition"]
|
|
||||||
assert "attachment;" in cd
|
|
||||||
assert cd.endswith('.pdf"')
|
|
||||||
# cad-двоеточия санитизированы в имени файла.
|
|
||||||
assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd
|
|
||||||
# WeasyPrint PDF начинается с маркера %PDF.
|
|
||||||
assert resp.content[:4] == b"%PDF"
|
|
||||||
# Читаем именно §22-схему "1.0".
|
|
||||||
_, kwargs = lrf.call_args
|
|
||||||
assert kwargs.get("schema_version") == "1.0"
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_forecast_pdf_no_run_returns_404() -> None:
|
|
||||||
"""format=pdf + рана нет → 404 (как md/json/docx/pptx export-вариант, НЕ 202/500)."""
|
|
||||||
from app.core.db import get_db
|
|
||||||
|
|
||||||
db = MagicMock()
|
|
||||||
app.dependency_overrides[get_db] = _override_db(db)
|
|
||||||
try:
|
|
||||||
with patch("app.api.v1.parcels.latest_run_for", return_value=None):
|
|
||||||
client = TestClient(app)
|
|
||||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
|
|
||||||
assert resp.status_code == 404, resp.text
|
|
||||||
assert "посчитан" in resp.json()["detail"]
|
|
||||||
finally:
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def test_export_forecast_invalid_format_returns_422() -> None:
|
def test_export_forecast_invalid_format_returns_422() -> None:
|
||||||
"""format вне {md,json,tg,docx,pptx,pdf} → 422 (Literal-валидация ДО работы с БД)."""
|
"""format вне {md,json,tg,docx,pptx} → 422 (Literal-валидация ДО работы с БД)."""
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
|
|
@ -672,7 +599,7 @@ def test_export_forecast_invalid_format_returns_422() -> None:
|
||||||
try:
|
try:
|
||||||
with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf:
|
with patch("app.api.v1.parcels.latest_run_for", return_value=None) as lrf:
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=xml")
|
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pdf")
|
||||||
assert resp.status_code == 422, resp.text
|
assert resp.status_code == 422, resp.text
|
||||||
lrf.assert_not_called()
|
lrf.assert_not_called()
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import Link from "next/link";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { ChatPanel } from "@/components/site-finder/ChatPanel";
|
import { ChatPanel } from "@/components/site-finder/ChatPanel";
|
||||||
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
|
|
||||||
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
|
import { HorizonSelector } from "@/components/site-finder/HorizonSelector";
|
||||||
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
|
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
|
||||||
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
|
import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities";
|
||||||
|
|
@ -161,48 +160,22 @@ export function AnalysisPageContent({ cad }: Props) {
|
||||||
|
|
||||||
{/* ── Main scroll area ──────────────────────────────────────────── */}
|
{/* ── Main scroll area ──────────────────────────────────────────── */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
|
||||||
{/* Вердикт-баннер участка — выше всех секций (#1741). */}
|
{/* Section 1 — IMPLEMENTED in A5 */}
|
||||||
<GateVerdictBanner verdict={analysis.gate_verdict} />
|
|
||||||
|
|
||||||
{/* ── Группа «Участок» ──────────────────────────────────────── */}
|
|
||||||
<GroupDivider label="Участок" />
|
|
||||||
|
|
||||||
{/* 1. Информация — IMPLEMENTED in A5 */}
|
|
||||||
<Section1ParcelInfo cad={cad} />
|
<Section1ParcelInfo cad={cad} />
|
||||||
|
|
||||||
{/* 2. Оценка участка — IMPLEMENTED in A10 */}
|
{/* Section 2 — IMPLEMENTED in A6 */}
|
||||||
<Section4Estimate cad={cad} />
|
|
||||||
|
|
||||||
{/* 3. Сети и точки подключения — IMPLEMENTED in A6 */}
|
|
||||||
<Section2NetworksUtilities cad={cad} />
|
<Section2NetworksUtilities cad={cad} />
|
||||||
|
|
||||||
{/* ── Группа «Стройка и рынок» ──────────────────────────────── */}
|
{/* Section 3 — IMPLEMENTED in A7 */}
|
||||||
<GroupDivider label="Стройка и рынок" />
|
|
||||||
|
|
||||||
{/* 4. Рынок и конкуренты — IMPLEMENTED in A7 */}
|
|
||||||
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
|
<Section3SettingsAndCompetitors cad={cad} data={analysis} />
|
||||||
|
|
||||||
{/* 5. Атмосфера — IMPLEMENTED in A11 */}
|
{/* Section 4 — IMPLEMENTED in A10 */}
|
||||||
|
<Section4Estimate cad={cad} />
|
||||||
|
|
||||||
|
{/* Section 5 — IMPLEMENTED in A11 */}
|
||||||
<Section5Atmosphere cad={cad} />
|
<Section5Atmosphere cad={cad} />
|
||||||
|
|
||||||
{/* Статус пересчёта прогноза при смене горизонта (#1744) —
|
{/* Section 6 — IMPLEMENTED in 958-B3 (§22 forecast) */}
|
||||||
нейтральная серая строка вместо молчаливого 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} />
|
<Section6Forecast cad={cad} selectedHorizon={horizon} />
|
||||||
|
|
||||||
{/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
|
{/* Chat — grounded parcel-chat over the §22 forecast (#958) */}
|
||||||
|
|
@ -272,85 +245,32 @@ function Breadcrumb({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Group divider ────────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Плашка-разделитель между смысловыми группами секций (#1741): тёмный фон
|
|
||||||
// --bg-headline + светлый uppercase-label --fg-on-dark, паттерн headline-bar.
|
|
||||||
|
|
||||||
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 ────────────────────────────────────────────────────────────────
|
// ── Sidebar nav ────────────────────────────────────────────────────────────────
|
||||||
//
|
|
||||||
// Две группы с групп-хедерами (#1741). Нумерация секций сквозная 1–6 под новый
|
|
||||||
// порядок: «Участок» → 1-3, «Стройка и рынок» → 4-6.
|
|
||||||
|
|
||||||
interface NavItem {
|
const NAV_ITEMS = [
|
||||||
id: string;
|
{ id: "section-1", label: "1. Информация" },
|
||||||
label: string;
|
{ id: "section-2", label: "2. Сети" },
|
||||||
children?: { id: string; label: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavGroup {
|
|
||||||
label: string;
|
|
||||||
items: NavItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const NAV_GROUPS: NavGroup[] = [
|
|
||||||
{
|
{
|
||||||
label: "Участок",
|
id: "section-3",
|
||||||
items: [
|
label: "3. Продажи конкурентов",
|
||||||
{ id: "section-1", label: "1. Информация" },
|
children: [
|
||||||
{ id: "section-4", label: "2. Оценка участка" },
|
{ id: "section-3-1", label: "3.1 Настройки выборки" },
|
||||||
{ id: "section-2", label: "3. Сети и точки подключения" },
|
{ id: "section-3-2", label: "3.2 Планировки" },
|
||||||
|
{ id: "section-3-3", label: "3.3 Остатки и скорость" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ id: "section-4", label: "4. Оценка" },
|
||||||
|
{ id: "section-5", label: "5. Атмосфера" },
|
||||||
{
|
{
|
||||||
label: "Стройка и рынок",
|
id: "section-6",
|
||||||
items: [
|
label: "6. Прогноз",
|
||||||
{
|
children: [
|
||||||
id: "section-3",
|
{ id: "section-6-1", label: "6.1 Прогноз по горизонтам" },
|
||||||
label: "4. Рынок и конкуренты",
|
{ id: "section-6-2", label: "6.2 Сценарии" },
|
||||||
children: [
|
{ id: "section-6-3", label: "6.3 Уверенность" },
|
||||||
{ id: "section-3-1", label: "4.1 Настройки выборки" },
|
{ id: "section-6-4", label: "6.4 Рекомендация по продукту" },
|
||||||
{ id: "section-3-2", label: "4.2 Планировки" },
|
{ id: "section-6-5", label: "6.5 Прозрачность скоринга" },
|
||||||
{ id: "section-3-3", label: "4.3 Остатки и скорость" },
|
{ id: "section-6-6", label: "6.6 Будущее предложение и конкуренты" },
|
||||||
],
|
|
||||||
},
|
|
||||||
{ 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 Будущее предложение и конкуренты" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -373,36 +293,49 @@ function AnalysisSidebarNav() {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{NAV_GROUPS.map((group, gi) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<div key={group.label}>
|
<div key={item.id}>
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => scrollTo(item.id)}
|
||||||
style={{
|
style={{
|
||||||
padding: gi === 0 ? "0 16px 6px" : "12px 16px 6px",
|
display: "block",
|
||||||
fontSize: 11,
|
width: "100%",
|
||||||
fontWeight: 600,
|
textAlign: "left",
|
||||||
letterSpacing: "0.06em",
|
padding: "8px 16px",
|
||||||
textTransform: "uppercase",
|
background: "none",
|
||||||
color: "var(--fg-tertiary, #73767E)",
|
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";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{group.label}
|
{item.label}
|
||||||
</div>
|
</button>
|
||||||
{group.items.map((item) => (
|
{"children" in item &&
|
||||||
<div key={item.id}>
|
item.children?.map((child) => (
|
||||||
<button
|
<button
|
||||||
|
key={child.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => scrollTo(item.id)}
|
onClick={() => scrollTo(child.id)}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
textAlign: "left",
|
textAlign: "left",
|
||||||
padding: "8px 16px",
|
padding: "6px 16px 6px 28px",
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: 500,
|
color: "var(--fg-secondary, #5B6066)",
|
||||||
color: "var(--fg-primary, #111111)",
|
|
||||||
transition: "background 0.1s",
|
transition: "background 0.1s",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
|
|
@ -413,38 +346,9 @@ function AnalysisSidebarNav() {
|
||||||
e.currentTarget.style.background = "none";
|
e.currentTarget.style.background = "none";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.label}
|
{child.label}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -22,88 +22,64 @@ interface Props {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подсказка на сегментах — поясняет, что даёт ближний / дальний горизонт.
|
|
||||||
const HORIZON_TOOLTIP =
|
|
||||||
"6 мес — ближний спрос, точнее; 24 мес — стратегия, шире интервал";
|
|
||||||
|
|
||||||
export function HorizonSelector({ value, onChange, disabled = false }: Props) {
|
export function HorizonSelector({ value, onChange, disabled = false }: Props) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||||
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
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
lineHeight: "16px",
|
fontWeight: 500,
|
||||||
color: "var(--fg-tertiary)",
|
letterSpacing: "0.04em",
|
||||||
textAlign: "right",
|
textTransform: "uppercase",
|
||||||
|
color: "var(--fg-secondary)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Смена горизонта пересчитывает прогноз заново (~10–30 сек)
|
Горизонт прогноза
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,33 +80,15 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "var(--fg-secondary, #5B6066)",
|
color: "#6b7280",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
letterSpacing: "0.06em",
|
letterSpacing: "0.06em",
|
||||||
}}
|
}}
|
||||||
title="Прогноз будущего предложения: ЖК-конкуренты со сроком сдачи в ближайшие 24 месяца в радиусе 5 км. Это оценка, а не факт продаж."
|
title="Pipeline 24 месяца — ЖК, которые сдаются в окне 24мес в радиусе 5км"
|
||||||
>
|
>
|
||||||
Будущее предложение конкурентов (24 мес)
|
Конкуренты на 24 мес (5 км)
|
||||||
<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>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -120,14 +102,13 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.severity_label ?? data.severity} ·{" "}
|
{data.severity_label ?? data.severity} ·{" "}
|
||||||
{data.flats_total.toLocaleString("ru")} лотов
|
{data.flats_total.toLocaleString("ru-RU")} квартир
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.objects_count === 0 ? (
|
{data.objects_count === 0 ? (
|
||||||
<div style={{ fontSize: 13, color: "var(--fg-tertiary, #73767E)" }}>
|
<div style={{ fontSize: 13, color: "#9ca3af" }}>
|
||||||
Нет ЖК-конкурентов со сроком сдачи в ближайшие 24 месяца в радиусе 5
|
Нет ЖК-конкурентов с planned_commissioning в окне 24мес в радиусе 5км.
|
||||||
км.
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -142,10 +123,20 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>Объектов</div>
|
||||||
<div
|
<div
|
||||||
style={{ color: "var(--fg-tertiary, #73767E)", fontSize: 11 }}
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
ЖК
|
{data.objects_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>
|
||||||
|
Квартир суммарно
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -154,31 +145,11 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
fontVariantNumeric: "tabular-nums",
|
fontVariantNumeric: "tabular-nums",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.objects_count.toLocaleString("ru")} ЖК
|
{data.flats_total.toLocaleString("ru-RU")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div style={{ color: "#9ca3af", fontSize: 11 }}>Горизонт</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
|
@ -215,7 +186,7 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
>
|
>
|
||||||
{fmtClass(cls)}:{" "}
|
{fmtClass(cls)}:{" "}
|
||||||
<strong style={{ fontVariantNumeric: "tabular-nums" }}>
|
<strong style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{flats.toLocaleString("ru")} лотов
|
{flats.toLocaleString("ru-RU")}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -229,11 +200,11 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: "var(--fg-secondary, #5B6066)",
|
color: "#6b7280",
|
||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
По кварталам сдачи (прогноз)
|
По кварталам сдачи
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -277,7 +248,7 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
borderRadius: "3px 3px 0 0",
|
borderRadius: "3px 3px 0 0",
|
||||||
}}
|
}}
|
||||||
title={`${q.quarter}: ${q.objects.toLocaleString("ru")} ЖК / ${q.flats.toLocaleString("ru")} лотов (прогноз)`}
|
title={`${q.quarter}: ${q.objects} ЖК / ${q.flats} квартир`}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -307,7 +278,7 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
color: "var(--accent, #1D4ED8)",
|
color: "#1d4ed8",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
|
@ -315,7 +286,7 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
>
|
>
|
||||||
{expanded
|
{expanded
|
||||||
? "Скрыть список"
|
? "Скрыть список"
|
||||||
: `Топ-${data.top_objects.length} ЖК в прогнозе`}
|
: `Топ-${data.top_objects.length} ЖК pipeline`}
|
||||||
</button>
|
</button>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -404,9 +375,7 @@ export function Pipeline24moBlock({ data }: Props) {
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{obj.flat_count != null
|
{obj.flat_count?.toLocaleString("ru-RU") ?? "—"}
|
||||||
? `${obj.flat_count.toLocaleString("ru")} лотов`
|
|
||||||
: "—"}
|
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,6 @@ function formatPercent(score: number): string {
|
||||||
return `${Math.round(score * 100)}%`;
|
return `${Math.round(score * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Словесный диапазон темпа: 0–33 «медленно» / 33–66 «средне» / 66–100 «быстро». */
|
|
||||||
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) {
|
export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
if (!velocity) {
|
if (!velocity) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -63,16 +55,6 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
const confColor = CONFIDENCE_COLOR[velocity.confidence];
|
const confColor = CONFIDENCE_COLOR[velocity.confidence];
|
||||||
const scorePct = formatPercent(velocity.velocity_score);
|
const scorePct = formatPercent(velocity.velocity_score);
|
||||||
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
|
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
|
||||||
const band = paceBand(velocity.velocity_score);
|
|
||||||
// Вердикт: «×1.4 к медиане ЕКБ — выше рынка» (краткое словесное «быстрее/
|
|
||||||
// медленнее рынка» по соотношению, отдельно от шкалы 0–100%).
|
|
||||||
const aboveMarket = ratio >= 1;
|
|
||||||
const verdict = `×${ratio.toFixed(1)} к медиане ЕКБ — ${
|
|
||||||
aboveMarket ? "выше рынка" : "ниже рынка"
|
|
||||||
}`;
|
|
||||||
const verdictColor = aboveMarket
|
|
||||||
? "var(--success, #0A7A3A)"
|
|
||||||
: "var(--danger, #B3261E)";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -115,46 +97,23 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
{/* Score gauge — показываем только если данные есть */}
|
{/* Score gauge — показываем только если данные есть */}
|
||||||
{dataAvailable && (
|
{dataAvailable && (
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
{/* Крупный вердикт — главное число секции */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: verdictColor,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
marginBottom: 8,
|
|
||||||
}}
|
|
||||||
title="Соотношение темпа продаж конкурентов к медианному темпу по ЕКБ (м² в месяц)"
|
|
||||||
>
|
|
||||||
{verdict}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "baseline",
|
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: "var(--fg-secondary, #5B6066)",
|
color: "#6b7280",
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span title="Шкала 0–100%: 0–33% медленно, 33–66% средне, 66–100% быстро">
|
<span>Velocity-score</span>
|
||||||
Темп продаж
|
<span style={{ fontWeight: 600, color: "#111827" }}>
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--fg-primary, #111111)",
|
|
||||||
fontVariantNumeric: "tabular-nums",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scorePct}
|
{scorePct}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "var(--border-card, #E6E8EC)",
|
background: "#e5e7eb",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
height: 8,
|
height: 8,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|
@ -162,40 +121,23 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: band.color,
|
background:
|
||||||
|
velocity.velocity_score >= 0.66
|
||||||
|
? "#10b981"
|
||||||
|
: velocity.velocity_score >= 0.33
|
||||||
|
? "#f59e0b"
|
||||||
|
: "#ef4444",
|
||||||
width: `${velocity.velocity_score * 100}%`,
|
width: `${velocity.velocity_score * 100}%`,
|
||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
|
||||||
style={{
|
{Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
|
||||||
display: "flex",
|
{Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "}
|
||||||
justifyContent: "space-between",
|
{ratio >= 1
|
||||||
fontSize: 11,
|
? `x${ratio.toFixed(1)} выше`
|
||||||
color: "var(--fg-tertiary, #73767E)",
|
: `${formatPercent(ratio)} от среднего`}
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,311 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -35,15 +35,11 @@ function isConfidenceFactor(v: unknown): v is ConfidenceFactor {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function factorLabel(key: string, factor: ConfidenceFactor): string {
|
function factorLabel(key: string): string {
|
||||||
// #1737: backend проносит RU-имя сервиса в `label` для component-факторов
|
|
||||||
// («Рынок» / «Будущее предложение» / …) — предпочитаем его, чтобы не показывать
|
|
||||||
// безличный «Компонент N».
|
|
||||||
if (factor.label) return factor.label;
|
|
||||||
if (FACTOR_RU[key]) return FACTOR_RU[key];
|
if (FACTOR_RU[key]) return FACTOR_RU[key];
|
||||||
// Fallback для старых конвертов без `label`: component / component_2..N → «Компонент N».
|
// component_2..N → «Компонент N» (note уже несёт «вкладывающий сервис: …»)
|
||||||
const m = key.match(/^component(?:_(\d+))?$/);
|
const m = key.match(/^component_(\d+)$/);
|
||||||
if (m) return m[1] ? `Компонент ${m[1]}` : "Компонент";
|
if (m) return `Компонент ${m[1]}`;
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,7 +142,7 @@ export function ForecastConfidenceBlock({ confidence }: Props) {
|
||||||
color: "var(--fg-primary)",
|
color: "var(--fg-primary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{factorLabel(key, factor)}
|
{factorLabel(key)}
|
||||||
{valueStr != null && (
|
{valueStr != null && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -77,17 +77,12 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||||
<tr>
|
<tr>
|
||||||
<th style={TH_STYLE}>Горизонт</th>
|
<th style={TH_STYLE}>Горизонт</th>
|
||||||
<th style={TH_STYLE}>Индекс дефицита</th>
|
<th style={TH_STYLE}>Индекс дефицита</th>
|
||||||
<th
|
<th style={{ ...TH_STYLE, textAlign: "right" }}>
|
||||||
style={{ ...TH_STYLE, textAlign: "right" }}
|
|
||||||
title="Во сколько месяцев распродаётся текущий объём при базовом темпе"
|
|
||||||
>
|
|
||||||
Мес. предложения
|
Мес. предложения
|
||||||
</th>
|
</th>
|
||||||
|
<th style={{ ...TH_STYLE, textAlign: "right" }}>Прогноз спроса</th>
|
||||||
<th style={{ ...TH_STYLE, textAlign: "right" }}>
|
<th style={{ ...TH_STYLE, textAlign: "right" }}>
|
||||||
Прогноз спроса, квартир
|
Прогноз предложения
|
||||||
</th>
|
|
||||||
<th style={{ ...TH_STYLE, textAlign: "right" }}>
|
|
||||||
Прогноз предложения, квартир
|
|
||||||
</th>
|
</th>
|
||||||
<th style={{ ...TH_STYLE, textAlign: "right" }}>Ставка</th>
|
<th style={{ ...TH_STYLE, textAlign: "right" }}>Ставка</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -159,19 +154,6 @@ export function ForecastHorizonsBlock({ forecasts, targetHorizon }: Props) {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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
|
{/* Rate-sensitivity phrase — shared across horizons in current data; show
|
||||||
the distinct phrases once each to avoid noisy repetition. */}
|
the distinct phrases once each to avoid noisy repetition. */}
|
||||||
<RateSensitivityNotes forecasts={rows} />
|
<RateSensitivityNotes forecasts={rows} />
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import type {
|
||||||
ProductCommercial,
|
ProductCommercial,
|
||||||
ProductMixEntry,
|
ProductMixEntry,
|
||||||
ProductReason,
|
ProductReason,
|
||||||
ProductReasonRejected,
|
|
||||||
ProductUsp,
|
ProductUsp,
|
||||||
ReportProductTz,
|
ReportProductTz,
|
||||||
} from "@/types/forecast";
|
} from "@/types/forecast";
|
||||||
|
|
@ -28,14 +27,12 @@ import type {
|
||||||
import { Badge } from "@/components/ui/Badge";
|
import { Badge } from "@/components/ui/Badge";
|
||||||
import type { BadgeVariant } from "@/components/ui/Badge";
|
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||||
import {
|
import {
|
||||||
aggregateClassDeficits,
|
|
||||||
DEFICIT_BALANCE_EPS,
|
DEFICIT_BALANCE_EPS,
|
||||||
deficitBarWidthPct,
|
deficitBarWidthPct,
|
||||||
deficitVariant,
|
deficitVariant,
|
||||||
deficitWord,
|
deficitWord,
|
||||||
fmtNum,
|
fmtNum,
|
||||||
} from "./forecast-helpers";
|
} from "./forecast-helpers";
|
||||||
import type { ClassDeficit } from "./forecast-helpers";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** §13.4 section; the whole block is skipped when absent (see Section6Forecast). */
|
/** §13.4 section; the whole block is skipped when absent (see Section6Forecast). */
|
||||||
|
|
@ -77,21 +74,14 @@ export function ForecastProductTzBlock({ productTz }: Props) {
|
||||||
// Graceful: nothing meaningful to show → render nothing.
|
// Graceful: nothing meaningful to show → render nothing.
|
||||||
if (!isNonEmpty(productTz)) return null;
|
if (!isNonEmpty(productTz)) return null;
|
||||||
|
|
||||||
// #1742 — объяснение выбора класса: per-класс дефицит (агрегат mix по классам).
|
|
||||||
const classDeficits = aggregateClassDeficits(productTz.mix);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
{/* Recommended class — prominent pill + автоген-фраза про дефицит + 3 класса */}
|
{/* Recommended class — prominent pill + summary line */}
|
||||||
<ClassHeadline
|
<ClassHeadline
|
||||||
obj_class={productTz.obj_class}
|
obj_class={productTz.obj_class}
|
||||||
summary={productTz.summary}
|
summary={productTz.summary}
|
||||||
classDeficits={classDeficits}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* #1742 — отвергнутые альтернативы подняты из-под ката (выше обоснования) */}
|
|
||||||
<RejectedAlternatives reasons={productTz.reasons} />
|
|
||||||
|
|
||||||
{/* Квартирография — deficit/share bars */}
|
{/* Квартирография — deficit/share bars */}
|
||||||
<MixBars mix={productTz.mix} />
|
<MixBars mix={productTz.mix} />
|
||||||
|
|
||||||
|
|
@ -101,7 +91,7 @@ export function ForecastProductTzBlock({ productTz }: Props) {
|
||||||
{/* USP-ниши */}
|
{/* USP-ниши */}
|
||||||
<UspList usp={productTz.usp} />
|
<UspList usp={productTz.usp} />
|
||||||
|
|
||||||
{/* §16 обоснование (раскрываемое) — без «отвергнутых» (подняты выше) */}
|
{/* §16 обоснование (раскрываемое) */}
|
||||||
<ReasonsDisclosure reasons={productTz.reasons} />
|
<ReasonsDisclosure reasons={productTz.reasons} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -112,68 +102,21 @@ export function ForecastProductTzBlock({ productTz }: Props) {
|
||||||
function ClassHeadline({
|
function ClassHeadline({
|
||||||
obj_class,
|
obj_class,
|
||||||
summary,
|
summary,
|
||||||
classDeficits,
|
|
||||||
}: {
|
}: {
|
||||||
obj_class: string | null;
|
obj_class: string | null;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
classDeficits: ClassDeficit[];
|
|
||||||
}) {
|
}) {
|
||||||
if (obj_class == null && !summary) return null;
|
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 (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{obj_class != null && (
|
{obj_class != null && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
<span style={SUBHEAD_STYLE}>Рекомендованный класс</span>
|
<span style={SUBHEAD_STYLE}>Рекомендованный класс</span>
|
||||||
<Badge variant="info" size="md">
|
<Badge variant="info" size="md">
|
||||||
{capitalize(obj_class)}
|
{obj_class}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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 && (
|
{summary && (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -190,159 +133,6 @@ 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) ─────────────────────────────────────────────────
|
// ── Квартирография (mix bars) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function mixLabel(m: ProductMixEntry): string {
|
function mixLabel(m: ProductMixEntry): string {
|
||||||
|
|
@ -656,8 +446,42 @@ function ReasonCard({ reason }: { reason: ProductReason }) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* «Отвергнутые альтернативы» подняты на уровень блока (#1742) —
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
{reason.what_would_change && reason.what_would_change.length > 0 && (
|
{reason.what_would_change && reason.what_would_change.length > 0 && (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import dynamic from "next/dynamic";
|
||||||
import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
|
import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
import type { Geometry } from "geojson";
|
import type { Geometry } from "geojson";
|
||||||
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
|
||||||
|
|
||||||
// MiniMap высота тайла карты — компромисс между плотностью Section 1 и
|
// MiniMap высота тайла карты — компромисс между плотностью Section 1 и
|
||||||
// читаемостью. Layer-controls/легенда вне этого размера (flow ниже).
|
// читаемостью. Layer-controls/легенда вне этого размера (flow ниже).
|
||||||
|
|
@ -88,18 +87,10 @@ function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
|
||||||
export function MiniMap({ data }: Props) {
|
export function MiniMap({ data }: Props) {
|
||||||
const adapted = toSiteMapData(data);
|
const adapted = toSiteMapData(data);
|
||||||
|
|
||||||
// #1736: точки подключения ресурсов (электро/газ/вода/тепло). Зеркалим
|
|
||||||
// обвязку legacy /site-finder (page.tsx:109,568) — тянем CP-данные по
|
|
||||||
// data.cad_num и пробрасываем prop `connectionPoints` в <SiteMap>, который
|
|
||||||
// рисует ConnectionPointsLayer + CpLayerControlPanel при truthy prop.
|
|
||||||
const { data: connectionPoints } = useConnectionPoints(data.cad_num);
|
|
||||||
|
|
||||||
// #1218: НЕ задаём height + overflow:hidden на обёртке. SiteMap сам
|
// #1218: НЕ задаём height + overflow:hidden на обёртке. SiteMap сам
|
||||||
// ограничивает высоту карты-тайла (mapHeight), а легенда + layer-controls
|
// ограничивает высоту карты-тайла (mapHeight), а легенда + layer-controls
|
||||||
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) + панель
|
// (Будущие проекты / Зоны риска / Перспективные ЗУ / Красные линии) лежат
|
||||||
// точек подключения (CpLayerControlPanel) лежат под картой потоком и должны
|
// под картой потоком и должны быть видны/кликабельны.
|
||||||
// быть видны/кликабельны. MINIMAP_HEIGHT ограничивает ТОЛЬКО тайл карты, не
|
|
||||||
// клипает CP-панель.
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -110,7 +101,6 @@ export function MiniMap({ data }: Props) {
|
||||||
>
|
>
|
||||||
<SiteMap
|
<SiteMap
|
||||||
data={adapted}
|
data={adapted}
|
||||||
connectionPoints={connectionPoints}
|
|
||||||
competitors={data.competitors ?? []}
|
competitors={data.competitors ?? []}
|
||||||
pipelineObjects={data.pipeline_24mo?.top_objects ?? []}
|
pipelineObjects={data.pipeline_24mo?.top_objects ?? []}
|
||||||
riskZones={data.nspd_risk_zones ?? []}
|
riskZones={data.nspd_risk_zones ?? []}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Section2NetworksUtilities — "3. Сети и точки подключения"
|
* Section2NetworksUtilities — "2. Сети и точки подключения"
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout:
|
||||||
* HeadlineBar (title «Сети» + subtitle с расстояниями)
|
* HeadlineBar (title «Сети» + subtitle с расстояниями)
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ function Section31Settings({
|
||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
4.1 Настройки выборки
|
3.1 Настройки выборки
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -281,7 +281,7 @@ function Section32Layouts({ cad }: { cad: string }) {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
4.2 Планировки
|
3.2 Планировки
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -299,95 +299,14 @@ function Section32Layouts({ cad }: { cad: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Section 3.3 «Как продаётся рынок рядом» ───────────────────────────────────
|
// ── Section 3.3 «Остатки и скорость» ──────────────────────────────────────────
|
||||||
|
|
||||||
/** Small uppercase label-заголовок над каждым подблоком 3.3. */
|
function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) {
|
||||||
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 hasPipeline = data.pipeline_24mo != null;
|
||||||
const hasVelocity = data.velocity != null;
|
const hasVelocity = data.velocity != null;
|
||||||
const hasTrend = data.market_trend != null;
|
const hasTrend = data.market_trend != null;
|
||||||
|
|
||||||
const isEmpty = !hasPipeline && !hasVelocity && !hasTrend;
|
const isEmpty = !hasPipeline && !hasVelocity && !hasTrend;
|
||||||
const verdict = buildVerdict(data);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="section-3-3" style={{ scrollMarginTop: 72 }}>
|
<div id="section-3-3" style={{ scrollMarginTop: 72 }}>
|
||||||
|
|
@ -400,7 +319,7 @@ function Section33MarketSales({ data }: { data: ParcelAnalysis }) {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
4.3 Как продаётся рынок рядом
|
3.3 Остатки и скорость
|
||||||
</h3>
|
</h3>
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -409,7 +328,7 @@ function Section33MarketSales({ data }: { data: ParcelAnalysis }) {
|
||||||
margin: "4px 0 0",
|
margin: "4px 0 0",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Остатки конкурентов · темп продаж · динамика цен
|
Pipeline конкурентов на 24 мес · темп продаж · тренд цен
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -424,65 +343,31 @@ function Section33MarketSales({ data }: { data: ParcelAnalysis }) {
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Данные по продажам рынка рядом отсутствуют для этого участка
|
Данные по остаткам и скорости продаж отсутствуют для этого участка
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div
|
||||||
{/* Headline-bar — один вердикт-предложение */}
|
style={{
|
||||||
{verdict && (
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasPipeline && <Pipeline24moBlock data={data.pipeline_24mo!} />}
|
||||||
|
{hasVelocity && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
background: "var(--bg-headline, #0F172A)",
|
background: "var(--bg-card, #FFFFFF)",
|
||||||
borderRadius: 12,
|
border: "1px solid var(--border-card, #E6E8EC)",
|
||||||
|
borderRadius: 10,
|
||||||
padding: "14px 18px",
|
padding: "14px 18px",
|
||||||
marginBottom: 16,
|
|
||||||
fontSize: 14,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
color: "var(--fg-on-dark, #E2E8F0)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{verdict}
|
<VelocityBlock velocity={data.velocity} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -799,7 +684,7 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Section32Layouts cad={cad} />
|
<Section32Layouts cad={cad} />
|
||||||
<Section33MarketSales data={data} />
|
<Section33RemainVelocity data={data} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtered competitors count hint */}
|
{/* Filtered competitors count hint */}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Section4Estimate — "2. Оценка участка"
|
* Section4Estimate — "4. Оценка участка"
|
||||||
*
|
*
|
||||||
* Layout:
|
* Layout:
|
||||||
* HeadlineBar (score + verdict label as title)
|
* HeadlineBar (score + verdict label as title)
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||||||
|
import { GateVerdictBanner } from "@/components/site-finder/GateVerdictBanner";
|
||||||
import { ScoreBreakdownPanel } from "@/components/site-finder/ScoreBreakdownPanel";
|
import { ScoreBreakdownPanel } from "@/components/site-finder/ScoreBreakdownPanel";
|
||||||
import { ScoreBreakdownStackedBar } from "@/components/site-finder/ScoreBreakdownStackedBar";
|
import { ScoreBreakdownStackedBar } from "@/components/site-finder/ScoreBreakdownStackedBar";
|
||||||
import { GeologyBlock } from "@/components/site-finder/GeologyBlock";
|
import { GeologyBlock } from "@/components/site-finder/GeologyBlock";
|
||||||
|
|
@ -165,7 +166,12 @@ export function Section4Estimate({ cad }: Props) {
|
||||||
<HeadlineBar title={headlineTitle} subtitle={headlineSubtitle} />
|
<HeadlineBar title={headlineTitle} subtitle={headlineSubtitle} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gate verdict banner перенесён НАД группой «Участок» (AnalysisPageContent, #1741) */}
|
{/* ── Gate verdict banner ──────────────────────────────────────────── */}
|
||||||
|
{analysis.gate_verdict && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<GateVerdictBanner verdict={analysis.gate_verdict} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Score breakdown: 2-col on desktop ───────────────────────────── */}
|
{/* ── Score breakdown: 2-col on desktop ───────────────────────────── */}
|
||||||
{hasBreakdown && (
|
{hasBreakdown && (
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ import { ForecastHorizonsBlock } from "./ForecastHorizonsBlock";
|
||||||
import { ScenariosBlock } from "./ScenariosBlock";
|
import { ScenariosBlock } from "./ScenariosBlock";
|
||||||
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
import { ForecastConfidenceBlock } from "./ForecastConfidenceBlock";
|
||||||
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
import { ForecastProductTzBlock } from "./ForecastProductTzBlock";
|
||||||
import { ForecastAssortmentBlock } from "./ForecastAssortmentBlock";
|
|
||||||
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
import { ForecastScoringBlock } from "./ForecastScoringBlock";
|
||||||
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
|
import { ForecastFutureSupplyBlock } from "./ForecastFutureSupplyBlock";
|
||||||
import { ForecastMetaLine } from "./ForecastMetaLine";
|
import { ForecastMetaLine } from "./ForecastMetaLine";
|
||||||
|
|
@ -218,17 +217,6 @@ function ForecastReady({
|
||||||
(pt.commercial != null &&
|
(pt.commercial != null &&
|
||||||
(pt.commercial.available != null || !!pt.commercial.caveat)));
|
(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
|
// 6.5 — прозрачность скоринга (§13.6). scoring is optional on the partial
|
||||||
// ForecastReport type; populated when product_scores / special_indices exist.
|
// ForecastReport type; populated when product_scores / special_indices exist.
|
||||||
const sc = report.scoring;
|
const sc = report.scoring;
|
||||||
|
|
@ -252,7 +240,6 @@ function ForecastReady({
|
||||||
hasScenarios ||
|
hasScenarios ||
|
||||||
hasConfidence ||
|
hasConfidence ||
|
||||||
hasProductTz ||
|
hasProductTz ||
|
||||||
hasAssortment ||
|
|
||||||
hasScoring ||
|
hasScoring ||
|
||||||
hasFutureSupplyDetail;
|
hasFutureSupplyDetail;
|
||||||
|
|
||||||
|
|
@ -392,15 +379,6 @@ function ForecastReady({
|
||||||
</SubBlock>
|
</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) */}
|
{/* 6.5 Прозрачность скоринга — почему итоговый скор такой (§13.6) */}
|
||||||
{hasScoring && sc && (
|
{hasScoring && sc && (
|
||||||
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">
|
<SubBlock id="section-6-5" title="6.5 Прозрачность скоринга">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
|
@ -40,23 +39,9 @@ const FIXTURE: ParcelAnalyzeResponse = {
|
||||||
nspd_red_lines: [],
|
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)", () => {
|
describe("MiniMap (#1218)", () => {
|
||||||
it("passes mapHeight=280 to SiteMap (compact tile, controls flow below)", async () => {
|
it("passes mapHeight=280 to SiteMap (compact tile, controls flow below)", async () => {
|
||||||
renderMiniMap();
|
render(<MiniMap data={FIXTURE} />);
|
||||||
// SiteMap is dynamically imported (ssr:false) — wait until our stub renders.
|
// SiteMap is dynamically imported (ssr:false) — wait until our stub renders.
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId("sitemap-stub")).toBeInTheDocument(),
|
expect(screen.getByTestId("sitemap-stub")).toBeInTheDocument(),
|
||||||
|
|
@ -65,7 +50,7 @@ describe("MiniMap (#1218)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("wrapper does NOT clip overflow — layer controls must remain visible", async () => {
|
it("wrapper does NOT clip overflow — layer controls must remain visible", async () => {
|
||||||
const { container } = renderMiniMap();
|
const { container } = render(<MiniMap data={FIXTURE} />);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId("sitemap-stub")).toBeInTheDocument(),
|
expect(screen.getByTestId("sitemap-stub")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BadgeVariant } from "@/components/ui/Badge";
|
import type { BadgeVariant } from "@/components/ui/Badge";
|
||||||
import type {
|
import type { ConfidenceLevel, FutureSupply } from "@/types/forecast";
|
||||||
ConfidenceLevel,
|
|
||||||
FutureSupply,
|
|
||||||
ProductMixEntry,
|
|
||||||
} from "@/types/forecast";
|
|
||||||
|
|
||||||
/** Below this |deficit_index| we treat the market as balanced (neutral). */
|
/** Below this |deficit_index| we treat the market as balanced (neutral). */
|
||||||
export const DEFICIT_BALANCE_EPS = 0.05;
|
export const DEFICIT_BALANCE_EPS = 0.05;
|
||||||
|
|
@ -30,70 +26,6 @@ export function deficitWord(deficitIndex: number): string {
|
||||||
return "баланс";
|
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
|
* 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
|
* magnitude so both недонасыщенность (+) and затоварка (−) read as a filled bar
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ export interface paths {
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* Export Parcel Forecast
|
* Export Parcel Forecast
|
||||||
* @description Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX / PDF (файл) или TG-сводка (#959).
|
* @description Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX (файл) или TG-сводка (#959).
|
||||||
*
|
*
|
||||||
* Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
|
* Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET
|
||||||
* /{cad}/forecast inline) и отдаёт его в нужной форме.
|
* /{cad}/forecast inline) и отдаёт его в нужной форме.
|
||||||
|
|
@ -243,8 +243,6 @@ export interface paths {
|
||||||
* зеркало содержания md/pdf; python-docx).
|
* зеркало содержания md/pdf; python-docx).
|
||||||
* • format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация,
|
* • format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация,
|
||||||
* сжатое зеркало содержания docx; python-pptx).
|
* сжатое зеркало содержания docx; python-pptx).
|
||||||
* • format=pdf → `export_report_pdf(run.result)` — attachment (.pdf, §13-отчёт,
|
|
||||||
* зеркало содержания md/docx; WeasyPrint).
|
|
||||||
* • format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка
|
* • format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка
|
||||||
* для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
|
* для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать
|
||||||
* и отправить, а не файл-download).
|
* и отправить, а не файл-download).
|
||||||
|
|
@ -256,10 +254,10 @@ export interface paths {
|
||||||
*
|
*
|
||||||
* Args:
|
* Args:
|
||||||
* cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
* cad_num: кадастровый номер участка (в имени файла `:` → `_`).
|
||||||
* format: "md" (default) | "json" | "tg" | "docx" | "pptx" | "pdf".
|
* format: "md" (default) | "json" | "tg" | "docx" | "pptx".
|
||||||
*
|
*
|
||||||
* Returns:
|
* Returns:
|
||||||
* Response — для md/json/docx/pptx/pdf attachment с Content-Disposition (имя
|
* Response — для md/json/docx/pptx attachment с Content-Disposition (имя
|
||||||
* `gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
|
* `gendesign_forecast_<cad>_<YYYY-MM-DD>.<ext>`); для tg — inline text/plain сводка.
|
||||||
*/
|
*/
|
||||||
get: operations["export_parcel_forecast_api_v1_parcels__cad_num__forecast_export_get"];
|
get: operations["export_parcel_forecast_api_v1_parcels__cad_num__forecast_export_get"];
|
||||||
|
|
@ -4917,8 +4915,8 @@ export interface operations {
|
||||||
export_parcel_forecast_api_v1_parcels__cad_num__forecast_export_get: {
|
export_parcel_forecast_api_v1_parcels__cad_num__forecast_export_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
/** @description Формат выгрузки: md (Markdown) | json (сырой отчёт) | tg (сводка) | docx (Word-документ) | pptx (презентация) | pdf (PDF-отчёт) */
|
/** @description Формат выгрузки: md (Markdown) | json (сырой отчёт) | tg (сводка) | docx (Word-документ) | pptx (презентация) */
|
||||||
format?: "md" | "json" | "tg" | "docx" | "pptx" | "pdf";
|
format?: "md" | "json" | "tg" | "docx" | "pptx";
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|
|
||||||
|
|
@ -158,13 +158,6 @@ export interface ConfidenceFactor {
|
||||||
note: string;
|
note: string;
|
||||||
level: ConfidenceLevel;
|
level: ConfidenceLevel;
|
||||||
value: number | null;
|
value: number | null;
|
||||||
/**
|
|
||||||
* Human-readable RU label (#1737). For per-service «component» factors this is
|
|
||||||
* the service name («Рынок» / «Будущее предложение» / …) so the UI never shows a
|
|
||||||
* bare «Компонент N». Optional — older envelopes omit it; the UI falls back to
|
|
||||||
* the factor key.
|
|
||||||
*/
|
|
||||||
label?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportConfidence {
|
export interface ReportConfidence {
|
||||||
|
|
@ -217,19 +210,6 @@ export interface ProductMixEntry {
|
||||||
deficit_index: number | null;
|
deficit_index: number | null;
|
||||||
/** Доля формата, % (0..100) — присутствует только в явном mix. */
|
/** Доля формата, % (0..100) — присутствует только в явном mix. */
|
||||||
pct?: number | null;
|
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;
|
confidence?: ConfidenceLevel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- avito_city_sweep → run_avito_city_sweep (scrape_pipeline.py)
|
- 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)
|
- yandex_city_sweep → run_yandex_city_sweep (scrape_pipeline.py, #561; shipped DORMANT)
|
||||||
- cian_history_backfill → run_cian_backfill (this module, #560)
|
- cian_history_backfill → run_cian_backfill (this module, #560)
|
||||||
- rosreestr_dkp_import → import_rosreestr_dkp (this module, #563)
|
- rosreestr_dkp_import → import_rosreestr_dkp (this module, #563)
|
||||||
|
|
@ -97,7 +95,6 @@ from app.core.db import SessionLocal
|
||||||
from app.services import scrape_runs as runs_mod
|
from app.services import scrape_runs as runs_mod
|
||||||
from app.services.scrape_pipeline import (
|
from app.services.scrape_pipeline import (
|
||||||
run_avito_city_sweep,
|
run_avito_city_sweep,
|
||||||
run_avito_newbuilding_sweep,
|
|
||||||
run_cian_city_sweep,
|
run_cian_city_sweep,
|
||||||
run_domclick_city_sweep,
|
run_domclick_city_sweep,
|
||||||
run_yandex_city_sweep,
|
run_yandex_city_sweep,
|
||||||
|
|
@ -312,44 +309,6 @@ async def trigger_avito_city_sweep_run(db: Session, schedule_row: dict[str, Any]
|
||||||
return run_id
|
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:
|
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.
|
"""Создать новый scrape_runs + launch run_yandex_city_sweep в asyncio.create_task.
|
||||||
|
|
||||||
|
|
@ -1443,8 +1402,6 @@ async def scheduler_loop() -> None:
|
||||||
source = sch["source"]
|
source = sch["source"]
|
||||||
if source == "avito_city_sweep":
|
if source == "avito_city_sweep":
|
||||||
await trigger_avito_city_sweep_run(db, sch)
|
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":
|
elif source == "yandex_city_sweep":
|
||||||
await trigger_yandex_city_sweep_run(db, sch)
|
await trigger_yandex_city_sweep_run(db, sch)
|
||||||
elif source == "cian_history_backfill":
|
elif source == "cian_history_backfill":
|
||||||
|
|
|
||||||
|
|
@ -990,114 +990,6 @@ async def run_avito_city_sweep(
|
||||||
raise
|
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 ───────────────────────────────────────────
|
# ── Yandex city sweep ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2374,7 +2266,8 @@ async def run_domclick_city_sweep(
|
||||||
return counters
|
return counters
|
||||||
|
|
||||||
logger.info(
|
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,
|
run_id,
|
||||||
city_id,
|
city_id,
|
||||||
_rooms,
|
_rooms,
|
||||||
|
|
@ -2440,13 +2333,11 @@ __all__ = [
|
||||||
"CianFullLoadCounters",
|
"CianFullLoadCounters",
|
||||||
"CitySweepCounters",
|
"CitySweepCounters",
|
||||||
"DomClickCitySweepCounters",
|
"DomClickCitySweepCounters",
|
||||||
"NewbuildingSweepCounters",
|
|
||||||
"PipelineCounters",
|
"PipelineCounters",
|
||||||
"PipelineResult",
|
"PipelineResult",
|
||||||
"YandexCitySweepCounters",
|
"YandexCitySweepCounters",
|
||||||
"YandexFullLoadCounters",
|
"YandexFullLoadCounters",
|
||||||
"run_avito_city_sweep",
|
"run_avito_city_sweep",
|
||||||
"run_avito_newbuilding_sweep",
|
|
||||||
"run_avito_pipeline",
|
"run_avito_pipeline",
|
||||||
"run_cian_city_sweep",
|
"run_cian_city_sweep",
|
||||||
"run_cian_full_load",
|
"run_cian_full_load",
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from collections.abc import Callable
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode, urljoin, urlparse, urlunparse
|
from urllib.parse import urlencode, urljoin, urlparse, urlunparse
|
||||||
|
|
@ -217,12 +216,6 @@ def _build_sort_timestamp_map(html: str) -> dict[str, date]:
|
||||||
# маркерам в начале документа (firewall-страница объёмна, не сканируем целиком).
|
# маркерам в начале документа (firewall-страница объёмна, не сканируем целиком).
|
||||||
_FIREWALL_MARKERS = ("доступ ограничен", "проблема с ip", "firewall-container")
|
_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:
|
def _is_firewall_page(html: str) -> bool:
|
||||||
"""True если Avito вернул firewall-страницу IP-блока (на HTTP 200)."""
|
"""True если Avito вернул firewall-страницу IP-блока (на HTTP 200)."""
|
||||||
|
|
@ -507,16 +500,6 @@ class AvitoScraper(BaseScraper):
|
||||||
params = {"s": 104, "p": page}
|
params = {"s": 104, "p": page}
|
||||||
return f"{self.base_url}/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?{urlencode(params)}"
|
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:
|
def _build_rooms_url(self, room_slug: str, page: int = 1) -> str:
|
||||||
"""T6: URL с фильтром по комнатности для всего ЕКБ (no geo).
|
"""T6: URL с фильтром по комнатности для всего ЕКБ (no geo).
|
||||||
|
|
||||||
|
|
@ -549,30 +532,6 @@ class AvitoScraper(BaseScraper):
|
||||||
Returns:
|
Returns:
|
||||||
Список ScrapedLot (все страницы, дедуп по source_id).
|
Список 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:
|
if delay_override_sec is not None:
|
||||||
self.request_delay_sec = delay_override_sec
|
self.request_delay_sec = delay_override_sec
|
||||||
|
|
||||||
|
|
@ -580,21 +539,21 @@ class AvitoScraper(BaseScraper):
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
for page in range(1, pages + 1):
|
for page in range(1, pages + 1):
|
||||||
url = url_builder(page)
|
url = self._build_citywide_url(page)
|
||||||
try:
|
try:
|
||||||
html = await self._fetch_serp_html(url, page)
|
html = await self._fetch_serp_html(url, page)
|
||||||
except (AvitoBlockedError, AvitoRateLimitedError):
|
except (AvitoBlockedError, AvitoRateLimitedError):
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("avito %s page=%d fetch failed url=%s", label, page, url)
|
logger.exception("avito citywide page=%d fetch failed url=%s", page, url)
|
||||||
break
|
break
|
||||||
if html is None:
|
if html is None:
|
||||||
logger.info("avito %s page=%d: non-200 — end of pagination", label, page)
|
logger.info("avito citywide page=%d: non-200 — end of pagination", page)
|
||||||
break
|
break
|
||||||
|
|
||||||
lots = self._parse_html(html, source_url_base=url)
|
lots = self._parse_html(html, source_url_base=url)
|
||||||
if not lots:
|
if not lots:
|
||||||
logger.info("avito %s page=%d: 0 lots — end of pagination", label, page)
|
logger.info("avito citywide page=%d: 0 lots — end of pagination", page)
|
||||||
break
|
break
|
||||||
|
|
||||||
new_lots = [lot for lot in lots if lot.source_id not in seen_ids]
|
new_lots = [lot for lot in lots if lot.source_id not in seen_ids]
|
||||||
|
|
@ -603,8 +562,7 @@ class AvitoScraper(BaseScraper):
|
||||||
seen_ids.add(lot.source_id)
|
seen_ids.add(lot.source_id)
|
||||||
all_lots.extend(new_lots)
|
all_lots.extend(new_lots)
|
||||||
logger.info(
|
logger.info(
|
||||||
"avito %s page=%d: %d lots (%d new, %d dedup'd, total=%d)",
|
"avito citywide page=%d: %d lots (%d new, %d dedup'd, total=%d)",
|
||||||
label,
|
|
||||||
page,
|
page,
|
||||||
len(lots),
|
len(lots),
|
||||||
len(new_lots),
|
len(new_lots),
|
||||||
|
|
@ -613,37 +571,8 @@ class AvitoScraper(BaseScraper):
|
||||||
)
|
)
|
||||||
if page < pages:
|
if page < pages:
|
||||||
await self.sleep_between_requests()
|
await self.sleep_between_requests()
|
||||||
return all_lots
|
|
||||||
|
|
||||||
async def fetch_newbuildings(
|
logger.info("avito fetch_city_wide pages=%d total_lots=%d", pages, len(all_lots))
|
||||||
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
|
return all_lots
|
||||||
|
|
||||||
async def fetch_by_rooms(
|
async def fetch_by_rooms(
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
-- 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;
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
<!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 м², 5/10 эт.</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 м², 8/12 эт.</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>
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
"""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
|
|
||||||
Loading…
Add table
Reference in a new issue