fix(week-review): backend-аудит v2 — 82 фиксов (#1660)
All checks were successful
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m20s
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m52s

Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
bot-backend 2026-06-17 17:13:38 +00:00 committed by lekss361
parent 50db2b82aa
commit 14f3ef2019
60 changed files with 1345 additions and 397 deletions

View file

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

View file

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

View file

@ -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
@ -2900,58 +2901,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):
@ -2987,6 +2950,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
@ -3228,8 +3247,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:
@ -3282,8 +3303,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:
@ -3301,9 +3324,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,

View file

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

View file

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

View file

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

View file

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

View file

@ -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 для регистрации будущих моделей)

View file

@ -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-текст).

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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="будущий рынок"))

View file

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

View file

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

View file

@ -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"""
<div class="section" id="scenarios">
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": [],

View file

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

View file

@ -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) + "."

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"(?<!\d)[78]\d{10}(?!\d)")
# Телефон РФ «локальный»: 10 значащих цифр БЕЗ кода страны, начинаются с мобильного
# «9», сгруппированы 9XX XXX XX XX через пробел/дефис (#1641). _PHONE_RE требует
# префикс +7/8, _PHONE_BARE_RE — ровно 11 слитных цифр → формат «922 123 45 67» /
# «922-123-45-67» проходил мимо. Якорь «9» + фиксированная группировка 3-3-2-2 не
# пересекается с паспортом (4+6) / СНИЛС (3-3-3 2). Ставится ПОСЛЕ префиксных форм,
# чтобы local-часть «+7 912 …» сначала ушла как полноценный phone.
_PHONE_LOCAL_RE = re.compile(r"(?<!\d)9\d{2}[\s-]\d{3}[\s-]\d{2}[\s-]\d{2}(?!\d)")
# Email.
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
# ИНН: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим цифрам.
# ИНН-кандидат: ровно 12 (физлицо) или 10 (юрлицо) цифр, не приклеенные к другим
# цифрам. ВНИМАНИЕ: regex здесь — лишь поиск кандидата. Голый «N{10}|N{12}» ловил бы и
# легитимные слитные суммы (1 млрд+ руб = 10 цифр, ~трлн = 12) и молча затирал бы их
# до отправки в LLM (#1640). Поэтому замена проходит ТОЛЬКО если кандидат проходит
# официальную проверку контрольных цифр ИНН (_inn_checksum_valid) — см. _inn_repl.
_INN_RE = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
def _inn_checksum_valid(digits: str) -> 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"],

View file

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

View file

@ -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) ближайшего здания в

View file

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

View file

@ -78,7 +78,7 @@ _RE_FLOORS = re.compile(
+ _DASH
+ r"\s*"
+ _NUM
+ r"\s*этаж",
+ r"(?:\s*этаж\w*)?",
re.IGNORECASE,
)
# высота — 'предельная высота ... 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 квартала перед следующим

View file

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

View file

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

View file

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

View file

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