diff --git a/backend/alembic/env.py b/backend/alembic/env.py
index 3b61fbfb..69ed8d7d 100644
--- a/backend/alembic/env.py
+++ b/backend/alembic/env.py
@@ -11,7 +11,7 @@ from app.core.db import Base
# Import models so they register on Base.metadata.
# Add new model modules here as they appear.
-from app.models import parcel # noqa: F401
+from app.models import job_settings, parcel # noqa: F401
config = context.config
diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py
index 6dad9e82..c8a1293e 100644
--- a/backend/app/api/v1/admin_scrape.py
+++ b/backend/app/api/v1/admin_scrape.py
@@ -15,7 +15,7 @@ import logging
from datetime import UTC, datetime
from typing import Annotated, Any
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.orm import Session
@@ -268,7 +268,7 @@ def revoke_task(
def list_failures(
db: Annotated[Session, Depends(get_db)],
run_id: int | None = None,
- limit: int = 50,
+ limit: int = Query(default=50, ge=0),
) -> list[dict[str, Any]]:
"""Per-request failure log for manual browser verification."""
where = ""
@@ -314,7 +314,7 @@ def list_logs(
db: Annotated[Session, Depends(get_db)],
run_id: int | None = None,
since_id: int | None = None,
- limit: int = 200,
+ limit: int = Query(default=200, ge=0),
) -> list[dict[str, Any]]:
"""Per-run progress events. Use since_id to poll incrementally:
pass the highest log_id seen → returns only newer rows."""
@@ -483,7 +483,7 @@ def trigger_objective_etl(
@router.get("/objective/runs")
def list_objective_runs(
db: Annotated[Session, Depends(get_db)],
- limit: int = 20,
+ limit: int = Query(default=20, ge=0),
) -> list[dict[str, Any]]:
rows = (
db.execute(
@@ -889,14 +889,20 @@ def bulk_enqueue_geo(
geo_queue = get_setting_value("nspd_geo", "queue_name", "geo")
+ # Валидируем ВСЕ thematic_ids ДО любых сайд-эффектов (создание jobs / apply_async),
+ # иначе невалидный id в середине списка приводит к partial execution: для предыдущих
+ # валидных id строки в nspd_geo_jobs уже созданы и задачи улетели в очередь geo,
+ # а клиент получает 400 без идемпотентного отката (#1562).
+ invalid_ids = [tid for tid in payload.thematic_ids if tid not in _THEMATIC_META]
+ if invalid_ids:
+ raise HTTPException(
+ status_code=400,
+ detail=f"thematic_id={invalid_ids} не поддерживается (допустимы: 1, 2, 5)",
+ )
+
jobs_summary: list[dict[str, Any]] = []
for thematic_id in payload.thematic_ids:
- if thematic_id not in _THEMATIC_META:
- raise HTTPException(
- status_code=400,
- detail=f"thematic_id={thematic_id} не поддерживается (допустимы: 1, 2, 5)",
- )
meta = _THEMATIC_META[thematic_id]
# 1) Собрать cad-номера
@@ -967,7 +973,7 @@ def bulk_enqueue_geo(
@router.get("/geo/jobs")
def list_geo_jobs(
db: Annotated[Session, Depends(get_db)],
- limit: int = 30,
+ limit: int = Query(default=30, ge=0),
) -> list[dict[str, Any]]:
"""Список последних geo-jobs (для UI dashboard)."""
rows = (
@@ -1087,7 +1093,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
def list_all_runs(
db: Annotated[Session, Depends(get_db)],
scraper_type: str | None = None,
- limit: int = 30,
+ limit: int = Query(default=30, ge=0),
) -> list[dict[str, Any]]:
"""Унифицированный список прогонов (kn + nspd + objective).
@@ -1145,7 +1151,7 @@ def list_all_logs(
db: Annotated[Session, Depends(get_db)],
scraper_type: str | None = None,
run_id: int | None = None,
- limit: int = 200,
+ limit: int = Query(default=200, ge=0),
) -> list[dict[str, Any]]:
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
where: list[str] = []
@@ -1416,7 +1422,7 @@ def scrape_freshness(
@router.get("/runs")
def list_runs(
db: Annotated[Session, Depends(get_db)],
- limit: int = 20,
+ limit: int = Query(default=20, ge=0),
) -> list[dict[str, Any]]:
rows = (
db.execute(
diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py
index a12e1946..c510c8b3 100644
--- a/backend/app/api/v1/parcels.py
+++ b/backend/app/api/v1/parcels.py
@@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
import httpx
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
+from fastapi.concurrency import run_in_threadpool
from shapely import wkt as _shp_wkt
from shapely.geometry import Polygon
from sqlalchemy import text
@@ -2855,58 +2856,20 @@ def analyze_parcel(
"risks": risks_block,
}
- # #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs.
- # Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
- # форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
- # district денормализуем из result["district"]["district_name"] (для фильтрации
- # без JSON-разбора); confidence — отчётный уровень high/medium/low из
- # confidence_label (нормализуется под CHECK в repository). schema_version —
- # ANALYZE_SCHEMA_VERSION: результат здесь — inline-dict analyze, НЕ
- # SiteFinderReport.as_dict() (у того свой _SCHEMA_VERSION "1.0").
- _district_name = (
- result_payload["district"].get("district_name")
- if isinstance(result_payload.get("district"), dict)
- else None
- )
- persist_analysis_run(
- db,
- cad_num=cad_num,
- result=result_payload,
- params={
- "profile_id": profile_id,
- "profile_user_id": profile_user_id,
- "inline_weights": _inline_weights,
- "weights_source": _weights_source,
- "x_session_id": _session_id,
- },
- district=_district_name,
- confidence=confidence_info["label"],
- status="complete",
- schema_version=ANALYZE_SCHEMA_VERSION,
- created_by=x_authenticated_user,
- )
+ # #1561: forecast/ird/developer_attribution дописываются в result_payload ДО persist —
+ # иначе persist_analysis_run сериализует jsonb-снимок (repository.py:124) и коммитит
+ # (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом
+ # (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open).
- # §22-форсайт (3b-ii, #995): best-effort fire-and-forget enqueue после persist.
- # Таска `forecast_site_finder_report` читает только что сохранённый analyze-1.0
- # ран и в фоне (~30-180s) считает §22 SiteFinderReport ('1.0'). analyze НЕ ждёт
- # её — возвращаемся сразу. Celery/Redis down НЕ должен валить ответ (он уже успешен:
- # frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
- # Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
- try:
- from app.workers.tasks.forecast import forecast_site_finder_report
-
- forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
- result_payload["forecast"] = {"status": "pending", "horizon": horizon}
- except Exception:
- # Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
- # клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
- logger.warning(
- "forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
- cad_num,
- horizon,
- exc_info=True,
- )
- result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
+ # §22-форсайт (3b-ii, #995): снимок статуса в result_payload ДО persist, чтобы
+ # jsonb-снимок совпадал с live-ответом. Оптимистично ставим "pending" — фактический
+ # enqueue делаем ПОСЛЕ persist_analysis_run (ниже): иначе Celery-воркер может стартануть
+ # ДО коммита analyze-рана и latest_run_for вернёт None/старый ран → форсайт молча не
+ # посчитается, а ретраев у таски нет (regression #1561-followup). result_payload
+ # передаётся в persist by-reference; если enqueue провалится после persist —
+ # перепишем снимок на "unavailable" уже только в возвращаемом ответе (persisted
+ # снимок останется "pending", но это безвредно: poll-ручка читает live-статус рана).
+ result_payload["forecast"] = {"status": "pending", "horizon": horizon}
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
@@ -2942,6 +2905,62 @@ def analyze_parcel(
exc_info=True,
)
+ # #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs — ПОСЛЕ дописывания
+ # forecast/ird/developer_attribution, чтобы jsonb-снимок совпадал с live-ответом (#1561).
+ # Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
+ # форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
+ # district денормализуем из result["district"]["district_name"] (для фильтрации
+ # без JSON-разбора); confidence — отчётный уровень high/medium/low из
+ # confidence_label (нормализуется под CHECK в repository). schema_version —
+ # ANALYZE_SCHEMA_VERSION: результат здесь — inline-dict analyze, НЕ
+ # SiteFinderReport.as_dict() (у того свой _SCHEMA_VERSION "1.0").
+ _district_name = (
+ result_payload["district"].get("district_name")
+ if isinstance(result_payload.get("district"), dict)
+ else None
+ )
+ persist_analysis_run(
+ db,
+ cad_num=cad_num,
+ result=result_payload,
+ params={
+ "profile_id": profile_id,
+ "profile_user_id": profile_user_id,
+ "inline_weights": _inline_weights,
+ "weights_source": _weights_source,
+ "x_session_id": _session_id,
+ },
+ district=_district_name,
+ confidence=confidence_info["label"],
+ status="complete",
+ schema_version=ANALYZE_SCHEMA_VERSION,
+ created_by=x_authenticated_user,
+ )
+
+ # §22-форсайт enqueue — СТРОГО ПОСЛЕ persist_analysis_run. persist_analysis_run —
+ # единственный commit analyze-рана (get_db() на success не коммитит), поэтому enqueue
+ # должен случиться только после того, как ран закоммичен: иначе фоновая таска
+ # `forecast_site_finder_report` (~30-180s) прочтёт latest_run_for и не найдёт свежий
+ # ран (None/старый) → форсайт молча не посчитается, ретраев нет (#1561-followup).
+ # Best-effort fire-and-forget: Celery/Redis down НЕ валит ответ (он уже успешен,
+ # frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
+ # Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
+ try:
+ from app.workers.tasks.forecast import forecast_site_finder_report
+
+ forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user)
+ except Exception:
+ # Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory,
+ # клиент узнаёт по status="unavailable" и не будет зря поллить /forecast.
+ # persisted снимок остаётся "pending" (безвреден — poll читает live-статус рана).
+ logger.warning(
+ "forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected",
+ cad_num,
+ horizon,
+ exc_info=True,
+ )
+ result_payload["forecast"] = {"status": "unavailable", "horizon": horizon}
+
return result_payload
@@ -3183,8 +3202,10 @@ async def get_parcel_competitors(
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
участка с рассчитанным velocity_per_month за указанный time_window.
"""
+ # sync get_competitors (несколько db.execute, competitors.py:518) мостится через
+ # run_in_threadpool — иначе sync DB-IO блокирует event loop (тот же приём, что в chat.py).
try:
- return get_competitors(db=db, cad_num=cad_num, request=body)
+ return await run_in_threadpool(get_competitors, db=db, cad_num=cad_num, request=body)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
@@ -3237,8 +3258,10 @@ async def get_parcel_best_layouts(
Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month
× objective_complex_mapping).
"""
+ # sync get_best_layouts (db.execute, best_layouts.py:377) мостится через
+ # run_in_threadpool — иначе sync DB-IO блокирует event loop.
try:
- return get_best_layouts(db=db, cad_num=cad_num, request=body)
+ return await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
@@ -3256,9 +3279,12 @@ async def get_parcel_best_layouts_pdf(
Issue #113 Phase 2.1: data-driven unit-mix recommendation для тендера.
"""
+ # sync get_best_layouts (DB-IO) + render_layout_tz_pdf (CPU-bound WeasyPrint
+ # write_pdf, сотни мс) мостятся через run_in_threadpool — иначе блокируют event loop.
try:
- response = get_best_layouts(db=db, cad_num=cad_num, request=body)
- pdf_bytes = render_layout_tz_pdf(
+ response = await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
+ pdf_bytes = await run_in_threadpool(
+ render_layout_tz_pdf,
response,
cad_num=cad_num,
radius_km=body.radius_km,
diff --git a/backend/app/api/v1/photos.py b/backend/app/api/v1/photos.py
index 7b3c5d7d..2f781f8a 100644
--- a/backend/app/api/v1/photos.py
+++ b/backend/app/api/v1/photos.py
@@ -111,16 +111,25 @@ def get_photo(
data = _fetch_upstream(upstream)
if data:
src = _persist_original(obj_id, file_id, photo_name, data)
+ # Record the original immediately so a failed thumbnail does not
+ # orphan the on-disk file and trigger an eternal re-fetch.
+ db.execute(
+ text(
+ "UPDATE domrf_kn_photos"
+ " SET local_path = :lp, downloaded_at = NOW()"
+ " WHERE obj_id = :o AND obj_file_id = :f"
+ ),
+ {"lp": str(src), "o": obj_id, "f": file_id},
+ )
+ db.commit()
generated = make_thumbnail(src)
if generated and generated.exists():
db.execute(
text(
- "UPDATE domrf_kn_photos"
- " SET local_path = :lp, thumb_path = :tp,"
- " downloaded_at = NOW()"
+ "UPDATE domrf_kn_photos SET thumb_path = :tp"
" WHERE obj_id = :o AND obj_file_id = :f"
),
- {"lp": str(src), "tp": str(generated), "o": obj_id, "f": file_id},
+ {"tp": str(generated), "o": obj_id, "f": file_id},
)
db.commit()
return FileResponse(str(generated), media_type="image/webp", headers=headers)
diff --git a/backend/app/api/v1/trade_in.py b/backend/app/api/v1/trade_in.py
index ed771099..e67eee7a 100644
--- a/backend/app/api/v1/trade_in.py
+++ b/backend/app/api/v1/trade_in.py
@@ -344,7 +344,7 @@ def estimate_pdf(
if row is None:
raise HTTPException(status_code=404, detail="estimate not found")
- if row.expires_at.replace(tzinfo=UTC) < datetime.now(tz=UTC):
+ if row.expires_at.astimezone(UTC) < datetime.now(tz=UTC):
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
diff --git a/backend/app/main.py b/backend/app/main.py
index 82910f2b..296ab9a2 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -59,6 +59,7 @@ if settings.glitchtip_dsn:
traces_sample_rate=settings.glitchtip_traces_sample_rate,
profiles_sample_rate=0.0,
send_default_pii=False,
+ before_send=scrub_sensitive_query,
before_send_transaction=scrub_sensitive_query,
integrations=[
StarletteIntegration(),
diff --git a/backend/app/models/job_settings.py b/backend/app/models/job_settings.py
index 76365043..efa62293 100644
--- a/backend/app/models/job_settings.py
+++ b/backend/app/models/job_settings.py
@@ -2,7 +2,7 @@
from datetime import datetime
-from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text
+from sqlalchemy import Boolean, DateTime, Integer, SmallInteger, Text, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
@@ -20,6 +20,8 @@ class JobSetting(Base):
max_retries: Mapped[int] = mapped_column(SmallInteger, default=2, nullable=False)
max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)
extra_config: Mapped[dict] = mapped_column(JSONB, default=dict, nullable=False)
- updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=text("now()"), nullable=False
+ )
updated_by: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
diff --git a/backend/app/models/parcel.py b/backend/app/models/parcel.py
index 048a0046..bc0627d7 100644
--- a/backend/app/models/parcel.py
+++ b/backend/app/models/parcel.py
@@ -1,29 +1,20 @@
-"""SQLAlchemy + GeoAlchemy2 ORM models.
+"""SQLAlchemy ORM models for parcel data.
-Stage 2a: real Parcel model. Geometry stored in WGS84 (EPSG:4326);
-project to МСК-66 via pyproj when computing distances/areas.
+NB: реальные данные участков живут в таблицах ``cad_parcels`` /
+``cad_parcels_geom`` (см. ``data/sql/92_cad_bulk_layers.sql`` и
+``data/sql/83_cad_parcels_geom.sql``) и читаются через сырые PostGIS-запросы
+(``app.services.site_finder.filters``, ``app.api.v1.parcels``), не через ORM.
+
+Прежняя ORM-модель ``Parcel`` (таблица ``parcels``) удалена: соответствующего
+DDL в ``data/sql/`` нет, ни один ORM-запрос её не использовал, а её колонки
+(в т.ч. ``geometry(POLYGON, 4326)``) расходились с реальной схемой
+``cad_parcels.geom`` — которую миграция 93 уже перевела на ``MultiPolygon``,
+т.к. НСПД отдаёт многоконтурные участки. Единственным её потребителем был
+alembic autogenerate (``alembic/env.py``), для которого она порождала
+фантомный ``CREATE TABLE parcels``.
+
+Модуль сохранён (его импортирует ``alembic/env.py``), чтобы новые ORM-модели
+регистрировались на ``Base.metadata`` именно отсюда.
"""
-from datetime import datetime
-
-from geoalchemy2 import Geometry
-from sqlalchemy import JSON, DateTime, Float, String
-from sqlalchemy.orm import Mapped, mapped_column
-
-from app.core.db import Base
-
-
-class Parcel(Base):
- __tablename__ = "parcels"
-
- id: Mapped[str] = mapped_column(String, primary_key=True)
- cadastral_number: Mapped[str] = mapped_column(String, unique=True, index=True)
- vri: Mapped[str] = mapped_column(String, index=True)
- area_sqm: Mapped[float] = mapped_column(Float)
- address: Mapped[str | None] = mapped_column(String, nullable=True)
- geometry: Mapped[object] = mapped_column(Geometry("POLYGON", srid=4326))
- enrichment: Mapped[dict] = mapped_column(JSON, default=dict)
- created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
- updated_at: Mapped[datetime] = mapped_column(
- DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
- )
+from app.core.db import Base # noqa: F401 (re-export для регистрации будущих моделей)
diff --git a/backend/app/schemas/chat.py b/backend/app/schemas/chat.py
index fdd625f7..4aa494a7 100644
--- a/backend/app/schemas/chat.py
+++ b/backend/app/schemas/chat.py
@@ -15,7 +15,7 @@ from __future__ import annotations
from enum import StrEnum
from typing import Literal
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict, Field, field_validator
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
# Кэпируем длину, чтобы payload не раздувался до подключения LLM.
@@ -84,10 +84,21 @@ class ChatAskRequest(BaseModel):
)
history: list[ChatTurn] | None = Field(
default=None,
- max_length=_HISTORY_MAX_TURNS,
description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)",
)
+ @field_validator("history")
+ @classmethod
+ def _cap_history(cls, v: list[ChatTurn] | None) -> list[ChatTurn] | None:
+ """Graceful-усечение: кэпируем хвост до последних _HISTORY_MAX_TURNS ходов.
+
+ НЕ отклоняем длинный диалог 422 (контракт обещает усечение, не отказ) —
+ оставляем самые свежие ходы, отбрасывая старые с головы.
+ """
+ if v is not None and len(v) > _HISTORY_MAX_TURNS:
+ return v[-_HISTORY_MAX_TURNS:]
+ return v
+
class ChatAskResponse(BaseModel):
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).
diff --git a/backend/app/schemas/concept.py b/backend/app/schemas/concept.py
index 4a00b50b..e4625448 100644
--- a/backend/app/schemas/concept.py
+++ b/backend/app/schemas/concept.py
@@ -12,7 +12,9 @@ class ConceptInput(BaseModel):
housing_class: Literal["econom", "comfort", "business"] = "comfort"
target_floors: int = Field(9, ge=1, le=30)
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
- land_cost_rub: float | None = Field(None, description="Optional land cost for financial model")
+ land_cost_rub: float | None = Field(
+ None, ge=0, description="Optional land cost for financial model"
+ )
class TEAP(BaseModel):
diff --git a/backend/app/scrapers/nspd_bulk_client.py b/backend/app/scrapers/nspd_bulk_client.py
index e2875e2c..c9dc89f3 100644
--- a/backend/app/scrapers/nspd_bulk_client.py
+++ b/backend/app/scrapers/nspd_bulk_client.py
@@ -302,10 +302,18 @@ class NSPDBulkClient:
try:
data = await self._get_json(NSPD_SEARCH_URL, params=params)
+ except (NspdBulkWafError, NspdBulkRateLimitError, NspdBulkServerError):
+ # 403 WAF / 429 / 5xx+ServiceException — НЕ «квартал не найден».
+ # Пробрасываем как есть: caller (autoretry) ретраит квартал, WAF
+ # останавливает harvest. Раньше подстрочная классификация по str(e)
+ # с подмешанным body_preview могла ложно проглотить 5xx как 404.
+ raise
except NspdBulkError as e:
- # 404 или пустой ответ → возвращаем пустой snapshot
+ # Остаётся базовый NspdBulkError = прочие 4xx (см. _get_json:234).
+ # Текст: f"HTTP {code}: {url} — {body_preview}" → классифицируем по
+ # ПРЕФИКСУ (код до URL), а не по вхождению в произвольное тело ответа.
err_str = str(e)
- if "HTTP 404" in err_str or "HTTP 400" in err_str:
+ if err_str.startswith("HTTP 404:") or err_str.startswith("HTTP 400:"):
logger.info(
"search_by_quarter: quarter %s not found (404/400), returning empty"
" (category_id=%s)",
@@ -342,8 +350,23 @@ class NSPDBulkClient:
for m in raw_meta:
cat_id = m.get("categoryId")
total = m.get("totalCount")
- if cat_id is not None and total is not None:
+ if cat_id is None or total is None:
+ continue
+ # NSPD изредка отдаёт categoryId/totalCount нечисловой/float-строкой
+ # ('20.0') или иным типом → голый int() кинул бы ValueError/TypeError,
+ # который НЕ подкласс NspdBulkError и завалил бы всю Phase 1 квартала.
+ # Зеркалит защитный try/except в schemas/nspd_bulk.py и
+ # list_objects_in_building. Битую meta-запись просто пропускаем.
+ try:
meta_counts[int(cat_id)] = int(total)
+ except (ValueError, TypeError):
+ logger.warning(
+ "search_by_quarter: non-numeric meta entry quarter=%s"
+ " categoryId=%r totalCount=%r — skipping",
+ quarter,
+ cat_id,
+ total,
+ )
logger.info(
"search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d",
@@ -416,7 +439,19 @@ class NSPDBulkClient:
}
data = await self._get_json(url, params=params)
- raw_features: list[dict[str, Any]] = (data or {}).get("features") or []
+ # NSPD/GeoServer изредка отдаёт валидный JSON, но не объект (list/str —
+ # Bug_Nspd_Geo_Str_Object). Тогда `(data or {})` вернул бы сам truthy
+ # data, а .get("features") кинул бы AttributeError (не NspdBulkError →
+ # уронил бы ячейку grid-walk без сигнала). Унифицируем guard с
+ # search_by_quarter: не-dict трактуем как пустой ответ.
+ if not isinstance(data, dict):
+ logger.warning(
+ "wms_feature_info: non-dict JSON response layer=%d type=%s — returning empty",
+ layer_id,
+ type(data).__name__,
+ )
+ return []
+ raw_features: list[dict[str, Any]] = data.get("features") or []
return [NSPDBulkFeature.model_validate(f) for f in raw_features]
# ── 3. get_features_in_bbox_grid ─────────────────────────────────────────
@@ -473,9 +508,39 @@ class NSPDBulkClient:
seen_ids: set[str] = set()
results: list[dict] = []
+ # Issue #252-mirror: считаем server-side провалы и успешные ячейки, чтобы
+ # отличить «слой реально пуст» (ok_cells>0, 0 features) от «слой/IP лёг»
+ # (все ячейки 5xx/WAF). Раньше любой Exception молча падал на DEBUG и метод
+ # отдавал [] → в БД писался ложный tz_count=0 без layer_failed-сигнала.
+ server_errors = 0
+ ok_cells = 0
+ first_server_error: NspdBulkServerError | None = None
for idx, cell_result in enumerate(cell_results):
+ if isinstance(cell_result, NspdBulkWafError):
+ # 403 WAF (бан IP) — по docstring должен ОСТАНОВИТЬ harvest, не
+ # маскироваться пустым результатом. Пробрасываем немедленно.
+ logger.warning(
+ "get_features_in_bbox_grid: layer=%d cell=%d WAF 403 — aborting grid-walk: %s",
+ layer_id,
+ idx,
+ cell_result,
+ )
+ raise cell_result
+ if isinstance(cell_result, NspdBulkServerError):
+ server_errors += 1
+ if first_server_error is None:
+ first_server_error = cell_result
+ logger.debug(
+ "get_features_in_bbox_grid: layer=%d cell=%d server error: %s",
+ layer_id,
+ idx,
+ cell_result,
+ )
+ continue
if isinstance(cell_result, Exception):
+ # Прочие (сетевые/parse) ошибки одной ячейки — не валим обход и НЕ
+ # считаем server-side fail (иначе сеть ложно triggers layer_failed).
logger.debug(
"get_features_in_bbox_grid: layer=%d cell=%d error: %s",
layer_id,
@@ -483,6 +548,7 @@ class NSPDBulkClient:
cell_result,
)
continue
+ ok_cells += 1
for feature in cell_result:
fid = str(feature.id) if feature.id is not None else ""
if fid and fid in seen_ids:
@@ -497,6 +563,21 @@ class NSPDBulkClient:
}
)
+ # Если БЫЛИ server-side провалы И ни одна ячейка не прошла — слой/NSPD лёг
+ # целиком. Возврат [] здесь означал бы ложный tz_count=0 («зонирование
+ # отсутствует»). Пробрасываем server-error, чтобы caller отличил сбой от
+ # реально пустого слоя (мирроринг _grid_walk_category.layer_failed).
+ if server_errors > 0 and ok_cells == 0 and first_server_error is not None:
+ logger.warning(
+ "get_features_in_bbox_grid: layer=%d grid=%dx%d ПОЛНОСТЬЮ сбойный "
+ "(%d server errors, 0 ok cells) — raising вместо ложного пустого результата",
+ layer_id,
+ grid_n,
+ grid_n,
+ server_errors,
+ )
+ raise first_server_error
+
logger.info(
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
layer_id,
diff --git a/backend/app/services/analysis_runs/repository.py b/backend/app/services/analysis_runs/repository.py
index 5267f7f3..c7004b8f 100644
--- a/backend/app/services/analysis_runs/repository.py
+++ b/backend/app/services/analysis_runs/repository.py
@@ -64,8 +64,15 @@ def _jsonb_param(value: Any) -> str:
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
json.dumps(..., ensure_ascii=False) — кириллица как есть (зеркало pzz_loader).
+
+ allow_nan=False (#1580): дефолтный allow_nan=True выводит нестандартные литералы
+ NaN/Infinity/-Infinity, которые JSONB-парсер PostgreSQL отвергает ("invalid input
+ syntax for type json") → INSERT падает, а broad-except в persist_analysis_run
+ проглатывает это и теряет ран молча. С allow_nan=False json.dumps бросает ValueError
+ ДО SQL — провал детерминирован и виден в logger.exception (с traceback), а не
+ маскируется под невнятную psycopg-ошибку синтаксиса.
"""
- return json.dumps(jsonable_encoder(value), ensure_ascii=False)
+ return json.dumps(jsonable_encoder(value), ensure_ascii=False, allow_nan=False)
def persist_analysis_run(
diff --git a/backend/app/services/analytics/ddu_price_indicator.py b/backend/app/services/analytics/ddu_price_indicator.py
index 3958d4b2..cf7268e8 100644
--- a/backend/app/services/analytics/ddu_price_indicator.py
+++ b/backend/app/services/analytics/ddu_price_indicator.py
@@ -24,11 +24,12 @@ list rather than silently ignored.
from __future__ import annotations
import logging
+import re
from decimal import Decimal
from typing import Any
from sqlalchemy import text
-from sqlalchemy.exc import OperationalError
+from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@@ -54,6 +55,11 @@ _SUPPORTED_METHODS = (_CALC_BASIS, _CALC_PREVIOUS)
# as unsupported.
_SUPPORTED_SUBJECT = "66"
+# ARN period_value format (matches mv_ddu_price_indicator.period_value, e.g.
+# '2026-Q1'). Used to reject malformed bounds whose lexicographic comparison
+# against well-formed period_value would silently drop rows.
+_PERIOD_RE = re.compile(r"^\d{4}-Q[1-4]$")
+
def _f(value: Any) -> float | None:
if value is None:
@@ -148,6 +154,50 @@ def get_ddu_indicator(
if clean_buckets:
bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
params["buckets"] = clean_buckets
+ else:
+ # Client narrowed by area buckets but every id is out of range (0..6)
+ # → honour the explicit narrowing with an empty result, never silently
+ # widen back to all buckets. Mirrors the `not subject_ok` branch above.
+ notes.append(
+ f"areaRanges={area_ranges} вне диапазона 0..6 "
+ f"(0=все площади, 1..6=диапазоны м²) — нет подходящих площадей."
+ )
+ return {
+ "meta": {
+ "market": "primary_ddu",
+ "region_code": int(_SUPPORTED_SUBJECT),
+ "calculation_method": method,
+ "period_type": "Q",
+ },
+ "table": [],
+ "graph": [],
+ "notes": notes,
+ }
+
+ # Validate period bounds against the documented ARN 'YYYY-QN' format before
+ # binding them. period_value is compared lexicographically (it is text); a
+ # malformed bound ('foo', '2026') would silently drop rows, so drop the bad
+ # bound and explain it in notes (this endpoint's convention is notes, not 422).
+ if period_from and not _PERIOD_RE.match(period_from):
+ notes.append(
+ f"periodFrom={period_from!r} не в формате 'YYYY-QN' (напр. '2025-Q2') "
+ f"— граница проигнорирована."
+ )
+ period_from = None
+ if period_to and not _PERIOD_RE.match(period_to):
+ notes.append(
+ f"periodTo={period_to!r} не в формате 'YYYY-QN' (напр. '2026-Q1') "
+ f"— граница проигнорирована."
+ )
+ period_to = None
+ # Inverted range (from > to) yields an empty table with no signal otherwise.
+ # Lexicographic comparison is correct here because the format is zero-padded
+ # 'YYYY-QN'.
+ if period_from and period_to and period_from > period_to:
+ notes.append(
+ f"periodFrom={period_from!r} > periodTo={period_to!r} — границы "
+ f"перепутаны (диапазон инвертирован), результат пуст."
+ )
period_filter = ""
if period_from:
@@ -184,8 +234,10 @@ def get_ddu_indicator(
.mappings()
.all()
)
- except OperationalError:
+ except (ProgrammingError, OperationalError):
# Most likely the MV does not exist yet (migration 152 not applied).
+ # A missing relation is SQLSTATE 42P01 (UndefinedTable) → ProgrammingError;
+ # OperationalError is kept for connection-level failures.
logger.exception("ddu_indicator: query failed (mv_ddu_price_indicator missing?)")
raise
diff --git a/backend/app/services/analytics/velocity_alerts.py b/backend/app/services/analytics/velocity_alerts.py
index 61160284..89a1bfcb 100644
--- a/backend/app/services/analytics/velocity_alerts.py
+++ b/backend/app/services/analytics/velocity_alerts.py
@@ -186,7 +186,7 @@ def detect_velocity_anomalies(
AVG(realised) FILTER (WHERE rn <= :recent_window) AS recent_mean,
AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean,
STDDEV_SAMP(realised) FILTER (WHERE rn > :recent_window) AS prior_std,
- COUNT(*) FILTER (WHERE rn > :recent_window) AS prior_n
+ COUNT(realised) FILTER (WHERE rn > :recent_window) AS prior_n
FROM ranked
GROUP BY obj_id, n_months
),
diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py
index 2083ed3a..9abb8021 100644
--- a/backend/app/services/analytics_queries.py
+++ b/backend/app/services/analytics_queries.py
@@ -2786,6 +2786,25 @@ def recommend_mix(
if b["bucket"] != top_bucket_name:
b["share_pct"] = round(b["share_pct"] * scale, 1)
+ # #1576: success-boost изменил share_pct → средневзвешенная цена
+ # должна пересчитаться под новые доли, иначе weighted_avg_price
+ # остаётся от ДО-boost микса (пробел в фиксе #1359, который
+ # обновлял только units/revenue). price_median_per_m2 уже включает
+ # combined_price_factor (line 2743), поэтому домножать НЕ нужно —
+ # это согласовано с per-bucket revenue ниже. Веса = area_avg×share,
+ # независимо от area_total_m2, поэтому вне ветки area_total_m2.
+ wnum = sum(
+ b["_area_avg_raw"] * b["share_pct"] * b["price_median_per_m2"]
+ for b in buckets
+ if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
+ )
+ wden = sum(
+ b["_area_avg_raw"] * b["share_pct"]
+ for b in buckets
+ if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
+ )
+ weighted_avg_price = round(wnum / wden, 2) if wden > 0 else None
+
# #1359: success-boost изменил share_pct → перераспределяем
# units/revenue/months_to_sellout и агрегаты под новые доли,
# иначе share_pct рассогласуется с units_planned/revenue/sellout
diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py
index c5726b64..5e4aad8c 100644
--- a/backend/app/services/cadastre/bulk_harvest.py
+++ b/backend/app/services/cadastre/bulk_harvest.py
@@ -247,21 +247,11 @@ async def harvest_quarter(
db.commit()
update_progress(done_progress)
- # ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
- # Выполняем после основного grid-walk (Phase 2-3). Требует bbox квартала.
- quarter_bbox = quarter_bbox_3857(db, quarter)
- if quarter_bbox is not None:
- update_progress({"phase": "territorial_zones_started", "quarter": quarter})
- try:
- tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
- tz_count = _save_territorial_zones(db, quarter, tz_features)
- logger.info(
- "harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
- )
- except Exception as e:
- logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
-
# ── Phase 4: quarter stats + auto-heal geom из snapshot ─────────────────
+ # Bug #1583: auto-heal geom выполняем ДО Phase 2.5 (territorial_zones). Иначе
+ # кварталы с broken/NULL geom дают quarter_bbox_3857() == None → ПЗЗ молча
+ # пропускаются, а на следующем harvest квартал отсекается skip_fresh_hours.
+ # Чиним geom здесь → Phase 2.5 ниже получит валидный bbox в этом же прогоне.
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
if stats_features:
upsert_quarter_stats(db, quarter, stats_features[0])
@@ -276,6 +266,22 @@ async def harvest_quarter(
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
db.commit()
+ # ── Phase 2.5: grid-walk для territorial_zones (ПЗЗ, layer 875838) ────────
+ # Выполняем после основного grid-walk (Phase 2-3) И после Phase 4 geom
+ # auto-heal (см. Bug #1583) — так broken-geom кварталы, починенные выше,
+ # получают валидный bbox и ПЗЗ собираются в том же прогоне. Требует bbox квартала.
+ quarter_bbox = quarter_bbox_3857(db, quarter)
+ if quarter_bbox is not None:
+ update_progress({"phase": "territorial_zones_started", "quarter": quarter})
+ try:
+ tz_features = await client.get_territorial_zones_in_bbox(quarter_bbox)
+ tz_count = _save_territorial_zones(db, quarter, tz_features)
+ logger.info(
+ "harvest_quarter: territorial_zones quarter=%s upserted=%d", quarter, tz_count
+ )
+ except Exception as e:
+ logger.warning("harvest_quarter: territorial_zones failed quarter=%s: %s", quarter, e)
+
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
@@ -344,6 +350,11 @@ async def _grid_walk_category(
grid_points = generate_grid_click_points(bbox, grid_size=grid_size, tile_size=tile_size)
+ # Bug #1584: считаем discovered ТОЛЬКО для таблицы запрошенного layer_id, а не
+ # sum(stats.values()). Иначе skipped/чужекатегорийные features завышают счётчик,
+ # ошибочно приписываясь к parcels/buildings вызывающим (harvest_quarter:230-233).
+ layer_count_key = "parcels" if layer_id == CAT_PARCEL else "buildings"
+
discovered_cads: set[str] = set()
upserted = 0
requests = 0
@@ -411,7 +422,7 @@ async def _grid_walk_category(
try:
with db.begin_nested():
stats = upsert_features(db, [feature], source="wms_grid_walk")
- upserted += sum(stats.values())
+ upserted += stats[layer_count_key]
except Exception as e:
logger.warning(
"_grid_walk_category: upsert failed cad=%s layer=%d: %s",
@@ -1423,7 +1434,7 @@ def upsert_quarter_stats(
"cost_value_total_geom": _safe_numeric(raw_opts.get("cost_value_total_geom")),
"sum_land_area": _safe_numeric(raw_opts.get("sum_land_area")),
"sum_land_geom_area": _safe_numeric(raw_opts.get("sum_land_geom_area")),
- "date_cr": raw_opts.get("date_cr"),
+ "date_cr": _parse_nspd_date(raw_opts.get("date_cr")),
"real_srid": _safe_int(raw_opts.get("real_srid")),
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
},
diff --git a/backend/app/services/cadastre/grid_geometry.py b/backend/app/services/cadastre/grid_geometry.py
index 40c4cb7c..281f38ec 100644
--- a/backend/app/services/cadastre/grid_geometry.py
+++ b/backend/app/services/cadastre/grid_geometry.py
@@ -7,12 +7,16 @@
from __future__ import annotations
import logging
+import math
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
+# Радиус сферы Web Mercator (EPSG:3857), м. Нужен для инверсии Y → широта.
+_WEB_MERCATOR_R = 6378137.0
+
def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, float] | None:
"""Получить bbox квартала из cad_quarters_geom в EPSG:3857.
@@ -56,8 +60,19 @@ def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, f
# geometry (width <0.01m). Это вызывает useless 1px tiles в WMS и 500 errors
# от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для
# broken — snapshot phase даст 20 per cat, что норм для MVP.
- width = bbox[2] - bbox[0]
- height = bbox[3] - bbox[1]
+ #
+ # Bug #1629: EPSG:3857 (Web Mercator) — конформная, НЕ равнопротяжённая
+ # проекция. Линейный масштаб = sec(lat); на широте ЕКБ (~56-60°N) фактор
+ # ≈1.8-2.0, т.е. 1 наземный метр ≈ 1.8-2.0 единиц 3857. Пороги ниже заданы
+ # в НАЗЕМНЫХ метрах, поэтому переводим протяжённость bbox из единиц 3857 в
+ # наземные метры умножением на cos(lat) (lat берём из центра bbox через
+ # инверсию Y). Иначе верхняя граница 10000 отсекала бы реальные крупные/
+ # вытянутые кварталы (10000 единиц 3857 ≈ всего ~5050 м земли на широте ЕКБ).
+ y_mid = (bbox[1] + bbox[3]) / 2.0
+ lat_mid = 2.0 * math.atan(math.exp(y_mid / _WEB_MERCATOR_R)) - math.pi / 2.0
+ ground_scale = math.cos(lat_mid) # ground_m = mercator_units * cos(lat)
+ width = (bbox[2] - bbox[0]) * ground_scale
+ height = (bbox[3] - bbox[1]) * ground_scale
if width < 100 or height < 100 or width > 10000 or height > 10000:
logger.warning(
"quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm "
diff --git a/backend/app/services/chat/intents.py b/backend/app/services/chat/intents.py
index 59c2ff75..d821c7e9 100644
--- a/backend/app/services/chat/intents.py
+++ b/backend/app/services/chat/intents.py
@@ -151,8 +151,8 @@ def _fmt_number(value: Any) -> str | None:
if isinstance(value, int):
return _fmt_thousands(value)
if isinstance(value, float):
- if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError
- return str(value)
+ if not math.isfinite(value): # NaN/Inf — не число: честно пропускаем (как None),
+ return None # иначе в RU-прозу утёк бы англ. литерал 'nan'/'inf' (#1585)
if value == int(value):
return _fmt_thousands(value)
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
@@ -251,7 +251,7 @@ def _render_what_to_build(report: dict[str, Any]) -> tuple[str, list[str]]:
if summary:
lines.append(str(summary))
- if len(sections_used) == 1 and not any(
+ if not any(
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
):
lines.append("Раздел рекомендации продукта в отчёте пуст.")
@@ -266,16 +266,21 @@ def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]:
future = report.get("future_market")
if isinstance(future, dict):
- sections_used.append("future_market")
+ future_emitted = False # секцию в provenance кладём только если выведена строка (#1630)
horizons = future.get("forecasts_by_horizon")
if isinstance(horizons, list) and horizons:
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
+ future_emitted = True
future_supply = future.get("future_supply")
if isinstance(future_supply, dict) and future_supply:
lines.append("Учтено давление будущего предложения (выходящие проекты).")
+ future_emitted = True
summary = future.get("summary")
if summary:
lines.append(str(summary))
+ future_emitted = True
+ if future_emitted:
+ sections_used.append("future_market")
else:
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))
diff --git a/backend/app/services/etl/objective_backfill.py b/backend/app/services/etl/objective_backfill.py
index bc7023ed..120ce993 100644
--- a/backend/app/services/etl/objective_backfill.py
+++ b/backend/app/services/etl/objective_backfill.py
@@ -94,6 +94,7 @@ def find_match_candidates(
objective_distinct AS (
SELECT DISTINCT project_name
FROM objective_corpus_room_month
+ WHERE group_name = 'Екатеринбург'
)
SELECT
d.obj_id,
@@ -168,7 +169,7 @@ def auto_apply_matches(
len(auto),
len(review),
)
- return {"auto_accepted": 0, "review_queue": len(review), "skipped": 0}
+ return {"auto_accepted": len(auto), "review_queue": len(review), "skipped": 0}
inserted = 0
skipped = 0
diff --git a/backend/app/services/exporters/report_md.py b/backend/app/services/exporters/report_md.py
index 5c8f67bc..3adeba6f 100644
--- a/backend/app/services/exporters/report_md.py
+++ b/backend/app/services/exporters/report_md.py
@@ -123,10 +123,17 @@ def _md_kv_table(data: dict[str, Any]) -> str:
def _md_kv_lines(pairs: list[tuple[str, Any]]) -> str:
- """Список «**метка:** значение» построчно (для коротких карточек meta). PURE."""
+ """Список «**метка:** значение» построчно (для коротких карточек meta). PURE.
+
+ Переводы строк в значении сворачиваем в пробел (по аналогии с `_md_cell`):
+ иначе многострочный value (напр. product_tz.summary) разорвал бы буллет.
+ """
if not pairs:
return _NO_DATA
- return "\n".join(f"- **{label}:** {_fmt(value)}" for label, value in pairs)
+ return "\n".join(
+ f"- **{label}:** {_fmt(value).replace(chr(10), ' ').replace(chr(13), ' ')}"
+ for label, value in pairs
+ )
def _join_horizons(values: list[Any]) -> Any:
diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py
index 538b245d..aa6a3370 100644
--- a/backend/app/services/exporters/report_pdf.py
+++ b/backend/app/services/exporters/report_pdf.py
@@ -458,6 +458,9 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
+
+ NB: эту функцию импортируют report_md/docx/pptx — НЕ менять сигнатуру (вернёт скаляр).
+ Для подписи фактического горизонта используй `_scenario_deficit_horizon`.
"""
forecasts = _as_list(payload.get("forecasts"))
primary = next(
@@ -476,6 +479,30 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
return None
+def _scenario_deficit_horizon(payload: dict[str, Any]) -> Any:
+ """Фактический горизонт (мес), из которого взят `_scenario_deficit_index`. PURE.
+
+ Зеркалит выбор `_scenario_deficit_index`: основной горизонт, иначе первый с не-None
+ дефицитом. Нужен, чтобы НЕ врать подписью «(12 мес)» при fallback на чужой горизонт
+ (#1590). Нет дефицита → None.
+ """
+ forecasts = _as_list(payload.get("forecasts"))
+ primary = next(
+ (
+ f
+ for f in forecasts
+ if isinstance(f, dict) and f.get("horizon_months") == _PRIMARY_HORIZON_MONTHS
+ ),
+ None,
+ )
+ if primary is not None and primary.get("deficit_index") is not None:
+ return _PRIMARY_HORIZON_MONTHS
+ for f in forecasts:
+ if isinstance(f, dict) and f.get("deficit_index") is not None:
+ return f.get("horizon_months")
+ return None
+
+
def _build_scenarios(report: dict[str, Any]) -> str:
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
scenarios = _as_dict(report.get("scenarios"))
@@ -484,9 +511,19 @@ def _build_scenarios(report: dict[str, Any]) -> str:
rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
- rows.append([name, _scenario_deficit_index(data), data.get("advisory")])
+ deficit = _scenario_deficit_index(data)
+ horizon = _scenario_deficit_horizon(data)
+ # Подпись столбца жёстко «(12 мес)» — если значение от другого горизонта
+ # (fallback), помечаем ячейку фактическим горизонтом, чтобы не врать (#1590).
+ if deficit is not None and horizon is not None and horizon != _PRIMARY_HORIZON_MONTHS:
+ deficit = f"{_fmt(deficit)} (гор. {horizon} мес)"
+ rows.append([name, deficit, data.get("advisory")])
- headers = ["Сценарий", "Индекс дефицита (12 мес)", "Advisory"]
+ headers = [
+ "Сценарий",
+ f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)",
+ "Advisory",
+ ]
return f"""
{html.escape(_TITLE_SCENARIOS)}
diff --git a/backend/app/services/exporters/snapshot_pdf.py b/backend/app/services/exporters/snapshot_pdf.py
index 756e25a6..3f1db81a 100644
--- a/backend/app/services/exporters/snapshot_pdf.py
+++ b/backend/app/services/exporters/snapshot_pdf.py
@@ -159,12 +159,14 @@ def generate_snapshot_pdf(
area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else "—"
poi_items = _build_poi_items(poi_rows, limit=7)
- # Конкуренты — берём топ N ближайших (уже отсортированы по flat_count DESC;
- # переупорядочиваем по distance_m для удобства чтения)
+ # Конкуренты — берём топ N БЛИЖАЙШИХ. competitor_rows приходят отсортированными
+ # по flat_count DESC (крупнейшие ЖК), поэтому сначала пересортировываем весь
+ # список по distance_m ASC, и лишь затем срезаем N — иначе в блок попадали бы
+ # 5 крупнейших из радиуса, а не непосредственное конкурентное окружение пятна.
competitors_display = sorted(
- competitor_rows[:competitors_limit],
+ competitor_rows,
key=lambda r: float(r.get("distance_m") or 0),
- )
+ )[:competitors_limit]
competitors_ctx: list[dict[str, Any]] = [
{
"comm_name": r.get("comm_name"),
diff --git a/backend/app/services/exporters/trade_in_pdf.py b/backend/app/services/exporters/trade_in_pdf.py
index e043d013..c917b295 100644
--- a/backend/app/services/exporters/trade_in_pdf.py
+++ b/backend/app/services/exporters/trade_in_pdf.py
@@ -58,7 +58,11 @@ def _analog_rows(lots: list[AnalogLot], *, is_deal: bool) -> str:
for lot in lots:
date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else "—"
dom_val = str(lot.days_on_market) if lot.days_on_market is not None else "—"
- floor_val = f"{lot.floor}/{lot.total_floors}" if lot.floor and lot.total_floors else "—"
+ floor_val = (
+ f"{lot.floor}/{lot.total_floors}"
+ if lot.floor is not None and lot.total_floors is not None
+ else "—"
+ )
label = "Дата сделки" if is_deal else "В продаже"
_ = label # used for header only
rows.append(
diff --git a/backend/app/services/forecasting/affordability.py b/backend/app/services/forecasting/affordability.py
index 9baf644b..04235f7d 100644
--- a/backend/app/services/forecasting/affordability.py
+++ b/backend/app/services/forecasting/affordability.py
@@ -294,8 +294,10 @@ def compute_affordability(
усреднение НЕнулевых месяцев). None → платёж None (graceful).
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
months=_ANNUITY_TERM_MONTHS).
- • payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h]) — платёж на
- каждом горизонте сценарной ставки (None rate_path → поле None).
+ • payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h] + спред) —
+ платёж на каждом горизонте сценарной КЛЮЧЕВОЙ ставки ЦБ, приведённой к той
+ же рыночной базе, что и monthly_payment_rub (key_rate + калиброванный спред
+ 4.5 п.п.); None rate_path → поле None (#1639).
Graceful: нет ставки/цены → платёж None; ставка ≤0 → аннуитет деградирует к
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
@@ -304,8 +306,10 @@ def compute_affordability(
db: SQLAlchemy sync Session.
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
price_per_m2: цена ₽/м² (None → берём сегментную среднюю reuse-ом).
- rate_path: сценарный {horizon: годовая ставка %} для payment_at_scenario;
- None → payment_at_scenario None.
+ rate_path: сценарный {horizon: КЛЮЧЕВАЯ ставка ЦБ %} для payment_at_scenario
+ (контракт #952/#984: конверт key_rate, НЕ рыночная — к ней внутри
+ добавляется тот же спред _KEY_RATE_MARKET_SPREAD_PP, что и в базовом
+ monthly_payment_rub); None → payment_at_scenario None.
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
@@ -337,7 +341,18 @@ def compute_affordability(
if rate_path is not None:
scenario: dict[int, float] = {}
for horizon, scenario_rate in rate_path.items():
- payment = _annuity(principal, scenario_rate, _ANNUITY_TERM_MONTHS)
+ # rate_path[h] = КЛЮЧЕВАЯ ставка ЦБ сценария (#952/#984 контракт:
+ # demand_supply_forecast.py:586, scenarios.py — конверт key_rate), НЕ
+ # рыночная. Приводим к той же рыночной базе, что и monthly_payment_rub:
+ # key_rate + калиброванный спред (_current_market_rate, строка 251).
+ # Иначе сценарный платёж считался бы по «голой» key_rate (≈ на 4.5 п.п.
+ # ниже базовой ставки) и был бы НЕсопоставим с monthly_payment_rub (#1639).
+ market_scenario_rate = (
+ scenario_rate + _KEY_RATE_MARKET_SPREAD_PP
+ if scenario_rate is not None
+ else None
+ )
+ payment = _annuity(principal, market_scenario_rate, _ANNUITY_TERM_MONTHS)
if payment is not None:
scenario[horizon] = payment
payment_at_scenario = scenario
diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py
index 6ae2a02e..16fd9399 100644
--- a/backend/app/services/forecasting/confidence_engine.py
+++ b/backend/app/services/forecasting/confidence_engine.py
@@ -330,9 +330,12 @@ def _build_rationale(
if advisory_capped and level == _ADVISORY_CEILING:
# Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина.
+ # _F_ADVISORY_CAP-фактор уже проговорён в base — исключаем его ноту из «также»,
+ # иначе advisory-cap-сообщение дублируется (частый all-high случай).
+ other = [f.note for f in drag if f.name != _F_ADVISORY_CAP]
base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
- if notes:
- base += "; также " + _join_notes(notes)
+ if other:
+ base += "; также " + _join_notes(other)
return base + "."
if not notes:
diff --git a/backend/app/services/forecasting/product_scoring.py b/backend/app/services/forecasting/product_scoring.py
index 846731aa..15ab89ea 100644
--- a/backend/app/services/forecasting/product_scoring.py
+++ b/backend/app/services/forecasting/product_scoring.py
@@ -818,7 +818,10 @@ def _competitor_signal(
)
return None, None
weights = [c.relevance_weight for c in response.competitors if c.relevance_weight is not None]
- return weights, len(response.competitors)
+ # #1595: count считаем по тем же конкурентам, у которых есть relevance_weight, иначе при
+ # частичных данных (None-веса) count > len(weights) → density завышена, future_competition
+ # занижен. В проде get_competitors всегда задаёт вес (число), но поле допускает None (мок).
+ return weights, len(weights)
def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py
index fb06020b..a1d56c55 100644
--- a/backend/app/services/forecasting/recommendation.py
+++ b/backend/app/services/forecasting/recommendation.py
@@ -609,17 +609,22 @@ def _demand_only_overlay(
room_bucket=forecast_bucket,
district=district,
)
- # §9.4 нормализация под будущий режим ставки (β внутри; rate_future None →
- # деградирует к нейтрали внутри себя, передаём 0.0 как placeholder).
- norm = compute_demand_normalization(
- db, spec=spec, rate_future=rate_future if rate_future is not None else 0.0
- )
+ # §9.4 нормализация под будущий режим ставки (β внутри). rate_future None
+ # (hold_last_rate не дал ставку) → НЕ применяем §9.4: 0.0-placeholder дал бы
+ # delta=−rate_window_avg → exp(β·delta) и клэмп к _NORM_MAX (макс. аплифт),
+ # а НЕ нейтраль. Честная нейтраль при отсутствии будущей ставки = коэф. 1.0.
+ if rate_future is not None:
+ norm_coefficient = compute_demand_normalization(
+ db, spec=spec, rate_future=rate_future
+ ).coefficient
+ else:
+ norm_coefficient = 1.0
# §9.5 макро-коэффициент (ортогонален β); профиль — класс + room_bucket.
profile: dict[str, Any] = {"room_bucket": forecast_bucket}
if mapped_class is not None:
profile["obj_class"] = mapped_class
macro_coef = compute_macro_coefficient(db, segment_profile=profile)
- pace = base_pace * norm.coefficient * macro_coef.coefficient
+ pace = base_pace * norm_coefficient * macro_coef.coefficient
paces.append((live_bucket, mapped_class, pace))
max_pace = max((p for _, _, p in paces), default=0.0)
@@ -698,13 +703,14 @@ def _overlay(
def _commercial_signal(
db: Session, district: str | None, horizon_months: int
) -> dict[str, Any] | None:
- """§10.4 советующий коммерческий сигнал (доля коммерции) — degraded-honest. Graceful.
+ """§10.4 советующий коммерческий сигнал (темп распродажи нежилого) — degraded-honest.
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
"нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда
возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число.
- Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка доли
- коммерции (sell_through_pct как прокси реализованной доли) + §16-подобный reason.
+ Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка ТЕМПА
+ РАСПРОДАЖИ нежилого стока (sell_through_pct = проданные ÷ (проданные+доступные)·100,
+ прокси ликвидности/спроса — НЕ доля нежилого в объёме застройки) + §16-подобный reason.
НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал.
Args:
@@ -747,22 +753,26 @@ def _commercial_signal(
)
return {"available": False, "caveat": caveat, "advisory": True}
- # Достаточно данных: советующая оценка реализованной доли коммерции (прокси).
- share_pct = round(sell_through, 1)
+ # Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (sell_through_pct
+ # = проданные ÷ (проданные+доступные)·100 — ликвидность/спрос, НЕ доля застройки).
+ # NB: ключ commercial_share_pct мислейблит метрику; честное переименование требует
+ # согласованной правки product_scoring._score_commercial (другой файл) → не трогаем.
+ sell_through_pct = round(sell_through, 1)
confidence = confidence if confidence in ("high", "medium", "low") else "low"
return {
"available": True,
"premise_kind": _COMMERCIAL_PREMISE_KIND,
- "commercial_share_pct": share_pct,
+ "commercial_share_pct": sell_through_pct,
"n_lots": n_lots,
"confidence": confidence,
"reason": {
"why": (
- f"Коммерция (нежилое): реализованная доля ~{share_pct}% по {n_lots} "
- f"лотам на горизонте {horizon_months} мес (прокси спроса на нежилые помещения)."
+ f"Коммерция (нежилое): темп распродажи ~{sell_through_pct}% по {n_lots} "
+ f"лотам на горизонте {horizon_months} мес (прокси ликвидности/спроса на "
+ f"нежилые помещения, НЕ доля нежилого в объёме застройки)."
),
"drivers": [
- {"factor": "sell_through_pct", "value": share_pct, "direction": "+"},
+ {"factor": "sell_through_pct", "value": sell_through_pct, "direction": "+"},
{"factor": "n_lots", "value": n_lots, "direction": "+"},
],
"rejected": [],
diff --git a/backend/app/services/forecasting/regression.py b/backend/app/services/forecasting/regression.py
index cf17dfec..4af2bcb1 100644
--- a/backend/app/services/forecasting/regression.py
+++ b/backend/app/services/forecasting/regression.py
@@ -54,7 +54,7 @@ import numpy as np
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached
-from app.services.forecasting.macro_series import get_monthly_macro
+from app.services.forecasting.macro_series import get_monthly_macro, is_confounded_window
from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta
from app.services.forecasting.sales_series import (
SegmentSpec,
@@ -672,7 +672,7 @@ def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity
def _fit_to_sensitivity(
- fit: DistributedLagFit, *, segment: dict[str, str | None]
+ fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False
) -> RateSensitivity:
"""Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract.
@@ -684,9 +684,16 @@ def _fit_to_sensitivity(
• r2 / n_obs ← fit.r2 / fit.n
• confidence ← 'regression' → "medium" (gated-OK but advisory-grade) |
'fallback' → "low"
- Source-B-only outputs (z_area_floor, most_sensitive_bucket, confounded,
- shrinkage_weight) have no analogue in a district×class distributed-lag fit
- (no room×area bucketing here) → None / sensible defaults. PURE.
+ • confounded ← passed in by the caller (computed from the ACTUAL fit window
+ via is_confounded_window — #1636). The §9.6 production path
+ OR-aggregates this with §9.5 macro_coefficient.confounded in
+ demand_supply_forecast._series_confounded → шок-фактор (#1222).
+ The 48-мес regression window overlaps the 2024-07-01 shock long
+ after the 12-мес macro window stops doing so, so hardcoding
+ False here silently dropped the shock signal on this channel.
+ Source-B-only outputs (z_area_floor, most_sensitive_bucket, shrinkage_weight) have
+ no analogue in a district×class distributed-lag fit (no room×area bucketing here)
+ → None / sensible defaults. PURE.
BETA SEMANTICS (important): `beta` here carries the Almon LONG-RUN multiplier
Σ_j β_j on Δln — the cumulative %-effect of a SUSTAINED +1pp regime shift, NOT
@@ -705,7 +712,7 @@ def _fit_to_sensitivity(
r2=fit.r2,
n_obs=fit.n,
shrinkage_weight=0.0,
- confounded=False,
+ confounded=confounded,
confidence=confidence,
phrase=fit.phrase,
)
@@ -778,4 +785,32 @@ def compute_rate_regime_sensitivity(
)
return _insufficient_sensitivity(segment)
- return _fit_to_sensitivity(fit, segment=segment)
+ # #1636: confounded must reflect the ACTUAL §9.6 fit window. The regression fits
+ # over the same macro grid as compute_district_rate_regression (get_monthly_macro,
+ # months_back); re-reading it here is a cache hit (same args). The 48-мес window
+ # crosses the 2024-07-01 shock long after the §9.5 12-мес macro window stops → this
+ # is exactly the channel that was silently never raising the shock flag (#1222).
+ confounded = _macro_window_confounded(db, months_back=months_back)
+ return _fit_to_sensitivity(fit, segment=segment, confounded=confounded)
+
+
+def _macro_window_confounded(db: Session, *, months_back: int) -> bool:
+ """True если §9.6 fit-окно [min..max] макро-сетки пересекает шок-дату (#1636).
+
+ Зеркалит macro_coefficient._series_confounded / rate_sensitivity._series_confounded
+ (PR2 is_confounded_window). Окно = та же сетка get_monthly_macro(months_back), что
+ использует compute_district_rate_regression → cache-hit, без лишнего запроса.
+ Пустая сетка / сбой → False (нет окна — нечего конфаундить), НЕ crash.
+ """
+ try:
+ months = [m.month for m in get_monthly_macro(db, months_back=months_back)]
+ except Exception:
+ logger.exception(
+ "rate_regime_sensitivity: macro window read for confounded-flag failed "
+ "(months_back=%d) → treating as not confounded",
+ months_back,
+ )
+ return False
+ if not months:
+ return False
+ return is_confounded_window(min(months), max(months))
diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py
index cd45e8f1..9dd27051 100644
--- a/backend/app/services/forecasting/report_assembler.py
+++ b/backend/app/services/forecasting/report_assembler.py
@@ -393,9 +393,21 @@ def _market_now_summary(
avg_price = analyze.get("market_avg_price_per_m2")
if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool):
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
- n_comp = _analog_count(analyze, market_metrics)
- if n_comp is not None:
- parts.append(f"{n_comp} ЖК-конкурентов рядом")
+ # #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во
+ # всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом».
+ # Метка честно следует источнику: obj_count → «в выборке района», локальный fallback
+ # из analyze (competitors_total / len(competitors)) → «рядом».
+ if market_metrics is not None and isinstance(market_metrics.get("obj_count"), int):
+ parts.append(f"{market_metrics['obj_count']} ЖК в выборке района")
+ else:
+ n_local: int | None = None
+ pulse = analyze.get("market_pulse")
+ if isinstance(pulse, dict) and isinstance(pulse.get("competitors_total"), int):
+ n_local = pulse["competitors_total"]
+ elif isinstance(analyze.get("competitors"), list):
+ n_local = len(analyze["competitors"])
+ if n_local is not None:
+ parts.append(f"{n_local} ЖК-конкурентов рядом")
if not parts:
return None
return "Текущий рынок: " + ", ".join(parts) + "."
diff --git a/backend/app/services/forecasting/special_indices.py b/backend/app/services/forecasting/special_indices.py
index 79a14142..d5c048f4 100644
--- a/backend/app/services/forecasting/special_indices.py
+++ b/backend/app/services/forecasting/special_indices.py
@@ -1689,7 +1689,13 @@ def compute_special_indices(
indices: dict[str, SpecialIndex] = {key: _run(key, builders[key]) for key in _INDEX_KEYS}
- confidence = _min_confidence([idx.confidence for idx in indices.values()])
+ # confidence = MIN по ДОСТУПНЫМ индексам (контракт SpecialIndices / docstring выше).
+ # Недоступный индекс (_unavailable → value=None, confidence='low') НЕ участвует: его
+ # 'low' — артефакт деградации, а не сигнал низкой уверенности доступных индексов
+ # (#1592: _min_confidence отбрасывает только None, поэтому фильтруем здесь по value).
+ confidence = _min_confidence(
+ [idx.confidence for idx in indices.values() if idx.value is not None]
+ )
n_available = sum(1 for idx in indices.values() if idx.value is not None)
logger.info(
diff --git a/backend/app/services/generative/exporters/dxf.py b/backend/app/services/generative/exporters/dxf.py
index 5ba88db8..fd1eb296 100644
--- a/backend/app/services/generative/exporters/dxf.py
+++ b/backend/app/services/generative/exporters/dxf.py
@@ -22,6 +22,7 @@ import logging
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
# импорт из модуля удовлетворяет strict no-implicit-reexport.
+from ezdxf.enums import TextEntityAlignment
from ezdxf.filemanagement import new as ezdxf_new
from shapely.geometry import Polygon
@@ -43,13 +44,33 @@ _LAYER_BUILDINGS = "BUILDINGS"
_LABEL_HEIGHT_M = 2.0
-def _polygon_points(poly: Polygon) -> list[tuple[float, float]]:
- """Внешнее кольцо полигона как список (x, y) для LWPolyline (без замыкающей точки)."""
- coords = list(poly.exterior.coords)
+def _ring_points(coords: object) -> list[tuple[float, float]]:
+ """Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
+ pts = list(coords)
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
- if len(coords) > 1 and coords[0] == coords[-1]:
- coords = coords[:-1]
- return [(float(x), float(y)) for x, y in coords]
+ if len(pts) > 1 and pts[0] == pts[-1]:
+ pts = pts[:-1]
+ return [(float(x), float(y)) for x, y in pts]
+
+
+def _add_polygon(msp: object, poly: Polygon, layer: str) -> None:
+ """Нарисовать полигон на слое: внешнее кольцо + каждое внутреннее (отверстие).
+
+ LWPolyline не умеет дырки, поэтому каждое interior-кольцо эмитируется отдельной
+ замкнутой полилинией на том же слое — иначе легальные вырезы (двор, сервитут,
+ охранная зона) терялись бы и заливались сплошняком.
+ """
+ msp.add_lwpolyline(
+ _ring_points(poly.exterior.coords),
+ close=True,
+ dxfattribs={"layer": layer},
+ )
+ for interior in poly.interiors:
+ msp.add_lwpolyline(
+ _ring_points(interior.coords),
+ close=True,
+ dxfattribs={"layer": layer},
+ )
def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
@@ -71,16 +92,8 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
msp = doc.modelspace()
# Участок и пятно застройки — из метрической геометрии Parcel.
- msp.add_lwpolyline(
- _polygon_points(parcel.polygon_m),
- close=True,
- dxfattribs={"layer": _LAYER_PARCEL},
- )
- msp.add_lwpolyline(
- _polygon_points(parcel.buildable_m),
- close=True,
- dxfattribs={"layer": _LAYER_BUILDABLE},
- )
+ _add_polygon(msp, parcel.polygon_m, _LAYER_PARCEL)
+ _add_polygon(msp, parcel.buildable_m, _LAYER_BUILDABLE)
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
features = variant.buildings_geojson.get("features", [])
@@ -91,18 +104,17 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
if footprint is None:
continue
section_count += 1
- msp.add_lwpolyline(
- _polygon_points(footprint),
- close=True,
- dxfattribs={"layer": _LAYER_BUILDINGS},
- )
+ _add_polygon(msp, footprint, _LAYER_BUILDINGS)
centroid = footprint.centroid
label = str(_feature_section_id(feature, section_count))
text = msp.add_text(
label,
dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M},
)
- text.set_placement((float(centroid.x), float(centroid.y)))
+ text.set_placement(
+ (float(centroid.x), float(centroid.y)),
+ align=TextEntityAlignment.MIDDLE_CENTER,
+ )
stream = io.BytesIO()
doc.write(stream, fmt="bin")
diff --git a/backend/app/services/generative/placement.py b/backend/app/services/generative/placement.py
index 677a69be..55441b7b 100644
--- a/backend/app/services/generative/placement.py
+++ b/backend/app/services/generative/placement.py
@@ -30,7 +30,7 @@ from shapely.strtree import STRtree
from app.schemas.concept import ConceptInput, ConceptVariant
from app.services.generative import financial, teap
-from app.services.generative.geometry import Parcel
+from app.services.generative.geometry import Parcel, ParcelGeometryError
logger = logging.getLogger(__name__)
@@ -191,11 +191,25 @@ def place_strategy(
parcel: Parcel,
payload: ConceptInput,
spec: StrategySpec,
-) -> ConceptVariant:
- """Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant."""
+) -> ConceptVariant | None:
+ """Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
+
+ Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
+ участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
+ с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный —
+ ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
+ """
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
footprints = _greedy_place(parcel, spec, coverage_cap)
+ if not footprints:
+ logger.warning(
+ "strategy=%s placed 0 sections (footprint %.0fx%.0f m not buildable) — отбраковка",
+ spec.name,
+ spec.section_w,
+ spec.section_d,
+ )
+ return None
teap_result = teap.compute_teap(
footprints=footprints,
@@ -220,8 +234,22 @@ def place_strategy(
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
- """Stage 1b entry: построить три варианта (max_area / max_insolation / balanced)."""
- variants = [place_strategy(parcel, payload, spec) for spec in _STRATEGIES]
+ """Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).
+
+ Вырожденные стратегии (нулевое размещение) отбраковываются — в результат попадают
+ только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
+ вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
+ :class:`ParcelGeometryError` (API мапит в 422) — лучше отказ, чем пустой/лживый ответ.
+ """
+ variants = [
+ variant
+ for spec in _STRATEGIES
+ if (variant := place_strategy(parcel, payload, spec)) is not None
+ ]
+ if not variants:
+ raise ParcelGeometryError(
+ "ни одна стратегия размещения не вместила секцию — участок слишком узкий/мелкий"
+ )
logger.info(
"placed all strategies: %s",
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py
index 86e0c85b..772db0b3 100644
--- a/backend/app/services/llm/client.py
+++ b/backend/app/services/llm/client.py
@@ -58,6 +58,10 @@ class LLMResult:
fallback_used: True если результат — сигнал к детерминированному fallback.
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
redaction_refused/provider_error/call_cap/no_api_key). None при ok.
+ finish_reason: причина завершения от провайдера (stop/length/content_filter/
+ tool_calls/…). None при fallback. Консьюмер ОБЯЗАН проверять её даже при
+ ok=True: 'length'/'content_filter' → ответ обрезан/отфильтрован (частичный
+ или пустой content) и не является полноценным результатом.
prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
model: модель, ответившая на запрос ("" при fallback).
"""
@@ -67,6 +71,7 @@ class LLMResult:
tool_calls: list[ToolCall] = field(default_factory=list)
fallback_used: bool = False
reason: str | None = None
+ finish_reason: str | None = None
prompt_tokens: int = 0
completion_tokens: int = 0
model: str = ""
@@ -81,6 +86,7 @@ class LLMResult:
ok=True,
content=resp.content,
tool_calls=list(resp.tool_calls),
+ finish_reason=resp.finish_reason,
prompt_tokens=resp.prompt_tokens,
completion_tokens=resp.completion_tokens,
model=resp.model,
diff --git a/backend/app/services/llm/provider.py b/backend/app/services/llm/provider.py
index 24be52f0..2dfdd565 100644
--- a/backend/app/services/llm/provider.py
+++ b/backend/app/services/llm/provider.py
@@ -91,6 +91,21 @@ class LLMProvider(ABC):
# ── OpenAI (external) ─────────────────────────────────────────────────────────
+def _coerce_token_count(value: Any) -> int:
+ """usage-токены → int, толерантно к мусору (str "abc"/None/dict → 0).
+
+ OpenAI обычно отдаёт int, но через внешний прокси/нестандартный провайдер поле
+ может прийти нечисловым. int() на таком значении бросил бы ValueError/TypeError
+ мимо LLM*-контракта (его ловит client._call_with_retries) и пробил бы инвариант
+ «complete никогда не падает наружу» (#1601). Невалидный токен-счётчик — не повод
+ ронять ответ: деградируем до 0.
+ """
+ try:
+ return int(value or 0)
+ except (ValueError, TypeError):
+ return 0
+
+
def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse:
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
choices = data.get("choices") or []
@@ -114,8 +129,8 @@ def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> Prov
content=message.get("content"),
tool_calls=tool_calls,
finish_reason=choice.get("finish_reason"),
- prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
- completion_tokens=int(usage.get("completion_tokens", 0) or 0),
+ prompt_tokens=_coerce_token_count(usage.get("prompt_tokens", 0)),
+ completion_tokens=_coerce_token_count(usage.get("completion_tokens", 0)),
model=str(data.get("model") or fallback_model),
)
diff --git a/backend/app/services/llm/redaction.py b/backend/app/services/llm/redaction.py
index e2ffcef0..4fc6dc81 100644
--- a/backend/app/services/llm/redaction.py
+++ b/backend/app/services/llm/redaction.py
@@ -24,6 +24,7 @@ from __future__ import annotations
import logging
import re
+from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
@@ -77,10 +78,54 @@ _PHONE_RE = re.compile(r"(?:\+7|\b8)[\s\-(]*\d{3}[\s\-)]*\d{3}[\s-]*\d{2}[\s-]*\
# пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя.
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
_PHONE_BARE_RE = re.compile(r"(? bool:
+ """Проверить контрольные цифры ИНН (официальный алгоритм ФНС).
+
+ 10-значный (юрлицо): одна контрольная цифра. 12-значный (физлицо/ИП): две.
+ Вес-коэффициенты фиксированы стандартом. Любая слитная группа 10/12 цифр, НЕ
+ проходящая checksum (напр. круглая сумма «1000000000»), считается НЕ-ИНН и не
+ редактируется — это и закрывает ложные срабатывания #1640.
+
+ NB: checksum резко снижает false-positive rate, но не доводит его до нуля —
+ ~1/11 случайных 10-значных чисел совпадает с валидным ИНН по контрольной цифре.
+ Это приемлемо для вторичной (belt-and-suspenders) защиты.
+ """
+ if len(digits) == 10:
+ weights = (2, 4, 10, 3, 5, 9, 4, 6, 8)
+ control = sum(int(digits[i]) * weights[i] for i in range(9)) % 11 % 10
+ return control == int(digits[9])
+ if len(digits) == 12:
+ w1 = (7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
+ w2 = (3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8)
+ c1 = sum(int(digits[i]) * w1[i] for i in range(10)) % 11 % 10
+ c2 = sum(int(digits[i]) * w2[i] for i in range(11)) % 11 % 10
+ return c1 == int(digits[10]) and c2 == int(digits[11])
+ return False
+
+
+def _inn_repl(match: re.Match[str]) -> str:
+ """re.sub-callback: редактировать кандидат ТОЛЬКО если checksum валиден."""
+ token = match.group(0)
+ return "[REDACTED:inn]" if _inn_checksum_valid(token) else token
+
+
# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
@@ -97,18 +142,22 @@ _FULLNAME_RE = re.compile(
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
)
-# (regex, placeholder-kind). Применяются последовательно в этом порядке.
+# (regex, kind, repl). ``repl`` — строка-плейсхолдер ИЛИ callback для re.subn
+# (используется ИНН: редактирует только checksum-валидные кандидаты — #1640).
+# Применяются последовательно в этом порядке.
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
-_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
- (_SNILS_RE, "snils"),
- (_PASSPORT_RE, "passport"),
- (_PHONE_RE, "phone"),
- (_PHONE_BARE_RE, "phone"),
- (_EMAIL_RE, "email"),
- (_INN_RE, "inn"),
- (_SNILS_BARE_RE, "snils"),
- (_FULLNAME_RE, "name"),
+_Repl = str | Callable[[re.Match[str]], str]
+_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, _Repl], ...] = (
+ (_SNILS_RE, "snils", "[REDACTED:snils]"),
+ (_PASSPORT_RE, "passport", "[REDACTED:passport]"),
+ (_PHONE_RE, "phone", "[REDACTED:phone]"),
+ (_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
+ (_PHONE_LOCAL_RE, "phone", "[REDACTED:phone]"),
+ (_EMAIL_RE, "email", "[REDACTED:email]"),
+ (_INN_RE, "inn", _inn_repl),
+ (_SNILS_BARE_RE, "snils", "[REDACTED:snils]"),
+ (_FULLNAME_RE, "name", "[REDACTED:name]"),
)
@@ -121,9 +170,16 @@ def scrub_text(value: str) -> str:
if not value:
return value
redacted = value
- for pattern, kind in _PII_PATTERNS:
- redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted)
- if n:
+ placeholder = "[REDACTED:%s]"
+ for pattern, kind, repl in _PII_PATTERNS:
+ # n из subn для callback-repl (ИНН) считает ВСЕ совпадения, включая кандидаты,
+ # которые callback вернул без изменений (не прошли checksum). Поэтому реальное
+ # число замен берём по приросту числа плейсхолдеров — корректно и для str, и
+ # для callback, без утечки самого PII-значения в лог.
+ before_count = redacted.count(placeholder % kind)
+ redacted = pattern.sub(repl, redacted)
+ n = redacted.count(placeholder % kind) - before_count
+ if n > 0:
# Логируем ТОЛЬКО kind и количество — без самого PII-значения.
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
return redacted
diff --git a/backend/app/services/objective_etl.py b/backend/app/services/objective_etl.py
index 7bdc201c..55efced9 100644
--- a/backend/app/services/objective_etl.py
+++ b/backend/app/services/objective_etl.py
@@ -24,6 +24,7 @@ import logging
import re
import sqlite3
from collections.abc import Callable
+from contextlib import closing
from datetime import date
from pathlib import Path
from typing import Any
@@ -466,13 +467,12 @@ def get_sqlite_info(sqlite_path: str | Path) -> dict[str, Any]:
info["size_bytes"] = st.st_size
info["modified_at"] = st.st_mtime # epoch seconds
try:
- c = sqlite3.connect(p)
- info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
- info["corp_room_month"] = c.execute("SELECT COUNT(*) FROM objective_corp_month").fetchone()[
- 0
- ]
- info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
- c.close()
+ with closing(sqlite3.connect(p)) as c:
+ info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
+ info["corp_room_month"] = c.execute(
+ "SELECT COUNT(*) FROM objective_corp_month"
+ ).fetchone()[0]
+ info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
except sqlite3.Error as e:
info["error"] = str(e)
return info
diff --git a/backend/app/services/photos/thumbs.py b/backend/app/services/photos/thumbs.py
index 5b79e22e..b7f69d4f 100644
--- a/backend/app/services/photos/thumbs.py
+++ b/backend/app/services/photos/thumbs.py
@@ -11,6 +11,7 @@ opens the original DOM.РФ URL (we don't need to mirror originals).
from __future__ import annotations
import logging
+import os
from pathlib import Path
from PIL import Image, ImageOps
@@ -50,7 +51,16 @@ def make_thumbnail(
im = im.convert("RGB")
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
dst.parent.mkdir(parents=True, exist_ok=True)
- im.save(dst, format="WEBP", quality=quality, method=4)
+ # Atomic write: encode to a sibling temp file, then os.replace() so a
+ # crash/OOM mid-encode never leaves a truncated .webp at the canonical
+ # path (which dst.exists() would later treat as a valid cached thumb).
+ tmp = dst.with_name(f".{dst.name}.tmp")
+ try:
+ im.save(tmp, format="WEBP", quality=quality, method=4)
+ os.replace(tmp, dst)
+ except BaseException:
+ tmp.unlink(missing_ok=True)
+ raise
return dst
except Exception as e:
logger.warning("thumbnail %s failed: %s", src, e)
diff --git a/backend/app/services/scrapers/domrf_catalog.py b/backend/app/services/scrapers/domrf_catalog.py
index 43eae883..76d3a04c 100644
--- a/backend/app/services/scrapers/domrf_catalog.py
+++ b/backend/app/services/scrapers/domrf_catalog.py
@@ -157,6 +157,16 @@ class _TextCollector(HTMLParser):
extraction известных структур страницы каталога.
"""
+ # HTML5 void-элементы: не имеют закрывающего тега → handle_endtag не вызывается.
+ # Если пушить их в стек/буфер, чужой handle_endtag поп'ает чужой фрейм →
+ # рассинхрон стека, текст блоков теряется (issue #1608).
+ _VOID_TAGS = frozenset(
+ {
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
+ "link", "meta", "param", "source", "track", "wbr",
+ }
+ )
+
def __init__(self) -> None:
super().__init__()
self._stack: list[tuple[str, dict[str, str]]] = []
@@ -165,11 +175,15 @@ class _TextCollector(HTMLParser):
self._buf: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
+ if tag in self._VOID_TAGS:
+ return # void-теги не имеют endtag — не открываем фрейм (issue #1608)
attr_dict = {k: (v or "") for k, v in attrs}
self._stack.append((tag, attr_dict))
self._buf.append("") # начало нового буфера для этого тега
def handle_endtag(self, _tag: str) -> None:
+ if _tag in self._VOID_TAGS:
+ return # void-теги фрейм не открывали — нечего поп'ать (issue #1608)
if not self._stack:
return
tag, attr_dict = self._stack.pop()
@@ -239,8 +253,11 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
# Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры.
# Price: "7 890 000 ₽" или "7 890 000 руб"
+ # Negative lookahead (?!\s*[/⁄]) исключает цену за м² ("217 835 ₽/м²"),
+ # которая на странице обычно выше полной стоимости и иначе матчилась бы
+ # первой → отбрасывалась санити-границей → price_rub=NULL (issue #1645).
price_match = re.search(
- r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)",
+ r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)(?!\s*[/⁄])",
html,
re.UNICODE,
)
@@ -267,19 +284,25 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
except ValueError:
pass
- # Status: ищем характерные слова рядом с "статус" или в badge
+ # Status: ищем характерные слова рядом с "статус" или в badge.
+ # Морфоварианты продан[ао]?/забронирован[ао]? — типовая "Квартира продана"
+ # (ж.р.) раньше не матчилась, status уходил во free (issue #1609).
+ # NOTE: классификация по первому совпадению во ВСЁМ HTML остаётся хрупкой —
+ # 'в продаже' из блока 'другие квартиры в продаже' может дать ложный free.
+ # Полный фикс (якорь к статус-бейджу / исключение секций "похожие") требует
+ # знания DOM-структуры страницы каталога — см. отчёт (needs-leha).
status_match = re.search(
- r"(в\s*продаже|свободна|free|продано|sold|забронирована|бронь|reserved)",
+ r"(в\s*продаже|свободн[ао]|free|продан[ао]?|sold|забронирован[ао]?|бронь|reserved)",
html,
re.IGNORECASE | re.UNICODE,
)
if status_match:
s = status_match.group(1).lower()
- if any(kw in s for kw in ("продаже", "свободна", "free")):
+ if any(kw in s for kw in ("продаже", "свободн", "free")):
result["status"] = STATUS_FREE
- elif any(kw in s for kw in ("продано", "sold")):
+ elif any(kw in s for kw in ("продан", "sold")):
result["status"] = STATUS_SOLD
- elif any(kw in s for kw in ("бронь", "забронирована", "reserved")):
+ elif any(kw in s for kw in ("бронь", "забронирован", "reserved")):
result["status"] = STATUS_RESERVED
# Finishing type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ"
@@ -483,7 +506,18 @@ async def scrape_one_flat(
outcome["fields_extracted"] = len([v for v in data.values() if v is not None])
outcome["updated"] = upsert_catalog_data(db, ods_id, catalog_url_hash, data)
+ # success отражает прохождение пайплайна fetch+parse без исключения; реально
+ # ли затронута строка в БД — см. outcome['updated']. Батч-статистика считает
+ # отдельный stats['updated'], чтобы не рапортовать ложно высокий success при
+ # ненайденном ods_id / пустом парсе (fields_extracted==0) — issue #1610.
outcome["success"] = True
+ if not outcome["updated"]:
+ logger.warning(
+ "catalog scrape ods_id=%s: fetched+parsed but DB row NOT updated "
+ "(fields=%d, ods_id missing or all-NULL parse)",
+ ods_id,
+ outcome["fields_extracted"],
+ )
logger.info(
"catalog scrape ods_id=%s: fields=%d updated=%s",
ods_id,
@@ -512,11 +546,15 @@ async def scrape_catalog_batch(
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
Returns:
- {total, success, failed, fields_total}
+ {total, success, updated, failed, fields_total}
+ - success: прошли fetch+parse без исключения
+ - updated: реально затронули строку в БД (issue #1610) — отражает
+ фактическое число записанных квартир, в отличие от success
"""
stats: dict[str, Any] = {
"total": len(flats),
"success": 0,
+ "updated": 0,
"failed": 0,
"fields_total": 0,
}
@@ -550,6 +588,8 @@ async def scrape_catalog_batch(
if outcome["success"]:
stats["success"] += 1
stats["fields_total"] += outcome["fields_extracted"]
+ if outcome["updated"]:
+ stats["updated"] += 1
else:
stats["failed"] += 1
@@ -565,9 +605,10 @@ async def scrape_catalog_batch(
raise
logger.info(
- "scrape_catalog_batch done: total=%d success=%d failed=%d fields_total=%d",
+ "scrape_catalog_batch done: total=%d success=%d updated=%d failed=%d fields_total=%d",
stats["total"],
stats["success"],
+ stats["updated"],
stats["failed"],
stats["fields_total"],
)
diff --git a/backend/app/services/scrapers/domrf_kn.py b/backend/app/services/scrapers/domrf_kn.py
index 5cdc0094..d018cb6e 100644
--- a/backend/app/services/scrapers/domrf_kn.py
+++ b/backend/app/services/scrapers/domrf_kn.py
@@ -115,6 +115,30 @@ def _to_int(v: Any) -> int | None:
return None
+def _to_float(v: Any) -> float | None:
+ """Coerce a DOM.РФ numeric value into float. None / bool / non-numeric / empty → None.
+ Accepts int, float, and numeric strings ('45.2'). Mirror of _to_int for area/price."""
+ if v is None or isinstance(v, bool):
+ return None
+ if isinstance(v, int | float):
+ f = float(v)
+ if f != f or f in (float("inf"), float("-inf")): # NaN / ±inf guard
+ return None
+ return f
+ if isinstance(v, str):
+ s = v.strip()
+ if not s:
+ return None
+ try:
+ f = float(s)
+ except (ValueError, OverflowError):
+ return None
+ if f != f or f in (float("inf"), float("-inf")):
+ return None
+ return f
+ return None
+
+
def _to_date(v: Any) -> date | None:
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
'DD-MM-YYYY HH:MM:SS', or quarter-string like 'IV кв. 2028'.
@@ -414,8 +438,13 @@ def _norm_flat(row: dict[str, Any], region_cd: int | None) -> dict[str, Any]:
# Derive price_per_m2 when API returns price_rub but omits pricePerSquareMeter.
# Covers cases where the table endpoint has the flat price but no pre-computed m² rate.
- if price_per_m2 is None and price_rub is not None and total_area and total_area > 0:
- price_per_m2 = round(price_rub / total_area, 2)
+ # Coerce оба операнда в float ДО сравнения/деления: API иногда отдаёт totalArea/price
+ # строкой ('45.2'), и `total_area > 0` на str роняло бы _norm_flat с TypeError
+ # → весь run падал бы в status='failed' (#1644).
+ price_rub_num = _to_float(price_rub)
+ total_area_num = _to_float(total_area)
+ if price_per_m2 is None and price_rub_num is not None and total_area_num and total_area_num > 0:
+ price_per_m2 = round(price_rub_num / total_area_num, 2)
logger.info(
"derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s",
price_per_m2,
@@ -1432,34 +1461,78 @@ def _place_str(region_code: int) -> str:
return str(region_code)
+OBJECTS_PAGE_SIZE = 500
+
+
async def fetch_objects_for_status(
sess: BrowserSession, place: str, status: int
) -> list[dict[str, Any]]:
- """Fetch ALL objects for a given (place, objStatus) — server returns up to limit=999999."""
- payload = await sess.get_json(
- PATH_OBJECTS,
- {
- "offset": 0,
- "limit": 999999,
- "sortField": "default",
- "sortType": "desc",
- "place": place,
- "objStatus": status,
- },
+ """Fetch ALL objects for a given (place, objStatus), пагинируя по страницам.
+
+ Раньше делали единственный запрос с limit=999999 и доверяли допущению, что сервер
+ вернёт всё. Если WAF/прокси DOM.РФ обрезает гигантский limit до своего max, хвост
+ объектов молча терялся (не скрейпился), а run всё равно рапортовал status='done' (#1605).
+ Теперь идём страницами по OBJECTS_PAGE_SIZE и аккумулируем, пока не наберём total
+ (из payload) либо страница не вернётся короче запрошенной / пустой.
+ """
+ rows: list[dict[str, Any]] = []
+ offset = 0
+ total: int | None = None
+ while True:
+ payload = await sess.get_json(
+ PATH_OBJECTS,
+ {
+ "offset": offset,
+ "limit": OBJECTS_PAGE_SIZE,
+ "sortField": "default",
+ "sortType": "desc",
+ "place": place,
+ "objStatus": status,
+ },
+ )
+ page = _extract_list(payload)
+ if total is None:
+ total = _extract_total(payload)
+ rows.extend(page)
+ # Стоп-условия: пустая страница, недобор до размера страницы (последняя),
+ # либо набрали заявленный total. total может быть None (сервер его не отдал) —
+ # тогда полагаемся на размер страницы как сигнал конца.
+ if not page or len(page) < OBJECTS_PAGE_SIZE:
+ break
+ if total is not None and len(rows) >= total:
+ break
+ offset += OBJECTS_PAGE_SIZE
+
+ logger.info(
+ "kn/object place=%s status=%d -> %d/%s rows (paginated, page=%d)",
+ place,
+ status,
+ len(rows),
+ total,
+ OBJECTS_PAGE_SIZE,
)
- rows = _extract_list(payload)
- total = _extract_total(payload)
- logger.info("kn/object place=%s status=%d -> %d/%s rows", place, status, len(rows), total)
+ if total is not None and len(rows) < total:
+ logger.warning(
+ "kn/object place=%s status=%d: получено %d < total=%d — возможен недобор хвоста",
+ place,
+ status,
+ len(rows),
+ total,
+ )
return rows
async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict[str, Any]]:
- """Fetch flat-table for one object, return flat rows (entrance/floor flattened)."""
- try:
- payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
- except Exception as e:
- logger.warning("flats fetch obj=%s failed: %s", obj_id, e)
- return []
+ """Fetch flat-table for one object, return flat rows (entrance/floor flattened).
+
+ On HTTP / WAF / non-JSON errors raises — caller (_fetch_flats_safe) ловит и кладёт
+ Exception в result-tuple, который result-loop отдаёт в _classify_and_log →
+ kn_scrape_failures. Раньше try/except здесь возвращал [] на ЛЮБУЮ ошибку, из-за чего
+ провал /portal/table (429, 5xx, WAF-challenge) не попадал в журнал отказов, а run
+ рапортовал ложную полноту по квартирам (#1643). Поведение теперь как у остальных
+ fetch_* endpoint'ов, которые исключения не глотают.
+ """
+ payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
# Body shape: {externalId, entrances: [{entranceNumber, floors:[{floorNumber, flats:[...]}]}]}
return _flatten_table(payload)
diff --git a/backend/app/services/scrapers/ekburg_permits.py b/backend/app/services/scrapers/ekburg_permits.py
index 4a678f95..50217aa8 100644
--- a/backend/app/services/scrapers/ekburg_permits.py
+++ b/backend/app/services/scrapers/ekburg_permits.py
@@ -83,7 +83,7 @@ _EKB_LAT_MAX = 57.5
# Паттерн для разбора первого числа из строки координат
# (ячейки могут содержать несколько точек через пробелы или запятую-десятичный разделитель)
-_COORD_FIRST_RE = re.compile(r"[\d]+[.,][\d]+")
+_COORD_FIRST_RE = re.compile(r"[\d]+(?:[.,][\d]+)?")
def msk66_to_wgs84(raw_x: str | None, raw_y: str | None) -> tuple[float, float] | None:
@@ -375,26 +375,28 @@ class EkburgPermitsClient:
Пропускает листы «Справочник», «Лист1» и неизвестные.
"""
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
+ try:
+ for sheet_name in wb.sheetnames:
+ if sheet_name.lower() in _SKIP_SHEETS:
+ continue
- for sheet_name in wb.sheetnames:
- if sheet_name.lower() in _SKIP_SHEETS:
- continue
+ permit_type = _detect_permit_type(sheet_name)
+ if permit_type is None:
+ logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
+ continue
- permit_type = _detect_permit_type(sheet_name)
- if permit_type is None:
- logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
- continue
-
- sheet = wb[sheet_name]
- data_start = _detect_header_row(sheet)
- logger.info(
- "Parsing sheet %r (%s) year=%d, data starts at row %d",
- sheet_name,
- permit_type,
- year,
- data_start,
- )
- yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
+ sheet = wb[sheet_name]
+ data_start = _detect_header_row(sheet)
+ logger.info(
+ "Parsing sheet %r (%s) year=%d, data starts at row %d",
+ sheet_name,
+ permit_type,
+ year,
+ data_start,
+ )
+ yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
+ finally:
+ wb.close()
def _parse_sheet(
self,
diff --git a/backend/app/services/scrapers/nspd_denorm.py b/backend/app/services/scrapers/nspd_denorm.py
index aef36139..58082cb7 100644
--- a/backend/app/services/scrapers/nspd_denorm.py
+++ b/backend/app/services/scrapers/nspd_denorm.py
@@ -329,25 +329,37 @@ def denorm_dump(
features: плоский list из features_json JSONB (уже декодированный Python list).
Returns:
- dict {"parcels": N, "buildings": M, "errors": K} — количество обработанных строк.
+ dict {"parcels": N, "buildings": M, "errors": K, "skipped": S} —
+ количество обработанных строк. ``errors`` — только реальные сбои UPSERT;
+ ``skipped`` — штатные пропуски feature без cad_num.
"""
snapshot_date = datetime.date.today().isoformat()
parcels_n = 0
buildings_n = 0
errors_n = 0
+ skipped_n = 0
for feat in features:
layer = feat.get("layer", "")
try:
if layer == "parcels":
- if denorm_parcel_feature(
+ # denorm_parcel_feature возвращает False и при штатном пропуске
+ # (нет cad_num), и при реальном сбое UPSERT — различаем их здесь,
+ # чтобы не завышать error-счётчик прогона.
+ props = feat.get("properties") or {}
+ if not (props.get("cad_num") or props.get("cadastral_number")):
+ skipped_n += 1
+ elif denorm_parcel_feature(
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
):
parcels_n += 1
else:
errors_n += 1
elif layer == "buildings":
- if denorm_building_feature(
+ props = feat.get("properties") or {}
+ if not (props.get("cad_num") or props.get("cadastral_number")):
+ skipped_n += 1
+ elif denorm_building_feature(
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
):
buildings_n += 1
@@ -360,10 +372,16 @@ def denorm_dump(
db.commit()
logger.info(
- "denorm_dump quarter=%s parcels=%d buildings=%d errors=%d",
+ "denorm_dump quarter=%s parcels=%d buildings=%d errors=%d skipped=%d",
quarter_cad,
parcels_n,
buildings_n,
errors_n,
+ skipped_n,
)
- return {"parcels": parcels_n, "buildings": buildings_n, "errors": errors_n}
+ return {
+ "parcels": parcels_n,
+ "buildings": buildings_n,
+ "errors": errors_n,
+ "skipped": skipped_n,
+ }
diff --git a/backend/app/services/scrapers/nspd_lite.py b/backend/app/services/scrapers/nspd_lite.py
index 0015c34c..f439aadf 100644
--- a/backend/app/services/scrapers/nspd_lite.py
+++ b/backend/app/services/scrapers/nspd_lite.py
@@ -113,7 +113,17 @@ def fetch_geoportal(
try:
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
body = r.read().decode("utf-8", "ignore")
- return json.loads(body)
+ try:
+ return json.loads(body)
+ except json.JSONDecodeError as e:
+ # HTTP 200, но тело — не JSON. Это WAF/прокси-челлендж (HTML или
+ # пустое тело) с кодом 200 вместо 403/429. Та же transient-ситуация,
+ # что и явный WAF → NspdLiteWafError, чтобы caller сделал backoff
+ # (harvest_quarter autoretry_for=(NspdLiteWafError,)), а не пометил
+ # строку постоянным harvest_error.
+ raise NspdLiteWafError(
+ f"HTTP 200 but non-JSON body (WAF challenge?): {body[:300]}"
+ ) from e
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
if e.code in (403, 429):
@@ -162,6 +172,12 @@ def fetch_via_rosreestr2coord(
_ = delay # silence unused — см. docstring выше
try:
from rosreestr2coord.parser import Area
+ from rosreestr2coord.request.exceptions import (
+ HTTPErrorException,
+ HTTPForbiddenException,
+ RequestException,
+ TimeoutException,
+ )
except ImportError as e:
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
@@ -170,19 +186,33 @@ def fetch_via_rosreestr2coord(
# denied при первом fetch.
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
- a = Area(
- code=cad_num,
- area_type=area_type,
- timeout=timeout,
- with_log=False,
- use_cache=False,
- media_path="/tmp/rosreestr2coord",
- )
+ #
+ # NB: with_log=False → конструктор Area() сразу делает HTTP-запрос
+ # (get_geometry без try/except), поэтому WAF/сетевые исключения летят уже
+ # отсюда, а не из to_geojson_poly(). Оба вызова под одним try.
try:
+ a = Area(
+ code=cad_num,
+ area_type=area_type,
+ timeout=timeout,
+ with_log=False,
+ use_cache=False,
+ media_path="/tmp/rosreestr2coord",
+ )
# dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
# строку. Default в v5 = True → строка → крах в `_persist_target` который
# ожидает dict с `.get("properties")` etc.
return a.to_geojson_poly(dumps=False)
- except Exception as e:
- logger.warning("rosreestr2coord failed for %s: %s", cad_num, e)
- return None
+ except (HTTPForbiddenException, HTTPErrorException, TimeoutException) as e:
+ # WAF/rate-limit (HTTP 403 → HTTPForbiddenException, 429/прочие HTTP →
+ # HTTPErrorException) и таймауты — transient. Поднимаем NspdLiteWafError,
+ # чтобы воркер process_nspd_geo_job сделал backoff (exponential, инкремент
+ # waf_blocked_count, пауза после серии WAF), а не пометил цель 'done' с 0
+ # features (false run-status). См. nspd_geo.py:464.
+ raise NspdLiteWafError(f"rosreestr2coord WAF/transient for {cad_num}: {e}") from e
+ except RequestException as e:
+ # Прочие ошибки запроса (RequestException, в т.ч. is_error_response с
+ # сообщением об ошибке от NSPD) — не отличить от transient WAF надёжно,
+ # но это НЕ легитимное 'участок не найден' (то возвращает feature=None →
+ # None без исключения). Классифицируем как ошибку, не как пустой результат.
+ raise NspdLiteError(f"rosreestr2coord request failed for {cad_num}: {e}") from e
diff --git a/backend/app/services/scrapers/obj_checks.py b/backend/app/services/scrapers/obj_checks.py
index 23a78aed..9fbf7cb1 100644
--- a/backend/app/services/scrapers/obj_checks.py
+++ b/backend/app/services/scrapers/obj_checks.py
@@ -69,6 +69,35 @@ _CHECK_TYPE_ALIASES: dict[str, list[str]] = {
"declaration": ["declaration", "hasDeclaration", "declarationFlg"],
}
+# Строковые флаги, которые сторонний API может прислать вместо bool.
+# Схема payload не верифицирована (см. docstring), поэтому приводим явно.
+_TRUE_STRINGS = {"true", "1", "yes", "y", "да", "passed", "ok"}
+_FALSE_STRINGS = {"false", "0", "no", "n", "нет", "failed", "not_passed"}
+
+
+def _coerce_flag(value: Any) -> bool | None:
+ """Привести значение флага проверки к bool либо None (UNKNOWN).
+
+ bool(value) ломается на строках ('false'/'0'/'нет' → True) и не отличает
+ отсутствие данных от False. Возвращаем None, если значение нераспознаваемо —
+ вызывающий код НЕ должен фабриковать False для UNKNOWN.
+ """
+ if isinstance(value, bool):
+ return value
+ if value is None:
+ return None
+ if isinstance(value, (int, float)):
+ return bool(value)
+ if isinstance(value, str):
+ s = value.strip().lower()
+ if s in _TRUE_STRINGS:
+ return True
+ if s in _FALSE_STRINGS:
+ return False
+ return None
+ return None
+
+
_UPSERT_CHECKS_SQL = text(
"""
INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at)
@@ -106,14 +135,21 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
for field, value in data.items():
ct = _CHECK_FIELD_MAP.get(field)
if ct and ct not in found:
- found[ct] = bool(value)
+ flag = _coerce_flag(value)
+ if flag is not None:
+ found[ct] = flag
# Также проверить canonical names напрямую
for ct in CHECK_TYPES:
if ct not in found and ct in data:
- found[ct] = bool(data[ct])
+ flag = _coerce_flag(data[ct])
+ if flag is not None:
+ found[ct] = flag
if found:
+ # Только фактически найденные флаги: отсутствие в payload = UNKNOWN,
+ # а не FAILED — не фабрикуем passed=False для непришедших проверок.
for ct in CHECK_TYPES:
- results.append({"check_type": ct, "passed": found.get(ct, False)})
+ if ct in found:
+ results.append({"check_type": ct, "passed": found[ct]})
return results
# dict не содержит известных полей — попробуем как list-формат ниже
logger.warning(
@@ -128,11 +164,24 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
continue
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
if ct_raw and str(ct_raw) in CHECK_TYPES:
- passed_raw = item.get("passed") or item.get("value") or item.get("status")
- found_list[str(ct_raw)] = bool(passed_raw)
+ # Не or-коалесинг: легитимный False теряется (False or 'n/a' → 'n/a').
+ # Берём первый ключ, который реально присутствует в item.
+ if "passed" in item:
+ passed_raw = item["passed"]
+ elif "value" in item:
+ passed_raw = item["value"]
+ elif "status" in item:
+ passed_raw = item["status"]
+ else:
+ passed_raw = None
+ flag = _coerce_flag(passed_raw)
+ if flag is not None:
+ found_list[str(ct_raw)] = flag
if found_list:
+ # См. dict-ветку: только найденные флаги, UNKNOWN не равно FAILED.
for ct in CHECK_TYPES:
- results.append({"check_type": ct, "passed": found_list.get(ct, False)})
+ if ct in found_list:
+ results.append({"check_type": ct, "passed": found_list[ct]})
return results
logger.warning(
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3]
diff --git a/backend/app/services/scrapers/stealth.py b/backend/app/services/scrapers/stealth.py
index acd590af..53b1f635 100644
--- a/backend/app/services/scrapers/stealth.py
+++ b/backend/app/services/scrapers/stealth.py
@@ -227,13 +227,14 @@ class BrowserSession:
"""
if self._context is None:
raise RuntimeError("BrowserSession not bootstrapped")
- await jitter_sleep(200, 500) # Lighter throttle for static assets.
- self._request_count += 1
- resp = await self._context.request.get(
- url,
- headers={"Authorization": self.auth} if self.auth else {},
- )
- if resp.status != 200:
- body = await resp.text()
- raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
- return await resp.body()
+ async with self._sem:
+ await jitter_sleep(200, 500) # Lighter throttle for static assets.
+ self._request_count += 1
+ resp = await self._context.request.get(
+ url,
+ headers={"Authorization": self.auth} if self.auth else {},
+ )
+ if resp.status != 200:
+ body = await resp.text()
+ raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
+ return await resp.body()
diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py
index 0235565c..5d436a0a 100644
--- a/backend/app/services/site_finder/competitors.py
+++ b/backend/app/services/site_finder/competitors.py
@@ -488,17 +488,82 @@ _AVG_PRICE_SQL = text("""
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
+#
+# #1615: velocity обогащается из ДВУХ источников (см. _COMPETITORS_SQL mapped CTE) —
+# явного objective_complex_mapping И спатиально-именного nearest_cx gap-fill. Ценовой
+# fallback должен покрывать ОБА, иначе конкурент с velocity>0 из spatial-матча получает
+# avg_price=None и price_similarity падает в нейтраль. Зеркалим тот же мост obj→lots:
+# PRIMARY: objective_complex_mapping.objective_complex_name == objective_lots.project_name
+# GAP-FILL: nearest_cx (≤ :velocity_match_radius_m м + tolerant-name) → complex_id →
+# objective_lots по complex_id (тот же DISTINCT ON ближайший complex, что и
+# velocity gap-fill — обязан совпадать, чтобы цена и velocity были про ОДИН ЖК).
+# obj_id мапится в РОВНО один источник (mapping 1:1; gap-fill — только для obj_id ВНЕ
+# mapping, см. NOT IN ниже), поэтому пересечения нет и UNION ALL безопасен.
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
+ WITH primary_price AS (
+ SELECT
+ cm.domrf_obj_id AS obj_id,
+ ol.price_per_m2_rub AS price_per_m2_rub
+ FROM objective_complex_mapping cm
+ JOIN objective_lots ol
+ ON ol.project_name = cm.objective_complex_name
+ WHERE cm.domrf_obj_id = ANY(:obj_ids)
+ AND ol.price_per_m2_rub IS NOT NULL
+ ),
+ nearest_cx AS (
+ SELECT DISTINCT ON (o.obj_id)
+ o.obj_id,
+ c.id AS complex_id
+ FROM domrf_kn_objects o
+ JOIN complexes c
+ ON c.latitude IS NOT NULL
+ AND c.longitude IS NOT NULL
+ AND c.canonical_name IS NOT NULL
+ AND EXISTS (
+ SELECT 1 FROM objective_lots ol
+ WHERE ol.complex_id = c.id AND ol.project_name IS NOT NULL
+ )
+ AND ST_DWithin(
+ ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
+ ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography,
+ CAST(:velocity_match_radius_m AS float)
+ )
+ AND (
+ lower(btrim(o.comm_name)) = lower(btrim(c.canonical_name))
+ OR lower(btrim(c.canonical_name)) LIKE '%' || lower(btrim(o.comm_name)) || '%'
+ OR lower(btrim(o.comm_name)) LIKE '%' || lower(btrim(c.canonical_name)) || '%'
+ )
+ WHERE o.obj_id = ANY(:obj_ids)
+ AND o.latitude IS NOT NULL
+ AND o.longitude IS NOT NULL
+ AND o.comm_name IS NOT NULL
+ AND btrim(o.comm_name) <> ''
+ AND o.obj_id NOT IN (SELECT domrf_obj_id FROM objective_complex_mapping)
+ ORDER BY o.obj_id,
+ ST_Distance(
+ ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
+ ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326)::geography
+ ) ASC
+ ),
+ gapfill_price AS (
+ SELECT
+ nc.obj_id AS obj_id,
+ ol.price_per_m2_rub AS price_per_m2_rub
+ FROM nearest_cx nc
+ JOIN objective_lots ol
+ ON ol.complex_id = nc.complex_id
+ AND ol.price_per_m2_rub IS NOT NULL
+ )
SELECT
- cm.domrf_obj_id AS obj_id,
- percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
+ p.obj_id,
+ percentile_cont(0.5) WITHIN GROUP (ORDER BY p.price_per_m2_rub)
AS median_price_per_m2
- FROM objective_complex_mapping cm
- JOIN objective_lots ol
- ON ol.project_name = cm.objective_complex_name
- WHERE cm.domrf_obj_id = ANY(:obj_ids)
- AND ol.price_per_m2_rub IS NOT NULL
- GROUP BY cm.domrf_obj_id
+ FROM (
+ SELECT obj_id, price_per_m2_rub FROM primary_price
+ UNION ALL
+ SELECT obj_id, price_per_m2_rub FROM gapfill_price
+ ) p
+ GROUP BY p.obj_id
""")
@@ -648,7 +713,13 @@ def get_competitors(
if missing_price_ids:
try:
obj_price_rows = (
- db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids})
+ db.execute(
+ _OBJECTIVE_PRICE_FALLBACK_SQL,
+ {
+ "obj_ids": missing_price_ids,
+ "velocity_match_radius_m": _VELOCITY_MATCH_RADIUS_M,
+ },
+ )
.mappings()
.all()
)
diff --git a/backend/app/services/site_finder/granddoc_lookup.py b/backend/app/services/site_finder/granddoc_lookup.py
index e91c3f5d..6b71b9c0 100644
--- a/backend/app/services/site_finder/granddoc_lookup.py
+++ b/backend/app/services/site_finder/granddoc_lookup.py
@@ -79,7 +79,7 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
Returns:
list[{project_type, doc_status_name, is_active, paga_number, paga_date,
subject, doc_full_name}].
- - ``is_active``: doc_status_name == 'Действующий'.
+ - ``is_active``: doc_status_name == 'действующий' (регистронезависимо).
- ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось.
- ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет.
Отсортировано: сначала действующие, внутри группы — по paga_date desc (свежие первыми).
@@ -97,7 +97,10 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
for r in rows:
full_name: str | None = r["full_name"]
paga_number, paga_date, subject = _parse_paga(full_name)
- is_active = r["doc_status_name"] == "Действующий"
+ status_name: str | None = r["doc_status_name"]
+ # Регистронезависимо: harvest пишет doc_status_name из WFS без нормализации,
+ # DDL/COMMENT документируют хранимое значение как lowercase ('действующий').
+ is_active = (status_name or "").strip().lower() == "действующий"
result.append(
{
"project_type": r["project_type"],
diff --git a/backend/app/services/site_finder/ppt_tep_lookup.py b/backend/app/services/site_finder/ppt_tep_lookup.py
index ef6ebcec..b499970e 100644
--- a/backend/app/services/site_finder/ppt_tep_lookup.py
+++ b/backend/app/services/site_finder/ppt_tep_lookup.py
@@ -10,8 +10,14 @@
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
source_key — целое и не совпадает форматом. Сопоставление — две дешёвые ветки:
-1) ``doc_ref = source_key::text`` (прямое равенство);
-2) ``doc_ref ILIKE '%doc_full_name%'`` (fallback для slug-ов вида ppt2018_22823).
+1) ``doc_ref = CAST(source_key AS text)`` (прямое равенство);
+2) ``doc_full_name ILIKE '%' || doc_ref || '%'`` (fallback для slug-ов вида
+ ppt2018_22823: длинное WFS-описание содержит короткий slug парсера; LIKE-метасимволы
+ в doc_ref экранируются ESCAPE '\').
+
+Дедуп: best-effort OR-JOIN может зацепить несколько строк ``ekb_ppt_tep`` на один
+``planning_projects`` (точная ветка + ILIKE-fallback). ``DISTINCT ON (source_key)``
+держит контракт «одна строка = один ППТ/ПМТ-документ», отдавая приоритет точной ветке.
Граф вызова: build_ird_analyze_block → parcel_ppt_tep → JOIN planning_projects ∩ ekb_ppt_tep.
Graceful: нет таблицы / пустой WKT / БД-ошибка → []. Зеркалит стиль ``planning_lookup.py``.
@@ -33,26 +39,49 @@ logger = logging.getLogger(__name__)
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
_PPT_TEP_OVERLAP_SQL = text(
- """
+ r"""
SELECT
- pp.project_type,
- pp.doc_status_name,
- pp.full_name,
- pp.doc_full_name,
- pp.source_key,
- t.doc_ref,
- t.zone_balance,
- t.tep,
- t.phasing,
- t.source_pdf,
- t.fetched_at
- FROM planning_projects pp
- JOIN ekb_ppt_tep t ON (
- t.doc_ref = CAST(pp.source_key AS text)
- OR (pp.doc_full_name IS NOT NULL AND t.doc_ref ILIKE '%' || pp.doc_full_name || '%')
- )
- WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
- ORDER BY pp.dmd_actual_year DESC NULLS LAST, pp.project_type
+ project_type,
+ doc_status_name,
+ full_name,
+ doc_full_name,
+ source_key,
+ doc_ref,
+ zone_balance,
+ tep,
+ phasing,
+ source_pdf,
+ fetched_at
+ FROM (
+ -- DISTINCT ON (source_key): один ППТ/ПМТ-документ = одна строка (контракт docstring).
+ -- Точная ветка (doc_ref = source_key) приоритетна над slug-fallback (ILIKE).
+ SELECT DISTINCT ON (pp.source_key)
+ pp.project_type,
+ pp.doc_status_name,
+ pp.full_name,
+ pp.doc_full_name,
+ pp.source_key,
+ pp.dmd_actual_year,
+ t.doc_ref,
+ t.zone_balance,
+ t.tep,
+ t.phasing,
+ t.source_pdf,
+ t.fetched_at
+ FROM planning_projects pp
+ JOIN ekb_ppt_tep t ON (
+ t.doc_ref = CAST(pp.source_key AS text)
+ OR (
+ pp.doc_full_name IS NOT NULL
+ AND pp.doc_full_name ILIKE
+ '%' || replace(replace(replace(t.doc_ref, '\', '\\'), '%', '\%'), '_', '\_')
+ || '%' ESCAPE '\'
+ )
+ )
+ WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
+ ORDER BY pp.source_key, (t.doc_ref = CAST(pp.source_key AS text)) DESC
+ ) deduped
+ ORDER BY dmd_actual_year DESC NULLS LAST, project_type
"""
)
diff --git a/backend/app/services/site_finder/premises_lookup.py b/backend/app/services/site_finder/premises_lookup.py
index 892a0be7..dfdb476f 100644
--- a/backend/app/services/site_finder/premises_lookup.py
+++ b/backend/app/services/site_finder/premises_lookup.py
@@ -74,13 +74,16 @@ class BuildingMatch:
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
-# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает
-# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по
-# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на
-# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно
-# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка
-# внутри площадного footprint даёт ST_Distance=0 (ST_Contains-эквивалент, см.
-# прод: 198 объектов внутри). psycopg v3: CAST(:x AS double precision) — НЕ ::type.
+# Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
+# (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
+# ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
+# Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
+# широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
+# кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
+# (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
+# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
+# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
+# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
_RESOLVE_CAD_SQL = text("""
SELECT
b.cad_num,
@@ -97,13 +100,13 @@ _RESOLVE_CAD_SQL = text("""
) AS distance_m
FROM cad_buildings b
WHERE b.geom IS NOT NULL
- ORDER BY b.geom <-> ST_SetSRID(
+ ORDER BY b.geom::geography <-> ST_SetSRID(
ST_MakePoint(
CAST(:lon AS double precision),
CAST(:lat AS double precision)
),
4326
- )
+ )::geography
LIMIT 1
""")
@@ -119,8 +122,9 @@ def resolve_cad_for_domrf(
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
- GIST-ускоренному KNN (`geom <-> point`) и принимаем матч, если расстояние до
- footprint ≤ max_dist_m. Точка внутри площадного footprint → distance 0.
+ GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
+ принимаем матч, если расстояние до footprint ≤ max_dist_m. Точка внутри
+ площадного footprint → distance 0.
Returns:
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
diff --git a/backend/app/services/site_finder/quarter_price_index_refresh.py b/backend/app/services/site_finder/quarter_price_index_refresh.py
index 56e7a558..2031f015 100644
--- a/backend/app/services/site_finder/quarter_price_index_refresh.py
+++ b/backend/app/services/site_finder/quarter_price_index_refresh.py
@@ -22,7 +22,7 @@ from __future__ import annotations
import logging
from sqlalchemy import text
-from sqlalchemy.exc import OperationalError
+from sqlalchemy.exc import DatabaseError
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
@@ -41,8 +41,11 @@ def _refresh_mv(db: Session, mv_name: str, *, concurrently: bool) -> None:
else:
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
db.commit()
- except OperationalError as e:
- if concurrently and "cannot refresh materialized view" in str(e).lower():
+ except DatabaseError as e:
+ # PostgreSQL emits "CONCURRENTLY cannot be used when the materialized
+ # view ... is not populated" (matview.c, SQLSTATE 55000), which psycopg3
+ # surfaces as InternalError (a DatabaseError sibling of OperationalError).
+ if concurrently and "concurrently cannot be used" in str(e).lower():
logger.warning(
"%s: CONCURRENTLY failed (MV likely not populated), "
"falling back to non-concurrent refresh",
diff --git a/backend/app/services/site_finder/zone_regulation.py b/backend/app/services/site_finder/zone_regulation.py
index 9c00238c..34e653c1 100644
--- a/backend/app/services/site_finder/zone_regulation.py
+++ b/backend/app/services/site_finder/zone_regulation.py
@@ -78,7 +78,7 @@ _RE_FLOORS = re.compile(
+ _DASH
+ r"\s*"
+ _NUM
- + r"\s*этаж",
+ + r"(?:\s*этаж\w*)?",
re.IGNORECASE,
)
# высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)
diff --git a/backend/app/services/weather_cache.py b/backend/app/services/weather_cache.py
index a037924f..f79c44fb 100644
--- a/backend/app/services/weather_cache.py
+++ b/backend/app/services/weather_cache.py
@@ -130,12 +130,15 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
if not daily.get("time"):
return None
- t_max = daily.get("temperature_2m_max") or []
- t_min = daily.get("temperature_2m_min") or []
- precip = daily.get("precipitation_sum") or []
- uv = daily.get("uv_index_max") or []
+ # Open-Meteo штатно возвращает null для отдельных дней (uv_index_max и др.)
+ # при непустом daily.time — отфильтровываем None ПЕРЕД min/max/sum, иначе
+ # TypeError в Python 3.12 уронит весь 7-day forecast в negative-cache (#1577).
+ t_max = [v for v in (daily.get("temperature_2m_max") or []) if v is not None]
+ t_min = [v for v in (daily.get("temperature_2m_min") or []) if v is not None]
+ precip = [v for v in (daily.get("precipitation_sum") or []) if v is not None]
+ uv = [v for v in (daily.get("uv_index_max") or []) if v is not None]
wind_d = daily.get("winddirection_10m_dominant") or []
- wind_s = daily.get("windspeed_10m_max") or []
+ wind_s = [v for v in (daily.get("windspeed_10m_max") or []) if v is not None]
# Circular mean направления ветра (vector sum) — избегает jump 359→1
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
@@ -226,13 +229,21 @@ def _fetch_seasonal_remote(lat: float, lon: float) -> dict[str, Any] | None:
if not vals["t_max"]:
seasons[season] = None
continue
+ # t_min/precip накапливаются НЕЗАВИСИМО от t_max (раздельные None-guard'ы
+ # выше) — при непустом t_max и all-null t_min/precip эти списки пусты, и
+ # sum()/len() даст ZeroDivisionError, min([]) — ValueError (#1578). Метрики
+ # по пустому списку → None вместо падения всего сезонного ответа.
+ t_min = vals["t_min"]
+ precip = vals["precip"]
seasons[season] = {
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
- "avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1),
+ "avg_t_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None,
"max_t_c": round(max(vals["t_max"]), 1),
- "min_t_c": round(min(vals["t_min"]), 1),
- "avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
- "total_precip_mm": round(sum(vals["precip"]), 0),
+ "min_t_c": round(min(t_min), 1) if t_min else None,
+ "avg_precip_per_day_mm": (
+ round(sum(precip) / len(precip), 1) if precip else None
+ ),
+ "total_precip_mm": round(sum(precip), 0) if precip else 0,
"days_observed": len(vals["t_max"]),
}
return {
diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py
index 22d2345b..fa25d58f 100644
--- a/backend/app/workers/celery_app.py
+++ b/backend/app/workers/celery_app.py
@@ -62,6 +62,8 @@ celery_app = Celery(
"app.workers.tasks.ekburg_permits_sync",
"app.workers.tasks.cbr_macro_sync",
"app.workers.tasks.rosstat_macro_sync",
+ "app.workers.tasks.refresh_quarter_price_index",
+ "app.workers.tasks.etl_newbuilding_crossload",
"app.workers.tasks.supply_layers_refresh",
"app.workers.tasks.location_refresh",
"app.workers.tasks.forecast",
diff --git a/backend/app/workers/lifecycle.py b/backend/app/workers/lifecycle.py
index ea12b219..c440c56f 100644
--- a/backend/app/workers/lifecycle.py
+++ b/backend/app/workers/lifecycle.py
@@ -8,8 +8,10 @@ Handlers регистрируются через Celery signals декорато
worker_process_init — dispose SQLAlchemy engine в каждом prefork child-процессе,
чтобы избежать shared TCP-сокетов к PostgreSQL после fork().
-worker_ready — при рестарте воркера находит все 'running'/'paused' записи
+worker_ready — при рестарте воркера находит 'running'/'paused' записи
kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume.
+ Для nspd_geo 'paused' (WAF-пауза) применяется 30-минутный cooldown
+ (_ZOMBIE_PAUSED_THRESHOLD), чтобы редеплой не аннулировал WAF-защиту.
"""
import logging
@@ -147,10 +149,18 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
# старых runs сохранена в nspd_scrape_runs — никаких side-effects.
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
- # nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' / 'paused' jobs
- # — на worker_ready по определению нет активных воркеров, всё running ==
+ # nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' jobs — на
+ # worker_ready по определению нет активных воркеров, всё running ==
# zombie. Раньше требовали heartbeat >10мин, что пропускало jobs убитых
# за минуту до редеплоя и оставляло их вечно висеть.
+ #
+ # 'paused' (consecutive_waf>=8 — NSPD-WAF забанил IP VPS) НЕ ре-enqueue'им
+ # безусловно: иначе каждый рестарт/редеплой воркера мгновенно аннулировал бы
+ # WAF-cooldown и worker снова долбил бы забаненный сервис. Применяем тот же
+ # 30-минутный порог, что и периодический cleanup_zombies
+ # (_ZOMBIE_PAUSED_THRESHOLD), переиспользуя константу чтобы избежать дрейфа.
+ from app.workers.tasks.nspd_geo import _ZOMBIE_PAUSED_THRESHOLD
+
db = SessionLocal()
geo_resume_jobs: list[int] = []
try:
@@ -161,10 +171,16 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
UPDATE nspd_geo_jobs
SET status = 'queued',
error = COALESCE(error, 'auto-resume at worker_ready')
- WHERE status IN ('running', 'paused')
+ WHERE status = 'running'
+ OR (
+ status = 'paused'
+ AND heartbeat_at
+ < NOW() - CAST(:paused_threshold AS interval)
+ )
RETURNING job_id
"""
- )
+ ),
+ {"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD},
)
.mappings()
.all()
diff --git a/backend/app/workers/tasks/nspd_geo.py b/backend/app/workers/tasks/nspd_geo.py
index 3d0c1b74..027a9a1f 100644
--- a/backend/app/workers/tasks/nspd_geo.py
+++ b/backend/app/workers/tasks/nspd_geo.py
@@ -51,6 +51,16 @@ WAF_BACKOFF_BASE_S = 30
# нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу.
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
_ZOMBIE_PAUSED_THRESHOLD = "30 minutes"
+# Issue #1655: jobs, застрявшие в 'queued' после ре-enqueue (worker_ready /
+# cleanup_zombies ставят 'queued' + apply_async), но чьё broker-сообщение
+# потерялось (flush брокера, crash до pickup, purge очереди) — раньше никто
+# не ре-reap'ил. cleanup_zombies матчил только 'running'/'paused'.
+# Матчим только 'queued' c НЕ-NULL stale heartbeat_at: свежесозданный job
+# (enqueue_geo_job) имеет heartbeat_at IS NULL (нет дефолта в схеме 77_) — его
+# initial apply_async ещё в полёте, ре-reap'ить рано. Порог = running-порог
+# (6 мин): сообщение либо взято воркером (→ status станет 'running' через claim),
+# либо потеряно — 6 мин достаточно, чтобы отличить потерю от задержки очереди.
+_ZOMBIE_QUEUED_THRESHOLD = "6 minutes"
# ── Helpers для job/target lifecycle ────────────────────────────────────────
@@ -79,8 +89,19 @@ def _log(
pass
-def _start_job(db: Session, job_id: int) -> None:
- db.execute(
+def _start_job(db: Session, job_id: int) -> bool:
+ """Атомарный claim job'а: переводит в 'running' ТОЛЬКО если он ещё не 'running'.
+
+ Issue #1621: дублирующие task-сообщения на один job_id (worker_ready resume +
+ cleanup_zombies beat одновременно, либо overlap контейнеров при rolling-redeploy,
+ либо два prefork-child при --concurrency>1) раньше оба безусловно делали
+ UPDATE→'running' и оба входили в while-loop → двойные WAF-хиты + затёртые счётчики.
+
+ Теперь claim атомарен: `WHERE job_id=:id AND status <> 'running'` + RETURNING.
+ Если строка не вернулась — кто-то уже держит claim, второй worker должен выйти.
+ Returns True если claim получен, False если job уже 'running' (или не найден).
+ """
+ claimed = db.execute(
text(
"""
UPDATE nspd_geo_jobs
@@ -89,12 +110,17 @@ def _start_job(db: Session, job_id: int) -> None:
heartbeat_at = NOW(),
updated_at = NOW()
WHERE job_id = :id
+ AND status <> 'running'
+ RETURNING job_id
"""
),
{"id": job_id},
- )
+ ).scalar_one_or_none()
db.commit()
+ if claimed is None:
+ return False
_log(db, job_id, "info", "start", "job started/resumed")
+ return True
def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
@@ -385,7 +411,15 @@ def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
if not job:
return {"error": "job_not_found", "job_id": job_id}
- _start_job(db, job_id)
+ # Атомарный claim (issue #1621): если job уже 'running', значит другое
+ # task-сообщение на тот же job_id уже в работе — выходим, не дублируя
+ # WAF-хиты и не затирая счётчики параллельным циклом.
+ if not _start_job(db, job_id):
+ logger.info(
+ "process_nspd_geo_job: job=%s already claimed (running) — skipping duplicate",
+ job_id,
+ )
+ return {"job_id": job_id, "skipped": True, "reason": "already_running"}
# Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings.
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
if job["rate_ms"]:
@@ -637,6 +671,10 @@ def cleanup_zombies() -> dict[str, Any]:
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин) →
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
сразу аннулировал бы защиту и worker снова долбил бы забаненный сервис.
+ - status='queued' c НЕ-NULL heartbeat старше _ZOMBIE_QUEUED_THRESHOLD (6 мин)
+ → ре-enqueue'нутый job, чьё broker-сообщение потерялось (issue #1655).
+ heartbeat_at IS NOT NULL отсекает свежесозданные jobs (initial apply_async
+ ещё в полёте) — их heartbeat ставится только при первом _start_job.
Idempotent: если зомби нет, ничего не делает. Активный job с свежим heartbeat
не матчит WHERE-clause.
@@ -661,12 +699,22 @@ def cleanup_zombies() -> dict[str, Any]:
AND heartbeat_at
< NOW() - CAST(:paused_threshold AS interval)
)
+ OR (
+ -- issue #1655: ре-enqueue'нутый job застрял в 'queued'
+ -- (потерянное broker-сообщение). heartbeat_at IS NOT NULL
+ -- отсекает свежесозданные jobs с ещё-в-полёте apply_async.
+ status = 'queued'
+ AND heartbeat_at IS NOT NULL
+ AND heartbeat_at
+ < NOW() - CAST(:queued_threshold AS interval)
+ )
RETURNING job_id
"""
),
{
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
+ "queued_threshold": _ZOMBIE_QUEUED_THRESHOLD,
},
)
.mappings()
diff --git a/backend/app/workers/tasks/objective_etl.py b/backend/app/workers/tasks/objective_etl.py
index da447a15..22ab2a4f 100644
--- a/backend/app/workers/tasks/objective_etl.py
+++ b/backend/app/workers/tasks/objective_etl.py
@@ -153,9 +153,12 @@ def import_anton_objective(
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
except Exception:
pass
- # Дополним результат полезной диагностикой
+ # Логируем полезную диагностику, но НЕ возвращаем dict —
+ # re-raise, чтобы Celery-таск ушёл в FAILURE (не SUCCESS) и не
+ # рассинхронился с objective_scrape_runs.status='failed' (#1623).
info = get_sqlite_info(sqlite_path_eff)
- return {"run_id": run_id, "error": "sqlite_not_found", "sqlite_info": info}
+ logger.error("sqlite_not_found diagnostics for run=%s: %s", run_id, info)
+ raise
except Exception as e:
logger.exception("import_anton_objective failed: %s", e)
diff --git a/backend/app/workers/tasks/opportunity_harvest.py b/backend/app/workers/tasks/opportunity_harvest.py
index 5a7589c8..e3e53cc7 100644
--- a/backend/app/workers/tasks/opportunity_harvest.py
+++ b/backend/app/workers/tasks/opportunity_harvest.py
@@ -70,7 +70,10 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
bbox = _geojson_bbox_3857(qfeat.geometry)
if bbox is None:
continue
- n_quarters += 1
+ # Staging-счётчик квартала: прибавляем к итогам ТОЛЬКО после успешного
+ # commit (строка ниже). Иначе при сбое commit + rollback откатятся
+ # незакоммиченные UPSERT'ы, а отчёт завысит число persisted фич (#1624).
+ q_features = 0
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
layer_id = LAYERS.get(layer_key)
if layer_id is None:
@@ -94,10 +97,13 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
feature=feat,
fetched_at=fetched_at,
):
- n_features += 1
+ q_features += 1
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
db.commit()
+ # Commit прошёл → фичи квартала реально persisted, учитываем в отчёте.
+ n_quarters += 1
+ n_features += q_features
except Exception as exc:
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
diff --git a/backend/app/workers/tasks/scrape_cadastre.py b/backend/app/workers/tasks/scrape_cadastre.py
index 70b6fcea..bee2adeb 100644
--- a/backend/app/workers/tasks/scrape_cadastre.py
+++ b/backend/app/workers/tasks/scrape_cadastre.py
@@ -401,11 +401,28 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
)
skipped_fresh = set(rows)
if skipped_fresh:
- # Корректируем targets_total — skipped quarters не входят в прогресс
+ # Корректируем targets_total — skipped quarters не входят в прогресс.
+ # Bug #1654: блок выполняется при КАЖДОМ вызове (resume re-enqueue'ит
+ # тот же enqueue_cadastre_harvest), а уже обработанные кварталы тоже
+ # получают свежий cad_quarter_stats.fetched_at → попадают в
+ # skipped_fresh повторно. Поэтому:
+ # • targets_skipped — идемпотентный SET (= число свежих сейчас),
+ # а не cumulative INCREMENT (иначе раздувается на каждый resume);
+ # • targets_total — :new_total это только ОСТАВШИЕСЯ к обработке
+ # кварталы (len(quarters) − skipped_fresh). При resume уже
+ # обработанные попадают в skipped_fresh, поэтому к остатку
+ # добавляем уже учтённый прогресс (done + failed), иначе total
+ # занижается и _maybe_finish_job помечает job 'done' после первого
+ # доработанного квартала (#1654-followup). GREATEST с самим
+ # прогрессом сохраняет монотонность (total не уменьшается).
db.execute(
text(
- "UPDATE cadastre_jobs SET targets_total = :new_total, "
- "targets_skipped = COALESCE(targets_skipped, 0) + :sk "
+ "UPDATE cadastre_jobs SET "
+ "targets_total = GREATEST("
+ ":new_total + COALESCE(targets_done, 0) + COALESCE(targets_failed, 0), "
+ "COALESCE(targets_done, 0) + COALESCE(targets_failed, 0)"
+ "), "
+ "targets_skipped = :sk "
"WHERE job_id = :id"
),
{
diff --git a/backend/app/workers/tasks/scrape_kn_catalog_objects.py b/backend/app/workers/tasks/scrape_kn_catalog_objects.py
index b956e85b..40d5b786 100644
--- a/backend/app/workers/tasks/scrape_kn_catalog_objects.py
+++ b/backend/app/workers/tasks/scrape_kn_catalog_objects.py
@@ -35,6 +35,10 @@ logger = logging.getLogger(__name__)
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
# При :force = true фильтр снимается — грузим все объекты последнего snapshot.
+#
+# LIMIT :max_objects: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
+# max_objects=None (force "Загрузить все" без явного потолка) грузим ВСЕ строки
+# последнего snapshot, а не молча режем до _DEFAULT_MAX_OBJECTS.
_SELECT_TARGETS_SQL = text(
"""
SELECT obj_id, snapshot_date
@@ -74,10 +78,12 @@ def scrape_kn_catalog_objects(
Args:
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
- max_objects: Максимум объектов за один run. Default 300.
- force: Если True — игнорирует фильтр "уже сегодня обновлён" и грузит
- все объекты последнего snapshot (admin "Загрузить все"). По умолчанию
- False — пропускает то, что уже скраплено сегодня.
+ max_objects: Максимум объектов за один run. Если не задан: при force=True
+ лимита нет (грузим все), при force=False — _DEFAULT_MAX_OBJECTS (300).
+ force: Если True — игнорирует фильтр "уже сегодня обновлён" и (при не
+ заданном max_objects) снимает лимит, грузя ВСЕ объекты последнего
+ snapshot (admin "Загрузить все"). По умолчанию False — пропускает
+ то, что уже скраплено сегодня, и режет batch до 300.
Returns:
dict с ключами: region_code, snapshot_date, obj_ids_count,
@@ -95,7 +101,15 @@ def scrape_kn_catalog_objects(
"""
from app.services.scrapers.domrf_catalog_object import scrape_catalog_objects
- limit = max_objects if max_objects is not None else _DEFAULT_MAX_OBJECTS
+ # Явный max_objects всегда уважается. Без него:
+ # force=True ("Загрузить все") → лимита нет (limit=None → SQL LIMIT NULL = все строки);
+ # force=False (beat / ad-hoc pass) → дефолтный batch _DEFAULT_MAX_OBJECTS.
+ if max_objects is not None:
+ limit: int | None = max_objects
+ elif force:
+ limit = None
+ else:
+ limit = _DEFAULT_MAX_OBJECTS
db = SessionLocal()
try:
@@ -136,11 +150,11 @@ def scrape_kn_catalog_objects(
snapshot_date_val: date = rows[0]["snapshot_date"]
logger.info(
- "scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%d force=%s",
+ "scrape_kn_catalog_objects: region=%d snapshot_date=%s obj_ids=%d limit=%s force=%s",
region_code,
snapshot_date_val,
len(obj_ids),
- limit,
+ "ALL" if limit is None else limit,
force,
)
diff --git a/backend/tests/services/forecasting/test_affordability.py b/backend/tests/services/forecasting/test_affordability.py
index 2e3dd4f6..153cdba0 100644
--- a/backend/tests/services/forecasting/test_affordability.py
+++ b/backend/tests/services/forecasting/test_affordability.py
@@ -269,11 +269,14 @@ class TestPaymentAtScenario:
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
assert res.payment_at_scenario is not None
principal = 120_000.0 * _REF_AREA_M2
+ # rate_path несёт КЛЮЧЕВУЮ ставку сценария; affordability приводит к рыночной
+ # базе (+ _KEY_RATE_MARKET_SPREAD_PP), как и monthly_payment_rub (#1639). Ожидания
+ # выражаем символически — тест переживёт перекалибровку спреда.
assert res.payment_at_scenario[6] == pytest.approx(
- _annuity(principal, 8.0, _ANNUITY_TERM_MONTHS)
+ _annuity(principal, 8.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
)
assert res.payment_at_scenario[12] == pytest.approx(
- _annuity(principal, 20.0, _ANNUITY_TERM_MONTHS)
+ _annuity(principal, 20.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS)
)
# Выше ставка → выше платёж на этом горизонте.
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
diff --git a/backend/tests/services/test_nspd_denorm.py b/backend/tests/services/test_nspd_denorm.py
index 3420a761..ca194b10 100644
--- a/backend/tests/services/test_nspd_denorm.py
+++ b/backend/tests/services/test_nspd_denorm.py
@@ -304,12 +304,15 @@ def test_denorm_dump_empty_features() -> None:
db = _make_mock_session()
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[])
- assert counts == {"parcels": 0, "buildings": 0, "errors": 0}
+ assert counts == {"parcels": 0, "buildings": 0, "errors": 0, "skipped": 0}
db.commit.assert_called_once()
-def test_denorm_dump_no_cad_num_counted_as_error() -> None:
- """Parcel без cad_num → denorm_parcel_feature returns False → errors += 1."""
+def test_denorm_dump_no_cad_num_counted_as_skipped() -> None:
+ """Parcel без cad_num → pre-check в denorm_dump → skipped += 1, не errors.
+
+ denorm_parcel_feature не вызывается вовсе — пропуск штатный, не сбой UPSERT.
+ """
db = _make_mock_session()
feature: dict[str, Any] = {
"layer": "parcels",
@@ -319,4 +322,5 @@ def test_denorm_dump_no_cad_num_counted_as_error() -> None:
}
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature])
assert counts["parcels"] == 0
- assert counts["errors"] == 1
+ assert counts["errors"] == 0
+ assert counts["skipped"] == 1