fix(week-review): backend-аудит v2 — 82 фиксов (#1660)
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
parent
50db2b82aa
commit
14f3ef2019
60 changed files with 1345 additions and 397 deletions
|
|
@ -11,7 +11,7 @@ from app.core.db import Base
|
||||||
|
|
||||||
# Import models so they register on Base.metadata.
|
# Import models so they register on Base.metadata.
|
||||||
# Add new model modules here as they appear.
|
# 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
|
config = context.config
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -268,7 +268,7 @@ def revoke_task(
|
||||||
def list_failures(
|
def list_failures(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
run_id: int | None = None,
|
run_id: int | None = None,
|
||||||
limit: int = 50,
|
limit: int = Query(default=50, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Per-request failure log for manual browser verification."""
|
"""Per-request failure log for manual browser verification."""
|
||||||
where = ""
|
where = ""
|
||||||
|
|
@ -314,7 +314,7 @@ def list_logs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
run_id: int | None = None,
|
run_id: int | None = None,
|
||||||
since_id: int | None = None,
|
since_id: int | None = None,
|
||||||
limit: int = 200,
|
limit: int = Query(default=200, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Per-run progress events. Use since_id to poll incrementally:
|
"""Per-run progress events. Use since_id to poll incrementally:
|
||||||
pass the highest log_id seen → returns only newer rows."""
|
pass the highest log_id seen → returns only newer rows."""
|
||||||
|
|
@ -483,7 +483,7 @@ def trigger_objective_etl(
|
||||||
@router.get("/objective/runs")
|
@router.get("/objective/runs")
|
||||||
def list_objective_runs(
|
def list_objective_runs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
limit: int = 20,
|
limit: int = Query(default=20, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
@ -889,14 +889,20 @@ def bulk_enqueue_geo(
|
||||||
|
|
||||||
geo_queue = get_setting_value("nspd_geo", "queue_name", "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]] = []
|
jobs_summary: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for thematic_id in payload.thematic_ids:
|
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]
|
meta = _THEMATIC_META[thematic_id]
|
||||||
|
|
||||||
# 1) Собрать cad-номера
|
# 1) Собрать cad-номера
|
||||||
|
|
@ -967,7 +973,7 @@ def bulk_enqueue_geo(
|
||||||
@router.get("/geo/jobs")
|
@router.get("/geo/jobs")
|
||||||
def list_geo_jobs(
|
def list_geo_jobs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
limit: int = 30,
|
limit: int = Query(default=30, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Список последних geo-jobs (для UI dashboard)."""
|
"""Список последних geo-jobs (для UI dashboard)."""
|
||||||
rows = (
|
rows = (
|
||||||
|
|
@ -1087,7 +1093,7 @@ def trigger_newbuilding_crossload() -> dict[str, Any]:
|
||||||
def list_all_runs(
|
def list_all_runs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
scraper_type: str | None = None,
|
scraper_type: str | None = None,
|
||||||
limit: int = 30,
|
limit: int = Query(default=30, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Унифицированный список прогонов (kn + nspd + objective).
|
"""Унифицированный список прогонов (kn + nspd + objective).
|
||||||
|
|
||||||
|
|
@ -1145,7 +1151,7 @@ def list_all_logs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
scraper_type: str | None = None,
|
scraper_type: str | None = None,
|
||||||
run_id: int | None = None,
|
run_id: int | None = None,
|
||||||
limit: int = 200,
|
limit: int = Query(default=200, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
|
"""Унифицированный список логов (kn + nspd). Objective пока не пишет log."""
|
||||||
where: list[str] = []
|
where: list[str] = []
|
||||||
|
|
@ -1416,7 +1422,7 @@ def scrape_freshness(
|
||||||
@router.get("/runs")
|
@router.get("/runs")
|
||||||
def list_runs(
|
def list_runs(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
limit: int = 20,
|
limit: int = Query(default=20, ge=0),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
rows = (
|
rows = (
|
||||||
db.execute(
|
db.execute(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Response
|
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 import wkt as _shp_wkt
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
@ -2900,58 +2901,20 @@ def analyze_parcel(
|
||||||
"risks": risks_block,
|
"risks": risks_block,
|
||||||
}
|
}
|
||||||
|
|
||||||
# #994 (961-C3, ТЗ §22): persist завершённого рана в analysis_runs.
|
# #1561: forecast/ird/developer_attribution дописываются в result_payload ДО persist —
|
||||||
# Best-effort — repository обёрнут в SAVEPOINT + try/except, провал НЕ меняет
|
# иначе persist_analysis_run сериализует jsonb-снимок (repository.py:124) и коммитит
|
||||||
# форму/успех ответа (frontend зависит от него) и не отравляет outer-сессию.
|
# (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом
|
||||||
# district денормализуем из result["district"]["district_name"] (для фильтрации
|
# (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open).
|
||||||
# без 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-форсайт (3b-ii, #995): best-effort fire-and-forget enqueue после persist.
|
# §22-форсайт (3b-ii, #995): снимок статуса в result_payload ДО persist, чтобы
|
||||||
# Таска `forecast_site_finder_report` читает только что сохранённый analyze-1.0
|
# jsonb-снимок совпадал с live-ответом. Оптимистично ставим "pending" — фактический
|
||||||
# ран и в фоне (~30-180s) считает §22 SiteFinderReport ('1.0'). analyze НЕ ждёт
|
# enqueue делаем ПОСЛЕ persist_analysis_run (ниже): иначе Celery-воркер может стартануть
|
||||||
# её — возвращаемся сразу. Celery/Redis down НЕ должен валить ответ (он уже успешен:
|
# ДО коммита analyze-рана и latest_run_for вернёт None/старый ран → форсайт молча не
|
||||||
# frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch.
|
# посчитается, а ретраев у таски нет (regression #1561-followup). result_payload
|
||||||
# Lazy import — избегаем import-цикла api ↔ workers.tasks на старте.
|
# передаётся в persist by-reference; если enqueue провалится после persist —
|
||||||
try:
|
# перепишем снимок на "unavailable" уже только в возвращаемом ответе (persisted
|
||||||
from app.workers.tasks.forecast import forecast_site_finder_report
|
# снимок останется "pending", но это безвредно: poll-ручка читает live-статус рана).
|
||||||
|
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
|
||||||
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}
|
|
||||||
|
|
||||||
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
|
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
|
||||||
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
|
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
|
||||||
|
|
@ -2987,6 +2950,62 @@ def analyze_parcel(
|
||||||
exc_info=True,
|
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
|
return result_payload
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3228,8 +3247,10 @@ async def get_parcel_competitors(
|
||||||
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
|
Возвращает список ЖК из domrf_kn_objects в радиусе radius_km от центроида
|
||||||
участка с рассчитанным velocity_per_month за указанный time_window.
|
участка с рассчитанным velocity_per_month за указанный time_window.
|
||||||
"""
|
"""
|
||||||
|
# sync get_competitors (несколько db.execute, competitors.py:518) мостится через
|
||||||
|
# run_in_threadpool — иначе sync DB-IO блокирует event loop (тот же приём, что в chat.py).
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except Exception as 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
|
Reads from mv_layout_velocity (auto-populated via objective_corpus_room_month
|
||||||
× objective_complex_mapping).
|
× objective_complex_mapping).
|
||||||
"""
|
"""
|
||||||
|
# sync get_best_layouts (db.execute, best_layouts.py:377) мостится через
|
||||||
|
# run_in_threadpool — иначе sync DB-IO блокирует event loop.
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
except Exception as 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 для тендера.
|
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:
|
try:
|
||||||
response = get_best_layouts(db=db, cad_num=cad_num, request=body)
|
response = await run_in_threadpool(get_best_layouts, db=db, cad_num=cad_num, request=body)
|
||||||
pdf_bytes = render_layout_tz_pdf(
|
pdf_bytes = await run_in_threadpool(
|
||||||
|
render_layout_tz_pdf,
|
||||||
response,
|
response,
|
||||||
cad_num=cad_num,
|
cad_num=cad_num,
|
||||||
radius_km=body.radius_km,
|
radius_km=body.radius_km,
|
||||||
|
|
|
||||||
|
|
@ -111,16 +111,25 @@ def get_photo(
|
||||||
data = _fetch_upstream(upstream)
|
data = _fetch_upstream(upstream)
|
||||||
if data:
|
if data:
|
||||||
src = _persist_original(obj_id, file_id, photo_name, 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)
|
generated = make_thumbnail(src)
|
||||||
if generated and generated.exists():
|
if generated and generated.exists():
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE domrf_kn_photos"
|
"UPDATE domrf_kn_photos SET thumb_path = :tp"
|
||||||
" SET local_path = :lp, thumb_path = :tp,"
|
|
||||||
" downloaded_at = NOW()"
|
|
||||||
" WHERE obj_id = :o AND obj_file_id = :f"
|
" 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()
|
db.commit()
|
||||||
return FileResponse(str(generated), media_type="image/webp", headers=headers)
|
return FileResponse(str(generated), media_type="image/webp", headers=headers)
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,7 @@ def estimate_pdf(
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="estimate not found")
|
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)")
|
raise HTTPException(status_code=410, detail="estimate expired (24h TTL)")
|
||||||
|
|
||||||
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ if settings.glitchtip_dsn:
|
||||||
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
traces_sample_rate=settings.glitchtip_traces_sample_rate,
|
||||||
profiles_sample_rate=0.0,
|
profiles_sample_rate=0.0,
|
||||||
send_default_pii=False,
|
send_default_pii=False,
|
||||||
|
before_send=scrub_sensitive_query,
|
||||||
before_send_transaction=scrub_sensitive_query,
|
before_send_transaction=scrub_sensitive_query,
|
||||||
integrations=[
|
integrations=[
|
||||||
StarletteIntegration(),
|
StarletteIntegration(),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from datetime import datetime
|
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.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
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_retries: Mapped[int] = mapped_column(SmallInteger, default=2, nullable=False)
|
||||||
max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)
|
max_concurrency: Mapped[int] = mapped_column(SmallInteger, default=1, nullable=False)
|
||||||
extra_config: Mapped[dict] = mapped_column(JSONB, default=dict, 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)
|
updated_by: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
NB: реальные данные участков живут в таблицах ``cad_parcels`` /
|
||||||
project to МСК-66 via pyproj when computing distances/areas.
|
``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 app.core.db import Base # noqa: F401 (re-export для регистрации будущих моделей)
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from __future__ import annotations
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
|
# История диалога принимается, но в Step 1 НЕ используется (LLM-контекст — Step 2).
|
||||||
# Кэпируем длину, чтобы payload не раздувался до подключения LLM.
|
# Кэпируем длину, чтобы payload не раздувался до подключения LLM.
|
||||||
|
|
@ -84,10 +84,21 @@ class ChatAskRequest(BaseModel):
|
||||||
)
|
)
|
||||||
history: list[ChatTurn] | None = Field(
|
history: list[ChatTurn] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
max_length=_HISTORY_MAX_TURNS,
|
|
||||||
description="История диалога (Step 1: принимается, НЕ используется; LLM-контекст — Step 2)",
|
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):
|
class ChatAskResponse(BaseModel):
|
||||||
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).
|
"""Ответ чата по отчёту участка (детерминированный, шаблонный RU-текст).
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ class ConceptInput(BaseModel):
|
||||||
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
housing_class: Literal["econom", "comfort", "business"] = "comfort"
|
||||||
target_floors: int = Field(9, ge=1, le=30)
|
target_floors: int = Field(9, ge=1, le=30)
|
||||||
development_type: Literal["spot", "mid_rise", "high_rise"] = "mid_rise"
|
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):
|
class TEAP(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -302,10 +302,18 @@ class NSPDBulkClient:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await self._get_json(NSPD_SEARCH_URL, params=params)
|
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:
|
except NspdBulkError as e:
|
||||||
# 404 или пустой ответ → возвращаем пустой snapshot
|
# Остаётся базовый NspdBulkError = прочие 4xx (см. _get_json:234).
|
||||||
|
# Текст: f"HTTP {code}: {url} — {body_preview}" → классифицируем по
|
||||||
|
# ПРЕФИКСУ (код до URL), а не по вхождению в произвольное тело ответа.
|
||||||
err_str = str(e)
|
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(
|
logger.info(
|
||||||
"search_by_quarter: quarter %s not found (404/400), returning empty"
|
"search_by_quarter: quarter %s not found (404/400), returning empty"
|
||||||
" (category_id=%s)",
|
" (category_id=%s)",
|
||||||
|
|
@ -342,8 +350,23 @@ class NSPDBulkClient:
|
||||||
for m in raw_meta:
|
for m in raw_meta:
|
||||||
cat_id = m.get("categoryId")
|
cat_id = m.get("categoryId")
|
||||||
total = m.get("totalCount")
|
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)
|
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(
|
logger.info(
|
||||||
"search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d",
|
"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)
|
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]
|
return [NSPDBulkFeature.model_validate(f) for f in raw_features]
|
||||||
|
|
||||||
# ── 3. get_features_in_bbox_grid ─────────────────────────────────────────
|
# ── 3. get_features_in_bbox_grid ─────────────────────────────────────────
|
||||||
|
|
@ -473,9 +508,39 @@ class NSPDBulkClient:
|
||||||
|
|
||||||
seen_ids: set[str] = set()
|
seen_ids: set[str] = set()
|
||||||
results: list[dict] = []
|
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):
|
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):
|
if isinstance(cell_result, Exception):
|
||||||
|
# Прочие (сетевые/parse) ошибки одной ячейки — не валим обход и НЕ
|
||||||
|
# считаем server-side fail (иначе сеть ложно triggers layer_failed).
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"get_features_in_bbox_grid: layer=%d cell=%d error: %s",
|
"get_features_in_bbox_grid: layer=%d cell=%d error: %s",
|
||||||
layer_id,
|
layer_id,
|
||||||
|
|
@ -483,6 +548,7 @@ class NSPDBulkClient:
|
||||||
cell_result,
|
cell_result,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
ok_cells += 1
|
||||||
for feature in cell_result:
|
for feature in cell_result:
|
||||||
fid = str(feature.id) if feature.id is not None else ""
|
fid = str(feature.id) if feature.id is not None else ""
|
||||||
if fid and fid in seen_ids:
|
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(
|
logger.info(
|
||||||
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
|
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
|
||||||
layer_id,
|
layer_id,
|
||||||
|
|
|
||||||
|
|
@ -64,8 +64,15 @@ def _jsonb_param(value: Any) -> str:
|
||||||
|
|
||||||
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
|
jsonable_encoder разворачивает Pydantic-модели / даты / Enum в JSON-native типы;
|
||||||
json.dumps(..., ensure_ascii=False) — кириллица как есть (зеркало pzz_loader).
|
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(
|
def persist_analysis_run(
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,12 @@ list rather than silently ignored.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -54,6 +55,11 @@ _SUPPORTED_METHODS = (_CALC_BASIS, _CALC_PREVIOUS)
|
||||||
# as unsupported.
|
# as unsupported.
|
||||||
_SUPPORTED_SUBJECT = "66"
|
_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:
|
def _f(value: Any) -> float | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
@ -148,6 +154,50 @@ def get_ddu_indicator(
|
||||||
if clean_buckets:
|
if clean_buckets:
|
||||||
bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
|
bucket_filter = "AND area_bucket = ANY(CAST(:buckets AS int[]))"
|
||||||
params["buckets"] = clean_buckets
|
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 = ""
|
period_filter = ""
|
||||||
if period_from:
|
if period_from:
|
||||||
|
|
@ -184,8 +234,10 @@ def get_ddu_indicator(
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
except OperationalError:
|
except (ProgrammingError, OperationalError):
|
||||||
# Most likely the MV does not exist yet (migration 152 not applied).
|
# 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?)")
|
logger.exception("ddu_indicator: query failed (mv_ddu_price_indicator missing?)")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 recent_mean,
|
||||||
AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean,
|
AVG(realised) FILTER (WHERE rn > :recent_window) AS prior_mean,
|
||||||
STDDEV_SAMP(realised) FILTER (WHERE rn > :recent_window) AS prior_std,
|
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
|
FROM ranked
|
||||||
GROUP BY obj_id, n_months
|
GROUP BY obj_id, n_months
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -2786,6 +2786,25 @@ def recommend_mix(
|
||||||
if b["bucket"] != top_bucket_name:
|
if b["bucket"] != top_bucket_name:
|
||||||
b["share_pct"] = round(b["share_pct"] * scale, 1)
|
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 → перераспределяем
|
# #1359: success-boost изменил share_pct → перераспределяем
|
||||||
# units/revenue/months_to_sellout и агрегаты под новые доли,
|
# units/revenue/months_to_sellout и агрегаты под новые доли,
|
||||||
# иначе share_pct рассогласуется с units_planned/revenue/sellout
|
# иначе share_pct рассогласуется с units_planned/revenue/sellout
|
||||||
|
|
|
||||||
|
|
@ -247,21 +247,11 @@ async def harvest_quarter(
|
||||||
db.commit()
|
db.commit()
|
||||||
update_progress(done_progress)
|
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 ─────────────────
|
# ── 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]
|
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
|
||||||
if stats_features:
|
if stats_features:
|
||||||
upsert_quarter_stats(db, quarter, stats_features[0])
|
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)
|
logger.warning("harvest_quarter: geom auto-heal failed for %s: %s", quarter, e)
|
||||||
db.commit()
|
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 по всем
|
# Issue #252: финальный phase_state несёт АГРЕГИРОВАННЫЙ harvest_meta по всем
|
||||||
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
# сбойным слоям. progress_cb мержит phase_state через JSONB `||` (shallow) —
|
||||||
# per-layer done-апдейты перетёрли бы harvest_meta друг друга, поэтому в
|
# 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)
|
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()
|
discovered_cads: set[str] = set()
|
||||||
upserted = 0
|
upserted = 0
|
||||||
requests = 0
|
requests = 0
|
||||||
|
|
@ -411,7 +422,7 @@ async def _grid_walk_category(
|
||||||
try:
|
try:
|
||||||
with db.begin_nested():
|
with db.begin_nested():
|
||||||
stats = upsert_features(db, [feature], source="wms_grid_walk")
|
stats = upsert_features(db, [feature], source="wms_grid_walk")
|
||||||
upserted += sum(stats.values())
|
upserted += stats[layer_count_key]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"_grid_walk_category: upsert failed cad=%s layer=%d: %s",
|
"_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")),
|
"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_area": _safe_numeric(raw_opts.get("sum_land_area")),
|
||||||
"sum_land_geom_area": _safe_numeric(raw_opts.get("sum_land_geom_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")),
|
"real_srid": _safe_int(raw_opts.get("real_srid")),
|
||||||
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
|
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def quarter_bbox_3857(db: Session, quarter: str) -> tuple[float, float, float, float] | None:
|
||||||
"""Получить bbox квартала из cad_quarters_geom в EPSG:3857.
|
"""Получить 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
|
# geometry (width <0.01m). Это вызывает useless 1px tiles в WMS и 500 errors
|
||||||
# от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для
|
# от NSPD. Реальный квартал ЕКБ имеет width 200-4000м. Skip grid-walk для
|
||||||
# broken — snapshot phase даст 20 per cat, что норм для MVP.
|
# 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:
|
if width < 100 or height < 100 or width > 10000 or height > 10000:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm "
|
"quarter_bbox_3857: квартал %s broken geom — width=%.2fm height=%.2fm "
|
||||||
|
|
|
||||||
|
|
@ -151,8 +151,8 @@ def _fmt_number(value: Any) -> str | None:
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
return _fmt_thousands(value)
|
return _fmt_thousands(value)
|
||||||
if isinstance(value, float):
|
if isinstance(value, float):
|
||||||
if not math.isfinite(value): # NaN/Inf: int(value) бросил бы ValueError
|
if not math.isfinite(value): # NaN/Inf — не число: честно пропускаем (как None),
|
||||||
return str(value)
|
return None # иначе в RU-прозу утёк бы англ. литерал 'nan'/'inf' (#1585)
|
||||||
if value == int(value):
|
if value == int(value):
|
||||||
return _fmt_thousands(value)
|
return _fmt_thousands(value)
|
||||||
# Точность отчёта сохраняем: repr float'а → '.'→','. (0.31 → '0,31').
|
# Точность отчёта сохраняем: 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:
|
if summary:
|
||||||
lines.append(str(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")
|
section.get(k) for k in ("obj_class", "mix", "commercial", "usp", "summary")
|
||||||
):
|
):
|
||||||
lines.append("Раздел рекомендации продукта в отчёте пуст.")
|
lines.append("Раздел рекомендации продукта в отчёте пуст.")
|
||||||
|
|
@ -266,16 +266,21 @@ def _render_why_forecast(report: dict[str, Any]) -> tuple[str, list[str]]:
|
||||||
|
|
||||||
future = report.get("future_market")
|
future = report.get("future_market")
|
||||||
if isinstance(future, dict):
|
if isinstance(future, dict):
|
||||||
sections_used.append("future_market")
|
future_emitted = False # секцию в provenance кладём только если выведена строка (#1630)
|
||||||
horizons = future.get("forecasts_by_horizon")
|
horizons = future.get("forecasts_by_horizon")
|
||||||
if isinstance(horizons, list) and horizons:
|
if isinstance(horizons, list) and horizons:
|
||||||
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
|
lines.append(f"Прогноз построен по {len(horizons)} горизонтам спроса/предложения.")
|
||||||
|
future_emitted = True
|
||||||
future_supply = future.get("future_supply")
|
future_supply = future.get("future_supply")
|
||||||
if isinstance(future_supply, dict) and future_supply:
|
if isinstance(future_supply, dict) and future_supply:
|
||||||
lines.append("Учтено давление будущего предложения (выходящие проекты).")
|
lines.append("Учтено давление будущего предложения (выходящие проекты).")
|
||||||
|
future_emitted = True
|
||||||
summary = future.get("summary")
|
summary = future.get("summary")
|
||||||
if summary:
|
if summary:
|
||||||
lines.append(str(summary))
|
lines.append(str(summary))
|
||||||
|
future_emitted = True
|
||||||
|
if future_emitted:
|
||||||
|
sections_used.append("future_market")
|
||||||
else:
|
else:
|
||||||
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))
|
lines.append(_NO_SECTION_TMPL.format(name="будущий рынок"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ def find_match_candidates(
|
||||||
objective_distinct AS (
|
objective_distinct AS (
|
||||||
SELECT DISTINCT project_name
|
SELECT DISTINCT project_name
|
||||||
FROM objective_corpus_room_month
|
FROM objective_corpus_room_month
|
||||||
|
WHERE group_name = 'Екатеринбург'
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
d.obj_id,
|
d.obj_id,
|
||||||
|
|
@ -168,7 +169,7 @@ def auto_apply_matches(
|
||||||
len(auto),
|
len(auto),
|
||||||
len(review),
|
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
|
inserted = 0
|
||||||
skipped = 0
|
skipped = 0
|
||||||
|
|
|
||||||
|
|
@ -123,10 +123,17 @@ def _md_kv_table(data: dict[str, Any]) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _md_kv_lines(pairs: list[tuple[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:
|
if not pairs:
|
||||||
return _NO_DATA
|
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:
|
def _join_horizons(values: list[Any]) -> Any:
|
||||||
|
|
|
||||||
|
|
@ -458,6 +458,9 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
||||||
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть
|
`payload` = `ScenarioForecast.as_dict()`: у сценария НЕТ скалярного «overall» — есть
|
||||||
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
|
список `forecasts` по горизонтам, каждый с `deficit_index`. Берём горизонт
|
||||||
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
|
`_PRIMARY_HORIZON_MONTHS`, иначе первый с не-None дефицитом. Нет → None (→ "—").
|
||||||
|
|
||||||
|
NB: эту функцию импортируют report_md/docx/pptx — НЕ менять сигнатуру (вернёт скаляр).
|
||||||
|
Для подписи фактического горизонта используй `_scenario_deficit_horizon`.
|
||||||
"""
|
"""
|
||||||
forecasts = _as_list(payload.get("forecasts"))
|
forecasts = _as_list(payload.get("forecasts"))
|
||||||
primary = next(
|
primary = next(
|
||||||
|
|
@ -476,6 +479,30 @@ def _scenario_deficit_index(payload: dict[str, Any]) -> Any:
|
||||||
return None
|
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:
|
def _build_scenarios(report: dict[str, Any]) -> str:
|
||||||
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
|
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
|
||||||
scenarios = _as_dict(report.get("scenarios"))
|
scenarios = _as_dict(report.get("scenarios"))
|
||||||
|
|
@ -484,9 +511,19 @@ def _build_scenarios(report: dict[str, Any]) -> str:
|
||||||
rows: list[list[Any]] = []
|
rows: list[list[Any]] = []
|
||||||
for name, payload in by_scenario.items():
|
for name, payload in by_scenario.items():
|
||||||
data = _as_dict(payload)
|
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"""
|
return f"""
|
||||||
<div class="section" id="scenarios">
|
<div class="section" id="scenarios">
|
||||||
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
|
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
|
||||||
|
|
|
||||||
|
|
@ -159,12 +159,14 @@ def generate_snapshot_pdf(
|
||||||
area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else "—"
|
area_ha = f"{area_m2 / 10_000:.2f}" if area_m2 else "—"
|
||||||
poi_items = _build_poi_items(poi_rows, limit=7)
|
poi_items = _build_poi_items(poi_rows, limit=7)
|
||||||
|
|
||||||
# Конкуренты — берём топ N ближайших (уже отсортированы по flat_count DESC;
|
# Конкуренты — берём топ N БЛИЖАЙШИХ. competitor_rows приходят отсортированными
|
||||||
# переупорядочиваем по distance_m для удобства чтения)
|
# по flat_count DESC (крупнейшие ЖК), поэтому сначала пересортировываем весь
|
||||||
|
# список по distance_m ASC, и лишь затем срезаем N — иначе в блок попадали бы
|
||||||
|
# 5 крупнейших из радиуса, а не непосредственное конкурентное окружение пятна.
|
||||||
competitors_display = sorted(
|
competitors_display = sorted(
|
||||||
competitor_rows[:competitors_limit],
|
competitor_rows,
|
||||||
key=lambda r: float(r.get("distance_m") or 0),
|
key=lambda r: float(r.get("distance_m") or 0),
|
||||||
)
|
)[:competitors_limit]
|
||||||
competitors_ctx: list[dict[str, Any]] = [
|
competitors_ctx: list[dict[str, Any]] = [
|
||||||
{
|
{
|
||||||
"comm_name": r.get("comm_name"),
|
"comm_name": r.get("comm_name"),
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,11 @@ def _analog_rows(lots: list[AnalogLot], *, is_deal: bool) -> str:
|
||||||
for lot in lots:
|
for lot in lots:
|
||||||
date_val = lot.listing_date.strftime("%d.%m.%Y") if lot.listing_date else "—"
|
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 "—"
|
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 = "Дата сделки" if is_deal else "В продаже"
|
||||||
_ = label # used for header only
|
_ = label # used for header only
|
||||||
rows.append(
|
rows.append(
|
||||||
|
|
|
||||||
|
|
@ -294,8 +294,10 @@ def compute_affordability(
|
||||||
усреднение НЕнулевых месяцев). None → платёж None (graceful).
|
усреднение НЕнулевых месяцев). None → платёж None (graceful).
|
||||||
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
|
• monthly_payment = _annuity(principal=price×ref_area, annual_rate=rate,
|
||||||
months=_ANNUITY_TERM_MONTHS).
|
months=_ANNUITY_TERM_MONTHS).
|
||||||
• payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h]) — платёж на
|
• payment_at_scenario[h] = _annuity(... annual_rate=rate_path[h] + спред) —
|
||||||
каждом горизонте сценарной ставки (None rate_path → поле None).
|
платёж на каждом горизонте сценарной КЛЮЧЕВОЙ ставки ЦБ, приведённой к той
|
||||||
|
же рыночной базе, что и monthly_payment_rub (key_rate + калиброванный спред
|
||||||
|
4.5 п.п.); None rate_path → поле None (#1639).
|
||||||
|
|
||||||
Graceful: нет ставки/цены → платёж None; ставка ≤0 → аннуитет деградирует к
|
Graceful: нет ставки/цены → платёж None; ставка ≤0 → аннуитет деградирует к
|
||||||
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
|
principal/months. НИКОГДА не crash. confidence ВСЕГДА 'low'. Детерминированно.
|
||||||
|
|
@ -304,8 +306,10 @@ def compute_affordability(
|
||||||
db: SQLAlchemy sync Session.
|
db: SQLAlchemy sync Session.
|
||||||
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
|
spec: целевой сегмент (для сегментной цены, если price_per_m2 не задан).
|
||||||
price_per_m2: цена ₽/м² (None → берём сегментную среднюю reuse-ом).
|
price_per_m2: цена ₽/м² (None → берём сегментную среднюю reuse-ом).
|
||||||
rate_path: сценарный {horizon: годовая ставка %} для payment_at_scenario;
|
rate_path: сценарный {horizon: КЛЮЧЕВАЯ ставка ЦБ %} для payment_at_scenario
|
||||||
None → payment_at_scenario None.
|
(контракт #952/#984: конверт key_rate, НЕ рыночная — к ней внутри
|
||||||
|
добавляется тот же спред _KEY_RATE_MARKET_SPREAD_PP, что и в базовом
|
||||||
|
monthly_payment_rub); None → payment_at_scenario None.
|
||||||
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
|
ref_area_m2: эталонная площадь тела кредита (по умолчанию _REF_AREA_M2).
|
||||||
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
|
price_source: источник сегментной цены (по умолчанию _PRICE_SOURCE = B).
|
||||||
|
|
||||||
|
|
@ -337,7 +341,18 @@ def compute_affordability(
|
||||||
if rate_path is not None:
|
if rate_path is not None:
|
||||||
scenario: dict[int, float] = {}
|
scenario: dict[int, float] = {}
|
||||||
for horizon, scenario_rate in rate_path.items():
|
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:
|
if payment is not None:
|
||||||
scenario[horizon] = payment
|
scenario[horizon] = payment
|
||||||
payment_at_scenario = scenario
|
payment_at_scenario = scenario
|
||||||
|
|
|
||||||
|
|
@ -330,9 +330,12 @@ def _build_rationale(
|
||||||
|
|
||||||
if advisory_capped and level == _ADVISORY_CEILING:
|
if advisory_capped and level == _ADVISORY_CEILING:
|
||||||
# Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина.
|
# Уровень упёрся в 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»"
|
base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
|
||||||
if notes:
|
if other:
|
||||||
base += "; также " + _join_notes(notes)
|
base += "; также " + _join_notes(other)
|
||||||
return base + "."
|
return base + "."
|
||||||
|
|
||||||
if not notes:
|
if not notes:
|
||||||
|
|
|
||||||
|
|
@ -818,7 +818,10 @@ def _competitor_signal(
|
||||||
)
|
)
|
||||||
return None, None
|
return None, None
|
||||||
weights = [c.relevance_weight for c in response.competitors if c.relevance_weight is not 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:
|
def _poi_weight_sum(db: Session, *, cad_num: str) -> float | None:
|
||||||
|
|
|
||||||
|
|
@ -609,17 +609,22 @@ def _demand_only_overlay(
|
||||||
room_bucket=forecast_bucket,
|
room_bucket=forecast_bucket,
|
||||||
district=district,
|
district=district,
|
||||||
)
|
)
|
||||||
# §9.4 нормализация под будущий режим ставки (β внутри; rate_future None →
|
# §9.4 нормализация под будущий режим ставки (β внутри). rate_future None
|
||||||
# деградирует к нейтрали внутри себя, передаём 0.0 как placeholder).
|
# (hold_last_rate не дал ставку) → НЕ применяем §9.4: 0.0-placeholder дал бы
|
||||||
norm = compute_demand_normalization(
|
# delta=−rate_window_avg → exp(β·delta) и клэмп к _NORM_MAX (макс. аплифт),
|
||||||
db, spec=spec, rate_future=rate_future if rate_future is not None else 0.0
|
# а НЕ нейтраль. Честная нейтраль при отсутствии будущей ставки = коэф. 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.
|
# §9.5 макро-коэффициент (ортогонален β); профиль — класс + room_bucket.
|
||||||
profile: dict[str, Any] = {"room_bucket": forecast_bucket}
|
profile: dict[str, Any] = {"room_bucket": forecast_bucket}
|
||||||
if mapped_class is not None:
|
if mapped_class is not None:
|
||||||
profile["obj_class"] = mapped_class
|
profile["obj_class"] = mapped_class
|
||||||
macro_coef = compute_macro_coefficient(db, segment_profile=profile)
|
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))
|
paces.append((live_bucket, mapped_class, pace))
|
||||||
|
|
||||||
max_pace = max((p for _, _, p in paces), default=0.0)
|
max_pace = max((p for _, _, p in paces), default=0.0)
|
||||||
|
|
@ -698,13 +703,14 @@ def _overlay(
|
||||||
def _commercial_signal(
|
def _commercial_signal(
|
||||||
db: Session, district: str | None, horizon_months: int
|
db: Session, district: str | None, horizon_months: int
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""§10.4 советующий коммерческий сигнал (доля коммерции) — degraded-honest. Graceful.
|
"""§10.4 советующий коммерческий сигнал (темп распродажи нежилого) — degraded-honest.
|
||||||
|
|
||||||
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
|
Пробует измерить нежилой сток через `compute_market_metrics(premise_kind=
|
||||||
"нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда
|
"нежилое")`. objective покрывает в основном жильё → выборка обычно тонкая. Тогда
|
||||||
возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число.
|
возвращаем degraded-honest {available: False, caveat, advisory} — НЕ фабрикуем число.
|
||||||
Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка доли
|
Если данных достаточно (≥ _COMMERCIAL_MIN_LOTS лотов) → советующая оценка ТЕМПА
|
||||||
коммерции (sell_through_pct как прокси реализованной доли) + §16-подобный reason.
|
РАСПРОДАЖИ нежилого стока (sell_through_pct = проданные ÷ (проданные+доступные)·100,
|
||||||
|
прокси ликвидности/спроса — НЕ доля нежилого в объёме застройки) + §16-подобный reason.
|
||||||
НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал.
|
НИКОГДА не бросает: любой сбой движка/импорта → degraded-honest None-сигнал.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -747,22 +753,26 @@ def _commercial_signal(
|
||||||
)
|
)
|
||||||
return {"available": False, "caveat": caveat, "advisory": True}
|
return {"available": False, "caveat": caveat, "advisory": True}
|
||||||
|
|
||||||
# Достаточно данных: советующая оценка реализованной доли коммерции (прокси).
|
# Достаточно данных: советующая оценка ТЕМПА РАСПРОДАЖИ нежилого (sell_through_pct
|
||||||
share_pct = round(sell_through, 1)
|
# = проданные ÷ (проданные+доступные)·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"
|
confidence = confidence if confidence in ("high", "medium", "low") else "low"
|
||||||
return {
|
return {
|
||||||
"available": True,
|
"available": True,
|
||||||
"premise_kind": _COMMERCIAL_PREMISE_KIND,
|
"premise_kind": _COMMERCIAL_PREMISE_KIND,
|
||||||
"commercial_share_pct": share_pct,
|
"commercial_share_pct": sell_through_pct,
|
||||||
"n_lots": n_lots,
|
"n_lots": n_lots,
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"reason": {
|
"reason": {
|
||||||
"why": (
|
"why": (
|
||||||
f"Коммерция (нежилое): реализованная доля ~{share_pct}% по {n_lots} "
|
f"Коммерция (нежилое): темп распродажи ~{sell_through_pct}% по {n_lots} "
|
||||||
f"лотам на горизонте {horizon_months} мес (прокси спроса на нежилые помещения)."
|
f"лотам на горизонте {horizon_months} мес (прокси ликвидности/спроса на "
|
||||||
|
f"нежилые помещения, НЕ доля нежилого в объёме застройки)."
|
||||||
),
|
),
|
||||||
"drivers": [
|
"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": "+"},
|
{"factor": "n_lots", "value": n_lots, "direction": "+"},
|
||||||
],
|
],
|
||||||
"rejected": [],
|
"rejected": [],
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ import numpy as np
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.services.forecast_request_cache import cached
|
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.rate_sensitivity import Confidence, RateSensitivity, _delta
|
||||||
from app.services.forecasting.sales_series import (
|
from app.services.forecasting.sales_series import (
|
||||||
SegmentSpec,
|
SegmentSpec,
|
||||||
|
|
@ -672,7 +672,7 @@ def _insufficient_sensitivity(segment: dict[str, str | None]) -> RateSensitivity
|
||||||
|
|
||||||
|
|
||||||
def _fit_to_sensitivity(
|
def _fit_to_sensitivity(
|
||||||
fit: DistributedLagFit, *, segment: dict[str, str | None]
|
fit: DistributedLagFit, *, segment: dict[str, str | None], confounded: bool = False
|
||||||
) -> RateSensitivity:
|
) -> RateSensitivity:
|
||||||
"""Map a DistributedLagFit (Almon-ADL) onto the §9.6 RateSensitivity contract.
|
"""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
|
• r2 / n_obs ← fit.r2 / fit.n
|
||||||
• confidence ← 'regression' → "medium" (gated-OK but advisory-grade) |
|
• confidence ← 'regression' → "medium" (gated-OK but advisory-grade) |
|
||||||
'fallback' → "low"
|
'fallback' → "low"
|
||||||
Source-B-only outputs (z_area_floor, most_sensitive_bucket, confounded,
|
• confounded ← passed in by the caller (computed from the ACTUAL fit window
|
||||||
shrinkage_weight) have no analogue in a district×class distributed-lag fit
|
via is_confounded_window — #1636). The §9.6 production path
|
||||||
(no room×area bucketing here) → None / sensible defaults. PURE.
|
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
|
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
|
Σ_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,
|
r2=fit.r2,
|
||||||
n_obs=fit.n,
|
n_obs=fit.n,
|
||||||
shrinkage_weight=0.0,
|
shrinkage_weight=0.0,
|
||||||
confounded=False,
|
confounded=confounded,
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
phrase=fit.phrase,
|
phrase=fit.phrase,
|
||||||
)
|
)
|
||||||
|
|
@ -778,4 +785,32 @@ def compute_rate_regime_sensitivity(
|
||||||
)
|
)
|
||||||
return _insufficient_sensitivity(segment)
|
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))
|
||||||
|
|
|
||||||
|
|
@ -393,9 +393,21 @@ def _market_now_summary(
|
||||||
avg_price = analyze.get("market_avg_price_per_m2")
|
avg_price = analyze.get("market_avg_price_per_m2")
|
||||||
if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool):
|
if isinstance(avg_price, (int, float)) and not isinstance(avg_price, bool):
|
||||||
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
|
parts.append(f"средняя цена ~{round(float(avg_price)):,} ₽/м²".replace(",", " "))
|
||||||
n_comp = _analog_count(analyze, market_metrics)
|
# #1634: НЕ через _analog_count — он отдаёт market_metrics.obj_count (число ЖК во
|
||||||
if n_comp is not None:
|
# всей district-wide/микрорайонной выборке §9.2), что НЕ равно «конкурентов рядом».
|
||||||
parts.append(f"{n_comp} ЖК-конкурентов рядом")
|
# Метка честно следует источнику: 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:
|
if not parts:
|
||||||
return None
|
return None
|
||||||
return "Текущий рынок: " + ", ".join(parts) + "."
|
return "Текущий рынок: " + ", ".join(parts) + "."
|
||||||
|
|
|
||||||
|
|
@ -1689,7 +1689,13 @@ def compute_special_indices(
|
||||||
|
|
||||||
indices: dict[str, SpecialIndex] = {key: _run(key, builders[key]) for key in _INDEX_KEYS}
|
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)
|
n_available = sum(1 for idx in indices.values() if idx.value is not None)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import logging
|
||||||
|
|
||||||
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
|
# ezdxf.new живёт в ezdxf.filemanagement и не реэкспортируется через ezdxf.__all__;
|
||||||
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
# импорт из модуля удовлетворяет strict no-implicit-reexport.
|
||||||
|
from ezdxf.enums import TextEntityAlignment
|
||||||
from ezdxf.filemanagement import new as ezdxf_new
|
from ezdxf.filemanagement import new as ezdxf_new
|
||||||
from shapely.geometry import Polygon
|
from shapely.geometry import Polygon
|
||||||
|
|
||||||
|
|
@ -43,13 +44,33 @@ _LAYER_BUILDINGS = "BUILDINGS"
|
||||||
_LABEL_HEIGHT_M = 2.0
|
_LABEL_HEIGHT_M = 2.0
|
||||||
|
|
||||||
|
|
||||||
def _polygon_points(poly: Polygon) -> list[tuple[float, float]]:
|
def _ring_points(coords: object) -> list[tuple[float, float]]:
|
||||||
"""Внешнее кольцо полигона как список (x, y) для LWPolyline (без замыкающей точки)."""
|
"""Кольцо (exterior/interior) как список (x, y) для LWPolyline (без замыкающей точки)."""
|
||||||
coords = list(poly.exterior.coords)
|
pts = list(coords)
|
||||||
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
# Shapely дублирует первую точку в конце; close=True у ezdxf замкнёт сам.
|
||||||
if len(coords) > 1 and coords[0] == coords[-1]:
|
if len(pts) > 1 and pts[0] == pts[-1]:
|
||||||
coords = coords[:-1]
|
pts = pts[:-1]
|
||||||
return [(float(x), float(y)) for x, y in coords]
|
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:
|
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()
|
msp = doc.modelspace()
|
||||||
|
|
||||||
# Участок и пятно застройки — из метрической геометрии Parcel.
|
# Участок и пятно застройки — из метрической геометрии Parcel.
|
||||||
msp.add_lwpolyline(
|
_add_polygon(msp, parcel.polygon_m, _LAYER_PARCEL)
|
||||||
_polygon_points(parcel.polygon_m),
|
_add_polygon(msp, parcel.buildable_m, _LAYER_BUILDABLE)
|
||||||
close=True,
|
|
||||||
dxfattribs={"layer": _LAYER_PARCEL},
|
|
||||||
)
|
|
||||||
msp.add_lwpolyline(
|
|
||||||
_polygon_points(parcel.buildable_m),
|
|
||||||
close=True,
|
|
||||||
dxfattribs={"layer": _LAYER_BUILDABLE},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
|
# Секции: восстанавливаем метрические footprints из WGS84-geojson варианта.
|
||||||
features = variant.buildings_geojson.get("features", [])
|
features = variant.buildings_geojson.get("features", [])
|
||||||
|
|
@ -91,18 +104,17 @@ def export_concept_dxf(parcel: Parcel, variant: ConceptVariant) -> bytes:
|
||||||
if footprint is None:
|
if footprint is None:
|
||||||
continue
|
continue
|
||||||
section_count += 1
|
section_count += 1
|
||||||
msp.add_lwpolyline(
|
_add_polygon(msp, footprint, _LAYER_BUILDINGS)
|
||||||
_polygon_points(footprint),
|
|
||||||
close=True,
|
|
||||||
dxfattribs={"layer": _LAYER_BUILDINGS},
|
|
||||||
)
|
|
||||||
centroid = footprint.centroid
|
centroid = footprint.centroid
|
||||||
label = str(_feature_section_id(feature, section_count))
|
label = str(_feature_section_id(feature, section_count))
|
||||||
text = msp.add_text(
|
text = msp.add_text(
|
||||||
label,
|
label,
|
||||||
dxfattribs={"layer": _LAYER_BUILDINGS, "height": _LABEL_HEIGHT_M},
|
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()
|
stream = io.BytesIO()
|
||||||
doc.write(stream, fmt="bin")
|
doc.write(stream, fmt="bin")
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ from shapely.strtree import STRtree
|
||||||
|
|
||||||
from app.schemas.concept import ConceptInput, ConceptVariant
|
from app.schemas.concept import ConceptInput, ConceptVariant
|
||||||
from app.services.generative import financial, teap
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -191,11 +191,25 @@ def place_strategy(
|
||||||
parcel: Parcel,
|
parcel: Parcel,
|
||||||
payload: ConceptInput,
|
payload: ConceptInput,
|
||||||
spec: StrategySpec,
|
spec: StrategySpec,
|
||||||
) -> ConceptVariant:
|
) -> ConceptVariant | None:
|
||||||
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant."""
|
"""Полный проход одной стратегии: размещение -> ТЭП -> финмодель -> ConceptVariant.
|
||||||
|
|
||||||
|
Возвращает ``None``, если ни одна секция не легла в пятно застройки (узкий/мелкий
|
||||||
|
участок, footprint стратегии целиком не помещается). Без этого вырожденный вариант
|
||||||
|
с нулевым размещением (revenue=0, margin=-land, IRR<0) выдавался бы как валидный —
|
||||||
|
ложь в отчёте. Отбраковку делает вызывающий :func:`place_all_strategies`.
|
||||||
|
"""
|
||||||
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
|
floors = _resolve_floors(payload.target_floors, spec.floors_factor)
|
||||||
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
|
coverage_cap = _COVERAGE_CAP_BY_TYPE.get(payload.development_type, _DEFAULT_COVERAGE_CAP)
|
||||||
footprints = _greedy_place(parcel, spec, 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(
|
teap_result = teap.compute_teap(
|
||||||
footprints=footprints,
|
footprints=footprints,
|
||||||
|
|
@ -220,8 +234,22 @@ def place_strategy(
|
||||||
|
|
||||||
|
|
||||||
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
|
def place_all_strategies(parcel: Parcel, payload: ConceptInput) -> list[ConceptVariant]:
|
||||||
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced)."""
|
"""Stage 1b entry: построить три варианта (max_area / max_insolation / balanced).
|
||||||
variants = [place_strategy(parcel, payload, spec) for spec in _STRATEGIES]
|
|
||||||
|
Вырожденные стратегии (нулевое размещение) отбраковываются — в результат попадают
|
||||||
|
только варианты с реальными секциями. Если ни одна стратегия не легла (участок не
|
||||||
|
вмещает даже самую компактную секцию), это вырожденный участок: поднимаем
|
||||||
|
: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(
|
logger.info(
|
||||||
"placed all strategies: %s",
|
"placed all strategies: %s",
|
||||||
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
|
", ".join(f"{v.strategy}={v.teap.apartments_count}кв" for v in variants),
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ class LLMResult:
|
||||||
fallback_used: True если результат — сигнал к детерминированному fallback.
|
fallback_used: True если результат — сигнал к детерминированному fallback.
|
||||||
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
|
reason: Машиночитаемая причина fallback (disabled/timeout/rate_limited/
|
||||||
redaction_refused/provider_error/call_cap/no_api_key). None при ok.
|
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).
|
prompt_tokens / completion_tokens: для оценки стоимости (0 при fallback).
|
||||||
model: модель, ответившая на запрос ("" при fallback).
|
model: модель, ответившая на запрос ("" при fallback).
|
||||||
"""
|
"""
|
||||||
|
|
@ -67,6 +71,7 @@ class LLMResult:
|
||||||
tool_calls: list[ToolCall] = field(default_factory=list)
|
tool_calls: list[ToolCall] = field(default_factory=list)
|
||||||
fallback_used: bool = False
|
fallback_used: bool = False
|
||||||
reason: str | None = None
|
reason: str | None = None
|
||||||
|
finish_reason: str | None = None
|
||||||
prompt_tokens: int = 0
|
prompt_tokens: int = 0
|
||||||
completion_tokens: int = 0
|
completion_tokens: int = 0
|
||||||
model: str = ""
|
model: str = ""
|
||||||
|
|
@ -81,6 +86,7 @@ class LLMResult:
|
||||||
ok=True,
|
ok=True,
|
||||||
content=resp.content,
|
content=resp.content,
|
||||||
tool_calls=list(resp.tool_calls),
|
tool_calls=list(resp.tool_calls),
|
||||||
|
finish_reason=resp.finish_reason,
|
||||||
prompt_tokens=resp.prompt_tokens,
|
prompt_tokens=resp.prompt_tokens,
|
||||||
completion_tokens=resp.completion_tokens,
|
completion_tokens=resp.completion_tokens,
|
||||||
model=resp.model,
|
model=resp.model,
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,21 @@ class LLMProvider(ABC):
|
||||||
# ── OpenAI (external) ─────────────────────────────────────────────────────────
|
# ── 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:
|
def _parse_openai_response(data: dict[str, Any], *, fallback_model: str) -> ProviderResponse:
|
||||||
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
|
"""Распарсить тело chat/completions OpenAI → ProviderResponse (с tool_calls)."""
|
||||||
choices = data.get("choices") or []
|
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"),
|
content=message.get("content"),
|
||||||
tool_calls=tool_calls,
|
tool_calls=tool_calls,
|
||||||
finish_reason=choice.get("finish_reason"),
|
finish_reason=choice.get("finish_reason"),
|
||||||
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
prompt_tokens=_coerce_token_count(usage.get("prompt_tokens", 0)),
|
||||||
completion_tokens=int(usage.get("completion_tokens", 0) or 0),
|
completion_tokens=_coerce_token_count(usage.get("completion_tokens", 0)),
|
||||||
model=str(data.get("model") or fallback_model),
|
model=str(data.get("model") or fallback_model),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
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» + хотя бы одного разделителя.
|
# пропускает из-за требования префикса «+7»/«\b8» + хотя бы одного разделителя.
|
||||||
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
|
# Ставится РАНЬШЕ _SNILS_BARE_RE (любые 11 цифр), чтобы не путать с СНИЛС.
|
||||||
_PHONE_BARE_RE = re.compile(r"(?<!\d)[78]\d{10}(?!\d)")
|
_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.
|
||||||
_EMAIL_RE = re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b")
|
_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)")
|
_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 требует
|
# СНИЛС «голый»: ровно 11 цифр без разделителей (#1207). _SNILS_RE требует
|
||||||
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
|
# формат «NNN-NNN-NNN NN»; raw «12345678901» проходит мимо. По длине не пересекается
|
||||||
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
|
# с ИНН (10/12); пересекается с _PHONE_BARE_RE (тоже 11 цифр), поэтому идёт ПОСЛЕ
|
||||||
|
|
@ -97,18 +142,22 @@ _FULLNAME_RE = re.compile(
|
||||||
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
|
r"\s+(?:[А-ЯЁ][а-яё]+|[А-ЯЁ]{2,})\b"
|
||||||
)
|
)
|
||||||
|
|
||||||
# (regex, placeholder-kind). Применяются последовательно в этом порядке.
|
# (regex, kind, repl). ``repl`` — строка-плейсхолдер ИЛИ callback для re.subn
|
||||||
|
# (используется ИНН: редактирует только checksum-валидные кандидаты — #1640).
|
||||||
|
# Применяются последовательно в этом порядке.
|
||||||
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
|
# Порядок критичен: _PHONE_BARE_RE раньше _SNILS_BARE_RE, чтобы 11-значные
|
||||||
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
|
# с префиксом 7/8 ушли как phone (телефон семантически точнее СНИЛС'а).
|
||||||
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
_Repl = str | Callable[[re.Match[str]], str]
|
||||||
(_SNILS_RE, "snils"),
|
_PII_PATTERNS: tuple[tuple[re.Pattern[str], str, _Repl], ...] = (
|
||||||
(_PASSPORT_RE, "passport"),
|
(_SNILS_RE, "snils", "[REDACTED:snils]"),
|
||||||
(_PHONE_RE, "phone"),
|
(_PASSPORT_RE, "passport", "[REDACTED:passport]"),
|
||||||
(_PHONE_BARE_RE, "phone"),
|
(_PHONE_RE, "phone", "[REDACTED:phone]"),
|
||||||
(_EMAIL_RE, "email"),
|
(_PHONE_BARE_RE, "phone", "[REDACTED:phone]"),
|
||||||
(_INN_RE, "inn"),
|
(_PHONE_LOCAL_RE, "phone", "[REDACTED:phone]"),
|
||||||
(_SNILS_BARE_RE, "snils"),
|
(_EMAIL_RE, "email", "[REDACTED:email]"),
|
||||||
(_FULLNAME_RE, "name"),
|
(_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:
|
if not value:
|
||||||
return value
|
return value
|
||||||
redacted = value
|
redacted = value
|
||||||
for pattern, kind in _PII_PATTERNS:
|
placeholder = "[REDACTED:%s]"
|
||||||
redacted, n = pattern.subn(f"[REDACTED:{kind}]", redacted)
|
for pattern, kind, repl in _PII_PATTERNS:
|
||||||
if n:
|
# 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-значения.
|
# Логируем ТОЛЬКО kind и количество — без самого PII-значения.
|
||||||
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
|
logger.info("redaction: scrubbed %d %s token(s) from free text", n, kind)
|
||||||
return redacted
|
return redacted
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import logging
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from contextlib import closing
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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["size_bytes"] = st.st_size
|
||||||
info["modified_at"] = st.st_mtime # epoch seconds
|
info["modified_at"] = st.st_mtime # epoch seconds
|
||||||
try:
|
try:
|
||||||
c = sqlite3.connect(p)
|
with closing(sqlite3.connect(p)) as c:
|
||||||
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
|
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
|
||||||
info["corp_room_month"] = c.execute("SELECT COUNT(*) FROM objective_corp_month").fetchone()[
|
info["corp_room_month"] = c.execute(
|
||||||
0
|
"SELECT COUNT(*) FROM objective_corp_month"
|
||||||
]
|
).fetchone()[0]
|
||||||
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
|
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
|
||||||
c.close()
|
|
||||||
except sqlite3.Error as e:
|
except sqlite3.Error as e:
|
||||||
info["error"] = str(e)
|
info["error"] = str(e)
|
||||||
return info
|
return info
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ opens the original DOM.РФ URL (we don't need to mirror originals).
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
|
@ -50,7 +51,16 @@ def make_thumbnail(
|
||||||
im = im.convert("RGB")
|
im = im.convert("RGB")
|
||||||
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
|
im = ImageOps.fit(im, size, method=Image.Resampling.LANCZOS)
|
||||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
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
|
return dst
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("thumbnail %s failed: %s", src, e)
|
logger.warning("thumbnail %s failed: %s", src, e)
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,16 @@ class _TextCollector(HTMLParser):
|
||||||
extraction известных структур страницы каталога.
|
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:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._stack: list[tuple[str, dict[str, str]]] = []
|
self._stack: list[tuple[str, dict[str, str]]] = []
|
||||||
|
|
@ -165,11 +175,15 @@ class _TextCollector(HTMLParser):
|
||||||
self._buf: list[str] = []
|
self._buf: list[str] = []
|
||||||
|
|
||||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
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}
|
attr_dict = {k: (v or "") for k, v in attrs}
|
||||||
self._stack.append((tag, attr_dict))
|
self._stack.append((tag, attr_dict))
|
||||||
self._buf.append("") # начало нового буфера для этого тега
|
self._buf.append("") # начало нового буфера для этого тега
|
||||||
|
|
||||||
def handle_endtag(self, _tag: str) -> None:
|
def handle_endtag(self, _tag: str) -> None:
|
||||||
|
if _tag in self._VOID_TAGS:
|
||||||
|
return # void-теги фрейм не открывали — нечего поп'ать (issue #1608)
|
||||||
if not self._stack:
|
if not self._stack:
|
||||||
return
|
return
|
||||||
tag, attr_dict = self._stack.pop()
|
tag, attr_dict = self._stack.pop()
|
||||||
|
|
@ -239,8 +253,11 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
# Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры.
|
# Ищем в сыром HTML — надёжнее чем DOM-обход для хрупкой структуры.
|
||||||
|
|
||||||
# Price: "7 890 000 ₽" или "7 890 000 руб"
|
# Price: "7 890 000 ₽" или "7 890 000 руб"
|
||||||
|
# Negative lookahead (?!\s*[/⁄]) исключает цену за м² ("217 835 ₽/м²"),
|
||||||
|
# которая на странице обычно выше полной стоимости и иначе матчилась бы
|
||||||
|
# первой → отбрасывалась санити-границей → price_rub=NULL (issue #1645).
|
||||||
price_match = re.search(
|
price_match = re.search(
|
||||||
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)",
|
r"([\d][\d\s]{3,12}[\d])\s*(?:₽|руб)(?!\s*[/⁄])",
|
||||||
html,
|
html,
|
||||||
re.UNICODE,
|
re.UNICODE,
|
||||||
)
|
)
|
||||||
|
|
@ -267,19 +284,25 @@ def parse_catalog_flat(html: str) -> dict[str, Any]:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Status: ищем характерные слова рядом с "статус" или в badge
|
# Status: ищем характерные слова рядом с "статус" или в badge.
|
||||||
|
# Морфоварианты продан[ао]?/забронирован[ао]? — типовая "Квартира продана"
|
||||||
|
# (ж.р.) раньше не матчилась, status уходил во free (issue #1609).
|
||||||
|
# NOTE: классификация по первому совпадению во ВСЁМ HTML остаётся хрупкой —
|
||||||
|
# 'в продаже' из блока 'другие квартиры в продаже' может дать ложный free.
|
||||||
|
# Полный фикс (якорь к статус-бейджу / исключение секций "похожие") требует
|
||||||
|
# знания DOM-структуры страницы каталога — см. отчёт (needs-leha).
|
||||||
status_match = re.search(
|
status_match = re.search(
|
||||||
r"(в\s*продаже|свободна|free|продано|sold|забронирована|бронь|reserved)",
|
r"(в\s*продаже|свободн[ао]|free|продан[ао]?|sold|забронирован[ао]?|бронь|reserved)",
|
||||||
html,
|
html,
|
||||||
re.IGNORECASE | re.UNICODE,
|
re.IGNORECASE | re.UNICODE,
|
||||||
)
|
)
|
||||||
if status_match:
|
if status_match:
|
||||||
s = status_match.group(1).lower()
|
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
|
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
|
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
|
result["status"] = STATUS_RESERVED
|
||||||
|
|
||||||
# Finishing type: "Предчистовая", "Чистовая", "Без отделки", "Под ключ"
|
# 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["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)
|
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
|
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(
|
logger.info(
|
||||||
"catalog scrape ods_id=%s: fields=%d updated=%s",
|
"catalog scrape ods_id=%s: fields=%d updated=%s",
|
||||||
ods_id,
|
ods_id,
|
||||||
|
|
@ -512,11 +546,15 @@ async def scrape_catalog_batch(
|
||||||
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
jitter_sleep между запросами встроен в fetch_catalog_html (через BrowserSession._sem).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{total, success, failed, fields_total}
|
{total, success, updated, failed, fields_total}
|
||||||
|
- success: прошли fetch+parse без исключения
|
||||||
|
- updated: реально затронули строку в БД (issue #1610) — отражает
|
||||||
|
фактическое число записанных квартир, в отличие от success
|
||||||
"""
|
"""
|
||||||
stats: dict[str, Any] = {
|
stats: dict[str, Any] = {
|
||||||
"total": len(flats),
|
"total": len(flats),
|
||||||
"success": 0,
|
"success": 0,
|
||||||
|
"updated": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"fields_total": 0,
|
"fields_total": 0,
|
||||||
}
|
}
|
||||||
|
|
@ -550,6 +588,8 @@ async def scrape_catalog_batch(
|
||||||
if outcome["success"]:
|
if outcome["success"]:
|
||||||
stats["success"] += 1
|
stats["success"] += 1
|
||||||
stats["fields_total"] += outcome["fields_extracted"]
|
stats["fields_total"] += outcome["fields_extracted"]
|
||||||
|
if outcome["updated"]:
|
||||||
|
stats["updated"] += 1
|
||||||
else:
|
else:
|
||||||
stats["failed"] += 1
|
stats["failed"] += 1
|
||||||
|
|
||||||
|
|
@ -565,9 +605,10 @@ async def scrape_catalog_batch(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
logger.info(
|
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["total"],
|
||||||
stats["success"],
|
stats["success"],
|
||||||
|
stats["updated"],
|
||||||
stats["failed"],
|
stats["failed"],
|
||||||
stats["fields_total"],
|
stats["fields_total"],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,30 @@ def _to_int(v: Any) -> int | None:
|
||||||
return 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:
|
def _to_date(v: Any) -> date | None:
|
||||||
"""Coerce date string to date. Accept 'YYYY-MM-DD', 'YYYY-MM-DD HH:MM:SS',
|
"""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'.
|
'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.
|
# 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.
|
# 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:
|
# Coerce оба операнда в float ДО сравнения/деления: API иногда отдаёт totalArea/price
|
||||||
price_per_m2 = round(price_rub / total_area, 2)
|
# строкой ('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(
|
logger.info(
|
||||||
"derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s",
|
"derive price_per_m2=%.2f for flat ods_id=%s obj_id=%s",
|
||||||
price_per_m2,
|
price_per_m2,
|
||||||
|
|
@ -1432,34 +1461,78 @@ def _place_str(region_code: int) -> str:
|
||||||
return str(region_code)
|
return str(region_code)
|
||||||
|
|
||||||
|
|
||||||
|
OBJECTS_PAGE_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
async def fetch_objects_for_status(
|
async def fetch_objects_for_status(
|
||||||
sess: BrowserSession, place: str, status: int
|
sess: BrowserSession, place: str, status: int
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Fetch ALL objects for a given (place, objStatus) — server returns up to limit=999999."""
|
"""Fetch ALL objects for a given (place, objStatus), пагинируя по страницам.
|
||||||
payload = await sess.get_json(
|
|
||||||
PATH_OBJECTS,
|
Раньше делали единственный запрос с limit=999999 и доверяли допущению, что сервер
|
||||||
{
|
вернёт всё. Если WAF/прокси DOM.РФ обрезает гигантский limit до своего max, хвост
|
||||||
"offset": 0,
|
объектов молча терялся (не скрейпился), а run всё равно рапортовал status='done' (#1605).
|
||||||
"limit": 999999,
|
Теперь идём страницами по OBJECTS_PAGE_SIZE и аккумулируем, пока не наберём total
|
||||||
"sortField": "default",
|
(из payload) либо страница не вернётся короче запрошенной / пустой.
|
||||||
"sortType": "desc",
|
"""
|
||||||
"place": place,
|
rows: list[dict[str, Any]] = []
|
||||||
"objStatus": status,
|
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)
|
if total is not None and len(rows) < total:
|
||||||
total = _extract_total(payload)
|
logger.warning(
|
||||||
logger.info("kn/object place=%s status=%d -> %d/%s rows", place, status, len(rows), total)
|
"kn/object place=%s status=%d: получено %d < total=%d — возможен недобор хвоста",
|
||||||
|
place,
|
||||||
|
status,
|
||||||
|
len(rows),
|
||||||
|
total,
|
||||||
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
async def fetch_flats_for_object(sess: BrowserSession, obj_id: int) -> list[dict[str, Any]]:
|
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)."""
|
"""Fetch flat-table for one object, return flat rows (entrance/floor flattened).
|
||||||
try:
|
|
||||||
payload = await sess.get_json(PATH_FLATS_TABLE, {"externalId": obj_id})
|
On HTTP / WAF / non-JSON errors raises — caller (_fetch_flats_safe) ловит и кладёт
|
||||||
except Exception as e:
|
Exception в result-tuple, который result-loop отдаёт в _classify_and_log →
|
||||||
logger.warning("flats fetch obj=%s failed: %s", obj_id, e)
|
kn_scrape_failures. Раньше try/except здесь возвращал [] на ЛЮБУЮ ошибку, из-за чего
|
||||||
return []
|
провал /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:[...]}]}]}
|
# Body shape: {externalId, entrances: [{entranceNumber, floors:[{floorNumber, flats:[...]}]}]}
|
||||||
return _flatten_table(payload)
|
return _flatten_table(payload)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def msk66_to_wgs84(raw_x: str | None, raw_y: str | None) -> tuple[float, float] | None:
|
||||||
|
|
@ -375,26 +375,28 @@ class EkburgPermitsClient:
|
||||||
Пропускает листы «Справочник», «Лист1» и неизвестные.
|
Пропускает листы «Справочник», «Лист1» и неизвестные.
|
||||||
"""
|
"""
|
||||||
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
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:
|
permit_type = _detect_permit_type(sheet_name)
|
||||||
if sheet_name.lower() in _SKIP_SHEETS:
|
if permit_type is None:
|
||||||
continue
|
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
|
||||||
|
continue
|
||||||
|
|
||||||
permit_type = _detect_permit_type(sheet_name)
|
sheet = wb[sheet_name]
|
||||||
if permit_type is None:
|
data_start = _detect_header_row(sheet)
|
||||||
logger.debug("Skipping unknown sheet %r in year %d", sheet_name, year)
|
logger.info(
|
||||||
continue
|
"Parsing sheet %r (%s) year=%d, data starts at row %d",
|
||||||
|
sheet_name,
|
||||||
sheet = wb[sheet_name]
|
permit_type,
|
||||||
data_start = _detect_header_row(sheet)
|
year,
|
||||||
logger.info(
|
data_start,
|
||||||
"Parsing sheet %r (%s) year=%d, data starts at row %d",
|
)
|
||||||
sheet_name,
|
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
|
||||||
permit_type,
|
finally:
|
||||||
year,
|
wb.close()
|
||||||
data_start,
|
|
||||||
)
|
|
||||||
yield from self._parse_sheet(sheet, permit_type, year, source_url, data_start)
|
|
||||||
|
|
||||||
def _parse_sheet(
|
def _parse_sheet(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -329,25 +329,37 @@ def denorm_dump(
|
||||||
features: плоский list из features_json JSONB (уже декодированный Python list).
|
features: плоский list из features_json JSONB (уже декодированный Python list).
|
||||||
|
|
||||||
Returns:
|
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()
|
snapshot_date = datetime.date.today().isoformat()
|
||||||
parcels_n = 0
|
parcels_n = 0
|
||||||
buildings_n = 0
|
buildings_n = 0
|
||||||
errors_n = 0
|
errors_n = 0
|
||||||
|
skipped_n = 0
|
||||||
|
|
||||||
for feat in features:
|
for feat in features:
|
||||||
layer = feat.get("layer", "")
|
layer = feat.get("layer", "")
|
||||||
try:
|
try:
|
||||||
if layer == "parcels":
|
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
|
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
|
||||||
):
|
):
|
||||||
parcels_n += 1
|
parcels_n += 1
|
||||||
else:
|
else:
|
||||||
errors_n += 1
|
errors_n += 1
|
||||||
elif layer == "buildings":
|
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
|
db, feature=feat, quarter_cad=quarter_cad, snapshot_date=snapshot_date
|
||||||
):
|
):
|
||||||
buildings_n += 1
|
buildings_n += 1
|
||||||
|
|
@ -360,10 +372,16 @@ def denorm_dump(
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(
|
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,
|
quarter_cad,
|
||||||
parcels_n,
|
parcels_n,
|
||||||
buildings_n,
|
buildings_n,
|
||||||
errors_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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,17 @@ def fetch_geoportal(
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
|
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
|
||||||
body = r.read().decode("utf-8", "ignore")
|
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:
|
except urllib.error.HTTPError as e:
|
||||||
body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
|
body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
|
||||||
if e.code in (403, 429):
|
if e.code in (403, 429):
|
||||||
|
|
@ -162,6 +172,12 @@ def fetch_via_rosreestr2coord(
|
||||||
_ = delay # silence unused — см. docstring выше
|
_ = delay # silence unused — см. docstring выше
|
||||||
try:
|
try:
|
||||||
from rosreestr2coord.parser import Area
|
from rosreestr2coord.parser import Area
|
||||||
|
from rosreestr2coord.request.exceptions import (
|
||||||
|
HTTPErrorException,
|
||||||
|
HTTPForbiddenException,
|
||||||
|
RequestException,
|
||||||
|
TimeoutException,
|
||||||
|
)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
|
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
|
||||||
|
|
||||||
|
|
@ -170,19 +186,33 @@ def fetch_via_rosreestr2coord(
|
||||||
# denied при первом fetch.
|
# denied при первом fetch.
|
||||||
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
|
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
|
||||||
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
|
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
|
||||||
a = Area(
|
#
|
||||||
code=cad_num,
|
# NB: with_log=False → конструктор Area() сразу делает HTTP-запрос
|
||||||
area_type=area_type,
|
# (get_geometry без try/except), поэтому WAF/сетевые исключения летят уже
|
||||||
timeout=timeout,
|
# отсюда, а не из to_geojson_poly(). Оба вызова под одним try.
|
||||||
with_log=False,
|
|
||||||
use_cache=False,
|
|
||||||
media_path="/tmp/rosreestr2coord",
|
|
||||||
)
|
|
||||||
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-сериализованную
|
# dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
|
||||||
# строку. Default в v5 = True → строка → крах в `_persist_target` который
|
# строку. Default в v5 = True → строка → крах в `_persist_target` который
|
||||||
# ожидает dict с `.get("properties")` etc.
|
# ожидает dict с `.get("properties")` etc.
|
||||||
return a.to_geojson_poly(dumps=False)
|
return a.to_geojson_poly(dumps=False)
|
||||||
except Exception as e:
|
except (HTTPForbiddenException, HTTPErrorException, TimeoutException) as e:
|
||||||
logger.warning("rosreestr2coord failed for %s: %s", cad_num, e)
|
# WAF/rate-limit (HTTP 403 → HTTPForbiddenException, 429/прочие HTTP →
|
||||||
return None
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,35 @@ _CHECK_TYPE_ALIASES: dict[str, list[str]] = {
|
||||||
"declaration": ["declaration", "hasDeclaration", "declarationFlg"],
|
"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(
|
_UPSERT_CHECKS_SQL = text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO domrf_obj_checks (obj_id, check_type, passed, checked_at, scraped_at)
|
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():
|
for field, value in data.items():
|
||||||
ct = _CHECK_FIELD_MAP.get(field)
|
ct = _CHECK_FIELD_MAP.get(field)
|
||||||
if ct and ct not in found:
|
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 напрямую
|
# Также проверить canonical names напрямую
|
||||||
for ct in CHECK_TYPES:
|
for ct in CHECK_TYPES:
|
||||||
if ct not in found and ct in data:
|
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:
|
if found:
|
||||||
|
# Только фактически найденные флаги: отсутствие в payload = UNKNOWN,
|
||||||
|
# а не FAILED — не фабрикуем passed=False для непришедших проверок.
|
||||||
for ct in CHECK_TYPES:
|
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
|
return results
|
||||||
# dict не содержит известных полей — попробуем как list-формат ниже
|
# dict не содержит известных полей — попробуем как list-формат ниже
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -128,11 +164,24 @@ def extract_obj_checks(raw_payload: Any) -> list[dict[str, Any]]:
|
||||||
continue
|
continue
|
||||||
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
|
ct_raw = item.get("checkType") or item.get("check_type") or item.get("type")
|
||||||
if ct_raw and str(ct_raw) in CHECK_TYPES:
|
if ct_raw and str(ct_raw) in CHECK_TYPES:
|
||||||
passed_raw = item.get("passed") or item.get("value") or item.get("status")
|
# Не or-коалесинг: легитимный False теряется (False or 'n/a' → 'n/a').
|
||||||
found_list[str(ct_raw)] = bool(passed_raw)
|
# Берём первый ключ, который реально присутствует в 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:
|
if found_list:
|
||||||
|
# См. dict-ветку: только найденные флаги, UNKNOWN не равно FAILED.
|
||||||
for ct in CHECK_TYPES:
|
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
|
return results
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3]
|
"domrf obj_checks: list payload has no recognisable check items: %s", data[:3]
|
||||||
|
|
|
||||||
|
|
@ -227,13 +227,14 @@ class BrowserSession:
|
||||||
"""
|
"""
|
||||||
if self._context is None:
|
if self._context is None:
|
||||||
raise RuntimeError("BrowserSession not bootstrapped")
|
raise RuntimeError("BrowserSession not bootstrapped")
|
||||||
await jitter_sleep(200, 500) # Lighter throttle for static assets.
|
async with self._sem:
|
||||||
self._request_count += 1
|
await jitter_sleep(200, 500) # Lighter throttle for static assets.
|
||||||
resp = await self._context.request.get(
|
self._request_count += 1
|
||||||
url,
|
resp = await self._context.request.get(
|
||||||
headers={"Authorization": self.auth} if self.auth else {},
|
url,
|
||||||
)
|
headers={"Authorization": self.auth} if self.auth else {},
|
||||||
if resp.status != 200:
|
)
|
||||||
body = await resp.text()
|
if resp.status != 200:
|
||||||
raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
|
body = await resp.text()
|
||||||
return await resp.body()
|
raise RuntimeError(f"binary http {resp.status}: {body[:200]}")
|
||||||
|
return await resp.body()
|
||||||
|
|
|
||||||
|
|
@ -488,17 +488,82 @@ _AVG_PRICE_SQL = text("""
|
||||||
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
|
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
|
||||||
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
|
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
|
||||||
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
|
# непустые 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("""
|
_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
|
SELECT
|
||||||
cm.domrf_obj_id AS obj_id,
|
p.obj_id,
|
||||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
|
percentile_cont(0.5) WITHIN GROUP (ORDER BY p.price_per_m2_rub)
|
||||||
AS median_price_per_m2
|
AS median_price_per_m2
|
||||||
FROM objective_complex_mapping cm
|
FROM (
|
||||||
JOIN objective_lots ol
|
SELECT obj_id, price_per_m2_rub FROM primary_price
|
||||||
ON ol.project_name = cm.objective_complex_name
|
UNION ALL
|
||||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
SELECT obj_id, price_per_m2_rub FROM gapfill_price
|
||||||
AND ol.price_per_m2_rub IS NOT NULL
|
) p
|
||||||
GROUP BY cm.domrf_obj_id
|
GROUP BY p.obj_id
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -648,7 +713,13 @@ def get_competitors(
|
||||||
if missing_price_ids:
|
if missing_price_ids:
|
||||||
try:
|
try:
|
||||||
obj_price_rows = (
|
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()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
||||||
Returns:
|
Returns:
|
||||||
list[{project_type, doc_status_name, is_active, paga_number, paga_date,
|
list[{project_type, doc_status_name, is_active, paga_number, paga_date,
|
||||||
subject, doc_full_name}].
|
subject, doc_full_name}].
|
||||||
- ``is_active``: doc_status_name == 'Действующий'.
|
- ``is_active``: doc_status_name == 'действующий' (регистронезависимо).
|
||||||
- ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось.
|
- ``paga_number`` / ``paga_date``: regex из full_name; None если не распарсилось.
|
||||||
- ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет.
|
- ``subject``: текст full_name после строки с ПАГЕ-реквизитами; None если нет.
|
||||||
Отсортировано: сначала действующие, внутри группы — по paga_date desc (свежие первыми).
|
Отсортировано: сначала действующие, внутри группы — по paga_date desc (свежие первыми).
|
||||||
|
|
@ -97,7 +97,10 @@ def parcel_granddoc(db: Session, parcel_wkt: str | None) -> list[dict[str, Any]]
|
||||||
for r in rows:
|
for r in rows:
|
||||||
full_name: str | None = r["full_name"]
|
full_name: str | None = r["full_name"]
|
||||||
paga_number, paga_date, subject = _parse_paga(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(
|
result.append(
|
||||||
{
|
{
|
||||||
"project_type": r["project_type"],
|
"project_type": r["project_type"],
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,14 @@
|
||||||
|
|
||||||
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
|
Прямой FK не ставим: doc_ref пишется парсером по PDF-источнику (slug/seed), а в WFS
|
||||||
source_key — целое и не совпадает форматом. Сопоставление — две дешёвые ветки:
|
source_key — целое и не совпадает форматом. Сопоставление — две дешёвые ветки:
|
||||||
1) ``doc_ref = source_key::text`` (прямое равенство);
|
1) ``doc_ref = CAST(source_key AS text)`` (прямое равенство);
|
||||||
2) ``doc_ref ILIKE '%doc_full_name%'`` (fallback для slug-ов вида ppt2018_22823).
|
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.
|
Граф вызова: build_ird_analyze_block → parcel_ppt_tep → JOIN planning_projects ∩ ekb_ppt_tep.
|
||||||
Graceful: нет таблицы / пустой WKT / БД-ошибка → []. Зеркалит стиль ``planning_lookup.py``.
|
Graceful: нет таблицы / пустой WKT / БД-ошибка → []. Зеркалит стиль ``planning_lookup.py``.
|
||||||
|
|
@ -33,26 +39,49 @@ logger = logging.getLogger(__name__)
|
||||||
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
|
# ST_Intersects через GIST idx_planning_projects_geom (4326 ↔ WKT 4326).
|
||||||
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
|
# CAST psycopg v3 — никогда :param::type (vault Pattern_CAST_AS_Type).
|
||||||
_PPT_TEP_OVERLAP_SQL = text(
|
_PPT_TEP_OVERLAP_SQL = text(
|
||||||
"""
|
r"""
|
||||||
SELECT
|
SELECT
|
||||||
pp.project_type,
|
project_type,
|
||||||
pp.doc_status_name,
|
doc_status_name,
|
||||||
pp.full_name,
|
full_name,
|
||||||
pp.doc_full_name,
|
doc_full_name,
|
||||||
pp.source_key,
|
source_key,
|
||||||
t.doc_ref,
|
doc_ref,
|
||||||
t.zone_balance,
|
zone_balance,
|
||||||
t.tep,
|
tep,
|
||||||
t.phasing,
|
phasing,
|
||||||
t.source_pdf,
|
source_pdf,
|
||||||
t.fetched_at
|
fetched_at
|
||||||
FROM planning_projects pp
|
FROM (
|
||||||
JOIN ekb_ppt_tep t ON (
|
-- DISTINCT ON (source_key): один ППТ/ПМТ-документ = одна строка (контракт docstring).
|
||||||
t.doc_ref = CAST(pp.source_key AS text)
|
-- Точная ветка (doc_ref = source_key) приоритетна над slug-fallback (ILIKE).
|
||||||
OR (pp.doc_full_name IS NOT NULL AND t.doc_ref ILIKE '%' || pp.doc_full_name || '%')
|
SELECT DISTINCT ON (pp.source_key)
|
||||||
)
|
pp.project_type,
|
||||||
WHERE ST_Intersects(pp.geom, ST_GeomFromText(CAST(:parcel_wkt AS text), 4326))
|
pp.doc_status_name,
|
||||||
ORDER BY pp.dmd_actual_year DESC NULLS LAST, pp.project_type
|
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
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,16 @@ class BuildingMatch:
|
||||||
|
|
||||||
|
|
||||||
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
|
# Geom-match domrf-центроида (lat/lon) → ближайшее здание cad_buildings (#96).
|
||||||
# Один LATERAL KNN: GIST `cad_buildings_geom_gist` (`geom <-> point`) выбирает
|
# Один KNN по geography: GIST `idx_cad_buildings_geom_geog`
|
||||||
# ОДНОГО ближайшего кандидата (~19ms прод, EXPLAIN ANALYZE), затем фильтруем по
|
# (`geom::geography <-> point::geography`) выбирает ОДНОГО ближайшего кандидата по
|
||||||
# фактической дистанции в метрах (geography ST_Distance — KNN-оператор `<->` на
|
# ФАКТИЧЕСКИМ МЕТРАМ (geography `<->` = сфероидное расстояние в метрах, не градусы).
|
||||||
# geometry SRID 4326 сортирует в ГРАДУСАХ, что для малых расстояний монотонно
|
# Важно: KNN на geometry SRID 4326 (`geom <-> point`) сортирует в ГРАДУСАХ, а на
|
||||||
# дистанции, но сама величина в градусах — поэтому метры считаем отдельно). Точка
|
# широте ЕКБ (~56.8°N) долгота сжата ×1.83 → degree-порядок ≠ meter-порядок для
|
||||||
# внутри площадного footprint даёт ST_Distance=0 (ST_Contains-эквивалент, см.
|
# кандидатов в разных направлениях, и LIMIT 1 мог взять не ближайший по метрам дом
|
||||||
# прод: 198 объектов внутри). psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
# (parking_ratio чужого здания). Поэтому ранжируем и фильтруем строго в метрах.
|
||||||
|
# distance_m считаем тем же geography ST_Distance: точка внутри площадного footprint
|
||||||
|
# даёт 0 (ST_Contains-эквивалент, прод: 198 объектов внутри).
|
||||||
|
# psycopg v3: CAST(:x AS double precision) — НЕ ::type.
|
||||||
_RESOLVE_CAD_SQL = text("""
|
_RESOLVE_CAD_SQL = text("""
|
||||||
SELECT
|
SELECT
|
||||||
b.cad_num,
|
b.cad_num,
|
||||||
|
|
@ -97,13 +100,13 @@ _RESOLVE_CAD_SQL = text("""
|
||||||
) AS distance_m
|
) AS distance_m
|
||||||
FROM cad_buildings b
|
FROM cad_buildings b
|
||||||
WHERE b.geom IS NOT NULL
|
WHERE b.geom IS NOT NULL
|
||||||
ORDER BY b.geom <-> ST_SetSRID(
|
ORDER BY b.geom::geography <-> ST_SetSRID(
|
||||||
ST_MakePoint(
|
ST_MakePoint(
|
||||||
CAST(:lon AS double precision),
|
CAST(:lon AS double precision),
|
||||||
CAST(:lat AS double precision)
|
CAST(:lat AS double precision)
|
||||||
),
|
),
|
||||||
4326
|
4326
|
||||||
)
|
)::geography
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|
@ -119,8 +122,9 @@ def resolve_cad_for_domrf(
|
||||||
|
|
||||||
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
|
Мост через PostGIS: domrf_kn_objects хранят только obj_id + lat/lon (без
|
||||||
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
|
cad_num), а premises_lookup нужен cad_num ЗДАНИЯ. Берём ближайшее здание по
|
||||||
GIST-ускоренному KNN (`geom <-> point`) и принимаем матч, если расстояние до
|
GIST-ускоренному KNN в МЕТРАХ (`geom::geography <-> point::geography`) и
|
||||||
footprint ≤ max_dist_m. Точка внутри площадного footprint → distance 0.
|
принимаем матч, если расстояние до footprint ≤ max_dist_m. Точка внутри
|
||||||
|
площадного footprint → distance 0.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
|
BuildingMatch (cad_num + objdoc_id + distance_m) ближайшего здания в
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import DatabaseError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -41,8 +41,11 @@ def _refresh_mv(db: Session, mv_name: str, *, concurrently: bool) -> None:
|
||||||
else:
|
else:
|
||||||
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
|
db.execute(text(f"REFRESH MATERIALIZED VIEW {mv_name}"))
|
||||||
db.commit()
|
db.commit()
|
||||||
except OperationalError as e:
|
except DatabaseError as e:
|
||||||
if concurrently and "cannot refresh materialized view" in str(e).lower():
|
# 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(
|
logger.warning(
|
||||||
"%s: CONCURRENTLY failed (MV likely not populated), "
|
"%s: CONCURRENTLY failed (MV likely not populated), "
|
||||||
"falling back to non-concurrent refresh",
|
"falling back to non-concurrent refresh",
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ _RE_FLOORS = re.compile(
|
||||||
+ _DASH
|
+ _DASH
|
||||||
+ r"\s*"
|
+ r"\s*"
|
||||||
+ _NUM
|
+ _NUM
|
||||||
+ r"\s*этаж",
|
+ r"(?:\s*этаж\w*)?",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
# высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)
|
# высота — 'предельная высота ... – 25 м' (в ЕКБ почти всегда «не подлежат», но для прочих МО)
|
||||||
|
|
|
||||||
|
|
@ -130,12 +130,15 @@ def _fetch_weather_remote(lat: float, lon: float) -> dict[str, Any] | None:
|
||||||
if not daily.get("time"):
|
if not daily.get("time"):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
t_max = daily.get("temperature_2m_max") or []
|
# Open-Meteo штатно возвращает null для отдельных дней (uv_index_max и др.)
|
||||||
t_min = daily.get("temperature_2m_min") or []
|
# при непустом daily.time — отфильтровываем None ПЕРЕД min/max/sum, иначе
|
||||||
precip = daily.get("precipitation_sum") or []
|
# TypeError в Python 3.12 уронит весь 7-day forecast в negative-cache (#1577).
|
||||||
uv = daily.get("uv_index_max") or []
|
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_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
|
# Circular mean направления ветра (vector sum) — избегает jump 359→1
|
||||||
x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None)
|
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"]:
|
if not vals["t_max"]:
|
||||||
seasons[season] = None
|
seasons[season] = None
|
||||||
continue
|
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] = {
|
seasons[season] = {
|
||||||
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
|
"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),
|
"max_t_c": round(max(vals["t_max"]), 1),
|
||||||
"min_t_c": round(min(vals["t_min"]), 1),
|
"min_t_c": round(min(t_min), 1) if t_min else None,
|
||||||
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
|
"avg_precip_per_day_mm": (
|
||||||
"total_precip_mm": round(sum(vals["precip"]), 0),
|
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"]),
|
"days_observed": len(vals["t_max"]),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.ekburg_permits_sync",
|
"app.workers.tasks.ekburg_permits_sync",
|
||||||
"app.workers.tasks.cbr_macro_sync",
|
"app.workers.tasks.cbr_macro_sync",
|
||||||
"app.workers.tasks.rosstat_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.supply_layers_refresh",
|
||||||
"app.workers.tasks.location_refresh",
|
"app.workers.tasks.location_refresh",
|
||||||
"app.workers.tasks.forecast",
|
"app.workers.tasks.forecast",
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ Handlers регистрируются через Celery signals декорато
|
||||||
worker_process_init — dispose SQLAlchemy engine в каждом prefork child-процессе,
|
worker_process_init — dispose SQLAlchemy engine в каждом prefork child-процессе,
|
||||||
чтобы избежать shared TCP-сокетов к PostgreSQL после fork().
|
чтобы избежать shared TCP-сокетов к PostgreSQL после fork().
|
||||||
|
|
||||||
worker_ready — при рестарте воркера находит все 'running'/'paused' записи
|
worker_ready — при рестарте воркера находит 'running'/'paused' записи
|
||||||
kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume.
|
kn_scrape_runs и nspd_geo_jobs и re-enqueue'ит их как zombie-resume.
|
||||||
|
Для nspd_geo 'paused' (WAF-пауза) применяется 30-минутный cooldown
|
||||||
|
(_ZOMBIE_PAUSED_THRESHOLD), чтобы редеплой не аннулировал WAF-защиту.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -147,10 +149,18 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
||||||
# старых runs сохранена в nspd_scrape_runs — никаких side-effects.
|
# старых runs сохранена в nspd_scrape_runs — никаких side-effects.
|
||||||
|
|
||||||
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
|
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
|
||||||
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' / 'paused' jobs
|
# nspd_geo_jobs / nspd_geo_targets. Resume любых 'running' jobs — на
|
||||||
# — на worker_ready по определению нет активных воркеров, всё running ==
|
# worker_ready по определению нет активных воркеров, всё running ==
|
||||||
# zombie. Раньше требовали heartbeat >10мин, что пропускало jobs убитых
|
# 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()
|
db = SessionLocal()
|
||||||
geo_resume_jobs: list[int] = []
|
geo_resume_jobs: list[int] = []
|
||||||
try:
|
try:
|
||||||
|
|
@ -161,10 +171,16 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
||||||
UPDATE nspd_geo_jobs
|
UPDATE nspd_geo_jobs
|
||||||
SET status = 'queued',
|
SET status = 'queued',
|
||||||
error = COALESCE(error, 'auto-resume at worker_ready')
|
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
|
RETURNING job_id
|
||||||
"""
|
"""
|
||||||
)
|
),
|
||||||
|
{"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
.all()
|
.all()
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,16 @@ WAF_BACKOFF_BASE_S = 30
|
||||||
# нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу.
|
# нужен большой cooldown, иначе минута beat сразу аннулирует WAF-паузу.
|
||||||
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
|
_ZOMBIE_RUNNING_THRESHOLD = "6 minutes"
|
||||||
_ZOMBIE_PAUSED_THRESHOLD = "30 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 ────────────────────────────────────────
|
# ── Helpers для job/target lifecycle ────────────────────────────────────────
|
||||||
|
|
@ -79,8 +89,19 @@ def _log(
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _start_job(db: Session, job_id: int) -> None:
|
def _start_job(db: Session, job_id: int) -> bool:
|
||||||
db.execute(
|
"""Атомарный 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(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE nspd_geo_jobs
|
UPDATE nspd_geo_jobs
|
||||||
|
|
@ -89,12 +110,17 @@ def _start_job(db: Session, job_id: int) -> None:
|
||||||
heartbeat_at = NOW(),
|
heartbeat_at = NOW(),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE job_id = :id
|
WHERE job_id = :id
|
||||||
|
AND status <> 'running'
|
||||||
|
RETURNING job_id
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"id": job_id},
|
{"id": job_id},
|
||||||
)
|
).scalar_one_or_none()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if claimed is None:
|
||||||
|
return False
|
||||||
_log(db, job_id, "info", "start", "job started/resumed")
|
_log(db, job_id, "info", "start", "job started/resumed")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
|
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:
|
if not job:
|
||||||
return {"error": "job_not_found", "job_id": job_id}
|
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.
|
# Если в job-строке нет rate_ms — берём глобальный дефолт из job_settings.
|
||||||
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
|
# Это позволяет менять дефолт через /admin/jobs/settings без перезапуска.
|
||||||
if job["rate_ms"]:
|
if job["rate_ms"]:
|
||||||
|
|
@ -637,6 +671,10 @@ def cleanup_zombies() -> dict[str, Any]:
|
||||||
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин) →
|
- status='paused' с heartbeat старше _ZOMBIE_PAUSED_THRESHOLD (30 мин) →
|
||||||
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
|
WAF-пауза (consecutive_waf>=8) живёт минимум 30 мин, иначе минутный beat
|
||||||
сразу аннулировал бы защиту и worker снова долбил бы забаненный сервис.
|
сразу аннулировал бы защиту и 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
|
Idempotent: если зомби нет, ничего не делает. Активный job с свежим heartbeat
|
||||||
не матчит WHERE-clause.
|
не матчит WHERE-clause.
|
||||||
|
|
@ -661,12 +699,22 @@ def cleanup_zombies() -> dict[str, Any]:
|
||||||
AND heartbeat_at
|
AND heartbeat_at
|
||||||
< NOW() - CAST(:paused_threshold AS interval)
|
< 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
|
RETURNING job_id
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
|
"running_threshold": _ZOMBIE_RUNNING_THRESHOLD,
|
||||||
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
|
"paused_threshold": _ZOMBIE_PAUSED_THRESHOLD,
|
||||||
|
"queued_threshold": _ZOMBIE_QUEUED_THRESHOLD,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.mappings()
|
.mappings()
|
||||||
|
|
|
||||||
|
|
@ -153,9 +153,12 @@ def import_anton_objective(
|
||||||
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
|
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Дополним результат полезной диагностикой
|
# Логируем полезную диагностику, но НЕ возвращаем dict —
|
||||||
|
# re-raise, чтобы Celery-таск ушёл в FAILURE (не SUCCESS) и не
|
||||||
|
# рассинхронился с objective_scrape_runs.status='failed' (#1623).
|
||||||
info = get_sqlite_info(sqlite_path_eff)
|
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:
|
except Exception as e:
|
||||||
logger.exception("import_anton_objective failed: %s", e)
|
logger.exception("import_anton_objective failed: %s", e)
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,10 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
|
||||||
bbox = _geojson_bbox_3857(qfeat.geometry)
|
bbox = _geojson_bbox_3857(qfeat.geometry)
|
||||||
if bbox is None:
|
if bbox is None:
|
||||||
continue
|
continue
|
||||||
n_quarters += 1
|
# Staging-счётчик квартала: прибавляем к итогам ТОЛЬКО после успешного
|
||||||
|
# commit (строка ниже). Иначе при сбое commit + rollback откатятся
|
||||||
|
# незакоммиченные UPSERT'ы, а отчёт завысит число persisted фич (#1624).
|
||||||
|
q_features = 0
|
||||||
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
|
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
|
||||||
layer_id = LAYERS.get(layer_key)
|
layer_id = LAYERS.get(layer_key)
|
||||||
if layer_id is None:
|
if layer_id is None:
|
||||||
|
|
@ -94,10 +97,13 @@ def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str,
|
||||||
feature=feat,
|
feature=feat,
|
||||||
fetched_at=fetched_at,
|
fetched_at=fetched_at,
|
||||||
):
|
):
|
||||||
n_features += 1
|
q_features += 1
|
||||||
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
|
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
|
||||||
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
|
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
|
||||||
db.commit()
|
db.commit()
|
||||||
|
# Commit прошёл → фичи квартала реально persisted, учитываем в отчёте.
|
||||||
|
n_quarters += 1
|
||||||
|
n_features += q_features
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
|
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
|
||||||
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
|
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
|
||||||
|
|
|
||||||
|
|
@ -401,11 +401,28 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
|
||||||
)
|
)
|
||||||
skipped_fresh = set(rows)
|
skipped_fresh = set(rows)
|
||||||
if skipped_fresh:
|
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(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE cadastre_jobs SET targets_total = :new_total, "
|
"UPDATE cadastre_jobs SET "
|
||||||
"targets_skipped = COALESCE(targets_skipped, 0) + :sk "
|
"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"
|
"WHERE job_id = :id"
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ logger = logging.getLogger(__name__)
|
||||||
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
|
# Фильтр (:force = false): берём только те, что ещё не обновлялись сегодня
|
||||||
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
|
# (catalog_scraped_at IS NULL — никогда не скрапились, либо DATE(...) < today).
|
||||||
# При :force = true фильтр снимается — грузим все объекты последнего snapshot.
|
# При :force = true фильтр снимается — грузим все объекты последнего snapshot.
|
||||||
|
#
|
||||||
|
# LIMIT :max_objects: в PostgreSQL `LIMIT NULL` == без лимита, поэтому при
|
||||||
|
# max_objects=None (force "Загрузить все" без явного потолка) грузим ВСЕ строки
|
||||||
|
# последнего snapshot, а не молча режем до _DEFAULT_MAX_OBJECTS.
|
||||||
_SELECT_TARGETS_SQL = text(
|
_SELECT_TARGETS_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT obj_id, snapshot_date
|
SELECT obj_id, snapshot_date
|
||||||
|
|
@ -74,10 +78,12 @@ def scrape_kn_catalog_objects(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
|
region_code: Код региона (ОКАТО prefix). Default 66 = Свердловская обл.
|
||||||
max_objects: Максимум объектов за один run. Default 300.
|
max_objects: Максимум объектов за один run. Если не задан: при force=True
|
||||||
force: Если True — игнорирует фильтр "уже сегодня обновлён" и грузит
|
лимита нет (грузим все), при force=False — _DEFAULT_MAX_OBJECTS (300).
|
||||||
все объекты последнего snapshot (admin "Загрузить все"). По умолчанию
|
force: Если True — игнорирует фильтр "уже сегодня обновлён" и (при не
|
||||||
False — пропускает то, что уже скраплено сегодня.
|
заданном max_objects) снимает лимит, грузя ВСЕ объекты последнего
|
||||||
|
snapshot (admin "Загрузить все"). По умолчанию False — пропускает
|
||||||
|
то, что уже скраплено сегодня, и режет batch до 300.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict с ключами: region_code, snapshot_date, obj_ids_count,
|
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
|
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()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|
@ -136,11 +150,11 @@ def scrape_kn_catalog_objects(
|
||||||
snapshot_date_val: date = rows[0]["snapshot_date"]
|
snapshot_date_val: date = rows[0]["snapshot_date"]
|
||||||
|
|
||||||
logger.info(
|
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,
|
region_code,
|
||||||
snapshot_date_val,
|
snapshot_date_val,
|
||||||
len(obj_ids),
|
len(obj_ids),
|
||||||
limit,
|
"ALL" if limit is None else limit,
|
||||||
force,
|
force,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -269,11 +269,14 @@ class TestPaymentAtScenario:
|
||||||
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
||||||
assert res.payment_at_scenario is not None
|
assert res.payment_at_scenario is not None
|
||||||
principal = 120_000.0 * _REF_AREA_M2
|
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(
|
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(
|
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]
|
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
|
||||||
|
|
|
||||||
|
|
@ -304,12 +304,15 @@ def test_denorm_dump_empty_features() -> None:
|
||||||
db = _make_mock_session()
|
db = _make_mock_session()
|
||||||
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[])
|
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()
|
db.commit.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_denorm_dump_no_cad_num_counted_as_error() -> None:
|
def test_denorm_dump_no_cad_num_counted_as_skipped() -> None:
|
||||||
"""Parcel без cad_num → denorm_parcel_feature returns False → errors += 1."""
|
"""Parcel без cad_num → pre-check в denorm_dump → skipped += 1, не errors.
|
||||||
|
|
||||||
|
denorm_parcel_feature не вызывается вовсе — пропуск штатный, не сбой UPSERT.
|
||||||
|
"""
|
||||||
db = _make_mock_session()
|
db = _make_mock_session()
|
||||||
feature: dict[str, Any] = {
|
feature: dict[str, Any] = {
|
||||||
"layer": "parcels",
|
"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])
|
counts = denorm_dump(db, quarter_cad="66:41:0101001", features=[feature])
|
||||||
assert counts["parcels"] == 0
|
assert counts["parcels"] == 0
|
||||||
assert counts["errors"] == 1
|
assert counts["errors"] == 0
|
||||||
|
assert counts["skipped"] == 1
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue