Compare commits
78 commits
fix/2013-a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 698576e77b | |||
| 67b50bf713 | |||
| c61ed5a96e | |||
| e61c2debe5 | |||
| c904fbf94e | |||
| 9fd6396fbc | |||
| 8b7af92ee3 | |||
| 2c5b30f8c9 | |||
| d530602fbd | |||
| 32037af6de | |||
| 5647c2b40e | |||
| 749c96cd0c | |||
| 987dce11bc | |||
| b5477362f0 | |||
| 60c21ae2a5 | |||
| 3bd0873230 | |||
| 5e73b35206 | |||
| 69ca746673 | |||
| 2cd24ecfbc | |||
| 92371e7d9e | |||
| 7fa52bb3ad | |||
| 348be445e3 | |||
|
|
8fdcd8df62 | ||
| c98a879a79 | |||
|
|
2ef3e42da1 | ||
|
|
720cee2c8e | ||
| 73f932d60a | |||
| e7c388dde7 | |||
|
|
07275c3c97 | ||
|
|
d92ff5c3d3 | ||
|
|
d31b1e01ee | ||
| 51473fc841 | |||
| c3d0f86f37 | |||
|
|
219cdfdbcc | ||
|
|
bf4ce1ba36 | ||
| 519b482cdd | |||
| e4bb2fc2e3 | |||
|
|
85321d1d1a | ||
| b1ac551451 | |||
| 032fd95a7e | |||
|
|
0583fb0efd | ||
| a3dfa2a99f | |||
|
|
20bdc7dec8 | ||
|
|
6487813ec5 | ||
|
|
1c9f0e1161 | ||
| 0f4ed2f6e9 | |||
|
|
27d1b09c72 | ||
|
|
c1a01fdbf9 | ||
| fb123bd97e | |||
|
|
f9ae6f0c53 | ||
| 3293df8e14 | |||
| 4c56c5189a | |||
|
|
25a865f27b | ||
|
|
69f8b50934 | ||
|
|
7801ac6cc1 | ||
| 5d7c3e1c30 | |||
| 6936558f6f | |||
|
|
f631af4de3 | ||
|
|
4a579e74e0 | ||
|
|
b93d26990b | ||
| 90a6e4bfe9 | |||
|
|
cf91ac6b24 | ||
| 74017b39c4 | |||
|
|
e250869c9d | ||
| b0b2c94ece | |||
|
|
e2502a0134 | ||
| 51952ebd70 | |||
|
|
c0cbdc2f99 | ||
| fcc37c46ab | |||
| 95f0b0aa64 | |||
|
|
752cb1044b | ||
| 338ccbb78f | |||
| a88e755403 | |||
| 4f205bd7da | |||
| 8dd35c01d8 | |||
| ed504cbbd9 | |||
| cc231946c2 | |||
| d22e0e00ae |
118 changed files with 11556 additions and 1809 deletions
|
|
@ -70,7 +70,7 @@ users:
|
|||
admin: admin
|
||||
kopylov: pilot
|
||||
user1: pilot
|
||||
user2: expired # «Брусника» — пробный доступ закрыт 2026-07-09 (аналогично praktika) — NoAccessScreen variant="trial"
|
||||
user2: pilot # «Брусника» — доступ восстановлен 2026-07-13 (снят trial-expire от 2026-07-09)
|
||||
user3: pilot
|
||||
user4: pilot
|
||||
user5: pilot
|
||||
|
|
|
|||
|
|
@ -114,9 +114,7 @@ def test_get_role_known_users() -> None:
|
|||
assert auth_mod.get_role("admin") == "admin"
|
||||
assert auth_mod.get_role("kopylov") == "pilot"
|
||||
for n in range(1, 11):
|
||||
# user2 = «Брусника»: пробный доступ закрыт 2026-07-09 (roles.yaml)
|
||||
expected = "expired" if n == 2 else "pilot"
|
||||
assert auth_mod.get_role(f"user{n}") == expected
|
||||
assert auth_mod.get_role(f"user{n}") == "pilot"
|
||||
|
||||
|
||||
def test_get_role_unknown_user_raises() -> None:
|
||||
|
|
|
|||
280
tradein-mvp/backend/app/api/v1/audit.py
Normal file
280
tradein-mvp/backend/app/api/v1/audit.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""Admin read API над `user_events` — Feature 2 (login/IP audit) + Feature 3 (behavior
|
||||
analytics dashboard data).
|
||||
|
||||
Read-only: только SELECT, никаких мутаций. Auth не нужен в этом файле — вся ветка
|
||||
`/api/v1/admin/*` уже гейтится `rbac_guard` middleware в app/main.py
|
||||
(`_ADMIN_API_RE`, role != admin → 403).
|
||||
|
||||
`user_events` (migration `184_user_events.sql`) может быть пустой (feature только
|
||||
что задеплоена) — все запросы ниже написаны так, чтобы на пустой таблице отдавать
|
||||
пустые списки/нулевые счётчики, а не падать.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.schemas.audit import (
|
||||
AccountActivityEntry,
|
||||
AccountDeviceEntry,
|
||||
AccountDrilldown,
|
||||
AccountIpEntry,
|
||||
AccountSearchEntry,
|
||||
AccountSummary,
|
||||
AnalyticsByAccount,
|
||||
AnalyticsDailyPoint,
|
||||
AnalyticsDashboard,
|
||||
AnalyticsSummary,
|
||||
AnalyticsTopPath,
|
||||
AnalyticsTopSearch,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/audit/accounts", response_model=list[AccountSummary])
|
||||
async def list_accounts(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> list[AccountSummary]:
|
||||
"""Список аккаунтов, по одной строке на username, с базовой сводкой активности."""
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT username,
|
||||
min(created_at) AS first_seen_at,
|
||||
max(created_at) AS last_seen_at,
|
||||
count(DISTINCT ip_address) AS distinct_ips,
|
||||
count(DISTINCT user_agent) AS distinct_devices,
|
||||
count(*) FILTER (WHERE event_type = 'login') AS login_count,
|
||||
count(*) FILTER (WHERE event_type = 'api_request') AS request_count,
|
||||
count(*) FILTER (WHERE event_type = 'estimate_request') AS search_count
|
||||
FROM user_events
|
||||
GROUP BY username
|
||||
ORDER BY last_seen_at DESC
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [AccountSummary.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/audit/accounts/{username}", response_model=AccountDrilldown)
|
||||
async def account_drilldown(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
username: Annotated[str, Path(min_length=1, max_length=200)],
|
||||
) -> AccountDrilldown:
|
||||
"""Drilldown по одному username: IP-адреса, устройства, поиски, недавняя активность.
|
||||
|
||||
Неизвестный username — НЕ 404, а пустой отчёт (все 4 списка == []): аудит не
|
||||
подтверждает/опровергает существование аккаунта, просто нет событий.
|
||||
"""
|
||||
params = {"username": username}
|
||||
|
||||
ips = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT CAST(ip_address AS text) AS ip_address,
|
||||
count(*) AS event_count,
|
||||
min(created_at) AS first_seen,
|
||||
max(created_at) AS last_seen
|
||||
FROM user_events
|
||||
WHERE username = :username AND ip_address IS NOT NULL
|
||||
GROUP BY ip_address
|
||||
ORDER BY last_seen DESC
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
devices = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT user_agent,
|
||||
count(*) AS event_count,
|
||||
min(created_at) AS first_seen,
|
||||
max(created_at) AS last_seen
|
||||
FROM user_events
|
||||
WHERE username = :username AND user_agent IS NOT NULL
|
||||
GROUP BY user_agent
|
||||
ORDER BY last_seen DESC
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
searches = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT payload ->> 'address' AS address,
|
||||
payload ->> 'area_m2' AS area_m2,
|
||||
payload ->> 'rooms' AS rooms,
|
||||
CAST(estimate_id AS text) AS estimate_id,
|
||||
CAST(ip_address AS text) AS ip_address,
|
||||
created_at
|
||||
FROM user_events
|
||||
WHERE username = :username AND event_type = 'estimate_request'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
recent_activity = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT event_type,
|
||||
path,
|
||||
method,
|
||||
CAST(ip_address AS text) AS ip_address,
|
||||
created_at
|
||||
FROM user_events
|
||||
WHERE username = :username
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 200
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
return AccountDrilldown(
|
||||
ips=[AccountIpEntry.model_validate(r) for r in ips],
|
||||
devices=[AccountDeviceEntry.model_validate(r) for r in devices],
|
||||
searches=[AccountSearchEntry.model_validate(r) for r in searches],
|
||||
recent_activity=[AccountActivityEntry.model_validate(r) for r in recent_activity],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics", response_model=AnalyticsDashboard)
|
||||
async def analytics_dashboard(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
days: Annotated[int, Query(ge=1, le=365)] = 30,
|
||||
) -> AnalyticsDashboard:
|
||||
"""Feature 3 dashboard bundle: сводка, дневной time-series, топ-поиски/пути/аккаунты."""
|
||||
summary_row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT count(*) AS total_events,
|
||||
count(DISTINCT username) AS distinct_users,
|
||||
count(*) FILTER (
|
||||
WHERE created_at >= now() - INTERVAL '24 hours'
|
||||
) AS events_last_24h,
|
||||
count(DISTINCT username) FILTER (
|
||||
WHERE created_at >= now() - INTERVAL '24 hours'
|
||||
) AS active_users_last_24h
|
||||
FROM user_events
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.one()
|
||||
)
|
||||
|
||||
daily_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT date_trunc('day', created_at)::date AS day,
|
||||
count(*) AS events,
|
||||
count(DISTINCT username) AS users
|
||||
FROM user_events
|
||||
WHERE created_at >= now() - make_interval(days => CAST(:days AS int))
|
||||
GROUP BY date_trunc('day', created_at)::date
|
||||
ORDER BY day
|
||||
"""
|
||||
),
|
||||
{"days": days},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
top_searches_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT payload ->> 'address' AS address, count(*) AS n
|
||||
FROM user_events
|
||||
WHERE event_type = 'estimate_request'
|
||||
AND payload ->> 'address' IS NOT NULL
|
||||
GROUP BY payload ->> 'address'
|
||||
ORDER BY n DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
top_paths_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT path, count(*) AS n
|
||||
FROM user_events
|
||||
WHERE event_type = 'api_request'
|
||||
GROUP BY path
|
||||
ORDER BY n DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
by_account_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT username,
|
||||
count(*) AS events,
|
||||
count(*) FILTER (WHERE event_type = 'estimate_request') AS searches,
|
||||
max(created_at) AS last_seen
|
||||
FROM user_events
|
||||
GROUP BY username
|
||||
ORDER BY events DESC
|
||||
LIMIT 50
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
return AnalyticsDashboard(
|
||||
summary=AnalyticsSummary.model_validate(summary_row),
|
||||
daily=[AnalyticsDailyPoint.model_validate(r) for r in daily_rows],
|
||||
top_searches=[AnalyticsTopSearch.model_validate(r) for r in top_searches_rows],
|
||||
top_paths=[AnalyticsTopPath.model_validate(r) for r in top_paths_rows],
|
||||
by_account=[AnalyticsByAccount.model_validate(r) for r in by_account_rows],
|
||||
)
|
||||
|
|
@ -56,10 +56,12 @@ async def suggest_addresses(
|
|||
limit: Annotated[int, Query(ge=1, le=15)] = 8,
|
||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||
) -> SuggestResponse:
|
||||
"""Автокомплит адресов в пределах ЕКБ.
|
||||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||
остаётся быстрым fast-path).
|
||||
|
||||
Используется в EstimateForm для подсказок пока пользователь печатает.
|
||||
Bounded viewbox = ЕКБ (lon 60.40-60.85, lat 56.65-56.95).
|
||||
Bounded viewbox — генеральный по всей области (см. geocoder.OBLAST66_VIEWBOX),
|
||||
ЕКБ (lon 60.40-60.85, lat 56.65-56.95) внутри него остаётся быстрым fast-path.
|
||||
|
||||
Пример:
|
||||
/api/v1/geocode/suggest?q=Малышева
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""Trade-in lead capture endpoint (issue #2376, sub-issue родителя #1971).
|
||||
|
||||
POST /api/v1/trade-in/lead — контактная заявка с результата оценки (или лендинга):
|
||||
POST /api/v1/trade-in/lead — контактная заявка с результата оценки:
|
||||
телефон + явное согласие на обработку персональных данных. Persist в
|
||||
trade_in_leads. Notification (Telegram/email) — вне scope: нет существующей
|
||||
SMTP/Telegram интеграции в коде (подтверждено при разборе issue), только
|
||||
|
|
@ -10,11 +10,12 @@ persist + log; `notified_at` в таблице зарезервирован по
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated, Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
|
@ -28,13 +29,45 @@ router = APIRouter()
|
|||
# скобки/дефисы/точки, 5-32 символа. Полная нормализация в E.164 — вне scope MVP,
|
||||
# см. #2376 DoD ("простая regex, не EmailStr-подобное").
|
||||
_PHONE_PATTERN = r"^[+]?[\d\s().-]{5,32}$"
|
||||
# Маска выше делает цифры ОПЦИОНАЛЬНЫМИ: "(()) -- .." её проходит (0 цифр).
|
||||
# Поэтому дополнительно требуем правдоподобное число реальных цифр. RU-мобильный =
|
||||
# 11 цифр; берём лениентный диапазон 10-15 (нац. номер без/с кодом страны).
|
||||
_PHONE_MIN_DIGITS = 10
|
||||
_PHONE_MAX_DIGITS = 15
|
||||
|
||||
# Версия политики обработки ПДн (152-ФЗ), под которую собрано согласие. Персистится
|
||||
# per-row в trade_in_leads.consent_policy_version (migration 182) — до неё писалась
|
||||
# только в audit-лог (#2497 TODO, теперь закрыт).
|
||||
_CONSENT_POLICY_VERSION = "2026-07"
|
||||
|
||||
# Снимок точного текста согласия, который видит пользователь при отправке лида.
|
||||
# Должен ДОСЛОВНО совпадать с чекбоксом в LeadForm.tsx (frontend/src/components/
|
||||
# trade-in/v2/LeadForm.tsx) — если текст политики меняется, здесь нужно поднять
|
||||
# _CONSENT_POLICY_VERSION И обновить этот снимок в одном PR, иначе новые строки
|
||||
# будут нести устаревший snapshot под новой version-меткой.
|
||||
_CONSENT_TEXT_SNAPSHOT = (
|
||||
"Согласен(-на) на обработку персональных данных в соответствии с "
|
||||
"Федеральным законом «О персональных данных» № 152-ФЗ"
|
||||
)
|
||||
|
||||
|
||||
class TradeInLeadInput(BaseModel):
|
||||
phone: str = Field(min_length=5, max_length=32, pattern=_PHONE_PATTERN)
|
||||
estimate_id: UUID | None = None
|
||||
consent: Literal[True]
|
||||
source: Literal["result", "landing"] = "result"
|
||||
# "landing"-воронка недостижима: /api/v1/trade-in/lead закрыт rbac_guard
|
||||
# (main.py — путь не в _PUBLIC_PATHS => 401 без X-Authenticated-User), а
|
||||
# публичного лендинг-роута нет. Убрали мёртвый литерал, чтобы контракт не
|
||||
# обещал невозможную воронку (#2376). Вернуть, если появится public-роут.
|
||||
source: Literal["result"] = "result"
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
def _phone_has_enough_digits(cls, value: str) -> str:
|
||||
digits = len(re.sub(r"\D", "", value))
|
||||
if not (_PHONE_MIN_DIGITS <= digits <= _PHONE_MAX_DIGITS):
|
||||
raise ValueError(f"phone must contain {_PHONE_MIN_DIGITS}-{_PHONE_MAX_DIGITS} digits")
|
||||
return value
|
||||
|
||||
|
||||
@router.post("/lead")
|
||||
|
|
@ -53,13 +86,31 @@ async def create_trade_in_lead(
|
|||
raise HTTPException(status_code=404, detail="estimate not found")
|
||||
|
||||
user_agent = request.headers.get("user-agent")
|
||||
# 152-ФЗ audit trail: реальный клиентский IP из X-Forwarded-For (его ставит
|
||||
# фронтящий Caddy), fallback — прямой peer. Персистится per-row в
|
||||
# trade_in_leads.client_ip (migration 182), а не только в лог.
|
||||
client_ip = request.headers.get("x-forwarded-for")
|
||||
if client_ip:
|
||||
client_ip = client_ip.split(",")[0].strip()
|
||||
elif request.client is not None:
|
||||
client_ip = request.client.host
|
||||
|
||||
# 152-ФЗ proof-of-consent: client_ip / consent_policy_version /
|
||||
# consent_text_snapshot теперь durable-колонки на trade_in_leads (migration 182,
|
||||
# ранее — только audit-лог, #2497 TODO). client_ip может быть None (нет
|
||||
# X-Forwarded-For и request.client) — колонка nullable, CAST(NULL AS inet) валиден.
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO trade_in_leads (estimate_id, phone, consent, source, user_agent)
|
||||
VALUES (CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent)
|
||||
INSERT INTO trade_in_leads (
|
||||
estimate_id, phone, consent, source, user_agent,
|
||||
client_ip, consent_policy_version, consent_text_snapshot
|
||||
)
|
||||
VALUES (
|
||||
CAST(:estimate_id AS uuid), :phone, :consent, :source, :user_agent,
|
||||
CAST(:client_ip AS inet), :consent_policy_version, :consent_text_snapshot
|
||||
)
|
||||
RETURNING CAST(id AS text), created_at
|
||||
"""
|
||||
),
|
||||
|
|
@ -69,6 +120,9 @@ async def create_trade_in_lead(
|
|||
"consent": payload.consent,
|
||||
"source": payload.source,
|
||||
"user_agent": user_agent,
|
||||
"client_ip": client_ip,
|
||||
"consent_policy_version": _CONSENT_POLICY_VERSION,
|
||||
"consent_text_snapshot": _CONSENT_TEXT_SNAPSHOT,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
|
|
@ -78,10 +132,12 @@ async def create_trade_in_lead(
|
|||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"trade_in_lead saved id=%s estimate_id=%s source=%s",
|
||||
"trade_in_lead saved id=%s estimate_id=%s source=%s ip=%s policy=%s",
|
||||
row["id"],
|
||||
payload.estimate_id,
|
||||
payload.source,
|
||||
client_ip,
|
||||
_CONSENT_POLICY_VERSION,
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -11,12 +11,13 @@ from datetime import UTC, date, datetime, timedelta
|
|||
from typing import Annotated, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, Response, UploadFile
|
||||
from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, Response, UploadFile
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.ratelimit import _client_ip
|
||||
from app.schemas.trade_in import (
|
||||
AggregatedEstimate,
|
||||
AnalogLot,
|
||||
|
|
@ -45,6 +46,7 @@ from app.schemas.trade_in import (
|
|||
from app.services import account_quota
|
||||
from app.services.exporters.trade_in_pdf import generate_trade_in_pdf
|
||||
from app.services.image_sanitizer import ImageSanitizationError, sanitize_image
|
||||
from app.services.user_events import schedule_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -147,6 +149,7 @@ def _resolve_target_house_id(
|
|||
@router.post("/estimate", response_model=AggregatedEstimate)
|
||||
async def estimate(
|
||||
payload: TradeInEstimateInput,
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
|
||||
) -> AggregatedEstimate:
|
||||
|
|
@ -180,9 +183,29 @@ async def estimate(
|
|||
) from None
|
||||
# #747: атомарно-условный инкремент — источник истины по лимиту. check_and_raise
|
||||
# выше остаётся быстрым pre-check (429 до дорогой оценки), но финальное решение
|
||||
# тут: при гонке двух /estimate на used=MONTHLY_LIMIT-1 второй получит False.
|
||||
if not account_quota.increment(db, x_authenticated_user):
|
||||
# тут: при гонке двух /estimate на used=lim-1 второй получит False.
|
||||
# Не списываем квоту за пустой результат (нерезолвящийся адрес и т.п.) — иначе
|
||||
# платный слот сгорает за insufficient_data=True (median=0, n_analogs=0) с HTTP 200.
|
||||
if not result.insufficient_data and not account_quota.increment(db, x_authenticated_user):
|
||||
raise HTTPException(status_code=429, detail=account_quota.LIMIT_EXHAUSTED_MESSAGE)
|
||||
|
||||
# Feature 2/3 foundation: "что искали" — обогащённая estimate_request-запись
|
||||
# в user_events (адрес/площадь/комнаты + estimate_id для join с trade_in_estimates).
|
||||
# schedule_event сам никогда не raises — сбой аудита не должен ронять ответ.
|
||||
schedule_event(
|
||||
event_type="estimate_request",
|
||||
username=x_authenticated_user or "",
|
||||
ip=_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
path=str(request.url.path),
|
||||
method="POST",
|
||||
estimate_id=str(result.estimate_id),
|
||||
payload={
|
||||
"address": payload.address,
|
||||
"area_m2": payload.area_m2,
|
||||
"rooms": payload.rooms,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -244,6 +267,7 @@ def get_estimate(
|
|||
_fetch_house_imv_anchor,
|
||||
_fetch_price_trend,
|
||||
_qc_geo_to_precision,
|
||||
_resolve_target_city,
|
||||
_source_counts,
|
||||
)
|
||||
|
||||
|
|
@ -283,7 +307,16 @@ def get_estimate(
|
|||
else None
|
||||
)
|
||||
|
||||
dkp_raw = _fetch_dkp_corridor(db, address=row.address, rooms=row.rooms, area=area_f)
|
||||
# (oblast C2): city-scope корридора на GET-rehydrate. NB: row.address здесь =
|
||||
# geo.full_address (персистится на estimate-time), который РОНЯЕТ город для
|
||||
# не-ЕКБ («Ленина, 1») → target_city=None → corridor unscoped/None для не-ЕКБ
|
||||
# (KNOWN LIMITATION: POST-path чинит через payload.address; GET получит паритет
|
||||
# когда raw payload.address начнёт персиститься — follow-up). Для ЕКБ ок; reorder
|
||||
# ниже безвреден (оба source city-stripped для не-ЕКБ, оба екб для ЕКБ).
|
||||
target_city = _resolve_target_city(row.address) or _resolve_target_city(row.canonical_address)
|
||||
dkp_raw = _fetch_dkp_corridor(
|
||||
db, address=row.address, rooms=row.rooms, area=area_f, city=target_city
|
||||
)
|
||||
dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None
|
||||
|
||||
imv_raw = _fetch_house_imv_anchor(
|
||||
|
|
@ -1628,7 +1661,12 @@ def get_street_deals(
|
|||
После PR-A (#549) таблица deals содержит только ДКП (ДДУ-первичка отфильтрована
|
||||
в import-rosreestr.sh).
|
||||
"""
|
||||
from app.services.estimator import _deal_to_analog, _percentile, extract_street_name
|
||||
from app.services.estimator import (
|
||||
_deal_to_analog,
|
||||
_percentile,
|
||||
_resolve_target_city,
|
||||
extract_street_name,
|
||||
)
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
# #1381: отображаемое окно должно совпадать с SQL-фильтром ниже, который
|
||||
|
|
@ -1663,16 +1701,25 @@ def get_street_deals(
|
|||
area_min = area_m2 * (1.0 - area_tolerance)
|
||||
area_max = area_m2 * (1.0 + area_tolerance)
|
||||
|
||||
# #C1 city-scope (п.3, консистентно с estimator._fetch_dkp_corridor): резолвим
|
||||
# город целевого адреса через _resolve_target_city (словарь ~30 городов обл.66
|
||||
# вкл. ЕКБ + sweep-города) и фильтруем сделки по этому городу — одноимённые улицы
|
||||
# др. городов не контаминируют витрину. None (адрес вне словаря) → фильтр не
|
||||
# применяется. `city_filter` — литерал (не user-input), значение идёт bind-параметром.
|
||||
target_city = _resolve_target_city(address)
|
||||
city_filter = "AND LOWER(city) = CAST(:target_city AS text)" if target_city else ""
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
f"""
|
||||
SELECT address, area_m2, rooms, floor, total_floors,
|
||||
price_rub, price_per_m2, deal_date, source
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND address ILIKE :street_pattern
|
||||
AND address ~* :street_regex
|
||||
{city_filter}
|
||||
AND rooms = CAST(:rooms AS integer)
|
||||
AND area_m2 BETWEEN :area_min AND :area_max
|
||||
AND deal_date > NOW() - (CAST(:period_months AS integer) || ' months')::interval
|
||||
|
|
@ -1683,6 +1730,7 @@ def get_street_deals(
|
|||
{
|
||||
"street_pattern": "%" + street_name + "%",
|
||||
"street_regex": r"\m" + street_name + r"\M",
|
||||
"target_city": target_city.lower() if target_city else None,
|
||||
"rooms": rooms,
|
||||
"area_min": area_min,
|
||||
"area_max": area_max,
|
||||
|
|
|
|||
|
|
@ -106,9 +106,7 @@ class Settings(BaseSettings):
|
|||
# house_imv_evaluations, либо Yandex sale) выше нашей медианы более чем в
|
||||
# `threshold` раз — подмешиваем якорь к медиане с весом `weight` и
|
||||
# расширяем верх диапазона. ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
|
||||
# Полностью за флагами — безопасно выкатить до демо; при отсутствии IMV/Yandex
|
||||
# no-op (медиана не меняется).
|
||||
estimate_imv_blend_enabled: bool = True
|
||||
# При отсутствии IMV/Yandex no-op (медиана не меняется).
|
||||
estimate_imv_blend_weight: float = 0.5 # вес якоря в blend: median*(1-w)+A*w
|
||||
estimate_imv_blend_threshold: float = 1.15 # якорь должен быть > медианы ×1.15
|
||||
|
||||
|
|
@ -117,9 +115,8 @@ class Settings(BaseSettings):
|
|||
# комфорт −15-25%. v2 берёт PRIMARY якорь из комплов ТОГО ЖЕ ДОМА (Tier A),
|
||||
# similarity-weighted по площади/комнатам, premium-uplift к ~p70 для топ-юнита
|
||||
# дома, asking→sold haircut (banded по ppm²), hard guardrail (est ≥ min-comp×0.95)
|
||||
# и tighter FSD-диапазон. Полностью за флагом — OFF ⇒ точно текущее поведение.
|
||||
# и tighter FSD-диапазон.
|
||||
# Спек+KPI: vault inbox 2026-05-30 tradein-valuation-algorithm-v2.
|
||||
estimate_same_building_anchor_enabled: bool = True
|
||||
estimate_sb_min_comps: int = 4 # стоп на первом тире с ≥ N активных комплов
|
||||
estimate_sb_area_sigma: float = 0.18 # σ log-нормального area-веса (Gaussian)
|
||||
estimate_sb_rooms_match_boost: float = 1.6 # ×вес если rooms компла == target
|
||||
|
|
@ -138,8 +135,7 @@ class Settings(BaseSettings):
|
|||
# интервал, выданный за полный). Эмпирически отношение actual_sold/expected_sold
|
||||
# по 2366 прод-сделкам имеет p10=0.649, p90=1.392 → band
|
||||
# expected_sold × [low_mult, high_mult] = настоящий ~80% prediction interval
|
||||
# (проверено: 80.0% coverage на тех же 2366). OFF ⇒ точно старое IQR-поведение.
|
||||
estimate_calibrated_pi_enabled: bool = True
|
||||
# (проверено: 80.0% coverage на тех же 2366).
|
||||
estimate_pi_low_mult: float = 0.649 # empirical p10 of sold/expected_sold (#1966, n=2366)
|
||||
estimate_pi_high_mult: float = 1.392 # empirical p90 of sold/expected_sold (#1966, n=2366)
|
||||
# ── #2002: hedonic year+area correction на точку expected_sold ─────────────
|
||||
|
|
@ -171,7 +167,6 @@ class Settings(BaseSettings):
|
|||
# median_ppm2 > high_ppm2×(1+slack) И count≥min_n И anchor_tier != "A"
|
||||
# (Tier A = реальные комплы того же дома → EXEMPT) — жёстко прижимаем headline
|
||||
# к high_ppm2×(1+slack) и пропорционально пересчитываем price/range/expected_sold.
|
||||
estimate_corridor_clamp_enabled: bool = True
|
||||
estimate_corridor_clamp_min_n: int = 10
|
||||
# cap = corridor_high×(1+slack) = ×1.40; даёт премиум-домам без own-листингов
|
||||
# (tier-C) больше воздуха над sold-коридором, не возвращая исходную 2× инфляцию
|
||||
|
|
@ -181,9 +176,8 @@ class Settings(BaseSettings):
|
|||
# Если итоговый median_ppm2 < dkp_low_ppm2 × factor — поднимаем до floor.
|
||||
# Применяется ТОЛЬКО на radius-пути (anchor_tier is None) и при dkp_raw.
|
||||
# factor=0.8: 20% зазор ниже P10 коридора → floor достаточно мягкий для эконома
|
||||
# (избегаем ложных подъёмов) и ловит явный undershoot. False → no-op.
|
||||
# ENV: ESTIMATE_RADIUS_FLOOR_ENABLED, ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||
estimate_radius_floor_enabled: bool = True
|
||||
# (избегаем ложных подъёмов) и ловит явный undershoot.
|
||||
# ENV: ESTIMATE_RADIUS_FLOOR_FACTOR.
|
||||
estimate_radius_floor_factor: float = 0.8
|
||||
# Шаг 5 — clamp expected_sold <= asking: ratio > 1.0 физически невозможен для
|
||||
# trade-in (ожидаемая цена сделки не должна превышать цену объявления).
|
||||
|
|
@ -215,7 +209,6 @@ class Settings(BaseSettings):
|
|||
# "novostroyki"-тег = переуступки/перепродажи собственниками (sale_type=free).
|
||||
# Чисто-первичный дом (0 вторички) → гард #1186 сохраняется. Tier C / радиус /
|
||||
# ratio — не затрагиваются.
|
||||
estimate_sb_tier_a_allow_primary_if_secondary_present: bool = True
|
||||
asking_to_sold_haircut: float = 0.05 # дефолтная asking→sold скидка (banded по ppm²)
|
||||
estimate_fsd_k: float = 1.65 # множитель FSD → полуширина диапазона
|
||||
|
||||
|
|
@ -225,8 +218,6 @@ class Settings(BaseSettings):
|
|||
# здоровые якоря (n≥4 с FSD<0.15) проходят без изменений.
|
||||
# estimate_sb_gate_min_n=3 : при n<3 И FSD>max_fsd гейт срабатывает
|
||||
# estimate_sb_gate_max_fsd=0.20: FSD>0.20 при малом n → ненадёжный якорь
|
||||
# Отдельный флаг: False → точно старое поведение (гейта нет).
|
||||
estimate_sb_low_conf_gate_enabled: bool = True
|
||||
estimate_sb_gate_min_n: int = 3
|
||||
estimate_sb_gate_max_fsd: float = 0.20
|
||||
|
||||
|
|
@ -235,25 +226,12 @@ class Settings(BaseSettings):
|
|||
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
|
||||
estimate_price_trend_max_age_months: int = 6
|
||||
|
||||
# ── Mera-audit fix-3: cross-source dedup в price_trend ────────────────────
|
||||
# Один объект на avito_imv + yandex_valuation с разными ext_item_id даёт
|
||||
# double-count в house_placement_history → шум в помесячной медиане тренда.
|
||||
# True (дефолт) = дедуплицировать строки перед агрегацией по ключу
|
||||
# (round(area_m2,0), floor, COALESCE(last_price,start_price),
|
||||
# COALESCE(last_price_date,start_price_date)), приоритет avito_imv.
|
||||
# False = старое поведение без дедупа (backward-compat).
|
||||
# ENV: ESTIMATE_PRICE_TREND_DEDUP_ENABLED.
|
||||
estimate_price_trend_dedup_enabled: bool = True
|
||||
|
||||
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
|
||||
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
|
||||
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
|
||||
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
|
||||
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
|
||||
# аналога → headline выглядит достоверным при нулевой реальной базе.
|
||||
# False = старое поведение без принудительного понижения (для отката).
|
||||
# ENV: ESTIMATE_CONFIDENCE_FLOOR_NO_ANALOGS.
|
||||
estimate_confidence_floor_no_analogs: bool = True
|
||||
|
||||
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
|
||||
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
|
||||
|
|
@ -261,8 +239,6 @@ class Settings(BaseSettings):
|
|||
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
|
||||
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
|
||||
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
|
||||
# False = флаг всегда выключен (manual_review_recommended=False, reasons=[]).
|
||||
estimate_manual_review_enabled: bool = True
|
||||
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
|
||||
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
|
||||
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
|
||||
|
|
@ -274,12 +250,8 @@ class Settings(BaseSettings):
|
|||
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
|
||||
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
|
||||
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
|
||||
# (source, source_id) — radius нет. True (дефолт) добавляет rn_dup=1 фильтр в
|
||||
# каждом тире (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||||
# False = старое поведение без radius-дедупа (для отката). Окно rn_dup всегда
|
||||
# в SQL (безвредно без фильтра); флаг управляет только применением фильтра.
|
||||
# ENV: ESTIMATE_RADIUS_DEDUP_ENABLED.
|
||||
estimate_radius_dedup_enabled: bool = True
|
||||
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
|
||||
# (freshest scraped_at на (source, source_id|source_url|ctid)).
|
||||
|
||||
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
|
||||
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
|
||||
|
|
@ -302,7 +274,7 @@ class Settings(BaseSettings):
|
|||
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
|
||||
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
|
||||
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор —
|
||||
# estimate_sb_clip_after_weight, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||||
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
|
||||
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median width/cv
|
||||
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
|
||||
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
|
||||
|
|
@ -367,11 +339,9 @@ class Settings(BaseSettings):
|
|||
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
|
||||
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
|
||||
# растягивается через несколько ценовых режимов → коридор range_low/high
|
||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True +
|
||||
# Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
|
||||
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
|
||||
# disclosure в explanation. НЕ трогает point/median/range.
|
||||
# ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED.
|
||||
estimate_wide_corridor_disclosure_enabled: bool = True
|
||||
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
|
||||
# 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
|
||||
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
|
||||
|
|
@ -388,11 +358,6 @@ class Settings(BaseSettings):
|
|||
cian_valuation_min_rub: float = 500_000
|
||||
cian_valuation_max_rub: float = 500_000_000
|
||||
|
||||
# ── #audit-4: MAD-clip after similarity-weighting ─────────────────────────
|
||||
# True = clip происходит ПОСЛЕ similarity-weighting (на взвешенных ppm²).
|
||||
# False = clip ДО weighting (старое поведение). Дефолт True.
|
||||
estimate_sb_clip_after_weight: bool = True
|
||||
|
||||
# ── #audit-5: data-age guards ─────────────────────────────────────────────
|
||||
# sber_index_max_age_days: максимальный допустимый возраст последнего месяца
|
||||
# СберИндекс-серии (дней). Если latest месяц старее — логируем warning.
|
||||
|
|
@ -410,8 +375,6 @@ class Settings(BaseSettings):
|
|||
# между квартальным уровнем целевого объекта и усреднённым квартальным уровнем
|
||||
# аналогов — не дублирует location, уже заложенный в медиану аналогов.
|
||||
# Формула: adjusted_ppm2 = base_ppm2 × target_index / avg_analog_index.
|
||||
# За флагом: False → точно текущее поведение (backward-compatible).
|
||||
estimate_quarter_index_enabled: bool = True
|
||||
# Минимальное число сделок в квартале (sparse fallback: меньше — no-op).
|
||||
estimate_quarter_index_min_n_deals: int = 10
|
||||
# Guard-2 (no double-count): если доля аналогов ИЗ ТОГО ЖЕ квартала > порога —
|
||||
|
|
|
|||
71
tradein-mvp/backend/app/core/request_audit.py
Normal file
71
tradein-mvp/backend/app/core/request_audit.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""RequestAuditMiddleware — пишет `api_request` (+ дедуплицированный `login`)
|
||||
события в `user_events` для каждого аутентифицированного `/api/*` запроса.
|
||||
|
||||
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
|
||||
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
|
||||
ответ клиенту) и через fire-and-forget `schedule_event` — сбой аудита никогда
|
||||
не влияет на HTTP-ответ.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.core.ratelimit import _client_ip
|
||||
from app.services.user_events import schedule_event, should_log_login
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно
|
||||
# импортирует этот модуль (регистрирует middleware), обратный импорт дал бы
|
||||
# циклическую зависимость.
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||
|
||||
|
||||
class RequestAuditMiddleware(BaseHTTPMiddleware):
|
||||
"""Логирует активность аутентифицированных пользователей в `user_events`."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def]
|
||||
response: Response = await call_next(request)
|
||||
|
||||
try:
|
||||
username = request.headers.get("x-authenticated-user")
|
||||
path = request.url.path
|
||||
if username and path.startswith("/api/") and path not in _PUBLIC_PATHS:
|
||||
ip = _client_ip(request)
|
||||
ua = request.headers.get("user-agent")
|
||||
|
||||
# Общий behavior/activity-поток — каждый authenticated API-запрос.
|
||||
# /api/v1/admin/* исключаем: это ops-действия (просмотр самих
|
||||
# дашбордов аудита/аналитики), а не поведение пилота — иначе
|
||||
# запросы дашборда зашумляют top_paths и счётчики активности.
|
||||
# login ниже логируем всегда (вход админа с IP — валидный аудит).
|
||||
if not path.startswith("/api/v1/admin/"):
|
||||
schedule_event(
|
||||
event_type="api_request",
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
path=path,
|
||||
method=request.method,
|
||||
)
|
||||
|
||||
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
|
||||
# на (юзер, IP, устройство).
|
||||
if should_log_login(username, ip, ua):
|
||||
schedule_event(
|
||||
event_type="login",
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
path=path,
|
||||
method=request.method,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
|
||||
|
||||
return response
|
||||
|
|
@ -23,12 +23,13 @@ from sentry_sdk.integrations.logging import LoggingIntegration
|
|||
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
|
||||
from sentry_sdk.integrations.starlette import StarletteIntegration
|
||||
|
||||
from app.api.v1 import admin, brand, buildings, geocode, lead, me, search, trade_in
|
||||
from app.core.auth import get_role
|
||||
from app.api.v1 import admin, audit, brand, buildings, geocode, lead, me, search, trade_in
|
||||
from app.core.auth import get_role, is_path_allowed
|
||||
from app.core.config import settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.fdw import ensure_fdw_user_mapping
|
||||
from app.core.ratelimit import RateLimitMiddleware
|
||||
from app.core.request_audit import RequestAuditMiddleware
|
||||
from app.observability.sentry_scrub import scrub_pii_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -108,6 +109,14 @@ app = FastAPI(
|
|||
# X-Authenticated-User там не приходит из Caddy.
|
||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||
# #R2-H3: Caddy срезает внешний префикс /trade-in (uri strip_prefix) перед
|
||||
# tradein-backend, а globs в roles.yaml — ВНЕШНИЕ (/trade-in/api/v1/**). Для
|
||||
# scope-проверки восстанавливаем внешний путь.
|
||||
_EXTERNAL_PREFIX = "/trade-in"
|
||||
# Bootstrap-пути, доступные ЛЮБОМУ известному юзеру независимо от роли: /me отдаёт
|
||||
# роль (expired → trial-экран), /brand/* — брендинг login/trial-экрана. Без них
|
||||
# expired (roles.yaml paths:[] deny:/**) не получил бы роль и не увидел trial-экран.
|
||||
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
|
|
@ -160,6 +169,39 @@ async def rbac_guard(
|
|||
status_code=403,
|
||||
content={"detail": "admin only"},
|
||||
)
|
||||
|
||||
# #R2-H3: энфорсим roles.yaml scope (paths/deny) для ВСЕХ non-admin путей, а не
|
||||
# только /admin/*. Иначе revoked (role=expired, paths:[] deny:/**) или узко-
|
||||
# скоупленный аккаунт достаёт non-admin API (напр. POST /api/v1/search —
|
||||
# экспорт листингов), который roles.yaml ему запрещает. Bootstrap-пути (/me,
|
||||
# /brand) исключены выше по списку. roles.yaml globs внешние → восстанавливаем
|
||||
# внешний путь (Caddy срезал /trade-in). На сбой парса — fail-open + громкий
|
||||
# лог: не лочим платящего pilot из-за конфиг-бага (admin-гейт выше остаётся).
|
||||
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
||||
external_path = _EXTERNAL_PREFIX + path
|
||||
try:
|
||||
allowed = is_path_allowed(role, external_path)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"RBAC scope-check raised for %s %s (ext=%s) — fail-open",
|
||||
username,
|
||||
path,
|
||||
external_path,
|
||||
)
|
||||
allowed = True
|
||||
if not allowed:
|
||||
logger.info(
|
||||
"RBAC: scope-blocked %s (role=%s) from %s (ext=%s)",
|
||||
username,
|
||||
role,
|
||||
path,
|
||||
external_path,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "forbidden for role"},
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
|
|
@ -172,6 +214,8 @@ app.add_middleware(
|
|||
)
|
||||
# Rate-limit публичного API (per-user / per-IP sliding window) — защита от абуза.
|
||||
app.add_middleware(RateLimitMiddleware)
|
||||
# Request-audit: пишет api_request/login события в user_events (Feature 2/3 foundation).
|
||||
app.add_middleware(RequestAuditMiddleware)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
|
@ -181,6 +225,7 @@ def health() -> dict[str, str]:
|
|||
|
||||
app.include_router(geocode.router, prefix="/api/v1/geocode", tags=["geocode"])
|
||||
app.include_router(admin.router, prefix="/api/v1/admin", tags=["admin"])
|
||||
app.include_router(audit.router, prefix="/api/v1/admin", tags=["admin-audit"])
|
||||
app.include_router(brand.router, prefix="/api/v1/brand", tags=["brand"])
|
||||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||
app.include_router(lead.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
||||
|
|
|
|||
146
tradein-mvp/backend/app/schemas/audit.py
Normal file
146
tradein-mvp/backend/app/schemas/audit.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Pydantic-схемы admin read API над `user_events` (Feature 2 audit + Feature 3 analytics).
|
||||
|
||||
`user_events` (migration `184_user_events.sql`) — unified append-only событийный лог
|
||||
(login/IP audit + behavior analytics), admin-read-only. Эти схемы описывают ответы
|
||||
`GET /api/v1/admin/audit/*` и `GET /api/v1/admin/analytics` (см. app/api/v1/audit.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class AccountSummary(BaseModel):
|
||||
"""Одна строка списка `GET /audit/accounts` — сводка по одному username."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
username: str
|
||||
first_seen_at: datetime
|
||||
last_seen_at: datetime
|
||||
distinct_ips: int
|
||||
distinct_devices: int
|
||||
login_count: int
|
||||
request_count: int
|
||||
search_count: int
|
||||
|
||||
|
||||
class AccountIpEntry(BaseModel):
|
||||
"""Одна строка `ips` в drilldown `GET /audit/accounts/{username}`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
ip_address: str | None = None
|
||||
event_count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class AccountDeviceEntry(BaseModel):
|
||||
"""Одна строка `devices` в drilldown `GET /audit/accounts/{username}`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
user_agent: str | None = None
|
||||
event_count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class AccountSearchEntry(BaseModel):
|
||||
"""Одна строка `searches` в drilldown — недавний estimate_request."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
address: str | None = None
|
||||
area_m2: str | None = None
|
||||
rooms: str | None = None
|
||||
estimate_id: str | None = None
|
||||
ip_address: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AccountActivityEntry(BaseModel):
|
||||
"""Одна строка `recent_activity` в drilldown — сырое событие."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
event_type: str
|
||||
path: str | None = None
|
||||
method: str | None = None
|
||||
ip_address: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AccountDrilldown(BaseModel):
|
||||
"""Полный ответ `GET /audit/accounts/{username}` — 4 списка.
|
||||
|
||||
Неизвестный username НЕ 404 — это просто пустой отчёт (все списки == []).
|
||||
"""
|
||||
|
||||
ips: list[AccountIpEntry]
|
||||
devices: list[AccountDeviceEntry]
|
||||
searches: list[AccountSearchEntry]
|
||||
recent_activity: list[AccountActivityEntry]
|
||||
|
||||
|
||||
class AnalyticsSummary(BaseModel):
|
||||
"""Верхнеуровневые счётчики дашборда `GET /analytics`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
total_events: int
|
||||
distinct_users: int
|
||||
events_last_24h: int
|
||||
active_users_last_24h: int
|
||||
|
||||
|
||||
class AnalyticsDailyPoint(BaseModel):
|
||||
"""Одна точка time-series `daily` — события/пользователи за день."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
day: date
|
||||
events: int
|
||||
users: int
|
||||
|
||||
|
||||
class AnalyticsTopSearch(BaseModel):
|
||||
"""Одна строка `top_searches` — самый частый искомый адрес."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
address: str | None = None
|
||||
n: int
|
||||
|
||||
|
||||
class AnalyticsTopPath(BaseModel):
|
||||
"""Одна строка `top_paths` — самый частый API-путь."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
path: str | None = None
|
||||
n: int
|
||||
|
||||
|
||||
class AnalyticsByAccount(BaseModel):
|
||||
"""Одна строка `by_account` — сводка активности по username."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
username: str
|
||||
events: int
|
||||
searches: int
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
class AnalyticsDashboard(BaseModel):
|
||||
"""Полный ответ `GET /analytics` — бандл для дашборда Feature 3."""
|
||||
|
||||
summary: AnalyticsSummary
|
||||
daily: list[AnalyticsDailyPoint]
|
||||
top_searches: list[AnalyticsTopSearch]
|
||||
top_paths: list[AnalyticsTopPath]
|
||||
by_account: list[AnalyticsByAccount]
|
||||
|
|
@ -222,7 +222,9 @@ class AggregatedEstimate(BaseModel):
|
|||
# конкретному дому за 12 мес) — другая база и другой смысл «торга», см. докстринг
|
||||
# HouseAnalyticsKpi.
|
||||
asking_to_sold_ratio: float | None = None # =sold/asking, ~0.72–0.93
|
||||
ratio_basis: str | None = None # 'per_rooms' | 'global_fallback'
|
||||
# 'per_rooms' | 'global_fallback' | 'expected_over_median' (LOW audit #2: set when
|
||||
# #2141's honest_ratio recompute overwrites the raw ratio_resolver value below).
|
||||
ratio_basis: str | None = None
|
||||
# ── DaData enrichment (PR Q1) — on-demand для target адреса ──
|
||||
# canonical_address — DaData-нормализованная форма (с улицей в short form).
|
||||
# house_cadnum — кадастровый номер ДОМА (для будущего matching Росреестра).
|
||||
|
|
@ -266,7 +268,7 @@ class AggregatedEstimate(BaseModel):
|
|||
# оценка человеком (премиальный дом / высокая стоимость / низкая уверенность /
|
||||
# широкий диапазон). Дефолт False — не трогает median/expected_sold/ranges.
|
||||
# manual_review_reasons — человекочитаемые RU-причины для ревьюера; пусто, когда
|
||||
# рекомендация не сработала (или флаг estimate_manual_review_enabled выключен).
|
||||
# рекомендация не сработала.
|
||||
manual_review_recommended: bool = False
|
||||
manual_review_reasons: list[str] = Field(default_factory=list)
|
||||
# ── #2043 (BE-1): метрики достоверности выборки — уже считаются, отдаём наружу ──
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
"""Сервис квоты оценок trade-in — N успешных оценок в месяц на аккаунт.
|
||||
|
||||
Правила:
|
||||
- Лимит = settings.estimate_quota_limit успешных оценок за календарный месяц
|
||||
(UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT, default 15.
|
||||
- Лимит по умолчанию = settings.estimate_quota_limit успешных оценок за календарный
|
||||
месяц (UTC, период 'YYYY-MM'); конфигурируется через env ESTIMATE_QUOTA_LIMIT,
|
||||
default 15.
|
||||
- Персональный override: таблица account_quota_overrides (username → monthly_limit),
|
||||
см. миграцию 185_account_quota_overrides.sql. Заменяет прежний хак бонусных попыток
|
||||
через negative `used` (ломал /quota — «Осталось 50 из 15»).
|
||||
- Без лимита (unlimited): роль admin ИЛИ username == 'kopylov'.
|
||||
- Учитываются ТОЛЬКО успешные оценки (инкремент ПОСЛЕ estimate_quality).
|
||||
- Если заголовок X-Authenticated-User отсутствует (dev без Caddy) → unlimited,
|
||||
|
|
@ -53,11 +57,34 @@ def is_unlimited(username: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def user_limit(db: Session, username: str) -> int:
|
||||
"""Персональный месячный лимит для username, иначе глобальный MONTHLY_LIMIT.
|
||||
|
||||
Источник override — таблица account_quota_overrides (см. миграцию
|
||||
185_account_quota_overrides.sql). Заменяет прежний хак бонусных попыток через
|
||||
negative `used`, который ломал /quota (limit=15, used=-35 → remaining=50 —
|
||||
«Осталось 50 из 15»).
|
||||
"""
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT monthly_limit FROM account_quota_overrides
|
||||
WHERE username = :u
|
||||
"""
|
||||
),
|
||||
{"u": username},
|
||||
).fetchone()
|
||||
if row is not None and row.monthly_limit is not None:
|
||||
return int(row.monthly_limit)
|
||||
return MONTHLY_LIMIT
|
||||
|
||||
|
||||
def get_status(db: Session, username: str | None) -> dict:
|
||||
"""Возвращает статус квоты для пользователя.
|
||||
|
||||
Если username is None → unlimited True, used 0, remaining 15.
|
||||
Если unlimited → used = фактический или 0, remaining = limit.
|
||||
Если username is None → unlimited True, used 0, remaining = MONTHLY_LIMIT.
|
||||
Если unlimited → used = фактический или 0, remaining = limit (per-user override
|
||||
или глобальный MONTHLY_LIMIT).
|
||||
"""
|
||||
if username is None:
|
||||
return {
|
||||
|
|
@ -69,6 +96,7 @@ def get_status(db: Session, username: str | None) -> dict:
|
|||
|
||||
unlimited = is_unlimited(username)
|
||||
period = current_period()
|
||||
limit = user_limit(db, username)
|
||||
|
||||
row = db.execute(
|
||||
text(
|
||||
|
|
@ -84,15 +112,18 @@ def get_status(db: Session, username: str | None) -> dict:
|
|||
|
||||
if unlimited:
|
||||
return {
|
||||
"limit": MONTHLY_LIMIT,
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
"remaining": MONTHLY_LIMIT,
|
||||
"remaining": limit,
|
||||
"unlimited": True,
|
||||
}
|
||||
|
||||
remaining = max(0, MONTHLY_LIMIT - used)
|
||||
# Защитный кламп: remaining никогда не превышает limit, даже если used всё же
|
||||
# снова просочится отрицательным (прежний бонус-хак) — max(0, used) обнуляет
|
||||
# отрицательный used перед вычитанием.
|
||||
remaining = max(0, limit - max(0, used))
|
||||
return {
|
||||
"limit": MONTHLY_LIMIT,
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
"remaining": remaining,
|
||||
"unlimited": False,
|
||||
|
|
@ -111,6 +142,7 @@ def check_and_raise(db: Session, username: str | None) -> None:
|
|||
return
|
||||
|
||||
period = current_period()
|
||||
limit = user_limit(db, username)
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -122,13 +154,13 @@ def check_and_raise(db: Session, username: str | None) -> None:
|
|||
).fetchone()
|
||||
|
||||
used = row.used if row is not None else 0
|
||||
if used >= MONTHLY_LIMIT:
|
||||
if used >= limit:
|
||||
logger.warning(
|
||||
"quota exhausted: username=%r period=%s used=%d limit=%d",
|
||||
username,
|
||||
period,
|
||||
used,
|
||||
MONTHLY_LIMIT,
|
||||
limit,
|
||||
)
|
||||
raise HTTPException(status_code=429, detail=LIMIT_EXHAUSTED_MESSAGE)
|
||||
|
||||
|
|
@ -140,14 +172,16 @@ def increment(db: Session, username: str | None) -> bool:
|
|||
None / unlimited → True (no-op success).
|
||||
|
||||
Защита от TOCTOU: предикат `WHERE used < :lim` применяется к ветке DO UPDATE —
|
||||
два параллельных запроса при used=MONTHLY_LIMIT-1 не могут оба инкрементировать
|
||||
(второй упрётся в WHERE → RETURNING пуст → False). Свежая вставка (used=1) НЕ
|
||||
задевается WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит.
|
||||
два параллельных запроса при used=lim-1 не могут оба инкрементировать (второй
|
||||
упрётся в WHERE → RETURNING пуст → False). Свежая вставка (used=1) НЕ задевается
|
||||
WHERE (он только для DO UPDATE), поэтому первая оценка месяца проходит. `lim` —
|
||||
персональный лимит (user_limit), НЕ жёстко зашитый глобальный MONTHLY_LIMIT.
|
||||
"""
|
||||
if username is None or is_unlimited(username):
|
||||
return True
|
||||
|
||||
period = current_period()
|
||||
lim = user_limit(db, username)
|
||||
row = db.execute(
|
||||
text(
|
||||
"""
|
||||
|
|
@ -161,7 +195,7 @@ def increment(db: Session, username: str | None) -> bool:
|
|||
RETURNING used
|
||||
"""
|
||||
),
|
||||
{"u": username, "p": period, "lim": MONTHLY_LIMIT},
|
||||
{"u": username, "p": period, "lim": lim},
|
||||
).fetchone()
|
||||
db.commit()
|
||||
ok = row is not None
|
||||
|
|
@ -172,6 +206,6 @@ def increment(db: Session, username: str | None) -> bool:
|
|||
"quota increment refused (atomic, #747): username=%r period=%s limit=%d",
|
||||
username,
|
||||
period,
|
||||
MONTHLY_LIMIT,
|
||||
lim,
|
||||
)
|
||||
return ok
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class Brand:
|
|||
|
||||
_DEFAULT = Brand(
|
||||
slug="generic",
|
||||
name="Trade-In Estimator",
|
||||
name="МЕРА",
|
||||
logo_url=None,
|
||||
primary_color="#1d4ed8",
|
||||
accent_color="#f59e0b",
|
||||
|
|
|
|||
|
|
@ -167,10 +167,27 @@ async def clean_address(address: str) -> DadataAddressResult | None:
|
|||
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
||||
return None
|
||||
if status in (401, 403):
|
||||
logger.error(
|
||||
"dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
|
||||
status,
|
||||
)
|
||||
body_preview = (response.text or "")[:200]
|
||||
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
||||
# но услуга «Стандартизация» (CLEAN) не подключена на аккаунте. Refresh токена НЕ
|
||||
# поможет — нужно включить услугу в кабинете DaData ИЛИ полагаться на suggest-fallback
|
||||
# (enrich_address). Разделяем сообщения, чтобы не гонять зря за ротацией токена.
|
||||
if status == 403 and (
|
||||
"disabled" in body_preview.lower() or "feature" in body_preview.lower()
|
||||
):
|
||||
logger.error(
|
||||
"dadata: HTTP 403 — услуга CLEAN (Стандартизация) выключена на аккаунте "
|
||||
"(токен валиден, НЕ отклонён). Включи услугу в кабинете DaData или "
|
||||
"полагайся на suggest-fallback (enrich_address). Ответ: %r",
|
||||
body_preview,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"dadata: HTTP %d — auth/secret rejected. "
|
||||
"Проверь DADATA_API_TOKEN/SECRET. Ответ: %r",
|
||||
status,
|
||||
body_preview,
|
||||
)
|
||||
return None
|
||||
if status >= 500:
|
||||
logger.warning("dadata: HTTP %d — transient server error", status)
|
||||
|
|
@ -246,6 +263,12 @@ class DadataSuggestion:
|
|||
street: str | None
|
||||
city: str | None
|
||||
kind: str
|
||||
# #dadata-403 — доп. поля для suggest-fallback в enrich_address (когда CLEAN
|
||||
# выключен на аккаунте). house_fias_id — ФИАС ДОМА (для house-level == fias_id);
|
||||
# kladr_id / qc_geo дублируют то, что отдаёт /clean, чтобы деградация была равноценной.
|
||||
house_fias_id: str | None = None
|
||||
kladr_id: str | None = None
|
||||
qc_geo: int | None = None
|
||||
|
||||
|
||||
def _classify_kind(fias_level: Any) -> str:
|
||||
|
|
@ -295,11 +318,17 @@ def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
|
|||
street=data.get("street"),
|
||||
city=data.get("city"),
|
||||
kind=_classify_kind(data.get("fias_level")),
|
||||
house_fias_id=data.get("house_fias_id"),
|
||||
kladr_id=data.get("kladr_id"),
|
||||
qc_geo=_coerce_int(data.get("qc_geo")),
|
||||
)
|
||||
|
||||
|
||||
async def suggest_addresses(
|
||||
query: str, limit: int = 8, city: str = "Екатеринбург"
|
||||
query: str,
|
||||
limit: int = 8,
|
||||
city: str | None = "Екатеринбург",
|
||||
region: str | None = None,
|
||||
) -> list[DadataSuggestion]:
|
||||
"""Автокомплит адресов через DaData /suggest/address.
|
||||
|
||||
|
|
@ -307,6 +336,13 @@ async def suggest_addresses(
|
|||
query: текст для подсказки (минимум 2 символа).
|
||||
limit: сколько вариантов вернуть (DaData параметр `count`, max 20).
|
||||
city: locality для constraint (передаётся в `locations`). По умолчанию ЕКБ.
|
||||
Игнорируется, если задан `region`.
|
||||
region: область для hard-constraint в `locations` — используется вместо
|
||||
`city`, когда нужен охват шире одного города (например, вся
|
||||
Свердловская область / region 66). DaData `locations` сам по себе уже
|
||||
жёсткий фильтр (не boost) на уровне указанного admin-поля — доп.
|
||||
параметров не требуется. По умолчанию не задан — поведение (и body
|
||||
запроса) для существующих вызовов не меняется.
|
||||
|
||||
Returns:
|
||||
list[DadataSuggestion] — пустой список если:
|
||||
|
|
@ -334,7 +370,13 @@ async def suggest_addresses(
|
|||
"query": query.strip(),
|
||||
"count": max(1, min(int(limit), 20)),
|
||||
}
|
||||
if city:
|
||||
if region:
|
||||
# `locations` с полем region — уже жёсткий фильтр сам по себе (DaData
|
||||
# ограничивает выдачу этим регионом). `restrict_value` — ТОП-LEVEL параметр
|
||||
# body (не ключ внутри объекта locations) — здесь он был бы silent no-op,
|
||||
# поэтому не добавляем.
|
||||
body["locations"] = [{"region": region}]
|
||||
elif city:
|
||||
body["locations"] = [{"city": city}]
|
||||
|
||||
try:
|
||||
|
|
@ -352,9 +394,7 @@ async def suggest_addresses(
|
|||
logger.warning("dadata suggest: HTTP 429 — quota exceeded (10k/день free?)")
|
||||
return []
|
||||
if status in (401, 403):
|
||||
logger.error(
|
||||
"dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status
|
||||
)
|
||||
logger.error("dadata suggest: HTTP %d — auth rejected. Проверь DADATA_API_TOKEN.", status)
|
||||
return []
|
||||
if status >= 500:
|
||||
logger.warning("dadata suggest: HTTP %d — transient server error", status)
|
||||
|
|
@ -387,3 +427,55 @@ async def suggest_addresses(
|
|||
|
||||
logger.info("dadata suggest: %r → %d вариантов", query[:60], len(out))
|
||||
return out
|
||||
|
||||
|
||||
# ── Enrichment orchestrator (clean → suggest fallback) ───────────────────────
|
||||
|
||||
|
||||
async def enrich_address(address: str) -> DadataAddressResult | None:
|
||||
"""Обогащение целевого адреса с graceful-деградацией CLEAN → SUGGEST.
|
||||
|
||||
Порядок:
|
||||
1. /clean/address (token+secret) — canonical + house_cadnum + house_fias_id +
|
||||
qc_geo + metro. Если вернул результат — используем его.
|
||||
2. Fallback: /suggest/address (token-only, доступен на free-tier 10k/день) —
|
||||
берём top house-level кандидата. Даёт house_fias_id + гео + kladr_id + qc_geo,
|
||||
но НЕ canonical_address / house_cadnum (их умеет только CLEAN) → они остаются
|
||||
None. Честная деградация: недостающие поля не выдумываем.
|
||||
|
||||
Мотивация (#dadata-403, vault Mera_Hard_Audit_0712): на проде у аккаунта услуга
|
||||
CLEAN выключена (HTTP 403 «Feature CLEAN disabled»), из-за чего house_fias_id —
|
||||
ключ join с houses/ДОМ.РФ (year_built и пр.) — терялся, хотя /suggest его отдаёт
|
||||
бесплатно. Город НЕ ограничиваем (city="") — enrichment работает по всей обл. (66).
|
||||
"""
|
||||
cleaned = await clean_address(address)
|
||||
if cleaned is not None:
|
||||
return cleaned
|
||||
|
||||
suggestions = await suggest_addresses(address, limit=5, city="")
|
||||
for s in suggestions:
|
||||
fias = s.house_fias_id or s.fias_id
|
||||
if s.kind == "house" and fias:
|
||||
# house-level ФИАС → гео дома точное; qc_geo из ответа либо 0 (exact) по умолчанию.
|
||||
qc_geo = s.qc_geo if s.qc_geo is not None else 0
|
||||
logger.info(
|
||||
"dadata: CLEAN недоступен → suggest-fallback %r → house_fias_id=%s "
|
||||
"(canonical/cadnum недоступны без CLEAN)",
|
||||
address[:60],
|
||||
fias,
|
||||
)
|
||||
return DadataAddressResult(
|
||||
canonical_address=None,
|
||||
house_cadnum=None,
|
||||
house_fias_id=fias,
|
||||
lat=s.lat,
|
||||
lon=s.lon,
|
||||
qc_geo=qc_geo,
|
||||
qc_house=None,
|
||||
kladr_id=s.kladr_id,
|
||||
okato=None,
|
||||
oktmo=None,
|
||||
metro=[],
|
||||
raw={"_source": "suggest_fallback", "value": s.value},
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
578
tradein-mvp/backend/app/services/domrf_kapremont_loader.py
Normal file
578
tradein-mvp/backend/app/services/domrf_kapremont_loader.py
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
"""ДОМ.РФ капремонт open data loader: houses.year_built/material_walls (issue #2013).
|
||||
|
||||
CONTEXT: houses.year_built заполнен только на 38%, houses.material_walls — на 0%.
|
||||
Существующий ГИС-ЖКХ loader (zhkh_flats_loader.py, мигр. 146/149) заполняет
|
||||
houses.zhkh_year (70%) и houses.zhkh_floors (70%), НО никогда не копирует их в
|
||||
houses.year_built/total_floors — это основной пробел. material_walls вообще ни разу
|
||||
не заполнялся никаким источником. Estimator (app/services/estimator.py) фильтрует
|
||||
когорту по `year_built BETWEEN ...` — реальные годы напрямую двигают точность оценки.
|
||||
|
||||
ИСТОЧНИК: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.):
|
||||
КР1.1 house registry (export/190) — zip → CSV, delimiter ';', UTF-8 BOM. Колонки:
|
||||
mkd_code, houseguid (ФИАС GUID дома), address, commission_year (год ввода, int),
|
||||
total_sq (decimal, запятая — «909,80»), number_floors_max (int), + служебные.
|
||||
КР1.2 constructive elements (export/275) — zip → CSV, тот же delimiter/encoding.
|
||||
LONG FORMAT: одна строка на конструктивный элемент на mkd_code; wall_material
|
||||
заполнен ТОЛЬКО на строке construction_element_type='фасад'. Чтобы получить
|
||||
материал стен на дом — группируем по mkd_code, берём первую строку с непустым
|
||||
wall_material.
|
||||
|
||||
TLS: домен домрф.рф отдаёт RU-сертификат, который httpx с default trust store не
|
||||
верифицирует ("unable to get local issuer certificate") — та же ситуация, что и у
|
||||
sber_index.py. Открытые неавторизованные данные, поэтому используем verify=False,
|
||||
как остальные RU-gov loader'ы в этом репо.
|
||||
|
||||
Матч staging→houses: domrf_kapremont.houseguid = COALESCE(houses.gar_house_guid,
|
||||
houses.house_fias_id, houses.zhkh_house_guid) — простой приоритетный COALESCE-джойн
|
||||
(без multi-attempt fallback-если-нет-матча — see PR discussion, "keep it simple/safe").
|
||||
|
||||
psycopg v3: SQL через `text(...)` использует `CAST(:x AS type)`, НИКОГДА `:x::type`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Константы
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# export/190 (КР1.1) и export/275 (КР1.2) без доп. параметров отдают Свердловскую
|
||||
# обл. (region 66) — подтверждено инспекцией скачанных файлов. Для других регионов
|
||||
# понадобится region-gid параметр (не реализовано — вне скоупа #2013).
|
||||
KR11_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/190"
|
||||
KR12_URL = "https://xn--80adsazqn.xn--p1aee.xn--p1ai/opendata/export/275"
|
||||
|
||||
DOWNLOAD_TIMEOUT_SEC = 180
|
||||
|
||||
# UPSERT в staging чанками (в одном SAVEPOINT), как zhkh_flats_loader.SAVEPOINT_CHUNK —
|
||||
# сбойный чанк откатывается изолированно, остальные доезжают.
|
||||
UPSERT_CHUNK_SIZE = 500
|
||||
|
||||
# Правдоподобные границы года постройки. Значение вне диапазона — почти наверняка
|
||||
# опечатка/мусор: для МКД-реестра капремонта дом < 1850 нереален, а > текущий+2 ещё
|
||||
# физически не введён в эксплуатацию. Такой год НЕ должен (а) попадать из ДОМ.РФ CSV
|
||||
# в staging (parse-гейт в parse_int_field), (б) экранировать авторитетный ДОМ.РФ
|
||||
# commission_year при бэкфилле houses.year_built (SQL-гейт в _BACKFILL_*_SQL).
|
||||
YEAR_BUILT_MIN = 1850
|
||||
YEAR_BUILT_FUTURE_SLACK = 2 # дом «в стройке» — текущий год + 2 ещё допустим
|
||||
|
||||
|
||||
def plausible_year_max() -> int:
|
||||
"""Верхняя граница правдоподобного года постройки (текущий год + slack)."""
|
||||
return date.today().year + YEAR_BUILT_FUTURE_SLACK
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Чистые хелперы парсинга (юнит-тестируются без сети/БД)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def parse_decimal_comma(raw: str | None) -> float | None:
|
||||
"""«909,80» / «909.80» / «» / None → float|None. Запятая — decimal separator ДОМ.РФ CSV."""
|
||||
if raw is None:
|
||||
return None
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return float(s.replace(",", "."))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_int_field(
|
||||
raw: str | None,
|
||||
*,
|
||||
min_value: int | None = None,
|
||||
max_value: int | None = None,
|
||||
) -> int | None:
|
||||
"""Устойчивый str → int|None (commission_year / number_floors_max).
|
||||
|
||||
Пусто/None/нечисло → None. Терпит decimal-строки («5,0») через float-фоллбек —
|
||||
ДОМ.РФ CSV в принципе может так отдать целочисленные поля.
|
||||
|
||||
min_value/max_value — опциональный sanity-гейт: распарсенное значение вне
|
||||
[min_value, max_value] трактуется как None (для commission_year передаём
|
||||
YEAR_BUILT_MIN..plausible_year_max(), чтобы мусорный год не попал в staging;
|
||||
для number_floors_max границы не задаём — там гейт не требуется).
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
s = raw.strip()
|
||||
if not s:
|
||||
return None
|
||||
value: int | None = None
|
||||
try:
|
||||
value = int(s)
|
||||
except ValueError:
|
||||
try:
|
||||
value = int(float(s.replace(",", ".")))
|
||||
except ValueError:
|
||||
return None
|
||||
if min_value is not None and value < min_value:
|
||||
return None
|
||||
if max_value is not None and value > max_value:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Kr11Row:
|
||||
"""Одна строка КР1.1 house registry (house-per-row)."""
|
||||
|
||||
mkd_code: str
|
||||
houseguid: str | None
|
||||
address: str | None
|
||||
commission_year: int | None
|
||||
number_floors_max: int | None
|
||||
total_sq: float | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DomrfHouse:
|
||||
"""Объединённая КР1.1 + КР1.2(wall_material) строка — готова к UPSERT в staging."""
|
||||
|
||||
mkd_code: str
|
||||
houseguid: str | None
|
||||
address: str | None
|
||||
commission_year: int | None
|
||||
number_floors_max: int | None
|
||||
total_sq: float | None
|
||||
wall_material: str | None
|
||||
|
||||
|
||||
def _open_csv(path: str | Path) -> Iterator[dict[str, str]]:
|
||||
"""csv.DictReader по ДОМ.РФ CSV: delimiter ';', UTF-8 BOM (utf-8-sig)."""
|
||||
with open(path, encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f, delimiter=";")
|
||||
yield from reader
|
||||
|
||||
|
||||
def parse_kr11_csv(path: str | Path) -> dict[str, Kr11Row]:
|
||||
"""КР1.1 → dict по mkd_code. Строки без mkd_code пропускаются.
|
||||
|
||||
Дубликат mkd_code (не ожидается — mkd_code PK в реестре ДОМ.РФ) — последняя
|
||||
строка побеждает (совпадает с семантикой ON CONFLICT DO UPDATE ниже).
|
||||
"""
|
||||
rows: dict[str, Kr11Row] = {}
|
||||
for raw in _open_csv(path):
|
||||
mkd_code = (raw.get("mkd_code") or "").strip()
|
||||
if not mkd_code:
|
||||
continue
|
||||
rows[mkd_code] = Kr11Row(
|
||||
mkd_code=mkd_code,
|
||||
houseguid=(raw.get("houseguid") or "").strip() or None,
|
||||
address=(raw.get("address") or "").strip() or None,
|
||||
# sanity-гейт: год вне [1850, текущий+2] — мусор, не пускаем в staging
|
||||
# (иначе он позже мог бы записаться в houses.year_built как «авторитетный»).
|
||||
commission_year=parse_int_field(
|
||||
raw.get("commission_year"),
|
||||
min_value=YEAR_BUILT_MIN,
|
||||
max_value=plausible_year_max(),
|
||||
),
|
||||
number_floors_max=parse_int_field(raw.get("number_floors_max")),
|
||||
total_sq=parse_decimal_comma(raw.get("total_sq")),
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def parse_kr12_wall_materials(path: str | Path) -> dict[str, str]:
|
||||
"""КР1.2 (long-format) → dict mkd_code → wall_material.
|
||||
|
||||
wall_material непустой ТОЛЬКО на строке construction_element_type='фасад' —
|
||||
группировка не по этому полю, а просто "первая непустая wall_material на
|
||||
mkd_code" (устойчиво даже если разметка типа элемента когда-то изменится).
|
||||
"""
|
||||
materials: dict[str, str] = {}
|
||||
for raw in _open_csv(path):
|
||||
mkd_code = (raw.get("mkd_code") or "").strip()
|
||||
wall = (raw.get("wall_material") or "").strip()
|
||||
if mkd_code and wall and mkd_code not in materials:
|
||||
materials[mkd_code] = wall
|
||||
return materials
|
||||
|
||||
|
||||
def build_domrf_houses(
|
||||
kr11: dict[str, Kr11Row], wall_materials: dict[str, str]
|
||||
) -> list[DomrfHouse]:
|
||||
"""КР1.1 rows + КР1.2 wall_material lookup → список DomrfHouse (для UPSERT)."""
|
||||
return [
|
||||
DomrfHouse(
|
||||
mkd_code=row.mkd_code,
|
||||
houseguid=row.houseguid,
|
||||
address=row.address,
|
||||
commission_year=row.commission_year,
|
||||
number_floors_max=row.number_floors_max,
|
||||
total_sq=row.total_sq,
|
||||
wall_material=wall_materials.get(row.mkd_code),
|
||||
)
|
||||
for row in kr11.values()
|
||||
]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# HTTP: скачивание zip + извлечение CSV
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def _download_zip(url: str, *, client: httpx.Client) -> bytes:
|
||||
resp = client.get(url, timeout=DOWNLOAD_TIMEOUT_SEC)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def _extract_csv_from_zip(data: bytes, dest_dir: Path) -> Path:
|
||||
"""Распаковывает первый *.csv из zip-байтов в dest_dir, возвращает путь."""
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
csv_names = [n for n in zf.namelist() if n.lower().endswith(".csv")]
|
||||
if not csv_names:
|
||||
raise ValueError(f"zip не содержит .csv записей: {zf.namelist()!r}")
|
||||
extracted = zf.extract(csv_names[0], dest_dir)
|
||||
return Path(extracted)
|
||||
|
||||
|
||||
def fetch_domrf_csvs(dest_dir: Path, *, client: httpx.Client) -> tuple[Path, Path]:
|
||||
"""Скачивает КР1.1 + КР1.2 zip'ы, распаковывает в dest_dir. Возвращает (kr11, kr12)."""
|
||||
kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest_dir)
|
||||
kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest_dir)
|
||||
return kr11_path, kr12_path
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# UPSERT staging (domrf_kapremont, мигр. 176)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_UPSERT_SQL = text(
|
||||
"""
|
||||
INSERT INTO domrf_kapremont (
|
||||
mkd_code, houseguid, address, commission_year,
|
||||
number_floors_max, total_sq, wall_material, loaded_at
|
||||
)
|
||||
VALUES (
|
||||
CAST(:mkd_code AS text), CAST(:houseguid AS text), CAST(:address AS text),
|
||||
CAST(:commission_year AS smallint), CAST(:number_floors_max AS int),
|
||||
CAST(:total_sq AS numeric), CAST(:wall_material AS text), now()
|
||||
)
|
||||
ON CONFLICT (mkd_code) DO UPDATE SET
|
||||
houseguid = EXCLUDED.houseguid,
|
||||
address = EXCLUDED.address,
|
||||
commission_year = EXCLUDED.commission_year,
|
||||
number_floors_max = EXCLUDED.number_floors_max,
|
||||
total_sq = EXCLUDED.total_sq,
|
||||
wall_material = EXCLUDED.wall_material,
|
||||
loaded_at = now()
|
||||
WHERE domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid
|
||||
OR domrf_kapremont.address IS DISTINCT FROM EXCLUDED.address
|
||||
OR domrf_kapremont.commission_year IS DISTINCT FROM EXCLUDED.commission_year
|
||||
OR domrf_kapremont.number_floors_max IS DISTINCT FROM EXCLUDED.number_floors_max
|
||||
OR domrf_kapremont.total_sq IS DISTINCT FROM EXCLUDED.total_sq
|
||||
OR domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _chunk_houses(items: list[DomrfHouse], size: int) -> Iterator[list[DomrfHouse]]:
|
||||
for i in range(0, len(items), size):
|
||||
yield items[i : i + size]
|
||||
|
||||
|
||||
def upsert_domrf_kapremont(
|
||||
db: Session, houses: list[DomrfHouse], *, chunk_size: int = UPSERT_CHUNK_SIZE
|
||||
) -> int:
|
||||
"""UPSERT списка DomrfHouse в domrf_kapremont, чанками по SAVEPOINT. Не коммитит (caller).
|
||||
|
||||
Идемпотентно (IS DISTINCT FROM gate в _UPSERT_SQL — повторный прогон с теми же
|
||||
данными не трогает уже актуальные строки). Сбойный чанк откатывается изолированно
|
||||
(та же схема, что zhkh_flats_loader.match_houses_to_zhkh).
|
||||
"""
|
||||
upserted = 0
|
||||
for chunk in _chunk_houses(houses, chunk_size):
|
||||
try:
|
||||
with db.begin_nested():
|
||||
for h in chunk:
|
||||
res = db.execute(
|
||||
_UPSERT_SQL,
|
||||
{
|
||||
"mkd_code": h.mkd_code,
|
||||
"houseguid": h.houseguid,
|
||||
"address": h.address,
|
||||
"commission_year": h.commission_year,
|
||||
"number_floors_max": h.number_floors_max,
|
||||
"total_sq": h.total_sq,
|
||||
"wall_material": h.wall_material,
|
||||
},
|
||||
)
|
||||
upserted += res.rowcount
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"domrf_kapremont upsert: чанк из %d строк сбойнул (откат savepoint)",
|
||||
len(chunk),
|
||||
exc_info=True,
|
||||
)
|
||||
return upserted
|
||||
|
||||
|
||||
def load_domrf_kapremont(
|
||||
db: Session,
|
||||
*,
|
||||
kr11_path: str | Path | None = None,
|
||||
kr12_path: str | Path | None = None,
|
||||
work_dir: str | Path | None = None,
|
||||
chunk_size: int = UPSERT_CHUNK_SIZE,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, int]:
|
||||
"""Скачивает (если пути не заданы) КР1.1+КР1.2, парсит, UPSERT в domrf_kapremont.
|
||||
|
||||
kr11_path/kr12_path — локальные CSV (пропустить скачивание; используется тестами
|
||||
и ops-дебагом). work_dir — куда распаковывать скачанные zip (по умолчанию —
|
||||
временный каталог, удаляется после загрузки). dry_run — парс происходит, но НИ
|
||||
ОДНОЙ записи в БД не делается. Не коммитит (caller).
|
||||
"""
|
||||
tmp_ctx: tempfile.TemporaryDirectory[str] | None = None
|
||||
if kr11_path is None or kr12_path is None:
|
||||
if work_dir is not None:
|
||||
dest = Path(work_dir)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
tmp_ctx = tempfile.TemporaryDirectory()
|
||||
dest = Path(tmp_ctx.name)
|
||||
# verify=False: домрф.рф отдаёт RU-сертификат, не верифицируемый дефолтным
|
||||
# trust store'ом (та же ситуация, что sber_index.py). Публичные open data,
|
||||
# без auth/PII — приемлемо skip'нуть TLS-верификацию.
|
||||
with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SEC, verify=False) as client:
|
||||
if kr11_path is None:
|
||||
kr11_path = _extract_csv_from_zip(_download_zip(KR11_URL, client=client), dest)
|
||||
if kr12_path is None:
|
||||
kr12_path = _extract_csv_from_zip(_download_zip(KR12_URL, client=client), dest)
|
||||
|
||||
try:
|
||||
kr11 = parse_kr11_csv(kr11_path)
|
||||
wall_materials = parse_kr12_wall_materials(kr12_path)
|
||||
houses = build_domrf_houses(kr11, wall_materials)
|
||||
upserted = 0 if dry_run else upsert_domrf_kapremont(db, houses, chunk_size=chunk_size)
|
||||
finally:
|
||||
if tmp_ctx is not None:
|
||||
tmp_ctx.cleanup()
|
||||
|
||||
result = {
|
||||
"kr11_rows": len(kr11),
|
||||
"kr12_wall_rows": len(wall_materials),
|
||||
"houses_built": len(houses),
|
||||
"upserted": upserted,
|
||||
}
|
||||
logger.info("domrf_kapremont load DONE (dry_run=%s): %s", dry_run, result)
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Backfill houses.year_built/material_walls/total_floors
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Шаг 1: дома с domrf-матчем — year_built/material_walls/total_floors из domrf,
|
||||
# С ЗАФОЛЖЕННЫМ zhkh_year/zhkh_floors фоллбеком (COALESCE, как в issue #2013).
|
||||
#
|
||||
# ПРИОРИТЕТ year_built (Fix A): ВАЛИДНОЕ существующее значение выигрывает (COALESCE
|
||||
# семантика сохранена для valid). НО невозможное существующее значение (< :ymin,
|
||||
# > :ymax или NULL) НЕ должно экранировать авторитетный ДОМ.РФ commission_year —
|
||||
# для домов реестра капремонта commission_year это источник истины по году ввода.
|
||||
# Поэтому impossible/NULL заменяется валидным commission_year → затем валидным
|
||||
# zhkh_year; если валидной замены нет — оставляем существующее как есть (не обнуляем
|
||||
# даже мусор, чтобы не терять данные без выигрыша). Кандидаты замены тоже проходят
|
||||
# плаузибилити-гейт [:ymin, :ymax], чтобы в houses.year_built не попал мусорный год.
|
||||
# material_walls/total_floors — прежняя COALESCE-семантика (только заполнить NULL).
|
||||
_BACKFILL_HOUSES_FROM_DOMRF_SQL = text(
|
||||
"""
|
||||
UPDATE houses h
|
||||
SET year_built = CASE
|
||||
WHEN h.year_built BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN h.year_built
|
||||
ELSE COALESCE(
|
||||
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN d.commission_year END,
|
||||
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN h.zhkh_year END,
|
||||
h.year_built
|
||||
)
|
||||
END,
|
||||
material_walls = COALESCE(h.material_walls, d.wall_material),
|
||||
total_floors = COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)
|
||||
FROM domrf_kapremont d
|
||||
WHERE d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
||||
AND (
|
||||
(
|
||||
(h.year_built IS NULL
|
||||
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
|
||||
AND COALESCE(
|
||||
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN d.commission_year END,
|
||||
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN h.zhkh_year END
|
||||
) IS NOT NULL
|
||||
)
|
||||
OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL)
|
||||
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL = text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM houses h
|
||||
JOIN domrf_kapremont d
|
||||
ON d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)
|
||||
WHERE (
|
||||
(h.year_built IS NULL
|
||||
OR h.year_built NOT BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int))
|
||||
AND COALESCE(
|
||||
CASE WHEN d.commission_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN d.commission_year END,
|
||||
CASE WHEN h.zhkh_year BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)
|
||||
THEN h.zhkh_year END
|
||||
) IS NOT NULL
|
||||
)
|
||||
OR (h.material_walls IS NULL AND d.wall_material IS NOT NULL)
|
||||
OR (h.total_floors IS NULL AND COALESCE(d.number_floors_max, h.zhkh_floors) IS NOT NULL)
|
||||
"""
|
||||
)
|
||||
|
||||
# Шаг 2: ЛЮБОЙ дом (включая без domrf-матча вообще — шаг 1 INNER JOIN их не трогает)
|
||||
# добивается zhkh_year/zhkh_floors, если ещё NULL. Домам, уже тронутым шагом 1, шаг 2
|
||||
# ничего не меняет: их COALESCE в шаге 1 уже включал zhkh_* как fallback, поэтому
|
||||
# гейт `IS NULL AND zhkh_* IS NOT NULL` здесь для них ложен — шаги взаимоисключающи
|
||||
# (не двойной счёт при суммировании houses_updated).
|
||||
_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL = text(
|
||||
"""
|
||||
UPDATE houses
|
||||
SET year_built = COALESCE(year_built, zhkh_year),
|
||||
total_floors = COALESCE(total_floors, zhkh_floors)
|
||||
WHERE (year_built IS NULL AND zhkh_year IS NOT NULL)
|
||||
OR (total_floors IS NULL AND zhkh_floors IS NOT NULL)
|
||||
"""
|
||||
)
|
||||
|
||||
_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL = text(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM houses
|
||||
WHERE (year_built IS NULL AND zhkh_year IS NOT NULL)
|
||||
OR (total_floors IS NULL AND zhkh_floors IS NOT NULL)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def backfill_houses_from_domrf(db: Session, *, dry_run: bool = False) -> dict[str, int]:
|
||||
"""COALESCE(year_built, domrf.commission_year, zhkh_year) + material_walls + total_floors.
|
||||
|
||||
Двухшаговый UPDATE (мирроринг zhkh guid-match → cadastre-fallback паттерна из
|
||||
zhkh_flats_loader.py):
|
||||
1) дома с domrf-матчем (COALESCE(gar_house_guid, house_fias_id, zhkh_house_guid) =
|
||||
domrf_kapremont.houseguid) — заполняются из domrf, с zhkh_year/zhkh_floors
|
||||
фоллбеком внутри той же COALESCE.
|
||||
2) ЛЮБОЙ дом (в т.ч. без domrf-матча) — добивает year_built/total_floors из
|
||||
zhkh_year/zhkh_floors, если шаг 1 их не тронул.
|
||||
Только COALESCE (заполняет NULL, никогда не перезаписывает existing non-null).
|
||||
Не коммитит (caller). dry_run — ноль записей, только SELECT count(*) по тем же
|
||||
предикатам.
|
||||
"""
|
||||
year_bounds = {"ymin": YEAR_BUILT_MIN, "ymax": plausible_year_max()}
|
||||
if dry_run:
|
||||
domrf_matched = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_COUNT_SQL, year_bounds).scalar_one()
|
||||
zhkh_fallback = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_COUNT_SQL).scalar_one()
|
||||
result = {
|
||||
"domrf_matched": domrf_matched,
|
||||
"zhkh_fallback": zhkh_fallback,
|
||||
"houses_updated": 0,
|
||||
}
|
||||
logger.info("backfill_houses_from_domrf DRY-RUN: %s", result)
|
||||
return result
|
||||
|
||||
domrf_updated = db.execute(_BACKFILL_HOUSES_FROM_DOMRF_SQL, year_bounds).rowcount
|
||||
zhkh_updated = db.execute(_BACKFILL_HOUSES_ZHKH_FALLBACK_SQL).rowcount
|
||||
|
||||
result = {
|
||||
"domrf_matched": domrf_updated,
|
||||
"zhkh_fallback": zhkh_updated,
|
||||
"houses_updated": domrf_updated + zhkh_updated,
|
||||
}
|
||||
logger.info("backfill_houses_from_domrf DONE: %s", result)
|
||||
return result
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Propagate houses.year_built → listings.year_built
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Только NULL listings.year_built, только когда houses.year_built уже известен —
|
||||
# никогда не перезаписывает существующий listings.year_built (source-provided данные
|
||||
# приоритетнее houses-агрегата).
|
||||
#
|
||||
# LINK-CONSISTENCY GUARD (Fix B): раньше пропагация шла по house_id_fk БЕЗ проверки,
|
||||
# что listing реально относится к этому дому. Перепривязанный FK (house dedup/merge
|
||||
# «съезжает» на дом за сотни метров) впрыскивал бы чужой когортный год в живой comp.
|
||||
# Пропагируем только если координаты самого объявления в пределах
|
||||
# PROPAGATE_MAX_DISTANCE_M от геометрии дома. Если у объявления нет координат —
|
||||
# консервативный address-фоллбек: адрес объявления должен содержать short_address
|
||||
# дома (тот же дом по адресной строке); нет short_address — не пропагируем.
|
||||
PROPAGATE_MAX_DISTANCE_M = 500
|
||||
|
||||
# Общий link-consistency предикат для UPDATE и COUNT (единый источник — одна правка
|
||||
# порога PROPAGATE_MAX_DISTANCE_M меняет обе SQL). Порог — int-константа, не bind-param
|
||||
# (в ST_DWithin проще литерал; значение доверенное, не из пользовательского ввода).
|
||||
_PROPAGATE_LINK_GUARD = f"""
|
||||
AND (
|
||||
(l.geom IS NOT NULL AND h.geom IS NOT NULL
|
||||
AND ST_DWithin(
|
||||
CAST(l.geom AS geography), CAST(h.geom AS geography),
|
||||
{PROPAGATE_MAX_DISTANCE_M}))
|
||||
OR (l.geom IS NULL
|
||||
AND h.short_address IS NOT NULL AND l.address IS NOT NULL
|
||||
AND l.address ILIKE '%' || h.short_address || '%')
|
||||
)
|
||||
"""
|
||||
|
||||
_PROPAGATE_LISTINGS_YEAR_SQL = text(
|
||||
f"""
|
||||
UPDATE listings l
|
||||
SET year_built = h.year_built
|
||||
FROM houses h
|
||||
WHERE l.house_id_fk = h.id
|
||||
AND l.year_built IS NULL
|
||||
AND h.year_built IS NOT NULL
|
||||
{_PROPAGATE_LINK_GUARD}
|
||||
"""
|
||||
)
|
||||
|
||||
_PROPAGATE_LISTINGS_YEAR_COUNT_SQL = text(
|
||||
f"""
|
||||
SELECT count(*)
|
||||
FROM listings l
|
||||
JOIN houses h ON l.house_id_fk = h.id
|
||||
WHERE l.year_built IS NULL
|
||||
AND h.year_built IS NOT NULL
|
||||
{_PROPAGATE_LINK_GUARD}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def propagate_listings_year_from_houses(db: Session, *, dry_run: bool = False) -> dict[str, int]:
|
||||
"""UPDATE listings.year_built = houses.year_built где listing.year_built ещё NULL.
|
||||
|
||||
Не коммитит (caller). dry_run — ноль записей, только SELECT count(*) по тому же
|
||||
предикату (ключ результата — would_update вместо listings_updated).
|
||||
"""
|
||||
if dry_run:
|
||||
would_update = db.execute(_PROPAGATE_LISTINGS_YEAR_COUNT_SQL).scalar_one()
|
||||
logger.info("propagate_listings_year_from_houses DRY-RUN: would_update=%d", would_update)
|
||||
return {"listings_updated": 0, "would_update": would_update}
|
||||
|
||||
updated = db.execute(_PROPAGATE_LISTINGS_YEAR_SQL).rowcount
|
||||
logger.info("propagate_listings_year_from_houses DONE: listings_updated=%d", updated)
|
||||
return {"listings_updated": updated}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -40,7 +40,6 @@ import logging
|
|||
import pathlib
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
import matplotlib
|
||||
|
||||
|
|
@ -376,11 +375,60 @@ def _mono(text: str) -> str:
|
|||
return f"<span class='mono'>{text}</span>"
|
||||
|
||||
|
||||
def _report_number(estimate_id: UUID) -> str:
|
||||
"""№ отчёта: 'EKБ-NNNN-XXXXXXX' где NNNN — короткий код."""
|
||||
short = int(estimate_id.int) % 10_000
|
||||
long = int(estimate_id.int) % 10_000_000_000
|
||||
return f"EKБ-{short:04d}-{long:010d}"
|
||||
# Город (region 66) → буквенный префикс № отчёта. Раньше _report_number
|
||||
# захардкоживал «EKБ» для ВСЕХ оценок — объект в Серове / Нижнем Тагиле получал
|
||||
# екатеринбургский код на обложке, в шапках и в футере (#pdf-honesty). Префикс
|
||||
# теперь выводится из адреса оценки; ключи в нижнем регистре, ё→е (симметрично
|
||||
# _resolve_target_city в estimator.py). Город вне карты → _DEFAULT_REPORT_PREFIX.
|
||||
_CITY_REPORT_PREFIX: dict[str, str] = {
|
||||
"екатеринбург": "ЕКБ",
|
||||
"нижний тагил": "НТ",
|
||||
"каменск-уральский": "КУ",
|
||||
"первоуральск": "ПРВ",
|
||||
"серов": "СЕР",
|
||||
"новоуральск": "НВУ",
|
||||
"ревда": "РЕВ",
|
||||
"полевской": "ПЛВ",
|
||||
"асбест": "АСБ",
|
||||
"верхняя пышма": "ВП",
|
||||
"березовский": "БРЗ",
|
||||
"краснотурьинск": "КРТ",
|
||||
"камышлов": "КМШ",
|
||||
}
|
||||
# Нейтральный префикс, когда город адреса не распознан (пустой / вне региона) —
|
||||
# НЕ выдумываем локацию, не подставляем ложный «EKБ» (#pdf-honesty).
|
||||
_DEFAULT_REPORT_PREFIX = "МЕРА"
|
||||
# Длинные имена раньше коротких — чтобы «нижний тагил» матчился целиком.
|
||||
_REPORT_CITY_RE = re.compile(
|
||||
r"\b(?:"
|
||||
+ "|".join(re.escape(c) for c in sorted(_CITY_REPORT_PREFIX, key=len, reverse=True))
|
||||
+ r")\b"
|
||||
)
|
||||
|
||||
|
||||
def _report_city_prefix(address: str | None) -> str:
|
||||
"""Буквенный префикс № отчёта по городу из адреса (region 66).
|
||||
|
||||
«МЕРА» если город не распознан — не выдумываем локацию (раньше был хардкод
|
||||
«EKБ» для любого адреса, включая не-екатеринбургские). ё→е нормализация до
|
||||
поиска (симметрично _resolve_target_city в estimator.py)."""
|
||||
if not address:
|
||||
return _DEFAULT_REPORT_PREFIX
|
||||
norm = address.replace("ё", "е").replace("Ё", "е").lower()
|
||||
m = _REPORT_CITY_RE.search(norm)
|
||||
return _CITY_REPORT_PREFIX[m.group(0)] if m else _DEFAULT_REPORT_PREFIX
|
||||
|
||||
|
||||
def _report_number(estimate: AggregatedEstimate) -> str:
|
||||
"""№ отчёта: 'PREFIX-NNNN-XXXXXXXXXX'.
|
||||
|
||||
PREFIX — буквенный код города оценки (ЕКБ / НТ / … по target_address, «МЕРА»
|
||||
если город не распознан); NNNN/XXXXXXXXXX — детерминированные коды от
|
||||
estimate_id. Раньше PREFIX был захардкожен «EKБ» для всех оценок (#pdf-honesty)."""
|
||||
prefix = _report_city_prefix(estimate.target_address or estimate.canonical_address)
|
||||
short = int(estimate.estimate_id.int) % 10_000
|
||||
long = int(estimate.estimate_id.int) % 10_000_000_000
|
||||
return f"{prefix}-{short:04d}-{long:010d}"
|
||||
|
||||
|
||||
def _expires_date(estimate: AggregatedEstimate) -> dt.date:
|
||||
|
|
@ -518,7 +566,7 @@ def _page_footer(
|
|||
# тела страницы) и был источником сложности; заменён на простую тонкую
|
||||
# градиентную линию-разделитель между строками meta/wordmark.
|
||||
return f"""
|
||||
<div style="margin-top:20pt;">
|
||||
<div style="margin-top:6pt;">
|
||||
{note_html}
|
||||
<div style="border-top:1pt solid {_LINE_SOFT};padding-top:8pt;
|
||||
font-family:{mono_family};font-size:{_FS_XS};letter-spacing:0.06em;
|
||||
|
|
@ -811,8 +859,8 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
)
|
||||
|
||||
explainer = (
|
||||
f'<p style="margin:6pt 0 2pt 0;padding:6pt 10pt;background:{_ACCENT_SOFT};'
|
||||
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.3;">'
|
||||
f'<p style="margin:4pt 0 2pt 0;padding:4pt 10pt;background:{_ACCENT_SOFT};'
|
||||
f'border-radius:4pt;font-size:{_FS_SM};color:{_MUTED};line-height:1.25;">'
|
||||
"<strong>Запрос</strong> — по чему выставлены сопоставимые квартиры в объявлениях. "
|
||||
"<strong>Ожидаемая цена продажи</strong> — реалистичная цена сделки по данным ДКП "
|
||||
"Росреестра, обычно ниже запроса."
|
||||
|
|
@ -821,7 +869,7 @@ def _dual_price_block(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
|
||||
return (
|
||||
f'<table style="width:100%;border-collapse:separate;border-spacing:8pt 0;'
|
||||
f'margin:6pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
|
||||
f'margin:3pt 0 0 0;"><tr>{asking_cell}{sold_cell}</tr></table>'
|
||||
f"{explainer}"
|
||||
)
|
||||
|
||||
|
|
@ -970,7 +1018,7 @@ def _quality_card_html(estimate: AggregatedEstimate) -> str:
|
|||
cv_str = f"{cv}%" if cv is not None else "—"
|
||||
gauge = _data_quality_gauge_svg(estimate.confidence)
|
||||
return f"""
|
||||
<div class="card-glass bracket-tr" style="margin:6pt 0 6pt 0;padding:8pt 12pt 7pt 12pt;
|
||||
<div class="card-glass bracket-tr" style="margin:3pt 0 3pt 0;padding:6pt 12pt 5pt 12pt;
|
||||
display:flex;align-items:center;gap:12pt;flex-wrap:wrap;">
|
||||
{gauge}
|
||||
<div style="flex:1;min-width:170pt;overflow-wrap:anywhere;">
|
||||
|
|
@ -1000,7 +1048,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
if estimate.expires_at is not None
|
||||
else today + dt.timedelta(days=30)
|
||||
)
|
||||
report_num = _report_number(estimate.estimate_id)
|
||||
report_num = _report_number(estimate)
|
||||
|
||||
# Короткий адрес (для cover): берём первую часть до запятой
|
||||
full_address = input_snapshot.get("address", "—")
|
||||
|
|
@ -1038,30 +1086,38 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
house_label = house_labels.get(house_type, "—") if house_type else "—"
|
||||
repair_label = repair_labels.get(repair_state, "Не указано") if repair_state else "Не указано"
|
||||
balcony_label = "Есть" if has_balcony else "Нет" if has_balcony is False else "—"
|
||||
# #pdf-none: year_built может быть None (адрес без house_metadata) — раньше в
|
||||
# таблицу утекало литеральное «None»; показываем «—» как в остальных пустых полях.
|
||||
year_label = _mono(str(year_built)) if year_built else "—"
|
||||
|
||||
# Active market subband — 4-118 days range (fallback без данных).
|
||||
# Если есть days_on_market в analogs — берём min/max, иначе фиксированно.
|
||||
days_min, days_max = _days_on_market_range(estimate.analogs)
|
||||
|
||||
# Deals range — если deals есть, считаем; иначе fallback к listings range
|
||||
deals_low, deals_high = _deals_range(
|
||||
estimate.actual_deals, fallback=(estimate.range_low_rub, estimate.range_high_rub)
|
||||
)
|
||||
# Срок экспозиции показываем ТОЛЬКО при реальных days_on_market в analogs;
|
||||
# если данных нет — days не рисуем (не выдумываем «4-118 дней», #pdf-honesty).
|
||||
days_range = _days_on_market_range(estimate.analogs)
|
||||
|
||||
listings_bar = _price_range_chart_svg(
|
||||
estimate.range_low_rub,
|
||||
estimate.range_high_rub,
|
||||
sub_label="Активный рынок с аналогичным состоянием ремонта",
|
||||
days_min=days_min,
|
||||
days_max=days_max,
|
||||
show_days=True,
|
||||
days_min=days_range[0] if days_range else None,
|
||||
days_max=days_range[1] if days_range else None,
|
||||
show_days=days_range is not None,
|
||||
)
|
||||
|
||||
deals_bar = _price_range_chart_svg(
|
||||
deals_low,
|
||||
deals_high,
|
||||
sub_label="Диапазон цен по фактическим сделкам",
|
||||
)
|
||||
# Совет «Цены в объявлениях — ожидания собственников» ссылается на РЕАЛЬНЫЙ
|
||||
# рассчитанный дисконт запрос→продажа (тот же, что chip «−N%» в dual-price),
|
||||
# а не хардкод «10–18%», который противоречил бы вычисленному «−N%» (#pdf-honesty).
|
||||
discount_pct = _discount_pct(estimate)
|
||||
if discount_pct is not None:
|
||||
advice_discount_text = (
|
||||
f"Фактические сделки проходят ниже цен в объявлениях — по этому объекту "
|
||||
f"на {discount_pct}% (см. «Ожидаемая цена продажи»); подтверждают Росреестр, "
|
||||
f"ДомКлик и продажи агентств недвижимости"
|
||||
)
|
||||
else:
|
||||
advice_discount_text = (
|
||||
"Фактические сделки проходят ниже цен в объявлениях, что подтверждают "
|
||||
"Росреестр, ДомКлик и продажи агентств недвижимости"
|
||||
)
|
||||
|
||||
disclaimer_html = ""
|
||||
if brand.pdf_disclaimer:
|
||||
|
|
@ -1070,8 +1126,6 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
f"border-top:1pt solid {_LINE};padding-top:6pt;overflow-wrap:anywhere;'>"
|
||||
f"{_html.escape(brand.pdf_disclaimer)}</p>"
|
||||
)
|
||||
footer_html = _page_footer(brand, report_num, today, valid_until=expires)
|
||||
|
||||
heading_html = _section_heading(
|
||||
"01",
|
||||
"АНАЛИЗ РЫНКА И РАСЧЕТ ВЫКУПНОЙ СТОИМОСТИ КВАРТИРЫ",
|
||||
|
|
@ -1082,12 +1136,10 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
return f"""
|
||||
<div style="page-break-after:always;">
|
||||
|
||||
{_page_header(brand, report_num, today)}
|
||||
|
||||
{heading_html}
|
||||
|
||||
<!-- Параметры квартиры (dotted-row — ObjectSummary.tsx summary-row идиома) -->
|
||||
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:10pt;">
|
||||
<table class="params-table" style="width:100%;border-collapse:collapse;margin-bottom:6pt;">
|
||||
<tr><td class="dotted-row">№ отчета</td>
|
||||
<td class="bold mono dotted-row">{_mono(report_num)}</td></tr>
|
||||
<tr><td class="dotted-row">Дата отчета</td>
|
||||
|
|
@ -1096,7 +1148,7 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
<td class="bold dotted-row">до {_mono(expires.strftime("%d.%m.%Y"))}</td></tr>
|
||||
<tr><td class="dotted-row">Адрес</td><td class="bold dotted-row">{address}</td></tr>
|
||||
<tr><td class="dotted-row">Год постройки</td>
|
||||
<td class="bold dotted-row">{_mono(str(year_built))}</td></tr>
|
||||
<td class="bold dotted-row">{year_label}</td></tr>
|
||||
<tr><td class="dotted-row">Тип дома</td><td class="bold dotted-row">{house_label}</td></tr>
|
||||
<tr><td class="dotted-row">Этаж / этажность</td>
|
||||
<td class="bold dotted-row">{_mono(f"{floor} / {total_floors}")}</td></tr>
|
||||
|
|
@ -1116,58 +1168,56 @@ def _build_cover(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> s
|
|||
{_dual_price_block(estimate, brand)}
|
||||
|
||||
<div style="break-inside:avoid;page-break-inside:avoid;">
|
||||
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||
<p style="margin:3pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||
Диапазон цен в объявлениях
|
||||
<span style="font-weight:400;font-size:{_FS_SM};color:{_MUTED};">(без учета ремонта)</span>
|
||||
</p>
|
||||
{listings_bar}
|
||||
</div>
|
||||
|
||||
<div style="break-inside:avoid;page-break-inside:avoid;">
|
||||
<p style="margin:6pt 0 2pt 0;font-size:{_FS_MD};font-weight:700;">
|
||||
Диапазон цен по фактическим сделкам
|
||||
</p>
|
||||
{deals_bar}
|
||||
</div>
|
||||
|
||||
<!-- Что важно при оценке -->
|
||||
<h3 style="margin-top:8pt;font-size:{_FS_MD};font-weight:700;">
|
||||
<h3 style="margin-top:4pt;font-size:{_FS_MD};font-weight:700;">
|
||||
Что важно при оценке стоимости квартиры:
|
||||
</h3>
|
||||
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:4pt;">
|
||||
<table class="advice-table" style="width:100%;border-collapse:collapse;margin-top:2pt;">
|
||||
<tr>
|
||||
<td class="advice-title">Цены в объявлениях — ожидания собственников</td>
|
||||
<td class="advice-text">Фактические сделки проходят на 10–18% ниже, что подтверждают
|
||||
Росреестр, ДомКлик и продажи агентств недвижимости</td>
|
||||
<td class="advice-text">{advice_discount_text}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="advice-title">Ремонт оценивается по состоянию, а не по вложенным суммам</td>
|
||||
<td class="advice-text">Инвестиции в 400 – 600 тыс. руб. повышают цену объекта всего
|
||||
на 150 – 250 тыс. руб — покупатель оценивает общее состояние квартиры</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="advice-title">Неочевидные расходы при самостоятельной продаже</td>
|
||||
<td class="advice-text">При самостоятельной продаже суммарные расходы могут достигать
|
||||
до 15% стоимости квартиры (торг, риелтор, нотариус, справки)</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- #4-pages-fix: 3-я строка advice-table («Неочевидные расходы при
|
||||
самостоятельной продаже — до 15%») убрана — дублирует детальную
|
||||
постатейную разбивку на Offer-странице (см. _build_offer_page: торг
|
||||
5-15% + риелтор 2-5% + аренда + юрсопровождение + реклама), которая
|
||||
даёт ту же цифру НАМНОГО подробнее. Единственный content-cut в этом
|
||||
фиксе (см. PR/задача про 2 пустые страницы); остальной контент cover
|
||||
сохранён полностью. -->
|
||||
|
||||
<p style="margin-top:6pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
|
||||
padding-top:4pt;overflow-wrap:anywhere;">
|
||||
<p style="margin-top:3pt;font-size:{_FS_XS};color:{_MUTED};border-top:1pt solid {_LINE};
|
||||
padding-top:3pt;overflow-wrap:anywhere;">
|
||||
<strong>Этот отчёт онлайн:</strong> {settings.public_url}?id={estimate.estimate_id}
|
||||
</p>
|
||||
{disclaimer_html}
|
||||
{footer_html}
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int]:
|
||||
"""Min/max days_on_market по аналогам. Fallback 4-118 при отсутствии данных."""
|
||||
def _days_on_market_range(lots: list[AnalogLot]) -> tuple[int, int] | None:
|
||||
"""Min/max days_on_market по аналогам, либо None если реальных данных нет.
|
||||
|
||||
Раньше возвращал хардкод (4, 118) при отсутствии days_on_market — обложка и
|
||||
страница объявлений печатали этот выдуманный коридор как измеренный срок
|
||||
экспозиции (~48% оценок без days_on_market). Теперь None → caller не рисует
|
||||
срок (show_days=False), как уже делает страница сделок (#pdf-honesty)."""
|
||||
days = [lot.days_on_market for lot in lots if lot.days_on_market is not None]
|
||||
if not days:
|
||||
return 4, 118
|
||||
return None
|
||||
return min(days), max(days)
|
||||
|
||||
|
||||
|
|
@ -1225,38 +1275,27 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
)
|
||||
rooms_label = "Студия" if rooms == 0 else f"{rooms} комнаты"
|
||||
|
||||
# Полоска диапазона
|
||||
days_min, days_max = _days_on_market_range(estimate.analogs)
|
||||
# Полоска диапазона — срок экспозиции только при реальных days_on_market
|
||||
# (иначе не рисуем, не выдумываем «4-118 дней», #pdf-honesty).
|
||||
days_range = _days_on_market_range(estimate.analogs)
|
||||
range_bar = _price_range_chart_svg(
|
||||
estimate.range_low_rub,
|
||||
estimate.range_high_rub,
|
||||
sub_label="Рынок",
|
||||
days_min=days_min,
|
||||
days_max=days_max,
|
||||
show_days=True,
|
||||
days_min=days_range[0] if days_range else None,
|
||||
days_max=days_range[1] if days_range else None,
|
||||
show_days=days_range is not None,
|
||||
)
|
||||
|
||||
# Топ-5 примеров (отсортированных по distance)
|
||||
top5 = sorted(estimate.analogs, key=lambda x: x.distance_m or 9999)[:5]
|
||||
examples_rows = _examples_rows(top5)
|
||||
|
||||
report_num = _report_number(estimate.estimate_id)
|
||||
today = dt.date.today()
|
||||
footer_html = _page_footer(
|
||||
brand,
|
||||
report_num,
|
||||
today,
|
||||
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 2",
|
||||
valid_until=_expires_date(estimate),
|
||||
)
|
||||
|
||||
heading_html = _section_heading("02", "РЫНОК КВАРТИР – АНАЛОГОВ ПО ОБЪЯВЛЕНИЯМ")
|
||||
|
||||
return f"""
|
||||
<div style="page-break-after:always;">
|
||||
|
||||
{_page_header(brand, report_num, today)}
|
||||
|
||||
{heading_html}
|
||||
|
||||
<!-- Два колонки: левая (counts + источники), правая (params card + bracket-tr) -->
|
||||
|
|
@ -1306,8 +1345,6 @@ def _build_listings_page(estimate: AggregatedEstimate, input_snapshot: dict, bra
|
|||
<tbody>{examples_rows}</tbody>
|
||||
</table>
|
||||
|
||||
{footer_html}
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
|
@ -1391,6 +1428,21 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
today = dt.date.today()
|
||||
period_start = today - dt.timedelta(days=estimate.period_months * 30)
|
||||
|
||||
# Баннер дисконта ссылается на РЕАЛЬНЫЙ рассчитанный дисконт запрос→продажа
|
||||
# (тот же _discount_pct, что chip «−N%» на обложке), а не хардкод «10–18%»,
|
||||
# который противоречил бы вычисленному «−N%» в том же PDF. Убран и ложный
|
||||
# хвост «(Екатеринбург, 2026)» — локация не привязана к объекту (#pdf-honesty).
|
||||
discount_pct = _discount_pct(estimate)
|
||||
if discount_pct is not None:
|
||||
deals_discount_text = (
|
||||
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в "
|
||||
f"объявлениях — по этому объекту на {discount_pct}%"
|
||||
)
|
||||
else:
|
||||
deals_discount_text = (
|
||||
"По данным реальных сделок, квартиры продаются дешевле, чем заявлено в объявлениях"
|
||||
)
|
||||
|
||||
# Источники для сделок — берём из estimate.sources_used (не захардкоженный список).
|
||||
# Фильтруем по известным источникам сделок; fallback к пустому (не fabricate).
|
||||
_deal_source_keys = {"etazhi", "domklik", "rosreestr"}
|
||||
|
|
@ -1438,22 +1490,11 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
top5 = estimate.actual_deals[:5]
|
||||
examples_rows = _examples_rows(top5)
|
||||
|
||||
report_num = _report_number(estimate.estimate_id)
|
||||
footer_html = _page_footer(
|
||||
brand,
|
||||
report_num,
|
||||
today,
|
||||
f"{_html.escape(brand.name)} · Анализ рынка вторичной недвижимости · стр. 3",
|
||||
valid_until=_expires_date(estimate),
|
||||
)
|
||||
|
||||
heading_html = _section_heading("03", "ФАКТИЧЕСКИЕ СДЕЛКИ ПО КВАРТИРАМ — АНАЛОГАМ")
|
||||
|
||||
return f"""
|
||||
<div style="page-break-after:always;">
|
||||
|
||||
{_page_header(brand, report_num, today)}
|
||||
|
||||
{heading_html}
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:14pt;">
|
||||
|
|
@ -1495,8 +1536,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
|
||||
<div style="margin-top:14pt;padding:10pt 14pt;border-left:3pt solid {_DANGER};
|
||||
background:{_DANGER_SOFT};font-size:{_FS_MD};color:{_DANGER};font-weight:600;">
|
||||
По данным реальных сделок, квартиры продаются в среднем на 10–18% дешевле,
|
||||
чем заявлено в объявлениях (Екатеринбург, 2026)
|
||||
{deals_discount_text}
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:16pt;font-size:{_FS_MD};font-weight:700;">
|
||||
|
|
@ -1508,8 +1548,6 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
<tbody>{examples_rows}</tbody>
|
||||
</table>
|
||||
|
||||
{footer_html}
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
|
@ -1517,7 +1555,7 @@ def _build_deals_page(estimate: AggregatedEstimate, input_snapshot: dict, brand)
|
|||
# ── Page 4: Offer (Trade-In vs Самопродажа) ──────────────────────────────────
|
||||
|
||||
|
||||
def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: ignore[no-untyped-def]
|
||||
def _build_offer_page(estimate: AggregatedEstimate, input_snapshot: dict, brand) -> str: # type: ignore[no-untyped-def,type-arg]
|
||||
median = estimate.median_price_rub
|
||||
|
||||
# Расчёт расходов: часть — на основе медианы (торг «от цены в объявлении»),
|
||||
|
|
@ -1544,8 +1582,24 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
rieltor_low = int(sold_price * rieltor_pct_low / 100)
|
||||
rieltor_high = int(sold_price * rieltor_pct_high / 100)
|
||||
|
||||
# 3 месяца аренды 1-комнатной квартиры, ЕКБ 2026: ~28-45 тыс. руб./мес.
|
||||
rent_low, rent_high = 84_000, 135_000
|
||||
# 3 месяца аренды квартиры, ЕКБ 2026 (базовая ставка — студия/1-комн., ~28-45
|
||||
# тыс. руб./мес.), масштабируется по числу комнат объекта (#pdf-honesty: раньше
|
||||
# был плоский 1-комнатный тариф для ЛЮБОГО объекта, включая 3-4-комнатные).
|
||||
# Ориентировочные месячные ставки аренды по комнатности, ЕКБ 2026:
|
||||
# студия/1к ~28-45k, 2к ~40-60k, 3к ~55-80k, 4к+ ~75-110k.
|
||||
# Множители — грубое отношение верхних границ этих диапазонов к базовой (1к).
|
||||
rooms = int(input_snapshot.get("rooms", 0) or 0)
|
||||
if rooms <= 1:
|
||||
rent_multiplier = 1.0
|
||||
elif rooms == 2:
|
||||
rent_multiplier = 1.35
|
||||
elif rooms == 3:
|
||||
rent_multiplier = 1.8
|
||||
else:
|
||||
rent_multiplier = 2.4
|
||||
rent_base_low, rent_base_high = 84_000, 135_000
|
||||
rent_low = round(rent_base_low * rent_multiplier / 1000) * 1000
|
||||
rent_high = round(rent_base_high * rent_multiplier / 1000) * 1000
|
||||
# Юридическое сопровождение сделки — диапазон (не фикс), варьируется по объёму работ.
|
||||
juridical_low, juridical_high = 15_000, 50_000
|
||||
# Реклама/продвижение за 3 месяца — низкая уверенность в оценке (нет единого
|
||||
|
|
@ -1558,22 +1612,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
brand_short = _html.escape(brand.name)
|
||||
trade_in_label = f"{brand_short}.Обмен" if brand.slug != "generic" else "Trade-In"
|
||||
|
||||
report_num = _report_number(estimate.estimate_id)
|
||||
today = dt.date.today()
|
||||
footer_html = _page_footer(
|
||||
brand,
|
||||
report_num,
|
||||
today,
|
||||
f"{brand_short} · Анализ рынка вторичной недвижимости · стр. 4 · "
|
||||
"Расчёт носит ориентировочный характер и не является офертой.",
|
||||
valid_until=_expires_date(estimate),
|
||||
)
|
||||
|
||||
return f"""
|
||||
<div><!-- last page: NO page-break-after -->
|
||||
|
||||
{_page_header(brand, report_num, today)}
|
||||
|
||||
{_section_heading("04", "ФОРМИРОВАНИЕ ВЫКУПНОЙ СТОИМОСТИ")}
|
||||
|
||||
<h3 style="font-size:{_FS_MD};font-weight:700;margin-bottom:8pt;">
|
||||
|
|
@ -1626,7 +1667,8 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
<tr style="border-bottom:1px solid {_BORDER};">
|
||||
<td style="padding:6pt 4pt;">
|
||||
<div class="bold">Аренда после сделки</div>
|
||||
<div style="font-size:{_FS_XS};color:{_MUTED};">однокомнатной квартиры на 3 месяца</div>
|
||||
<div style="font-size:{_FS_XS};color:{_MUTED};">
|
||||
{f"{rooms}-комн. квартиры на 3 месяца" if rooms > 0 else "квартиры на 3 месяца"}</div>
|
||||
</td>
|
||||
<td style="padding:6pt 4pt;text-align:right;color:{_SUCCESS};font-weight:700;">
|
||||
бесплатно</td>
|
||||
|
|
@ -1738,7 +1780,9 @@ def _build_offer_page(estimate: AggregatedEstimate, brand) -> str: # type: igno
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{footer_html}
|
||||
<p style="margin-top:14pt;font-size:{_FS_XS};color:{_MUTED};">
|
||||
Расчёт носит ориентировочный характер и не является офертой.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
"""
|
||||
|
|
@ -1794,8 +1838,12 @@ def _build_css(brand=None) -> str: # type: ignore[no-untyped-def]
|
|||
}}
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 20mm 18mm 20mm 18mm;
|
||||
margin: 25mm 18mm 19mm 18mm;
|
||||
@top-center {{ content: element(runningHeader); vertical-align: bottom; }}
|
||||
@bottom-center {{ content: element(runningFooter); vertical-align: top; }}
|
||||
}}
|
||||
.running-header {{ position: running(runningHeader); }}
|
||||
.running-footer {{ position: running(runningFooter); }}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: 'PT Sans', 'DejaVu Sans', sans-serif;
|
||||
|
|
@ -2015,7 +2063,7 @@ def _build_insufficient_data_page(estimate: AggregatedEstimate, input_snapshot:
|
|||
Предотвращает публикацию «0,0 млн» и fabricated таблиц потерь.
|
||||
"""
|
||||
address = _html.escape(input_snapshot.get("address", "—"))
|
||||
report_num = _report_number(estimate.estimate_id)
|
||||
report_num = _report_number(estimate)
|
||||
today = dt.date.today()
|
||||
return f"""
|
||||
<div>
|
||||
|
|
@ -2103,11 +2151,28 @@ def generate_trade_in_pdf(
|
|||
if estimate.insufficient_data:
|
||||
body_html = _build_insufficient_data_page(estimate, input_snapshot, brand)
|
||||
else:
|
||||
# Running header/footer — единый HUD-хром печатается WeasyPrint'ом в page
|
||||
# margin-boxes (@top-center/@bottom-center, см. _build_css) на КАЖДОЙ странице
|
||||
# документа, а не только там, где явно вставлен в поток секции-билдера.
|
||||
# Рендерится один раз здесь (не внутри каждого _build_*_page) — иначе WeasyPrint
|
||||
# печатает независимый running-элемент с последнего "видимого" перед разрывом
|
||||
# страницы, что дало бы 4 разных чанка вместо одного персистентного хрома.
|
||||
_chrome_report_num = _report_number(estimate)
|
||||
_chrome_today = dt.date.today()
|
||||
_chrome_expires = _expires_date(estimate)
|
||||
chrome_html = (
|
||||
f'<div class="running-header">'
|
||||
f"{_page_header(brand, _chrome_report_num, _chrome_today)}</div>"
|
||||
f'<div class="running-footer">'
|
||||
f"{_page_footer(brand, _chrome_report_num, _chrome_today, valid_until=_chrome_expires)}"
|
||||
f"</div>"
|
||||
)
|
||||
body_html = (
|
||||
_build_cover(estimate, input_snapshot, brand)
|
||||
chrome_html
|
||||
+ _build_cover(estimate, input_snapshot, brand)
|
||||
+ _build_listings_page(estimate, input_snapshot, brand)
|
||||
+ _build_deals_page(estimate, input_snapshot, brand)
|
||||
+ _build_offer_page(estimate, brand)
|
||||
+ _build_offer_page(estimate, input_snapshot, brand)
|
||||
)
|
||||
|
||||
html_str = (
|
||||
|
|
|
|||
|
|
@ -72,6 +72,165 @@ def is_within_ekb_bbox_wide(lat: float, lon: float) -> bool:
|
|||
return is_within_ekb_bbox(lat, lon, EKB_BBOX_WIDE)
|
||||
|
||||
|
||||
# ── Sverdlovsk oblast (region 66) ────────────────────────────────────────────
|
||||
# Генеральный bbox всей области — от южной границы (ЕКБ/Полевской) до
|
||||
# северных городов (Ивдель/Североуральск/Краснотурьинск). Специально щедрый:
|
||||
# используется как fallback-accept для провайдеров БЕЗ структурного region-поля
|
||||
# (сырые Point.pos от Yandex/Nominatim) — когда нет ФИАС/КЛАДР для точной
|
||||
# проверки region==66. Дороже false-positive у самой границы соседних регионов,
|
||||
# дешевле false-negative (выбросить корректный областной адрес — исходный баг).
|
||||
# bbox = (lat_min, lat_max, lon_min, lon_max).
|
||||
OBLAST66_BBOX = (55.8, 62.2, 56.7, 66.6)
|
||||
|
||||
# Nominatim/Yandex viewport для областного (не только ЕКБ) поиска — тот же
|
||||
# формат что EKB_BBOX ниже, но покрывает всю область. viewbox — Nominatim
|
||||
# (lon1,lat1,lon2,lat2, NW→SE); ll/spn — Yandex центр+охват (lon,lat).
|
||||
OBLAST66_VIEWBOX = {
|
||||
"viewbox": "56.7,62.2,66.6,55.8",
|
||||
"ll": "61.65,59.0",
|
||||
"spn": "9.9,6.4",
|
||||
}
|
||||
|
||||
|
||||
def is_within_oblast66_bbox(lat: float, lon: float) -> bool:
|
||||
"""True если (lat, lon) в генеральном bbox Свердловской области (регион 66)."""
|
||||
return is_within_ekb_bbox(lat, lon, OBLAST66_BBOX)
|
||||
|
||||
|
||||
# Крупные города области (кроме ЕКБ) — НЕ исчерпывающий список (в области ~30
|
||||
# городов), покрывает основные региональные центры. Используется чтобы:
|
||||
# (a) не навязывать "Екатеринбург, " в запрос, если пользователь уже указал
|
||||
# другой город региона (геокодер forward/suggest prefix-логика);
|
||||
# (b) как known-locality set в _yandex_reverse при перестановке "улица, город".
|
||||
SVERDLOVSK_OBLAST_CITIES = frozenset(
|
||||
{
|
||||
"екатеринбург",
|
||||
"нижний тагил",
|
||||
"каменск-уральский",
|
||||
"первоуральск",
|
||||
"серов",
|
||||
"новоуральск",
|
||||
"ревда",
|
||||
"полевской",
|
||||
"асбест",
|
||||
"богданович",
|
||||
"ирбит",
|
||||
"красноуфимск",
|
||||
"верхняя пышма",
|
||||
"берёзовский",
|
||||
"березовский",
|
||||
"заречный",
|
||||
"качканар",
|
||||
"краснотурьинск",
|
||||
"североуральск",
|
||||
"ивдель",
|
||||
"тавда",
|
||||
"туринск",
|
||||
"сысерть",
|
||||
"среднеуральск",
|
||||
"дегтярск",
|
||||
"верхняя салда",
|
||||
"нижняя салда",
|
||||
"невьянск",
|
||||
"артёмовский",
|
||||
"артемовский",
|
||||
"камышлов",
|
||||
"алапаевск",
|
||||
"сухой лог",
|
||||
"кушва",
|
||||
"красноуральск",
|
||||
"карпинск",
|
||||
"нижняя тура",
|
||||
"верхний тагил",
|
||||
"нижние серги",
|
||||
# НЕ добавляем "лесной": ekb_geoportal_buildings содержит реальную ЕКБ-улицу
|
||||
# «Лесной» (3 дома) — точное совпадение целого слова, не отличимое от
|
||||
# города ЗАТО Лесной word-boundary'ом (в отличие от «Серова»/«Серов»,
|
||||
# где разные словоформы). Бары-адрес "Лесной, 5" без "Екатеринбург" и
|
||||
# без district-префикса ложно ушёл бы в non-EKB gate.
|
||||
}
|
||||
)
|
||||
SVERDLOVSK_OBLAST_REGION = "Свердловская область"
|
||||
|
||||
# Word/phrase-boundary regex — НЕ substring — чтобы «Серова 27» не матчил город
|
||||
# «Серов», «Ирбитская 5» — «Ирбит», «Асбестовский пер.» — «Асбест», «Невьянский
|
||||
# пер.» — «Невьянск», «Богдановича» — «Богданович» (все реальные EKB-адреса,
|
||||
# substring-матч ложно ронял "Екатеринбург, "-префикс на них → неверный город
|
||||
# → неверная цена). Python `\b` для str-паттернов юникод-осведомлён (Cyrillic
|
||||
# входит в `\w`), поэтому «серова» корректно НЕ матчит `\bсеров\b` (после
|
||||
# «серов» идёт «а» — тоже \w, границы нет).
|
||||
_OBLAST_CITY_RE = re.compile(
|
||||
r"\b(?:" + "|".join(re.escape(city) for city in SVERDLOVSK_OBLAST_CITIES) + r")\b"
|
||||
)
|
||||
_OBLAST_MARKER_RE = re.compile(r"\bсвердловск\w*\b")
|
||||
# Приставки, после которых токен обозначает РАЙОН/микрорайон города, а не сам
|
||||
# город региона: «мкр Заречный» — микрорайон ВНУТРИ другого города (не ЗАТО
|
||||
# Заречный). Без этого исключения точное совпадение слова "заречный" всё равно
|
||||
# ложно сработало бы (это не substring-баг, а реальная омонимия имени).
|
||||
_DISTRICT_PREFIXES = frozenset({"мкр", "мкр.", "микрорайон", "р-н", "р-он", "район", "жк"})
|
||||
|
||||
|
||||
def _has_oblast_marker(text_lower: str) -> bool:
|
||||
"""True если текст уже содержит упоминание области/города региона 66.
|
||||
|
||||
Используется чтобы НЕ навязывать "Екатеринбург, " в запрос, когда адрес
|
||||
уже привязан к другому городу/области — иначе получим двойной город
|
||||
("Екатеринбург, Нижний Тагил, Ленина 10") и провайдер вернёт мусор/пусто.
|
||||
|
||||
Матчинг — по границе слова/фразы (см. `_OBLAST_CITY_RE`), НЕ substring —
|
||||
и с исключением "мкр/микрорайон/р-н <город>" (район ВНУТРИ другого города).
|
||||
"""
|
||||
normalized = " ".join(text_lower.split())
|
||||
if _OBLAST_MARKER_RE.search(normalized):
|
||||
return True
|
||||
for m in _OBLAST_CITY_RE.finditer(normalized):
|
||||
prefix_words = normalized[: m.start()].split()
|
||||
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||||
continue # «мкр Заречный» — район, не город-ЗАТО Заречный
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Города региона 66 КРОМЕ Екатеринбурга — используется чтобы отсечь EKB-only
|
||||
# локальные тиры (geoportal/cadastral, см. `geocode()`) от адреса другого
|
||||
# города области. re.escape на элементах SVERDLOVSK_OBLAST_CITIES-{ekb}.
|
||||
_NON_EKB_OBLAST_CITIES = SVERDLOVSK_OBLAST_CITIES - {"екатеринбург"}
|
||||
_NON_EKB_CITY_RE = re.compile(
|
||||
r"\b(?:" + "|".join(re.escape(c) for c in _NON_EKB_OBLAST_CITIES) + r")\b"
|
||||
)
|
||||
# Word-boundary, НЕ substring — «екатеринбургское шоссе» (реальная улица в
|
||||
# городах-спутниках, напр. Первоуральске) не должно матчить как «Екатеринбург».
|
||||
_EKATERINBURG_RE = re.compile(r"\bекатеринбург\b")
|
||||
|
||||
|
||||
def _names_non_ekb_city(address: str) -> bool:
|
||||
"""True если адрес явно называет город Свердловской области, ОТЛИЧНЫЙ от
|
||||
Екатеринбурга (по границе слова, с исключением district-приставок «мкр/р-н
|
||||
<город>»). Используется чтобы НЕ пускать EKB-only локальные тиры
|
||||
(geoportal/cadastral) на не-ЕКБ адрес: улица+дом, совпадающие с ЕКБ-домом
|
||||
(проспект Ленина 1 есть в обоих городах), иначе адрес снапается в ЕКБ (#11).
|
||||
|
||||
Явное "Екатеринбург" в адресе имеет приоритет над любым омонимом другого
|
||||
города региона — иначе ЖК/посёлки с названием, совпадающим с другим
|
||||
городом области (ЖК «Заречный», пос. «Сысерть» внутри ЕКБ), ложно
|
||||
отсекали бы EKB-only тиры для реального ЕКБ-адреса.
|
||||
"""
|
||||
normalized = " ".join(address.lower().split())
|
||||
# Явное "Екатеринбург" (отдельным словом — см. `_EKATERINBURG_RE`) в адресе
|
||||
# → это ЕКБ, даже если в тексте встречается омоним соседнего города (ЖК
|
||||
# «Заречный», пос. Сысерть в составе МО «город Екатеринбург» и т.п. —
|
||||
# реальные false-positive из прод-кэша). Word-boundary же не даёт слову
|
||||
# «Екатеринбургское» (шоссе в городах-спутниках) ложно сработать как город.
|
||||
if _EKATERINBURG_RE.search(normalized):
|
||||
return False
|
||||
for m in _NON_EKB_CITY_RE.finditer(normalized):
|
||||
prefix_words = normalized[: m.start()].split()
|
||||
if prefix_words and prefix_words[-1] in _DISTRICT_PREFIXES:
|
||||
continue # «мкр Заречный» — район внутри другого города, не ЗАТО Заречный
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ── Address normalisation ───────────────────────────────────────────────────
|
||||
def normalize_address(address: str) -> str:
|
||||
"""Нормализация для cache lookup: lowercase + trim + collapse whitespace.
|
||||
|
|
@ -166,11 +325,35 @@ def _cache_put(db: Session, address_norm: str, result: GeocodeResult) -> None:
|
|||
|
||||
|
||||
# ── Provider: Nominatim (OSM, без ключа) ────────────────────────────────────
|
||||
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
|
||||
"""Single Nominatim search. Возвращает первый item или None.
|
||||
def _nominatim_region_ok(item: dict) -> bool | None:
|
||||
"""Кросс-чек региона по Nominatim `address.state` (доступно т.к. addressdetails=1).
|
||||
|
||||
ВАЖНО: фильтруем результаты по ЕКБ bbox прямо тут, чтобы при опечатках
|
||||
не возвращать Пермский край / Челябинск.
|
||||
True/False если state однозначно про/не про Свердловскую область. None если
|
||||
поле отсутствует/не строка — тогда accept-логика падает обратно на bbox.
|
||||
Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри генерального OBLAST66_BBOX
|
||||
(специально щедрого), но их state явно другой регион.
|
||||
"""
|
||||
addr = item.get("address")
|
||||
if not isinstance(addr, dict):
|
||||
return None
|
||||
state = addr.get("state")
|
||||
if not isinstance(state, str) or not state:
|
||||
return None
|
||||
return "свердловск" in state.lower()
|
||||
|
||||
|
||||
async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | None:
|
||||
"""Single Nominatim search. Возвращает лучший item или None.
|
||||
|
||||
ВАЖНО: фильтруем результаты по bbox области (region 66) прямо тут, чтобы при
|
||||
опечатках не возвращать Пермский край / Челябинск — но не резать легитимные
|
||||
Нижний Тагил / Серов и т.д. (генеральный bbox всей Свердловской области).
|
||||
|
||||
Two-pass tie-break: среди кандидатов предпочитаем того, кто попадает в TIGHT
|
||||
ЕКБ-bbox (byte-identical для ЕКБ-запросов, даже если Nominatim ранжировал его
|
||||
не первым) — иначе первый кандидат внутри OBLAST66. Плюс region cross-check
|
||||
(`address.state`) — отсекает кандидатов ЯВНО из другого региона (Тюмень и
|
||||
т.п.), даже если координаты попали в генеральный bbox.
|
||||
"""
|
||||
response = await client.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
|
|
@ -180,21 +363,26 @@ async def _nominatim_query(client: httpx.AsyncClient, address: str) -> dict | No
|
|||
"limit": "3",
|
||||
"countrycodes": "ru",
|
||||
"addressdetails": "1",
|
||||
"viewbox": EKB_BBOX["viewbox"],
|
||||
"bounded": "1", # строго в ЕКБ bbox
|
||||
"viewbox": OBLAST66_VIEWBOX["viewbox"],
|
||||
"bounded": "1", # строго в пределах области (region 66)
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
oblast_fallback: dict | None = None
|
||||
for item in data:
|
||||
try:
|
||||
lat_f = float(item["lat"])
|
||||
lon_f = float(item["lon"])
|
||||
if is_within_ekb_bbox(lat_f, lon_f):
|
||||
return item
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
if _nominatim_region_ok(item) is False:
|
||||
continue # регион явно не Свердловская область — не рассматриваем
|
||||
if is_within_ekb_bbox(lat_f, lon_f):
|
||||
return item # tight-ЕКБ приоритетнее — тот же результат, что и раньше
|
||||
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
|
||||
oblast_fallback = item
|
||||
return oblast_fallback
|
||||
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||
|
|
@ -202,8 +390,8 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None:
|
|||
"""OSM Nominatim — бесплатно, без ключа, 1 req/sec policy.
|
||||
|
||||
Бан-policy: User-Agent с email обязателен.
|
||||
Tier 1: bounded ЕКБ на оригинальный адрес.
|
||||
Tier 2: bounded ЕКБ на typo-варианты (Цвилинга → Цвиллинга).
|
||||
Tier 1: bounded область (region 66) на оригинальный адрес.
|
||||
Tier 2: bounded область (region 66) на typo-варианты (Цвилинга → Цвиллинга).
|
||||
"""
|
||||
headers = {
|
||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||
|
|
@ -237,6 +425,30 @@ async def _nominatim_lookup(address: str) -> GeocodeResult | None:
|
|||
)
|
||||
|
||||
|
||||
def _yandex_region_ok(geo_object: dict) -> bool | None:
|
||||
"""Кросс-чек региона по Yandex `AdministrativeAreaName`.
|
||||
|
||||
True/False если Yandex вернул поле и оно однозначно про/не про Свердловскую
|
||||
область. None если поля нет / оно не строка — тогда accept-логика падает
|
||||
обратно на bbox. Ловит Тюмень/Шадринск/Кунгур/Снежинск — они внутри
|
||||
генерального OBLAST66_BBOX (специально щедрого), но их регион явно другой.
|
||||
"""
|
||||
try:
|
||||
admin_area = (
|
||||
geo_object.get("metaDataProperty", {})
|
||||
.get("GeocoderMetaData", {})
|
||||
.get("AddressDetails", {})
|
||||
.get("Country", {})
|
||||
.get("AdministrativeArea", {})
|
||||
.get("AdministrativeAreaName")
|
||||
)
|
||||
except AttributeError:
|
||||
return None
|
||||
if not isinstance(admin_area, str) or not admin_area:
|
||||
return None
|
||||
return "свердловск" in admin_area.lower()
|
||||
|
||||
|
||||
# ── Provider: Yandex Geocoder (требует key, лучшее покрытие РФ) ─────────────
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||
async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
||||
|
|
@ -247,8 +459,9 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
|||
Запрашиваем с ll+spn (центр ЕКБ) для приоритизации местных результатов,
|
||||
но БЕЗ rspn — чтобы fuzzy matching работал при опечатках.
|
||||
"""
|
||||
# Не запихиваем "Екатеринбург" если оно уже есть в адресе (типичный кейс из suggest)
|
||||
geocode_query = address if "екатеринбург" in address.lower() else f"Екатеринбург, {address}"
|
||||
# Не навязываем "Екатеринбург, " если в адресе уже есть город/область региона 66
|
||||
# (типичный кейс из suggest, либо явный запрос по другому городу области).
|
||||
geocode_query = address if _has_oblast_marker(address.lower()) else f"Екатеринбург, {address}"
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
"https://geocode-maps.yandex.ru/1.x/",
|
||||
|
|
@ -269,22 +482,44 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
|||
if not members:
|
||||
return None
|
||||
|
||||
# Фильтруем top-5 по ЕКБ bbox — игнорируем Челябинск/Уфу/Москву при опечатке
|
||||
# Фильтруем top-5 по bbox области (region 66) — игнорируем Челябинск/Уфу/Москву
|
||||
# при опечатке, но принимаем легитимные Нижний Тагил/Серов/etc. Two-pass
|
||||
# tie-break: tight-ЕКБ приоритетнее (byte-identical для ЕКБ-запросов, даже
|
||||
# если Yandex ранжировал его не первым), иначе первый oblast-bbox кандидат.
|
||||
# Region cross-check (AdministrativeAreaName) отсекает кандидатов ЯВНО из
|
||||
# другого региона (Тюмень и т.п. попадают в генеральный bbox координатно).
|
||||
best = None
|
||||
oblast_fallback = None
|
||||
for m in members:
|
||||
obj = m.get("GeoObject", {})
|
||||
try:
|
||||
lon_str, lat_str = obj["Point"]["pos"].split()
|
||||
lat_f, lon_f = float(lat_str), float(lon_str)
|
||||
if is_within_ekb_bbox(lat_f, lon_f):
|
||||
best = obj
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if _yandex_region_ok(obj) is False:
|
||||
continue # регион явно не Свердловская область — не рассматриваем
|
||||
if is_within_ekb_bbox(lat_f, lon_f):
|
||||
best = obj
|
||||
break
|
||||
if oblast_fallback is None and is_within_oblast66_bbox(lat_f, lon_f):
|
||||
oblast_fallback = obj
|
||||
|
||||
if best is None:
|
||||
# Никто из top-5 не попал в ЕКБ → берем первый «как есть» (вне ЕКБ — но хоть что-то)
|
||||
best = members[0]["GeoObject"]
|
||||
best = oblast_fallback
|
||||
|
||||
if best is None:
|
||||
# Никто из top-5 не попал ни в tight, ни в oblast bbox (или все явно
|
||||
# отсеяны по региону) → берём первый «как есть», НО только если его
|
||||
# регион не опровергнут явно — иначе весь смысл region cross-check
|
||||
# теряется на garbage-query, где top-1 — гарантированно не тот регион
|
||||
# (например, Тюмень).
|
||||
first_obj = members[0]["GeoObject"]
|
||||
if _yandex_region_ok(first_obj) is not False:
|
||||
best = first_obj
|
||||
|
||||
if best is None:
|
||||
return None
|
||||
|
||||
lon_str, lat_str = best["Point"]["pos"].split()
|
||||
precision_raw = (
|
||||
|
|
@ -310,6 +545,8 @@ async def _yandex_lookup(address: str, api_key: str) -> GeocodeResult | None:
|
|||
|
||||
# ── Suggest (автокомплит) ───────────────────────────────────────────────────
|
||||
# ЕКБ bounding box (приблизительно): юг 56.65, запад 60.40, север 56.95, восток 60.85
|
||||
# Это ЕКБ fast-path (Yandex rspn=1 bounded tier + soft-bias ll/spn). Для областного
|
||||
# охвата (region 66) — см. OBLAST66_VIEWBOX выше.
|
||||
EKB_BBOX = {
|
||||
"viewbox": "60.40,56.95,60.85,56.65", # Nominatim format: lon1,lat1,lon2,lat2 (NW,SE)
|
||||
"ll": "60.605,56.838", # Yandex center (lon,lat)
|
||||
|
|
@ -375,9 +612,13 @@ async def _dadata_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]
|
|||
типа город/район, для autocomplete с привязкой к карте они бесполезны).
|
||||
|
||||
Label собирается из DaData `value` (короткая форма «ул Малышева, д 30»).
|
||||
Без city suffix — query уже ограничен `locations=[{city: 'Екатеринбург'}]`.
|
||||
Constraint — вся область (region='Свердловская область', hard-filter внутри
|
||||
`suggest_addresses`), а не один город ЕКБ — иначе Нижний Тагил/Серов/etc
|
||||
никогда не появились бы в подсказках.
|
||||
"""
|
||||
raw = await dadata.suggest_addresses(query, limit=limit)
|
||||
raw = await dadata.suggest_addresses(
|
||||
query, limit=limit, city=None, region=SVERDLOVSK_OBLAST_REGION
|
||||
)
|
||||
out: list[GeocodeSuggestion] = []
|
||||
for s in raw:
|
||||
if s.lat is None or s.lon is None:
|
||||
|
|
@ -424,16 +665,19 @@ async def _yandex_geocode_request(
|
|||
async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
||||
"""Yandex Geocoder с авто-fallback на typo-tolerant режим.
|
||||
|
||||
Tier 1: bounded ЕКБ (rspn=1) на оригинальный query.
|
||||
Tier 1: bounded ЕКБ (rspn=1) — быстрый путь для основного (ЕКБ) трафика.
|
||||
Tier 2: bounded ЕКБ на typo-variants (удвоение согласных).
|
||||
Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по ЕКБ bbox.
|
||||
Tier 3: без rspn — fuzzy по всей стране, фильтр результатов по bbox области
|
||||
(region 66) — ловит легитимные Нижний Тагил/Серов/etc, которые Tier 1/2
|
||||
(bounded строго ЕКБ) структурно вернуть не могут.
|
||||
"""
|
||||
prefixed_query = query if _has_oblast_marker(query.lower()) else f"Екатеринбург, {query}"
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
# Tier 1: strict bounded на оригинал
|
||||
# Tier 1: strict bounded на оригинал (ЕКБ fast path)
|
||||
members = await _yandex_geocode_request(
|
||||
client,
|
||||
api_key,
|
||||
f"Екатеринбург, {query}",
|
||||
prefixed_query,
|
||||
limit,
|
||||
bounded=True,
|
||||
)
|
||||
|
|
@ -441,12 +685,15 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
|
|||
if results:
|
||||
return results
|
||||
|
||||
# Tier 2: bounded на typo-варианты
|
||||
# Tier 2: bounded на typo-варианты (тот же ЕКБ fast path)
|
||||
for variant in _typo_variants(query, limit=4):
|
||||
variant_query = (
|
||||
variant if _has_oblast_marker(variant.lower()) else f"Екатеринбург, {variant}"
|
||||
)
|
||||
members = await _yandex_geocode_request(
|
||||
client,
|
||||
api_key,
|
||||
f"Екатеринбург, {variant}",
|
||||
variant_query,
|
||||
limit,
|
||||
bounded=True,
|
||||
)
|
||||
|
|
@ -454,21 +701,21 @@ async def _yandex_suggest(query: str, api_key: str, limit: int = 8) -> list[Geoc
|
|||
if results:
|
||||
return results
|
||||
|
||||
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox
|
||||
# Tier 3: без rspn — даём fuzzy сделать своё дело, фильтр по bbox области
|
||||
members = await _yandex_geocode_request(
|
||||
client,
|
||||
api_key,
|
||||
f"Екатеринбург, {query}",
|
||||
prefixed_query,
|
||||
limit,
|
||||
bounded=False,
|
||||
)
|
||||
results = _parse_yandex_members(members)
|
||||
in_ekb = [r for r in results if 60.40 <= r.lon <= 60.85 and 56.65 <= r.lat <= 56.95]
|
||||
return in_ekb
|
||||
in_oblast = [r for r in results if is_within_oblast66_bbox(r.lat, r.lon)]
|
||||
return in_oblast
|
||||
|
||||
|
||||
async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: int) -> list[dict]:
|
||||
"""Один Nominatim search с фильтром по ЕКБ bbox. Возвращает up to N items."""
|
||||
"""Один Nominatim search с фильтром по bbox области (region 66). Возвращает up to N items."""
|
||||
response = await client.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
params={
|
||||
|
|
@ -476,7 +723,7 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
|
|||
"format": "json",
|
||||
"limit": str(limit),
|
||||
"countrycodes": "ru",
|
||||
"viewbox": EKB_BBOX["viewbox"],
|
||||
"viewbox": OBLAST66_VIEWBOX["viewbox"],
|
||||
"bounded": "1",
|
||||
"addressdetails": "1",
|
||||
},
|
||||
|
|
@ -488,21 +735,29 @@ async def _nominatim_query_multi(client: httpx.AsyncClient, query: str, limit: i
|
|||
|
||||
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=1, min=1, max=4))
|
||||
async def _nominatim_suggest(query: str, limit: int = 8) -> list[GeocodeSuggestion]:
|
||||
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен)."""
|
||||
"""Nominatim в режиме suggest. С typo-fallback (для случаев когда Yandex недоступен).
|
||||
|
||||
Суффикс ", Екатеринбург" навязывается ТОЛЬКО если в запросе ещё нет города/области
|
||||
региона 66 — иначе не режем явные запросы по другим городам области.
|
||||
"""
|
||||
headers = {
|
||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||
"Accept": "application/json",
|
||||
"Accept-Language": "ru,en;q=0.8",
|
||||
}
|
||||
suffixed_query = query if _has_oblast_marker(query.lower()) else f"{query}, Екатеринбург"
|
||||
async with httpx.AsyncClient(timeout=8.0, headers=headers) as client:
|
||||
# Tier 1: оригинальный query
|
||||
data = await _nominatim_query_multi(client, f"{query}, Екатеринбург", limit)
|
||||
data = await _nominatim_query_multi(client, suffixed_query, limit)
|
||||
|
||||
# Tier 2: typo-варианты если оригинал пустой
|
||||
if not data:
|
||||
for variant in _typo_variants(query, limit=3):
|
||||
await asyncio.sleep(1.0) # Nominatim 1 req/sec
|
||||
data = await _nominatim_query_multi(client, f"{variant}, Екатеринбург", limit)
|
||||
variant_query = (
|
||||
variant if _has_oblast_marker(variant.lower()) else f"{variant}, Екатеринбург"
|
||||
)
|
||||
data = await _nominatim_query_multi(client, variant_query, limit)
|
||||
if data:
|
||||
logger.info("nominatim suggest typo-fixed: %s → %s", query, variant)
|
||||
break
|
||||
|
|
@ -832,7 +1087,8 @@ def _cadastral_reverse_sync(db: Session, lat: float, lon: float, radius_m: int =
|
|||
|
||||
|
||||
async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list[GeocodeSuggestion]:
|
||||
"""Автокомплит адресов в ЕКБ. Cadastral FDW → DaData → Yandex → Nominatim → [].
|
||||
"""Автокомплит адресов в Свердловской области (region 66; ЕКБ — основной трафик,
|
||||
остаётся быстрым fast-path). Cadastral FDW → DaData → Yandex → Nominatim → [].
|
||||
|
||||
db: если передан — cadastral lookup через gendesign_cad_buildings (первый tier).
|
||||
DaData /suggest (PR Q2) — token-only, 10k/день, заменяет Yandex который
|
||||
|
|
@ -842,8 +1098,12 @@ async def suggest(query: str, db: Session | None = None, limit: int = 8) -> list
|
|||
if not query or len(query.strip()) < 2:
|
||||
return []
|
||||
|
||||
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов
|
||||
if db is not None:
|
||||
# Tier 1: cadastral FDW (если db доступна) — самый быстрый, без внешних запросов.
|
||||
# EKB-only (см. `geocode()` #11) — пропускаем, если query явно называет
|
||||
# другой город области, иначе не-ЕКБ автокомплит может всплыть ЕКБ-домом
|
||||
# с совпадающими улица+дом. Внешние тиры (2/3/4 ниже) не гейтим — они уже
|
||||
# oblast-aware.
|
||||
if db is not None and not _names_non_ekb_city(query):
|
||||
# 1a. Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
||||
# Решает кейс «Серова 27» где raw-ILIKE по readable_address давал 0 hits.
|
||||
parsed = _parse_street_house(query.strip())
|
||||
|
|
@ -910,8 +1170,14 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
|||
# 2. Локальные источники по street+house (без внешнего API).
|
||||
parsed = _parse_street_house(address.strip())
|
||||
|
||||
# #11: EKB-only локальные тиры (geoportal/cadastral) не должны отвечать на
|
||||
# адрес другого города области — иначе улица+дом, коллизящие с ЕКБ-домом
|
||||
# (напр. "проспект Ленина 1" есть и в Нижнем Тагиле, и в ЕКБ), снапаются в
|
||||
# ЕКБ. Пропускаем сразу к oblast-aware внешним провайдерам ниже (3/4).
|
||||
use_local_ekb = not _names_non_ekb_city(address)
|
||||
|
||||
# 2a. Геопортал ЕКБ — ПЕРВЫЙ локальный tier (полнее cad_buildings ~на 70%).
|
||||
if parsed is not None:
|
||||
if use_local_ekb and parsed is not None:
|
||||
street, house = parsed
|
||||
try:
|
||||
hit = await asyncio.to_thread(_geoportal_house_match, db, street, house)
|
||||
|
|
@ -939,7 +1205,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
|||
# Anchored house-match: парсим street+house → точный матч по дом-маркеру.
|
||||
# raw-ILIKE по полному readable_address давал 0 hits для «Серова 27» / DaData-форм
|
||||
# (литеральная подстрока не совпадает).
|
||||
if parsed is not None:
|
||||
if use_local_ekb and parsed is not None:
|
||||
street, house = parsed
|
||||
hit = await asyncio.to_thread(_cadastral_house_match, db, street, house)
|
||||
if hit is not None:
|
||||
|
|
@ -960,29 +1226,32 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
|||
return result
|
||||
|
||||
# 2d. Fallback: legacy raw-ILIKE forward search (для нераспарсенных форм)
|
||||
cad_suggestions = await asyncio.to_thread(_cadastral_forward_sync, db, address.strip(), limit=1)
|
||||
if cad_suggestions:
|
||||
s = cad_suggestions[0]
|
||||
result = GeocodeResult(
|
||||
lat=s.lat,
|
||||
lon=s.lon,
|
||||
full_address=s.full_address,
|
||||
provider="nominatim", # treat as "local" — same confidence as nominatim
|
||||
confidence="exact",
|
||||
if use_local_ekb:
|
||||
cad_suggestions = await asyncio.to_thread(
|
||||
_cadastral_forward_sync, db, address.strip(), limit=1
|
||||
)
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info("geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
|
||||
return result
|
||||
if cad_suggestions:
|
||||
s = cad_suggestions[0]
|
||||
result = GeocodeResult(
|
||||
lat=s.lat,
|
||||
lon=s.lon,
|
||||
full_address=s.full_address,
|
||||
provider="nominatim", # treat as "local" — same confidence as nominatim
|
||||
confidence="exact",
|
||||
)
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info(
|
||||
"geocode cadastral fdw: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon
|
||||
)
|
||||
return result
|
||||
|
||||
# 3. Yandex (если есть key) с typo-fallback
|
||||
if settings.yandex_geocoder_api_key:
|
||||
try:
|
||||
result = await _yandex_lookup(address, settings.yandex_geocoder_api_key)
|
||||
# Если результат вне ЕКБ — пробуем typo-варианты
|
||||
in_ekb = (
|
||||
result is not None and 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95
|
||||
)
|
||||
if result is not None and in_ekb:
|
||||
# Если результат вне области (region 66) — пробуем typo-варианты
|
||||
in_oblast = result is not None and is_within_oblast66_bbox(result.lat, result.lon)
|
||||
if result is not None and in_oblast:
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info("geocode yandex: %s → (%.5f, %.5f)", addr_norm, result.lat, result.lon)
|
||||
return result
|
||||
|
|
@ -994,7 +1263,7 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
|||
continue
|
||||
if result is None:
|
||||
continue
|
||||
if 60.40 <= result.lon <= 60.85 and 56.65 <= result.lat <= 56.95:
|
||||
if is_within_oblast66_bbox(result.lat, result.lon):
|
||||
await asyncio.to_thread(_cache_put, db, addr_norm, result)
|
||||
logger.info(
|
||||
"geocode yandex typo-fixed: %s → %s → (%.5f, %.5f)",
|
||||
|
|
@ -1128,11 +1397,12 @@ async def _yandex_reverse(lat: float, lon: float, api_key: str) -> ReverseGeocod
|
|||
for p in parts
|
||||
if p not in {"Россия", "Свердловская область"} and not p.startswith("городской округ")
|
||||
]
|
||||
# Найдём locality (Екатеринбург / Берёзовский / …) и переставим в конец
|
||||
# Найдём locality (Екатеринбург / Нижний Тагил / Берёзовский / …) и переставим
|
||||
# в конец — любой город региона 66, не только ЕКБ (case-insensitive match).
|
||||
locality = None
|
||||
rest: list[str] = []
|
||||
for p in filtered:
|
||||
if locality is None and p in {"Екатеринбург"}:
|
||||
if locality is None and p.lower() in SVERDLOVSK_OBLAST_CITIES:
|
||||
locality = p
|
||||
else:
|
||||
rest.append(p)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,24 @@ logger = logging.getLogger(__name__)
|
|||
# small enough to bound storage & decode cost.
|
||||
_MAX_DIMENSION = 2400
|
||||
|
||||
# Hard cap on declared pixel count (width*height) enforced BEFORE any decode.
|
||||
# A highly-compressible ≤10 MB upload can declare ~89-178 MP; once img.load()
|
||||
# inflates it to raw RGB (3 B/px) that is a 260-530 MB buffer (+ transpose/convert
|
||||
# copies) → OOM-kills the 768 MiB backend (#2214), amplified ×12 photo slots.
|
||||
# 40 MP (~8000×5000) sits far above any real listing photo yet well below the
|
||||
# ~89 MP default Pillow bomb threshold. img.size is read from the header on
|
||||
# open() without decoding pixels, so this check is cheap and pre-decode.
|
||||
_MAX_PIXELS = 40_000_000
|
||||
|
||||
# Backstop byte cap (the upload endpoint already enforces 10 MB → 413, but this
|
||||
# service is standalone; keep it self-defending against oversized inputs).
|
||||
_MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
# Belt-and-braces: lower Pillow's own DecompressionBombError threshold from its
|
||||
# ~89 MP default to our cap, so oversized declared dimensions raise even on any
|
||||
# decode path that bypasses the explicit size guard below.
|
||||
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS
|
||||
|
||||
# JPEG quality. 85 is industry standard for photos — visually lossless,
|
||||
# ~70% size reduction vs original phone JPEGs.
|
||||
_JPEG_QUALITY = 85
|
||||
|
|
@ -46,10 +64,28 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
|
|||
(sanitized_bytes, "image/jpeg") — both ready for storage/serving.
|
||||
|
||||
Raises:
|
||||
ImageSanitizationError: bytes are not a recognized image format.
|
||||
ImageSanitizationError: bytes are not a recognized image format, or the
|
||||
declared dimensions / byte size exceed the pixel-flood safety caps.
|
||||
"""
|
||||
if len(content) > _MAX_BYTES:
|
||||
raise ImageSanitizationError(f"image too large: {len(content)} bytes exceeds cap")
|
||||
try:
|
||||
with Image.open(io.BytesIO(content)) as img:
|
||||
# #2214: reject pixel-flood bombs BEFORE img.load(). img.size comes
|
||||
# from the header (no pixel decode); loading an oversized declared
|
||||
# image would inflate a small file into a hundreds-of-MB RGB buffer.
|
||||
width, height = img.size
|
||||
if width * height > _MAX_PIXELS:
|
||||
logger.warning(
|
||||
"image rejected: %dx%d (%d px) exceeds %d px cap",
|
||||
width,
|
||||
height,
|
||||
width * height,
|
||||
_MAX_PIXELS,
|
||||
)
|
||||
raise ImageSanitizationError(
|
||||
f"image too large: {width}x{height} exceeds {_MAX_PIXELS} px cap"
|
||||
)
|
||||
img.load() # force full decode; surfaces UnidentifiedImageError early
|
||||
# Bake EXIF Orientation into pixels BEFORE stripping EXIF, else phone
|
||||
# portrait photos (Orientation=6/8) end up rotated 90/270° once the tag
|
||||
|
|
@ -65,10 +101,16 @@ def sanitize_image(content: bytes) -> tuple[bytes, str]:
|
|||
# save() does NOT carry over .info / EXIF unless we pass exif=...; we don't.
|
||||
img.save(buf, format="JPEG", quality=_JPEG_QUALITY, optimize=True)
|
||||
return buf.getvalue(), SANITIZED_CONTENT_TYPE
|
||||
except ImageSanitizationError:
|
||||
raise # already a handled, user-facing rejection (byte/pixel cap)
|
||||
except UnidentifiedImageError as e:
|
||||
raise ImageSanitizationError("not a recognized image format") from e
|
||||
except Image.DecompressionBombError as e:
|
||||
# Any decode path that outran the size guard above (e.g. >2× cap) lands here.
|
||||
logger.warning("decompression bomb rejected: %s", e)
|
||||
raise ImageSanitizationError("image dimensions exceed safe limits") from e
|
||||
except Exception as e:
|
||||
# PIL can raise broad errors (DecompressionBombError, OSError on truncated, ...);
|
||||
# normalize to one user-facing error type.
|
||||
# PIL can raise broad errors (OSError on truncated, DecompressionBombWarning
|
||||
# promoted to error, ...); normalize to one user-facing error type.
|
||||
logger.warning("image sanitization failed: %s", e)
|
||||
raise ImageSanitizationError(f"image decode failed: {e}") from e
|
||||
|
|
|
|||
|
|
@ -53,11 +53,35 @@ CATEGORY_WEIGHTS: dict[str, float] = {
|
|||
DEFAULT_RADIUS_M = 1200
|
||||
DEFAULT_TOP_N = 7
|
||||
|
||||
# Теоретический максимум суммы весов top-7 POI при идеальном расположении (d=0):
|
||||
# w_i = cat_weight_i / (0 + 100) → max_sum = Σ(top7 cat_weights) / 100.
|
||||
# Теоретический максимум суммы весов top-7 POI.
|
||||
#
|
||||
# ИСТОРИЯ (audit R2 #7a): раньше максимум считался при d=0 (все top-7 категорий прямо у
|
||||
# порога) — недостижимо на практике. Аудит показал, что даже сильная центральная локация в
|
||||
# Екб (транспорт/садик/супермаркет рядом, школа/метро/ТЦ уже заметно дальше — типичный
|
||||
# профиль квартала, реалистичные дистанции по категориям ~80-700м) даёт raw_sum лишь
|
||||
# ~0.08-0.095, т.е. score ~25-30/100 при старой нормировке (/100 при d=0) → coef ~0.975-0.98.
|
||||
# Сильная локация систематически читалась как "немного снижает стоимость" — знаменатель
|
||||
# сжимал реалистичные scores в нижнюю треть шкалы 0-100.
|
||||
#
|
||||
# ФИКС: нормируем не на d=0, а на РЕФЕРЕНСНОЕ расстояние _REF_DISTANCE_M — top-7 весов,
|
||||
# как если бы каждая категория была на этом расстоянии. REF=100м ровно удваивает
|
||||
# знаменатель (distance_m + 100): 100+100=200 вместо 0+100=100 → теоретический максимум
|
||||
# вдвое ниже прежнего → реалистичные scores вдвое выше: сильный центр ~25-30 → ~50-60
|
||||
# (coef ~1.00-1.01), с запасом выше 1.0 для исключительно плотных локаций (все top-7
|
||||
# категорий <150м). Слабые/разреженные локации остаются далеко ниже 1.0; пустая
|
||||
# osm_poi_ekb_local (рефреш не запускался) по-прежнему даёт coef=1.0 через отдельный
|
||||
# graceful-fallback путь в compute_location_coef, эту нормировку не трогающий.
|
||||
#
|
||||
# ВНИМАНИЕ: REF_DIST — калибровочная константа для MVP-эвристики (см. _score_to_coef),
|
||||
# НЕ откалибрована на реальных ценовых дельтах — только на распределении реалистичных POI-
|
||||
# профилей, чтобы диапазон [0.95, 1.05] соответствовал интуиции "сильный центр ≈ 1.0".
|
||||
#
|
||||
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5 (тот же набор, что у Ptica).
|
||||
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
|
||||
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
|
||||
_REF_DISTANCE_M: float = 100.0
|
||||
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / (
|
||||
_REF_DISTANCE_M + 100.0
|
||||
) # ≈ 0.1575 (было 0.315 при d=0)
|
||||
|
||||
# coef диапазон ±5% вокруг 1.0 — heuristic v1, НЕ откалибровано на реальных ценовых дельтах
|
||||
# (в отличие от Ptica, где poi_weighted_score — ранжирующая метрика, не ценовой множитель).
|
||||
|
|
|
|||
|
|
@ -16,14 +16,25 @@ from sqlalchemy import text
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.services.matching.normalize import (
|
||||
EKB_CITY_TOKEN,
|
||||
address_fingerprint,
|
||||
has_city_token,
|
||||
has_house_number,
|
||||
house_number_token,
|
||||
normalize_address,
|
||||
resolve_city_token,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Tier-2b oblast guard radius (metres). A bare street+number normalized_address carries
|
||||
# NO city token yet is globally unique in house_address_aliases, so 'улица ленина 100'
|
||||
# physically exists in ЕКБ, Н.Тагил, Каменск… When the incoming card HAS coords we accept
|
||||
# the alias only if its house geom is within this radius. Cities in обл.66 are ≥ ~9-15 km
|
||||
# apart, so 3 km cleanly separates them while absorbing the intra-city geocoder drift that
|
||||
# Tier-2b exists to bridge (same building, different provider coords).
|
||||
_TIER2B_GUARD_M = 3000
|
||||
|
||||
|
||||
def match_or_create_house(
|
||||
db: Session,
|
||||
|
|
@ -180,30 +191,95 @@ def match_or_create_house(
|
|||
# Without 2b, two scrapers for the same house with slightly different lat/lon (beyond
|
||||
# the 4-decimal rounding tolerance) would produce distinct fingerprints, miss Tier 2a,
|
||||
# and each potentially create a duplicate house row.
|
||||
row = (
|
||||
db.execute(
|
||||
text("SELECT house_id FROM house_address_aliases " "WHERE fingerprint = :fp LIMIT 1"),
|
||||
{"fp": fp},
|
||||
#
|
||||
# Tier-2a/2b oblast city guard (#2500 follow-up, closes the Tier-2a mis-bucket that the
|
||||
# #2500 Tier-2b geo-guard could not reach): a coord-less card whose address explicitly
|
||||
# resolves a NON-ЕКБ обл.66 city must NOT match a globally-unique alias (fingerprint OR
|
||||
# normalized_address) that almost certainly belongs to ЕКБ/another city — that would
|
||||
# corrupt ЕКБ house data. When it fires we skip both alias lookups and fall through to a
|
||||
# New house (Tier 3 is coord-gated, so coord-less cards skip it too).
|
||||
#
|
||||
# This PREVENTS ЕКБ CORRUPTION. Full oblast-internal dedup (deduping two listings of the
|
||||
# SAME oblast building) still needs city-keyed aliases — a separate follow-up, out of
|
||||
# scope, only relevant once the oblast sweep is enabled.
|
||||
#
|
||||
# EKB happy-path is byte-identical: the guard fires ONLY when the address names a non-ЕКБ
|
||||
# city AND no coords disambiguate. ЕКБ cards (resolved city = екатеринбург) and the
|
||||
# dominant bare/city-less Avito coord-less cards (resolved city None) run Tier-2a/2b
|
||||
# exactly as before. NB: a BARE oblast card (no city token in the address — today's Avito
|
||||
# SERP format) carries no signal here and is deliberately left on the unchanged path; that
|
||||
# residual needs sweep-context and is out of this fix's scope.
|
||||
_resolved_city = resolve_city_token(norm_addr) if (lat is None and lon is None) else None
|
||||
_skip_oblast_alias = _resolved_city is not None and _resolved_city != EKB_CITY_TOKEN
|
||||
if _skip_oblast_alias:
|
||||
logger.info(
|
||||
"house tier2a/2b skip: coord-less non-ЕКБ city %r na=%r src=%s",
|
||||
_resolved_city,
|
||||
norm_addr,
|
||||
ext_source,
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if row is None and norm_addr and has_num:
|
||||
# Tier 2b: same normalized address, possibly different coords fingerprint.
|
||||
# Gated on has_num (P1): never match a bare-street normalized_address — any
|
||||
# numberless listing would otherwise collapse into whichever house first
|
||||
# registered that street.
|
||||
|
||||
row = None
|
||||
if not _skip_oblast_alias:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT house_id FROM house_address_aliases "
|
||||
"WHERE normalized_address = :na LIMIT 1"
|
||||
),
|
||||
{"na": norm_addr},
|
||||
text("SELECT house_id FROM house_address_aliases WHERE fingerprint = :fp LIMIT 1"),
|
||||
{"fp": fp},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
if row is None and norm_addr and has_num and not _skip_oblast_alias:
|
||||
# Tier 2b: same normalized address, possibly different coords fingerprint.
|
||||
# Gated on has_num (P1): never match a bare-street normalized_address — any
|
||||
# numberless listing would otherwise collapse into whichever house first
|
||||
# registered that street.
|
||||
#
|
||||
# Oblast guard (bug #2, обл.66 per-city rollout): normalized_address has NO city
|
||||
# token, so a bare street+number is globally unique in the alias table yet exists
|
||||
# physically in several cities. Accept the match only when the incoming listing is
|
||||
# confirmably the same place:
|
||||
# • coords present → aliased house geom within _TIER2B_GUARD_M metres;
|
||||
# • coords absent → norm_addr carries a city token (self-disambiguating —
|
||||
# 'екатеринбург улица ленина 5' can't collide with a Н.Тагил address);
|
||||
# • neither → skip. A bare common-street with no geo signal is too
|
||||
# ambiguous — matching would mis-bucket an oblast card into a same-named ЕКБ
|
||||
# house. Conservative: fall through to a New house rather than mis-match.
|
||||
if lat is not None and lon is not None:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT a.house_id FROM house_address_aliases a "
|
||||
"JOIN houses h ON h.id = a.house_id "
|
||||
"WHERE a.normalized_address = :na "
|
||||
" AND h.geom IS NOT NULL "
|
||||
" AND ST_DWithin(h.geom::geography, "
|
||||
" ST_MakePoint(:lon, :lat)::geography, :thr) "
|
||||
"LIMIT 1"
|
||||
),
|
||||
{"na": norm_addr, "lon": lon, "lat": lat, "thr": _TIER2B_GUARD_M},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
elif has_city_token(norm_addr):
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT house_id FROM house_address_aliases "
|
||||
"WHERE normalized_address = :na LIMIT 1"
|
||||
),
|
||||
{"na": norm_addr},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"house tier2b skip: bare common-street, no coords/city na=%r src=%s",
|
||||
norm_addr,
|
||||
ext_source,
|
||||
)
|
||||
if row:
|
||||
house_id = int(row["house_id"])
|
||||
_upsert_house_source(
|
||||
|
|
@ -520,6 +596,12 @@ def _insert_alias(
|
|||
row with the latest fingerprint, which is then found by Tier 2a on the next scrape.
|
||||
house_id is not updated on conflict: the first writer wins canonical ownership.
|
||||
|
||||
Oblast guard (bug #2): the fingerprint/source are refreshed ONLY when the conflicting
|
||||
row belongs to the SAME house. A DIFFERENT house colliding on a bare common-street
|
||||
normalized_address (an oblast card that fell through Tier-2b's guard to a New INSERT)
|
||||
must NOT rewrite the owner's fingerprint — doing so would downgrade a safe coord-bearing
|
||||
alias to a coord-less one and let the next no-coord card mis-bucket via Tier 2a.
|
||||
|
||||
P1: a bare-street normalized_address (no house number) is NOT registered as an
|
||||
alias — it is too ambiguous to serve as a building key. Any later numberless
|
||||
listing would otherwise hit that alias via Tier 2b and be mass-dumped into the
|
||||
|
|
@ -533,8 +615,12 @@ def _insert_alias(
|
|||
INSERT INTO house_address_aliases (house_id, normalized_address, fingerprint, source)
|
||||
VALUES (CAST(:hid AS bigint), :na, :fp, :src)
|
||||
ON CONFLICT (normalized_address) DO UPDATE SET
|
||||
fingerprint = EXCLUDED.fingerprint,
|
||||
source = EXCLUDED.source
|
||||
fingerprint = CASE
|
||||
WHEN house_address_aliases.house_id = EXCLUDED.house_id
|
||||
THEN EXCLUDED.fingerprint ELSE house_address_aliases.fingerprint END,
|
||||
source = CASE
|
||||
WHEN house_address_aliases.house_id = EXCLUDED.house_id
|
||||
THEN EXCLUDED.source ELSE house_address_aliases.source END
|
||||
"""),
|
||||
{
|
||||
"hid": house_id,
|
||||
|
|
|
|||
|
|
@ -112,6 +112,62 @@ def house_number_token(normalized: str | None) -> str | None:
|
|||
return m.group(1) if m else None
|
||||
|
||||
|
||||
# Екатеринбург — the home city; every other _CITY_TOKENS entry is a non-ЕКБ обл.66 city.
|
||||
# The Tier-2a oblast guard treats a resolved non-ЕКБ city specially (see resolve_city_token).
|
||||
EKB_CITY_TOKEN = "екатеринбург"
|
||||
|
||||
# Cities covered by the обл.66 sweep rollout (ЕКБ + oblast per-city schedules).
|
||||
# Normalized form: lowercase, hyphens collapsed to spaces (mirrors normalize_address,
|
||||
# e.g. 'каменск-уральский' -> 'каменск уральский'). Mirrors CITY_ANCHORS in
|
||||
# scraper_kit.orchestration.pipeline — extend together as the sweep adds cities.
|
||||
# EKB_CITY_TOKEN must be first (leftmost-alternation determinism / documentation).
|
||||
_CITY_TOKENS: tuple[str, ...] = (
|
||||
EKB_CITY_TOKEN,
|
||||
"нижний тагил",
|
||||
"каменск уральский",
|
||||
"первоуральск",
|
||||
"верхняя пышма",
|
||||
"серов",
|
||||
)
|
||||
# Token-bounded (not substring) so a street named after a city does NOT false-positive:
|
||||
# 'улица серова 5' has token 'серова' (trailing 'а'), never bare 'серов'.
|
||||
_CITY_TOKEN_RE = re.compile(
|
||||
r"(?:^|\s)(?:" + "|".join(re.escape(c) for c in _CITY_TOKENS) + r")(?:\s|$)"
|
||||
)
|
||||
|
||||
|
||||
def resolve_city_token(normalized: str | None) -> str | None:
|
||||
"""Return the обл.66 city token present in the normalized address, else None.
|
||||
|
||||
Token-bounded (see _CITY_TOKEN_RE) so a street named after a city never matches
|
||||
('улица серова 5' -> None, not 'серов'). When the address names ЕКБ, returns
|
||||
EKB_CITY_TOKEN; a non-ЕКБ oblast city returns its own token. Used by the Tier-2a
|
||||
oblast guard in match_or_create_house: a coord-less card that resolves a NON-ЕКБ city
|
||||
must not fingerprint/normalized_address-match a globally-unique alias that (almost
|
||||
certainly) belongs to ЕКБ/another city — that would corrupt ЕКБ house data.
|
||||
"""
|
||||
if not normalized:
|
||||
return None
|
||||
m = _CITY_TOKEN_RE.search(normalized)
|
||||
if m is None:
|
||||
return None
|
||||
# group(0) carries the (?:^|\s)...(?:\s|$) boundary whitespace — strip it to the token.
|
||||
return m.group(0).strip()
|
||||
|
||||
|
||||
def has_city_token(normalized: str | None) -> bool:
|
||||
"""True if the normalized address carries a known обл.66 city token.
|
||||
|
||||
A city token makes the normalized_address city-scoped — two addresses in different
|
||||
cities can then never collapse to the same key, so the alias is a safe cross-source
|
||||
building key. Used by the Tier-2b oblast guard in match_or_create_house: a bare
|
||||
common-street address with NO city token AND no coords is too ambiguous to match a
|
||||
globally-unique normalized_address alias (it would mis-bucket an oblast card into a
|
||||
same-named ЕКБ house — bug #2 oblast rollout).
|
||||
"""
|
||||
return resolve_city_token(normalized) is not None
|
||||
|
||||
|
||||
def address_fingerprint(address: str | None, lat: float | None, lon: float | None) -> str:
|
||||
"""SHA-256 fingerprint of normalized address + rounded coordinates (4 dp ≈ 11 m).
|
||||
|
||||
|
|
|
|||
|
|
@ -193,6 +193,16 @@ async def _job_deals_freshness_monitor(
|
|||
await loop.run_in_executor(None, check_deals_freshness, db, run_id, params)
|
||||
|
||||
|
||||
# ── sber_freshness_monitor — sync DB-only freshness check в executor ──────────
|
||||
async def _job_sber_freshness_monitor(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
) -> None:
|
||||
from app.tasks.sber_freshness_monitor import check_sber_freshness
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, check_sber_freshness, db, run_id, params)
|
||||
|
||||
|
||||
# ── newbuilding_enrich — async, owns lifecycle ───────────────────────────────
|
||||
async def _job_newbuilding_enrich(
|
||||
db: Session, run_id: int, params: dict[str, Any], ctx: SchedulerContext
|
||||
|
|
@ -394,6 +404,7 @@ def build_product_handlers(ctx: SchedulerContext) -> dict[str, Handler]:
|
|||
"sber_index_pull": Handler(_job_sber_index_pull, "sber_index_pull"),
|
||||
"rosreestr_quarter_poll": Handler(_job_rosreestr_quarter_poll, "rosreestr_quarter_poll"),
|
||||
"deals_freshness_monitor": Handler(_job_deals_freshness_monitor, "deals_freshness_monitor"),
|
||||
"sber_freshness_monitor": Handler(_job_sber_freshness_monitor, "sber_freshness_monitor"),
|
||||
"newbuilding_enrich": Handler(_job_newbuilding_enrich, "newbuilding_enrich"),
|
||||
"yandex_newbuilding_sweep": Handler(
|
||||
_job_yandex_newbuilding_sweep, "yandex_newbuilding_sweep"
|
||||
|
|
|
|||
|
|
@ -5,31 +5,62 @@
|
|||
открытых данных Росреестра.
|
||||
- ALERT: залогировать actionable-сообщение, если квартал доступен.
|
||||
- НЕ скачивает много-гигабайтный ZIP и НЕ вызывает shell-loaders — это ручной
|
||||
ops-шаг. После алерта оператор запускает:
|
||||
ops-шаг. После алерта оператор запускает (оба скрипта живут вне этого
|
||||
репозитория — при смене базового пути их тоже нужно поправить вручную):
|
||||
data/sql/02_load_all_quarters.sh
|
||||
tradein-mvp/deploy/import-rosreestr.sh
|
||||
|
||||
Архив открытых данных Росреестра (сделки ДКП/ДДУ) публикуется на портале:
|
||||
https://rosreestr.gov.ru/opendata/
|
||||
ИСТОРИЯ (важно для будущих правок): раньше архив открытых данных Росреестра
|
||||
(сделки ДКП/ДДУ) публиковался на https://rosreestr.gov.ru/opendata/ с индексом
|
||||
/opendata/f.json. Этот портал был переделан на Bitrix — прямые ссылки на ZIP
|
||||
теперь отвечают HTTP 200 с Content-Type: text/html (soft-404 "заглушка"), а
|
||||
/opendata/f.json больше не отдаёт JSON. Старая версия поллера трактовала любой
|
||||
HTTP 200 как "квартал доступен" и из-за soft-404 стала ложно репортить КАЖДЫЙ
|
||||
квартал как доступный — это и есть баг #issue, который чинит этот модуль.
|
||||
|
||||
Файлы именуются по шаблону:
|
||||
dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
|
||||
АКТУАЛЬНОЕ (2026-07) расположение датасетов — открытый Apache autoindex без
|
||||
авторизации:
|
||||
https://rosreestr.gov.ru/data-sets/
|
||||
Структура:
|
||||
- Папки кварталов названы в кодировке Windows-1251 (percent-encoded href —
|
||||
именно cp1251, НЕ utf-8), например "1 квартал 2026г." →
|
||||
href="1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./". Плюс папка
|
||||
"Архив до 2023г. включительно/" для старых периодов.
|
||||
- Внутри папки квартала лежит dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
|
||||
(тоже cp1251-кодированный href, например
|
||||
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"), рядом —
|
||||
dataset_КАДАСТРСТОИМОСТЬ_* файлы (не наши).
|
||||
|
||||
Индексный JSON живёт по адресу:
|
||||
https://rosreestr.gov.ru/opendata/f.json (список всех датасетов)
|
||||
Вместо того чтобы вручную высчитывать percent-encoding (хрупко: имена папок
|
||||
расставляют руками, возможны отличия в пробелах/пунктуации между кварталами) —
|
||||
поллер запрашивает autoindex HTML и парсит href'ы, декодируя каждый
|
||||
percent-encoded href как cp1251 (urllib.parse.unquote(href, encoding="cp1251")),
|
||||
затем ищет папку/файл по совпадению с ожидаемым (год, квартал) в декодированном
|
||||
имени. См. check_new_quarter_available().
|
||||
|
||||
Проверяем HEAD-запросом (без скачивания) прямой ссылки на следующий квартал.
|
||||
Формат ссылки:
|
||||
https://rosreestr.gov.ru/opendata/dataset_СДЕЛКИ_r-r_01-92_y_{YYYY}_q_{N}.csv.zip
|
||||
КЛЮЧЕВАЯ проверка доступности (защита от soft-404, см. "ИСТОРИЯ" выше): найденный
|
||||
файл датасета считается ДОСТУПНЫМ только если HEAD-ответ имеет
|
||||
Content-Type: application/zip И Content-Length больше ~100 KB. Голый HTTP 200
|
||||
недостаточен.
|
||||
|
||||
При сетевой ошибке / HTTP 5xx — логируем warning, возвращаем available=False.
|
||||
Статус 404 → квартал не опубликован → available=False (штатный случай до начала августа).
|
||||
rosreestr.gov.ru отдаёт сертификат от российского TLS root CA, которому нет
|
||||
доверия в стандартном trust store — соединение делаем с verify=False, как
|
||||
sber_index.py для sberindex.ru (см. #922, тот же паттерн: публичные
|
||||
неавторизованные открытые данные, TLS verify отключаем осознанно). Сервер также
|
||||
отвечает HTTP 403 без браузерного User-Agent — шлём Chrome UA (тот же паттерн,
|
||||
что DEFAULT_UA в zhkh_flats_loader.py).
|
||||
|
||||
При сетевой ошибке / HTTP 5xx / таймауте — логируем warning, возвращаем
|
||||
available=False. Отсутствие папки/файла квартала → available=False (штатный
|
||||
случай до публикации квартала, до начала следующего месяца после конца квартала).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote, unquote, urljoin
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
|
@ -37,16 +68,24 @@ from sqlalchemy.orm import Session
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Базовый URL открытых данных Росреестра (прямая ссылка на ZIP-файл квартала).
|
||||
# Шаблон подтверждён по документации портала opendata.rosreestr.gov.ru.
|
||||
_ROSREESTR_DATASET_URL_TEMPLATE = (
|
||||
"https://rosreestr.gov.ru/opendata/"
|
||||
"dataset_%D0%A1%D0%94%D0%95%D0%9B%D0%9A%D0%98_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
|
||||
# Открытый Apache autoindex открытых данных Росреестра (см. докстринг модуля).
|
||||
_DATA_SETS_BASE_URL = "https://rosreestr.gov.ru/data-sets/"
|
||||
|
||||
# Браузерный UA — без него rosreestr.gov.ru отвечает HTTP 403.
|
||||
# Тот же паттерн, что DEFAULT_UA в app/services/zhkh_flats_loader.py.
|
||||
_BROWSER_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
# Таймаут HEAD-запроса. Росреестр может быть медленным — 15s достаточно.
|
||||
# Порог отличия реального ZIP-архива от soft-404 заглушки/пустого файла.
|
||||
_MIN_DATASET_SIZE_BYTES = 100_000 # 100 KB
|
||||
|
||||
# Таймаут запросов. Росреестр может быть медленным — 15s достаточно.
|
||||
_HTTP_TIMEOUT = 15.0
|
||||
|
||||
_HREF_RE = re.compile(r'href="([^"]+)"', re.IGNORECASE)
|
||||
|
||||
|
||||
def _next_quarter(year: int, quarter: int) -> tuple[int, int]:
|
||||
"""Вернуть (year, quarter) для следующего квартала.
|
||||
|
|
@ -117,11 +156,68 @@ def latest_loaded_quarter(db: Session) -> tuple[int, int] | None:
|
|||
|
||||
|
||||
def rosreestr_dataset_url(year: int, quarter: int) -> str:
|
||||
"""Сформировать URL датасета Росреестра для заданного квартала.
|
||||
"""Сформировать best-effort прямую ссылку на ZIP датасета (для alert-сообщения оператору).
|
||||
|
||||
Возвращает прямую ссылку на ZIP-архив (percent-encoded шаблон).
|
||||
ВНИМАНИЕ: это ДЕТЕРМИНИРОВАННАЯ догадка по известному шаблону именования —
|
||||
папка "{quarter} квартал {year}г.", файл
|
||||
"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip" — закодированная как
|
||||
cp1251 percent-encoding (подтверждено вручную против реального URL портала).
|
||||
Она НЕ используется для самой проверки доступности: check_new_quarter_available()
|
||||
парсит реальный autoindex и не полагается на угаданный URL, потому что
|
||||
Росреестр расставляет имена папок вручную и они могут отличаться в мелочах
|
||||
(лишний пробел, пунктуация) от квартала к кварталу.
|
||||
|
||||
Если угаданная ссылка не откроется — ориентир для оператора: _DATA_SETS_BASE_URL
|
||||
(https://rosreestr.gov.ru/data-sets/), там нужно найти папку глазами.
|
||||
"""
|
||||
return _ROSREESTR_DATASET_URL_TEMPLATE.format(year=year, quarter=quarter)
|
||||
folder_name = f"{quarter} квартал {year}г."
|
||||
file_name = f"dataset_СДЕЛКИ_r-r_01-92_y_{year}_q_{quarter}.csv.zip"
|
||||
folder_enc = quote(folder_name.encode("cp1251"), safe="")
|
||||
file_enc = quote(file_name.encode("cp1251"), safe="")
|
||||
return f"{_DATA_SETS_BASE_URL}{folder_enc}/{file_enc}"
|
||||
|
||||
|
||||
def _decode_cp1251_href(href: str) -> str:
|
||||
"""Декодировать percent-encoded href как cp1251 (портал кодирует кириллицу так, НЕ utf-8)."""
|
||||
try:
|
||||
return unquote(href, encoding="cp1251", errors="strict")
|
||||
except (UnicodeDecodeError, LookupError):
|
||||
# href без кириллицы (например "../") или неожиданная кодировка — fallback.
|
||||
return unquote(href)
|
||||
|
||||
|
||||
def _normalize_ws(s: str) -> str:
|
||||
"""Схлопнуть повторяющиеся пробелы — Росреестр расставляет имена папок вручную."""
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def _extract_hrefs(html: str) -> list[str]:
|
||||
"""Достать все href="..." из HTML autoindex-страницы (простой regex, не наш HTML)."""
|
||||
return _HREF_RE.findall(html)
|
||||
|
||||
|
||||
def _find_quarter_folder_href(html: str, year: int, quarter: int) -> str | None:
|
||||
"""Найти (не декодированный) href папки квартала (year, quarter) в HTML /data-sets/.
|
||||
|
||||
Сравнение — по декодированному (cp1251) и нормализованному по пробелам имени
|
||||
папки, ожидаемый паттерн "{quarter} квартал {year}г.".
|
||||
"""
|
||||
expected_name = f"{quarter} квартал {year}г."
|
||||
for href in _extract_hrefs(html):
|
||||
decoded = _normalize_ws(_decode_cp1251_href(href).rstrip("/"))
|
||||
if decoded == expected_name:
|
||||
return href
|
||||
return None
|
||||
|
||||
|
||||
def _find_dataset_file_href(html: str, year: int, quarter: int) -> str | None:
|
||||
"""Найти href файла dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip в HTML папки квартала."""
|
||||
expected_suffix = f"_y_{year}_q_{quarter}.csv.zip"
|
||||
for href in _extract_hrefs(html):
|
||||
decoded = _decode_cp1251_href(href)
|
||||
if decoded.startswith("dataset_СДЕЛКИ") and decoded.endswith(expected_suffix):
|
||||
return href
|
||||
return None
|
||||
|
||||
|
||||
async def check_new_quarter_available(
|
||||
|
|
@ -129,48 +225,109 @@ async def check_new_quarter_available(
|
|||
year: int,
|
||||
quarter: int,
|
||||
) -> bool:
|
||||
"""Проверить, опубликован ли датасет Росреестра для (year, quarter).
|
||||
"""Проверить, опубликован ли датасет СДЕЛКИ Росреестра для (year, quarter).
|
||||
|
||||
HEAD-запрос к прямой ссылке на ZIP — без скачивания файла.
|
||||
Шаги (см. докстринг модуля за подробностями):
|
||||
1. GET autoindex _DATA_SETS_BASE_URL, найти папку квартала по декодированному
|
||||
(cp1251) имени "{quarter} квартал {year}г.".
|
||||
2. GET найденную папку, найти файл dataset_СДЕЛКИ_..._y_{year}_q_{quarter}.csv.zip.
|
||||
3. HEAD найденный файл — считаем ДОСТУПНЫМ, только если Content-Type
|
||||
начинается с application/zip И Content-Length > _MIN_DATASET_SIZE_BYTES.
|
||||
Голый HTTP 200 НЕ считается доступностью — это была причина бага
|
||||
(Bitrix-заглушка /opendata/ тоже отвечала 200).
|
||||
|
||||
Возвращает True если сервер ответил 200 (или 2xx/3xx с redirect).
|
||||
Возвращает False при 404 (квартал ещё не опубликован) или сетевой ошибке.
|
||||
Отсутствие папки/файла квартала → available=False (нормально до публикации).
|
||||
Сетевая ошибка / таймаут / неожиданное исключение → warning, available=False.
|
||||
Никогда не поднимает исключения в вызывающий код (scheduler-safe).
|
||||
"""
|
||||
url = rosreestr_dataset_url(year, quarter)
|
||||
try:
|
||||
resp = await client.head(url, follow_redirects=True)
|
||||
if resp.status_code == 200:
|
||||
logger.info(
|
||||
"rosreestr_poll: Q%d %d available at %s (HTTP %d)",
|
||||
quarter,
|
||||
year,
|
||||
url,
|
||||
resp.status_code,
|
||||
)
|
||||
return True
|
||||
if resp.status_code == 404:
|
||||
logger.info(
|
||||
"rosreestr_poll: Q%d %d not yet published (HTTP 404)",
|
||||
index_resp = await client.get(_DATA_SETS_BASE_URL, follow_redirects=True)
|
||||
if index_resp.status_code != 200:
|
||||
logger.warning(
|
||||
"rosreestr_poll: unexpected HTTP %d listing %s — treating Q%d %d as unavailable",
|
||||
index_resp.status_code,
|
||||
_DATA_SETS_BASE_URL,
|
||||
quarter,
|
||||
year,
|
||||
)
|
||||
return False
|
||||
# Другие коды (403, 5xx, etc.) — логируем как warning, не кидаем
|
||||
logger.warning(
|
||||
"rosreestr_poll: unexpected HTTP %d for Q%d %d url=%s — treating as unavailable",
|
||||
resp.status_code,
|
||||
|
||||
folder_href = _find_quarter_folder_href(index_resp.text, year, quarter)
|
||||
if folder_href is None:
|
||||
logger.info(
|
||||
"rosreestr_poll: quarter folder for Q%d %d not found under %s — not yet published",
|
||||
quarter,
|
||||
year,
|
||||
_DATA_SETS_BASE_URL,
|
||||
)
|
||||
return False
|
||||
|
||||
folder_url = urljoin(_DATA_SETS_BASE_URL, folder_href)
|
||||
folder_resp = await client.get(folder_url, follow_redirects=True)
|
||||
if folder_resp.status_code != 200:
|
||||
logger.warning(
|
||||
"rosreestr_poll: unexpected HTTP %d listing folder %s — "
|
||||
"treating Q%d %d as unavailable",
|
||||
folder_resp.status_code,
|
||||
folder_url,
|
||||
quarter,
|
||||
year,
|
||||
)
|
||||
return False
|
||||
|
||||
file_href = _find_dataset_file_href(folder_resp.text, year, quarter)
|
||||
if file_href is None:
|
||||
logger.info(
|
||||
"rosreestr_poll: dataset_СДЕЛКИ file for Q%d %d not found in folder %s",
|
||||
quarter,
|
||||
year,
|
||||
folder_url,
|
||||
)
|
||||
return False
|
||||
|
||||
file_url = urljoin(folder_url, file_href)
|
||||
file_resp = await client.head(file_url, follow_redirects=True)
|
||||
|
||||
content_type = file_resp.headers.get("content-type", "")
|
||||
try:
|
||||
content_length = int(file_resp.headers.get("content-length") or "0")
|
||||
except ValueError:
|
||||
content_length = 0
|
||||
|
||||
if (
|
||||
file_resp.status_code == 200
|
||||
and content_type.startswith("application/zip")
|
||||
and content_length > _MIN_DATASET_SIZE_BYTES
|
||||
):
|
||||
logger.info(
|
||||
"rosreestr_poll: Q%d %d available at %s (HTTP 200, %s, %d bytes)",
|
||||
quarter,
|
||||
year,
|
||||
file_url,
|
||||
content_type,
|
||||
content_length,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.info(
|
||||
"rosreestr_poll: Q%d %d file found (%s) but failed availability check "
|
||||
"(HTTP %d, Content-Type=%r, Content-Length=%d) — soft-404 guard, "
|
||||
"treating as unavailable",
|
||||
quarter,
|
||||
year,
|
||||
url,
|
||||
file_url,
|
||||
file_resp.status_code,
|
||||
content_type,
|
||||
content_length,
|
||||
)
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(
|
||||
"rosreestr_poll: timeout checking Q%d %d url=%s — treating as unavailable",
|
||||
"rosreestr_poll: timeout checking Q%d %d under %s — treating as unavailable",
|
||||
quarter,
|
||||
year,
|
||||
url,
|
||||
_DATA_SETS_BASE_URL,
|
||||
)
|
||||
return False
|
||||
except httpx.RequestError as exc:
|
||||
|
|
@ -197,7 +354,7 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
|
|||
Шаги:
|
||||
1. Найти MAX(deal_date) WHERE source='rosreestr' → (loaded_year, loaded_quarter).
|
||||
2. Вычислить next = _next_quarter(loaded_year, loaded_quarter).
|
||||
3. HEAD-запросом проверить наличие датасета на rosreestr.gov.ru.
|
||||
3. Проверить наличие датасета на rosreestr.gov.ru/data-sets/ (autoindex + Content-Type).
|
||||
4. Если доступен — логировать actionable INFO с инструкцией по запуску ingest.
|
||||
5. Вернуть dict с результатом.
|
||||
|
||||
|
|
@ -230,18 +387,27 @@ async def poll_rosreestr_new_quarter(db: Session) -> dict[str, Any]:
|
|||
loaded_year,
|
||||
)
|
||||
|
||||
# 3. Проверка наличия
|
||||
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT) as client:
|
||||
# 3. Проверка наличия (verify=False: см. докстринг модуля — RU root CA не в trust store;
|
||||
# браузерный UA: без него rosreestr.gov.ru отвечает 403)
|
||||
async with httpx.AsyncClient(
|
||||
timeout=_HTTP_TIMEOUT,
|
||||
verify=False,
|
||||
headers={"User-Agent": _BROWSER_USER_AGENT},
|
||||
) as client:
|
||||
available = await check_new_quarter_available(client, next_year, next_quarter)
|
||||
|
||||
# 4. Алерт если доступен
|
||||
if available:
|
||||
logger.info(
|
||||
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d. "
|
||||
"Run ingest to load: "
|
||||
"data/sql/02_load_all_quarters.sh + tradein-mvp/deploy/import-rosreestr.sh",
|
||||
"rosreestr_poll: NEW QUARTER AVAILABLE — Q%d %d (%s). "
|
||||
"Run ingest to load: data/sql/02_load_all_quarters.sh + "
|
||||
"tradein-mvp/deploy/import-rosreestr.sh "
|
||||
"(both live outside this repo — if they still point at the old "
|
||||
"rosreestr.gov.ru/opendata/ base, update them to %s first)",
|
||||
next_quarter,
|
||||
next_year,
|
||||
rosreestr_dataset_url(next_year, next_quarter),
|
||||
_DATA_SETS_BASE_URL,
|
||||
)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ from app.services import scrape_runs as runs_mod
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# import_rosreestr_dkp: доля per-row INSERT-ошибок (rows_errored / rows_fetched), выше
|
||||
# которой прогон помечается FAILED, а не silent-green (Fix C). Единичные битые строки
|
||||
# (редкий bad row) не валят импорт; систематический сбой (≈100% ошибок) — валит.
|
||||
DKP_IMPORT_ERROR_RATE_THRESHOLD = 0.05
|
||||
|
||||
|
||||
def compute_next_run_at(
|
||||
window_start_hour: int,
|
||||
|
|
@ -180,9 +185,16 @@ def import_rosreestr_dkp(
|
|||
SERVER gendesign_remote настроен в 060_postgres_fdw_extension.sql.
|
||||
USER MAPPING создаётся при startup в core/fdw.py (tradein_fdw_reader).
|
||||
|
||||
Область покрытия: вся Свердловская область (region_code=66), не только Екатеринбург —
|
||||
прежний ILIKE-фильтр по подстроке города (ограничивавший импорт одним Екатеринбургом)
|
||||
снят (Mera trade-in расширяется на весь регион, unlocks +47183 сделок вне ЕКБ уже
|
||||
сидящих в source foreign table). address и deals.city строятся из реального city
|
||||
источника (не хардкод "Екатеринбург"), deals.region_code заполняется из строки
|
||||
источника (= 66 при текущем фильтре).
|
||||
|
||||
Фильтры (совпадают с import-rosreestr.sh + Fix_Rosreestr_Dkp_Filter_May24):
|
||||
- region_code = 66 (Свердловская область)
|
||||
- city ILIKE '%катеринбург%'
|
||||
- region_code = 66 (вся Свердловская область, все города)
|
||||
- city IS NOT NULL AND trim(city) != '' (непустой город → корректный address)
|
||||
- realestate_type_code = '002001003000' (квартира)
|
||||
- area BETWEEN 18 AND 200
|
||||
- deal_price BETWEEN 1000000 AND 100000000
|
||||
|
|
@ -210,7 +222,16 @@ def import_rosreestr_dkp(
|
|||
counters: dict[str, int] = {
|
||||
"rows_fetched": 0,
|
||||
"rows_inserted": 0,
|
||||
# rows_updated: ON CONFLICT DO UPDATE обновил существующую строку
|
||||
# (исправленный/переопубликованный квартал — Fix D).
|
||||
"rows_updated": 0,
|
||||
# rows_skipped: ТОЛЬКО легитимный dedup-пропуск (строка уже есть, факты
|
||||
# идентичны — DO UPDATE ... WHERE distinct не сработал).
|
||||
"rows_skipped": 0,
|
||||
# rows_errored: реальные per-row INSERT-ошибки, отделены от dedup-skip (Fix C),
|
||||
# раньше обе категории клались в rows_skipped → систематический сбой выглядел
|
||||
# как обычный дедуп и прогон рапортовал success.
|
||||
"rows_errored": 0,
|
||||
"batches_done": 0,
|
||||
}
|
||||
|
||||
|
|
@ -248,8 +269,9 @@ def import_rosreestr_dkp(
|
|||
# #1182 Phase 2: кооперативный SIGTERM-drain (деплой recreate scraper).
|
||||
# Это НЕ user-cancel → mark_done (partial), не mark_cancelled. Курсор
|
||||
# уже зафиксирован update_heartbeat'ом каждый батч; следующий run
|
||||
# пере-сканирует с id=0 (INSERT ... ON CONFLICT DO NOTHING идемпотентен)
|
||||
# → уже вставленные строки пропускаются, остаток до-импортируется.
|
||||
# пере-сканирует с id=0 — INSERT ... ON CONFLICT DO UPDATE идемпотентен
|
||||
# для неизменных строк (WHERE ... IS DISTINCT FROM пропускает совпадающие
|
||||
# факты), поэтому повторный проход не плодит дубликаты и не трогает данные.
|
||||
# mark_done выводит run из 'running' → reap_zombies его не тронет.
|
||||
logger.info(
|
||||
"rosreestr_dkp_import run_id=%d: SIGTERM-drain — committing partial "
|
||||
|
|
@ -273,7 +295,9 @@ def import_rosreestr_dkp(
|
|||
id,
|
||||
id AS source_id_src,
|
||||
'ros:dkp:' || CAST(id AS text) AS dedup_hash,
|
||||
'Екатеринбург, ' || trim(street) AS address,
|
||||
trim(city) || ', ' || trim(street) AS address,
|
||||
region_code,
|
||||
trim(city) AS city,
|
||||
CASE
|
||||
WHEN area < 30 THEN 0
|
||||
WHEN area < 44 THEN 1
|
||||
|
|
@ -296,7 +320,7 @@ def import_rosreestr_dkp(
|
|||
period_start_date AS deal_date
|
||||
FROM gendesign_rosreestr_deals
|
||||
WHERE region_code = 66
|
||||
AND city ILIKE '%катеринбург%'
|
||||
AND city IS NOT NULL AND trim(city) <> ''
|
||||
AND realestate_type_code = '002001003000'
|
||||
AND area BETWEEN 18 AND 200
|
||||
AND deal_price BETWEEN 1000000 AND 100000000
|
||||
|
|
@ -318,7 +342,9 @@ def import_rosreestr_dkp(
|
|||
|
||||
total_batches += 1
|
||||
batch_inserted = 0
|
||||
batch_updated = 0
|
||||
batch_skipped = 0
|
||||
batch_errored = 0
|
||||
batch_max_id = last_id
|
||||
|
||||
for row in batch_rows:
|
||||
|
|
@ -328,17 +354,30 @@ def import_rosreestr_dkp(
|
|||
|
||||
try:
|
||||
with db.begin_nested(): # SAVEPOINT per row
|
||||
inserted = db.execute(
|
||||
# Fix D: ON CONFLICT DO UPDATE вместо прежнего no-op-дедупа — при
|
||||
# повторном импорте ИСПРАВЛЕННОГО/переопубликованного квартала обновляем
|
||||
# сырые факты Росреестра. WHERE ... IS DISTINCT FROM оставляет
|
||||
# неизменные строки нетронутыми (идемпотентность resume: строка
|
||||
# без изменений → 0 returned → legit dedup-skip). source/source_id
|
||||
# (identity) и dedup_hash (ключ конфликта) стабильны, не трогаем.
|
||||
# Обогащение (lat/lon/geom/geocode_tried_at, cadastral_number,
|
||||
# total_floors, house_type ...) НЕ в EXCLUDED-списке → сохраняется.
|
||||
# RETURNING (xmax = 0): freshly-inserted → xmax=0 (was_inserted),
|
||||
# обновлённая по ON CONFLICT → xmax<>0; отличаем insert от update.
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO deals (
|
||||
source, dedup_hash, source_id, address, rooms, area_m2,
|
||||
floor, year_built, price_rub, price_per_m2, deal_date
|
||||
source, dedup_hash, source_id, address, region_code, city,
|
||||
rooms, area_m2, floor, year_built, price_rub, price_per_m2,
|
||||
deal_date
|
||||
)
|
||||
VALUES (
|
||||
'rosreestr',
|
||||
CAST(:dedup_hash AS text),
|
||||
CAST(:source_id AS text),
|
||||
CAST(:address AS text),
|
||||
CAST(:region_code AS int),
|
||||
CAST(:city AS text),
|
||||
CAST(:rooms AS int),
|
||||
CAST(:area_m2 AS numeric),
|
||||
CAST(:floor_num AS int),
|
||||
|
|
@ -347,13 +386,35 @@ def import_rosreestr_dkp(
|
|||
CAST(:price_per_m2 AS int),
|
||||
CAST(:deal_date AS date)
|
||||
)
|
||||
ON CONFLICT (dedup_hash) DO NOTHING
|
||||
RETURNING id
|
||||
ON CONFLICT (dedup_hash) DO UPDATE SET
|
||||
address = EXCLUDED.address,
|
||||
region_code = EXCLUDED.region_code,
|
||||
city = EXCLUDED.city,
|
||||
rooms = EXCLUDED.rooms,
|
||||
area_m2 = EXCLUDED.area_m2,
|
||||
floor = EXCLUDED.floor,
|
||||
year_built = EXCLUDED.year_built,
|
||||
price_rub = EXCLUDED.price_rub,
|
||||
price_per_m2 = EXCLUDED.price_per_m2,
|
||||
deal_date = EXCLUDED.deal_date
|
||||
WHERE deals.address IS DISTINCT FROM EXCLUDED.address
|
||||
OR deals.region_code IS DISTINCT FROM EXCLUDED.region_code
|
||||
OR deals.city IS DISTINCT FROM EXCLUDED.city
|
||||
OR deals.rooms IS DISTINCT FROM EXCLUDED.rooms
|
||||
OR deals.area_m2 IS DISTINCT FROM EXCLUDED.area_m2
|
||||
OR deals.floor IS DISTINCT FROM EXCLUDED.floor
|
||||
OR deals.year_built IS DISTINCT FROM EXCLUDED.year_built
|
||||
OR deals.price_rub IS DISTINCT FROM EXCLUDED.price_rub
|
||||
OR deals.price_per_m2 IS DISTINCT FROM EXCLUDED.price_per_m2
|
||||
OR deals.deal_date IS DISTINCT FROM EXCLUDED.deal_date
|
||||
RETURNING (xmax = 0) AS was_inserted
|
||||
"""),
|
||||
{
|
||||
"dedup_hash": row["dedup_hash"],
|
||||
"source_id": str(row["source_id_src"]),
|
||||
"address": row["address"],
|
||||
"region_code": row["region_code"],
|
||||
"city": row["city"],
|
||||
"rooms": row["rooms"],
|
||||
"area_m2": row["area_m2"],
|
||||
"floor_num": row["floor_num"],
|
||||
|
|
@ -363,25 +424,33 @@ def import_rosreestr_dkp(
|
|||
"deal_date": row["deal_date"],
|
||||
},
|
||||
).fetchone()
|
||||
if inserted is not None:
|
||||
if result is None:
|
||||
# DO UPDATE ... WHERE distinct не сработал → строка есть и
|
||||
# факты идентичны = легитимный dedup-skip (Fix C: только это
|
||||
# теперь считается skip, INSERT-ошибки — отдельно ниже).
|
||||
batch_skipped += 1
|
||||
elif result[0]:
|
||||
batch_inserted += 1
|
||||
else:
|
||||
batch_skipped += 1
|
||||
batch_updated += 1
|
||||
except Exception as exc:
|
||||
# Fix C: реальная per-row INSERT-ошибка — ОТДЕЛЬНЫЙ счётчик, не skip.
|
||||
logger.warning(
|
||||
"rosreestr_dkp_import run_id=%d: row id=%d INSERT failed: %s",
|
||||
run_id,
|
||||
row_id,
|
||||
exc,
|
||||
)
|
||||
batch_skipped += 1
|
||||
batch_errored += 1
|
||||
|
||||
last_id = batch_max_id
|
||||
db.commit()
|
||||
|
||||
counters["rows_fetched"] += len(batch_rows)
|
||||
counters["rows_inserted"] += batch_inserted
|
||||
counters["rows_updated"] += batch_updated
|
||||
counters["rows_skipped"] += batch_skipped
|
||||
counters["rows_errored"] += batch_errored
|
||||
counters["batches_done"] = total_batches
|
||||
counters["last_id"] = last_id # type: ignore[assignment]
|
||||
|
||||
|
|
@ -389,26 +458,42 @@ def import_rosreestr_dkp(
|
|||
runs_mod.update_heartbeat(db, run_id, counters)
|
||||
logger.info(
|
||||
"rosreestr_dkp_import run_id=%d: batch=%d fetched=%d "
|
||||
"inserted=%d skipped=%d last_id=%d",
|
||||
"inserted=%d updated=%d skipped=%d errored=%d last_id=%d",
|
||||
run_id,
|
||||
total_batches,
|
||||
len(batch_rows),
|
||||
batch_inserted,
|
||||
batch_updated,
|
||||
batch_skipped,
|
||||
batch_errored,
|
||||
last_id,
|
||||
)
|
||||
|
||||
if len(batch_rows) < batch_size:
|
||||
break # Last partial batch — no more rows
|
||||
|
||||
# Fix C: систематический per-row INSERT-сбой больше не рапортует success.
|
||||
# Отделив rows_errored от rows_skipped, проверяем долю ошибок: выше порога —
|
||||
# прогон FAILED (raise → внешний except → mark_failed), а не silent-green.
|
||||
fetched = counters["rows_fetched"]
|
||||
errored = counters["rows_errored"]
|
||||
if fetched > 0 and errored / fetched > DKP_IMPORT_ERROR_RATE_THRESHOLD:
|
||||
raise RuntimeError(
|
||||
f"rosreestr_dkp_import: per-row INSERT error rate "
|
||||
f"{errored}/{fetched} ({errored / fetched:.1%}) exceeds "
|
||||
f"{DKP_IMPORT_ERROR_RATE_THRESHOLD:.0%} threshold — marking run failed"
|
||||
)
|
||||
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"rosreestr_dkp_import run_id=%d done: "
|
||||
"total_fetched=%d inserted=%d skipped=%d batches=%d",
|
||||
"total_fetched=%d inserted=%d updated=%d skipped=%d errored=%d batches=%d",
|
||||
run_id,
|
||||
counters["rows_fetched"],
|
||||
counters["rows_inserted"],
|
||||
counters["rows_updated"],
|
||||
counters["rows_skipped"],
|
||||
counters["rows_errored"],
|
||||
total_batches,
|
||||
)
|
||||
|
||||
|
|
|
|||
146
tradein-mvp/backend/app/services/user_events.py
Normal file
146
tradein-mvp/backend/app/services/user_events.py
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
"""Сервис записи в `user_events` — Feature 2 (login/IP audit) + база для Feature 3
|
||||
(behavior analytics).
|
||||
|
||||
`user_events` (migration `184_user_events.sql`) — unified append-only event log,
|
||||
admin-read-only. Запись события НИКОГДА не должна ронять реальный HTTP-запрос:
|
||||
любая ошибка (БД недоступна, сетевой сбой, схема разъехалась) ловится и логируется
|
||||
через `logger.warning`, без re-raise — вызывающий код (middleware / handler)
|
||||
продолжает работать так, как если бы аудит был выключен.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def record_event(
|
||||
*,
|
||||
event_type: str,
|
||||
username: str,
|
||||
ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
path: str | None = None,
|
||||
method: str | None = None,
|
||||
estimate_id: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Синхронно вставляет одну строку в `user_events`.
|
||||
|
||||
Открывает СОБСТВЕННУЮ сессию (`SessionLocal()`), декаплённую от транзакции
|
||||
вызывающего запроса — событие коммитится независимо и переживает rollback
|
||||
основного хендлера (и наоборот: сбой записи события не трогает основную tx,
|
||||
т.к. она уже закоммичена/не связана с этой сессией).
|
||||
|
||||
Никогда не поднимает исключение — вызывающий код (middleware / endpoint)
|
||||
не должен падать из-за проблем с аудит-логом.
|
||||
"""
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO user_events
|
||||
(event_type, username, ip_address, user_agent, path, method,
|
||||
estimate_id, payload)
|
||||
VALUES
|
||||
(:event_type, :username, CAST(:ip AS inet), :user_agent, :path,
|
||||
:method, CAST(:estimate_id AS uuid), CAST(:payload AS jsonb))
|
||||
"""
|
||||
),
|
||||
{
|
||||
"event_type": event_type,
|
||||
"username": username,
|
||||
"ip": ip,
|
||||
"user_agent": user_agent,
|
||||
"path": path,
|
||||
"method": method,
|
||||
"estimate_id": estimate_id,
|
||||
"payload": json.dumps(payload or {}, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"user_events: failed to record event_type=%r username=%r path=%r",
|
||||
event_type,
|
||||
username,
|
||||
path,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def schedule_event(**kwargs: Any) -> None:
|
||||
"""Fire-and-forget обёртка над `record_event` — никогда не блокирует запрос и
|
||||
никогда не поднимает исключение наружу.
|
||||
|
||||
- Есть running event loop (обычный async FastAPI handler / middleware) →
|
||||
INSERT уводится в отдельный поток через `asyncio.to_thread`, вызывающая
|
||||
корутина не ждёт результата. Ошибки ловятся done-callback'ом и просто
|
||||
логируются (иначе asyncio ругался бы "Task exception was never retrieved").
|
||||
- Нет running loop (sync-контекст: тесты, Celery task, скрипт) → вызывает
|
||||
`record_event(**kwargs)` синхронно inline (она сама никогда не raises).
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
record_event(**kwargs)
|
||||
return
|
||||
|
||||
try:
|
||||
task = loop.create_task(asyncio.to_thread(record_event, **kwargs))
|
||||
except Exception:
|
||||
logger.warning("user_events: failed to schedule background event", exc_info=True)
|
||||
return
|
||||
|
||||
def _log_task_exception(t: asyncio.Task[None]) -> None:
|
||||
if t.cancelled():
|
||||
return
|
||||
exc = t.exception()
|
||||
if exc is not None:
|
||||
logger.warning("user_events: background record_event failed: %s", exc)
|
||||
|
||||
task.add_done_callback(_log_task_exception)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-process best-effort dedup для login-события.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LOGIN_DEDUP_LOCK = threading.Lock()
|
||||
_LOGIN_DEDUP_SEEN: set[str] = set()
|
||||
_LOGIN_DEDUP_MAX = 50_000
|
||||
|
||||
|
||||
def should_log_login(username: str, ip: str | None, user_agent: str | None) -> bool:
|
||||
"""True максимум один раз в сутки на комбинацию (username, ip, user_agent) в
|
||||
рамках жизни процесса — держит объём `event_type='login'` строк на уровне
|
||||
~одной записи на аккаунт+устройство в день, а не одной на каждый API-запрос.
|
||||
|
||||
Best-effort/не персистентно: рестарт процесса (деплой, worker respawn)
|
||||
сбрасывает in-memory set, так что после рестарта первая комбинация дня
|
||||
залогируется заново — недо-дедуп, не потеря данных (для отчётов по
|
||||
user_events всё равно используется `DISTINCT (username, ip_address,
|
||||
user_agent, date_trunc('day', created_at))`, так что дубликаты не искажают
|
||||
метрики, лишь чуть увеличивают объём записи).
|
||||
"""
|
||||
day = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||
key = f"{username}|{ip or ''}|{user_agent or ''}|{day}"
|
||||
|
||||
with _LOGIN_DEDUP_LOCK:
|
||||
if key in _LOGIN_DEDUP_SEEN:
|
||||
return False
|
||||
if len(_LOGIN_DEDUP_SEEN) >= _LOGIN_DEDUP_MAX:
|
||||
_LOGIN_DEDUP_SEEN.clear()
|
||||
_LOGIN_DEDUP_SEEN.add(key)
|
||||
return True
|
||||
|
|
@ -40,6 +40,14 @@ from app.services import scrape_runs as runs_mod
|
|||
|
||||
# Нижняя граница ppm² — отсекает нежилые/технические сделки; не меняется.
|
||||
_PPM2_MIN: int = 30_000
|
||||
|
||||
# #C2 — asking-сторона (listings) покрыта скрейпом ТОЛЬКО по ЕКБ (per-city scrape B1/B2
|
||||
# ещё нет; в listings даже нет колонки city). Миграция 177 залила ДКП-сделки по всей
|
||||
# обл.66 (368 городов) → sold-медиана смешивала дешёвую область с ЕКБ-asking и обваливала
|
||||
# ratio (0.877→0.62, «выкупная» −29% системно). Скоупим SOLD-сторону (deal_side/deal_global)
|
||||
# на ЕКБ, чтобы sold и asking считались по ОДНОМУ рынку. Когда появятся oblast-листинги —
|
||||
# заменить на per-city ratio через зарезервированный столбец `district` (#647).
|
||||
_ASKING_CITY_PATTERN: str = "%Екатеринбург%"
|
||||
# Верхняя граница берётся из settings.asking_ratio_ppm2_max (default 1_200_000).
|
||||
# QA-note: точное значение сверить с `SELECT max(price_per_m2) FROM deals
|
||||
# WHERE source='rosreestr'` на проде — ceiling должен быть > max(ppm²) premium-сделок.
|
||||
|
|
@ -80,6 +88,7 @@ _REDERIVE_SQL = text(
|
|||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND rooms IS NOT NULL
|
||||
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||
GROUP BY LEAST(GREATEST(rooms, 0), 4)
|
||||
|
|
@ -128,6 +137,7 @@ _REDERIVE_SQL = text(
|
|||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND rooms IS NOT NULL
|
||||
AND city ILIKE :asking_city -- #C2 SOLD-сторона на ЕКБ (match asking-рынок)
|
||||
AND price_per_m2 BETWEEN :ppm2_min AND :ppm2_max
|
||||
AND deal_date >= CURRENT_DATE - INTERVAL '12 months'
|
||||
),
|
||||
|
|
@ -213,7 +223,11 @@ def recompute_asking_to_sold_ratios(db: Session, run_id: int) -> dict[str, int]:
|
|||
db.execute(_DELETE_SQL)
|
||||
db.execute(
|
||||
_REDERIVE_SQL,
|
||||
{"ppm2_min": _PPM2_MIN, "ppm2_max": settings.asking_ratio_ppm2_max},
|
||||
{
|
||||
"ppm2_min": _PPM2_MIN,
|
||||
"ppm2_max": settings.asking_ratio_ppm2_max,
|
||||
"asking_city": _ASKING_CITY_PATTERN,
|
||||
},
|
||||
)
|
||||
|
||||
row = db.execute(_COUNTERS_SQL).mappings().first()
|
||||
|
|
|
|||
124
tradein-mvp/backend/app/tasks/domrf_kapremont_load.py
Normal file
124
tradein-mvp/backend/app/tasks/domrf_kapremont_load.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""CLI: ДОМ.РФ капремонт open data → houses.year_built/material_walls (issue #2013).
|
||||
|
||||
Тянет КР1.1 (house registry) + КР1.2 (constructive elements) с открытых данных
|
||||
ДОМ.РФ (region 66), UPSERT'ит в staging `domrf_kapremont` (мигр. 176), затем:
|
||||
1. backfill_houses_from_domrf — COALESCE(houses.year_built, domrf.commission_year,
|
||||
houses.zhkh_year) + material_walls + total_floors (fold-in уже загруженного
|
||||
ГИС-ЖКХ года — houses без domrf-матча тоже выигрывают).
|
||||
2. propagate_listings_year_from_houses — UPDATE listings.year_built = houses.year_built
|
||||
там, где listing ещё NULL. Именно это открывает estimator'у (year_built BETWEEN
|
||||
когорта-фильтр) реальные годы на источниках вроде avito (0% year_built, 52.5%
|
||||
linked к houses).
|
||||
|
||||
Запуск из контейнера tradein-backend (у прода есть интернет к домрф.рф):
|
||||
|
||||
python -m app.tasks.domrf_kapremont_load # полный прогон
|
||||
python -m app.tasks.domrf_kapremont_load --dry-run # без записи, только подсчёт
|
||||
python -m app.tasks.domrf_kapremont_load --load-only # только staging, без backfill
|
||||
python -m app.tasks.domrf_kapremont_load --backfill-only # staging уже загружена — только
|
||||
# backfill+propagate
|
||||
python -m app.tasks.domrf_kapremont_load \\
|
||||
--kr11-path /tmp/kr11.csv --kr12-path /tmp/kr12.csv # локальные CSV, без скачивания
|
||||
|
||||
В режиме --dry-run скачивание/парс (если применимо) происходят, но НИ ОДНОЙ записи
|
||||
в БД не делается.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.services.domrf_kapremont_loader import (
|
||||
backfill_houses_from_domrf,
|
||||
load_domrf_kapremont,
|
||||
propagate_listings_year_from_houses,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""Парсер CLI (вынесен для тестируемости флагов без запуска main)."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"ДОМ.РФ капремонт loader: КР1.1+КР1.2 → domrf_kapremont staging → "
|
||||
"houses.year_built/material_walls/total_floors → listings.year_built"
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kr11-path", default=None, help="локальный CSV КР1.1 (пропустить скачивание)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kr12-path", default=None, help="локальный CSV КР1.2 (пропустить скачивание)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--work-dir",
|
||||
default=None,
|
||||
help="каталог для скачанных/распакованных CSV (по умолчанию — временный, удаляется после)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="без записи в БД — только скачивание/парс/подсчёт"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--load-only",
|
||||
action="store_true",
|
||||
help="только скачать+распарсить+UPSERT staging, без backfill houses/propagate listings",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backfill-only",
|
||||
action="store_true",
|
||||
help="пропустить скачивание/load staging (считаем что уже загружена этим же "
|
||||
"или предыдущим прогоном) — только backfill houses + propagate listings",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.load_only and args.backfill_only:
|
||||
parser.error("--load-only и --backfill-only взаимоисключающи")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
load_counts: dict[str, int] = {}
|
||||
if not args.backfill_only:
|
||||
load_counts = load_domrf_kapremont(
|
||||
db,
|
||||
kr11_path=args.kr11_path,
|
||||
kr12_path=args.kr12_path,
|
||||
work_dir=args.work_dir,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
if not args.dry_run:
|
||||
db.commit()
|
||||
logger.info("domrf_kapremont_load: load stage done: %s", load_counts)
|
||||
|
||||
houses_counts: dict[str, int] = {}
|
||||
listings_counts: dict[str, int] = {}
|
||||
if not args.load_only:
|
||||
houses_counts = backfill_houses_from_domrf(db, dry_run=args.dry_run)
|
||||
listings_counts = propagate_listings_year_from_houses(db, dry_run=args.dry_run)
|
||||
if not args.dry_run:
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
"domrf_kapremont_load DONE: dry_run=%s load=%s houses=%s listings=%s",
|
||||
args.dry_run,
|
||||
load_counts,
|
||||
houses_counts,
|
||||
listings_counts,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
196
tradein-mvp/backend/app/tasks/sber_freshness_monitor.py
Normal file
196
tradein-mvp/backend/app/tasks/sber_freshness_monitor.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1.
|
||||
|
||||
Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет
|
||||
СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning,
|
||||
когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба
|
||||
`sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует
|
||||
данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит
|
||||
(на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness
|
||||
видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок.
|
||||
|
||||
Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и
|
||||
поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага
|
||||
публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах.
|
||||
|
||||
Порог алерта (документирование выбора):
|
||||
Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д).
|
||||
Монитор: age > sber_index_max_age_days + lag_allowance.
|
||||
lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг
|
||||
публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month — лейбл
|
||||
ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог:
|
||||
35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» → алерта нет
|
||||
(иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев
|
||||
→ алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1.
|
||||
|
||||
Задача синхронная (DB-only, один SELECT max(period_month)) — запускается
|
||||
kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в
|
||||
run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ
|
||||
функция evaluate_sber_freshness() (frozen-now тестируется без БД).
|
||||
|
||||
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING
|
||||
достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего
|
||||
оценивать).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services import scrape_runs as runs_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_LAG_ALLOWANCE_DAYS",
|
||||
"SberFreshnessVerdict",
|
||||
"check_sber_freshness",
|
||||
"evaluate_sber_freshness",
|
||||
]
|
||||
|
||||
# Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate
|
||||
# guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days.
|
||||
DEFAULT_LAG_ALLOWANCE_DAYS = 25
|
||||
|
||||
# Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями
|
||||
# sber_price_index для областного уровня.
|
||||
SBER_MONITOR_CITY = "Свердловская область"
|
||||
|
||||
_LATEST_SBER_PERIOD_SQL = text("""
|
||||
SELECT max(period_month) AS latest
|
||||
FROM sber_price_index
|
||||
WHERE city = CAST(:city AS text)
|
||||
-- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка
|
||||
-- (новостройки) = направленно неверная коррекция. Зеркалит фильтр
|
||||
-- estimator._load_sber_index_series.
|
||||
AND (segment IS NULL OR segment ILIKE '%вторичн%')
|
||||
""")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SberFreshnessVerdict:
|
||||
"""Вердикт свежести СберИндекса по max(period_month)."""
|
||||
|
||||
latest_period: date
|
||||
age_days: int
|
||||
stale: bool
|
||||
|
||||
|
||||
def evaluate_sber_freshness(
|
||||
latest_period: date,
|
||||
now: datetime,
|
||||
max_age_days: int,
|
||||
) -> SberFreshnessVerdict:
|
||||
"""Чистая логика: устарел ли latest период СберИндекса.
|
||||
|
||||
stale = age_days > max_age_days, где age_days = now.date() - latest_period.
|
||||
`max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance),
|
||||
вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД.
|
||||
"""
|
||||
age_days = (now.date() - latest_period).days
|
||||
stale = age_days > max_age_days
|
||||
return SberFreshnessVerdict(
|
||||
latest_period=latest_period,
|
||||
age_days=age_days,
|
||||
stale=stale,
|
||||
)
|
||||
|
||||
|
||||
def check_sber_freshness(
|
||||
db: Session,
|
||||
run_id: int,
|
||||
params: dict | None = None, # type: ignore[type-arg]
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Проверить свежесть СберИндекса по max(period_month) и алертить при staleness.
|
||||
|
||||
Sync (вызывается scheduler-триггером в executor, как check_deals_freshness).
|
||||
Читает один SELECT max(period_month) вторичного сегмента по региону, считает
|
||||
вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops)
|
||||
и финализирует run.
|
||||
|
||||
Params (default_params jsonb):
|
||||
lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25).
|
||||
`now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC).
|
||||
|
||||
Returns counters {latest_year, latest_month, age_days, alert}.
|
||||
mark_failed только если sber_price_index пуста/недоступна (нечего оценивать);
|
||||
при алерте прогон помечается done (это монитор, не сбой джобы).
|
||||
"""
|
||||
params = params or {}
|
||||
now = now or datetime.now(UTC)
|
||||
counters: dict[str, int] = {
|
||||
"latest_year": 0,
|
||||
"latest_month": 0,
|
||||
"age_days": 0,
|
||||
"alert": 0,
|
||||
}
|
||||
try:
|
||||
runs_mod.update_heartbeat(db, run_id, counters)
|
||||
|
||||
row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first()
|
||||
latest: date | None = row.latest if row is not None else None
|
||||
if latest is None:
|
||||
logger.warning(
|
||||
"sber freshness: sber_price_index пуст/недоступен для region=%s "
|
||||
"(вторичка) — оценить свежесть нельзя",
|
||||
SBER_MONITOR_CITY,
|
||||
)
|
||||
runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters)
|
||||
return counters
|
||||
|
||||
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
|
||||
max_age_days = settings.sber_index_max_age_days + lag_days
|
||||
verdict = evaluate_sber_freshness(latest, now, max_age_days)
|
||||
|
||||
counters = {
|
||||
"latest_year": latest.year,
|
||||
"latest_month": latest.month,
|
||||
"age_days": verdict.age_days,
|
||||
"alert": int(verdict.stale),
|
||||
}
|
||||
|
||||
if verdict.stale:
|
||||
logger.warning(
|
||||
"sber freshness: max(period_month)=%s устарел на %d дней "
|
||||
"(> порога %d = sber_index_max_age_days %d + lag %d); "
|
||||
"СберИндекс time-adjustment ДКП-сделок мог отстать — "
|
||||
"проверь sber_index_pull и доступность новых периодов источника",
|
||||
latest,
|
||||
verdict.age_days,
|
||||
max_age_days,
|
||||
settings.sber_index_max_age_days,
|
||||
lag_days,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) "
|
||||
"region=%s — алерта нет",
|
||||
latest,
|
||||
verdict.age_days,
|
||||
max_age_days,
|
||||
SBER_MONITOR_CITY,
|
||||
)
|
||||
|
||||
runs_mod.mark_done(db, run_id, counters)
|
||||
logger.info(
|
||||
"check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d",
|
||||
run_id,
|
||||
latest,
|
||||
counters["alert"],
|
||||
counters["age_days"],
|
||||
)
|
||||
return counters
|
||||
except Exception as exc:
|
||||
logger.exception("check_sber_freshness run_id=%d failed", run_id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
|
||||
raise
|
||||
|
|
@ -22,6 +22,19 @@
|
|||
--
|
||||
-- Dependencies: 002_core_tables.sql (house_id space). No backend schema coupling.
|
||||
-- Re-run safe: CREATE TABLE IF NOT EXISTS + idempotent upsert.
|
||||
--
|
||||
-- LOW audit #4 — EKB scope (doc-only, no behavior change): this seed and its
|
||||
-- `premium_houses` MV fallback (data/sql/139) are both curated/calibrated against
|
||||
-- Ekaterinburg secondary-market listings ONLY. Non-EKB descriptions encountered
|
||||
-- during curation were treated as noise and actively DEMOTED (is_premium=false) —
|
||||
-- e.g. house_id=372944 below ("г. Нижний Новгород, Александровская слобода... это
|
||||
-- НЕ ЕКБ" — mislabeled, not premium). Consequence: an oblast (non-EKB) elite
|
||||
-- building will NOT receive the `premium_building` flag from this table or its MV
|
||||
-- fallback — the estimator's `_is_premium_building` (app/services/estimator.py)
|
||||
-- has no per-city variant of this overlay. This is not a silent gap: the estimator's
|
||||
-- `_manual_review` still routes genuinely high-value objects to manual review via
|
||||
-- absolute-value thresholds (estimate_manual_review_high_value_rub,
|
||||
-- estimate_manual_review_elite_ppm2) independent of the premium_building flag.
|
||||
|
||||
BEGIN;
|
||||
|
||||
|
|
|
|||
67
tradein-mvp/backend/data/sql/176_domrf_kapremont.sql
Normal file
67
tradein-mvp/backend/data/sql/176_domrf_kapremont.sql
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
-- 176_domrf_kapremont.sql
|
||||
--
|
||||
-- CONTEXT: issue #2013 — houses.year_built заполнен только на 38%, houses.material_walls
|
||||
-- на 0%. Существующий ГИС-ЖКХ loader (app/tasks/zhkh_flats_load.py, мигр. 146/149)
|
||||
-- заполняет houses.zhkh_year (70% = 5002/7128) и houses.zhkh_floors (70%), НО НИКОГДА
|
||||
-- не копирует их в houses.year_built/total_floors — это основной пробел. material_walls
|
||||
-- вообще ни разу не заполнялся никаким источником (0%). Estimator
|
||||
-- (app/services/estimator.py) фильтрует когорту по `year_built BETWEEN ...` — реальные
|
||||
-- годы напрямую двигают точность оценки. Это REAL fix для #2013 (взамен запаркованного
|
||||
-- когорт-исключения PR #2474).
|
||||
--
|
||||
-- Источник: ДОМ.РФ капремонт open data (free, no auth), region 66 (Свердловская обл.):
|
||||
-- КР1.1 house registry (export/190) — zip → CSV. mkd_code, houseguid (ФИАС GUID дома),
|
||||
-- address, commission_year (год ввода, int), total_sq, number_floors_max.
|
||||
-- КР1.2 constructive elements (export/275) — zip → CSV, LONG FORMAT (строка на
|
||||
-- конструктивный элемент на mkd_code); wall_material заполнен только на строке
|
||||
-- элемента «фасад».
|
||||
-- listings→houses линковка через listings.house_id_fk (avito 52.5% linked, year_built
|
||||
-- 0% на avito → backfill поднимет до ~36-52% через houses.year_built propagation).
|
||||
--
|
||||
-- WHAT:
|
||||
-- Таблица domrf_kapremont — staging-приёмник КР1.1+КР1.2 (region 66), ключ mkd_code.
|
||||
-- Заполняется отдельным loader'ом (app/services/domrf_kapremont_loader.py,
|
||||
-- app/tasks/domrf_kapremont_load.py). Backfill houses.year_built/material_walls/
|
||||
-- total_floors и propagation listings.year_built — отдельный код-путь той же таски,
|
||||
-- НЕ в этой миграции (только схема здесь, никакого DML над houses/listings).
|
||||
--
|
||||
-- DEPENDENCIES (existing prod objects): houses (gar_house_guid, house_fias_id,
|
||||
-- zhkh_house_guid, zhkh_year, zhkh_floors, year_built, material_walls, total_floors —
|
||||
-- все уже существуют: мигр. 009/010/070/146/149/152), listings (house_id_fk, year_built —
|
||||
-- мигр. 002/130).
|
||||
--
|
||||
-- SAFETY / IDEMPOTENCY: только CREATE TABLE/INDEX IF NOT EXISTS + COMMENT. Повторный
|
||||
-- прогон — no-op. Деплой-раннер гонит файл через psql -v ON_ERROR_STOP=on БЕЗ
|
||||
-- --single-transaction → транзакцию открывает САМ файл (BEGIN/COMMIT ниже), как 143/149.
|
||||
--
|
||||
-- NB по нумерации: последний занятый = 175 (ls data/sql | grep '^17[0-9]'); следующий
|
||||
-- свободный sequential = 176.
|
||||
--
|
||||
-- Deploy order: после 175_scrape_schedules_seed_domclick_detail_backfill.sql.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domrf_kapremont (
|
||||
mkd_code text PRIMARY KEY, -- ДОМ.РФ код МКД (КР1.1/КР1.2 join-ключ)
|
||||
houseguid text, -- ФИАС GUID дома (КР1.1 houseguid)
|
||||
address text,
|
||||
commission_year smallint, -- год ввода в эксплуатацию (КР1.1 commission_year)
|
||||
number_floors_max int, -- этажность (КР1.1 number_floors_max)
|
||||
total_sq numeric(10, 2), -- общая площадь дома, м² (КР1.1 total_sq)
|
||||
wall_material text, -- материал стен (КР1.2, строка «фасад»; wall_material)
|
||||
loaded_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS domrf_kapremont_houseguid_idx
|
||||
ON domrf_kapremont (houseguid)
|
||||
WHERE houseguid IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE domrf_kapremont IS
|
||||
'ДОМ.РФ капремонт open data (КР1.1 house registry export/190 + КР1.2 constructive '
|
||||
'elements export/275), region 66 (Свердловская обл.). Ключ mkd_code. houseguid — ФИАС '
|
||||
'GUID дома, матчится к houses через COALESCE(gar_house_guid, house_fias_id, '
|
||||
'zhkh_house_guid) (app/services/domrf_kapremont_loader.py). wall_material берётся из '
|
||||
'КР1.2 со строки конструктивного элемента «фасад» (единственная строка на mkd_code, '
|
||||
'где это поле непустое). Заполняется app/tasks/domrf_kapremont_load.py (issue #2013).';
|
||||
|
||||
COMMIT;
|
||||
51
tradein-mvp/backend/data/sql/177_deals_city_region.sql
Normal file
51
tradein-mvp/backend/data/sql/177_deals_city_region.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
-- 177_deals_city_region.sql
|
||||
-- deals.city + region_code backfill — расширение Mera trade-in estimator с
|
||||
-- Екатеринбурга на ВСЮ Свердловскую область (region_code=66, все города).
|
||||
--
|
||||
-- WHY:
|
||||
-- Ночной import_rosreestr_dkp (scheduler.py, source='rosreestr_dkp_import')
|
||||
-- до сих пор фильтровал `city ILIKE '%катеринбург%'` и хардкодил
|
||||
-- 'Екатеринбург, ' в address, а deals.region_code никогда не заполнялся
|
||||
-- (NULL на всех 49791 текущих строках). Foreign table
|
||||
-- gendesign_rosreestr_deals уже отдаёт чистые city + region_code из
|
||||
-- source-таблицы (migration 072) — снятие фильтра открывает +47183
|
||||
-- не-ЕКБ сделок, уже сидящих в источнике.
|
||||
--
|
||||
-- WHAT:
|
||||
-- - deals.region_code уже существует (002_core_tables.sql), но deals.city
|
||||
-- отсутствовал (было только текстовое поле address с хардкод-префиксом
|
||||
-- 'Екатеринбург, '). Добавляем city text — импортёр (scheduler.py,
|
||||
-- тот же PR) теперь заполняет его из реального source.city.
|
||||
-- - Индекс (city, deal_date) — под будущие per-city аналитические запросы
|
||||
-- (аналог deals_source_idx для (source, deal_date)).
|
||||
-- - Backfill существующих 49791 строк: все текущие deals с source='rosreestr'
|
||||
-- импортированы ДО этой миграции старым EKB-only кодом, т.е. фактически
|
||||
-- все они — Екатеринбург (то же допущение, что зашито в address
|
||||
-- 'Екатеринбург, ' + street). Простановка region_code=66, city='Екатеринбург'
|
||||
-- задним числом делает старые строки self-describing наравне с новыми.
|
||||
--
|
||||
-- SCOPE: только schema (city column + index) + backfill старых rosreestr-строк.
|
||||
-- Новые импорты (после деплоя scheduler.py) заполняют оба поля сами.
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- - ADD COLUMN IF NOT EXISTS / CREATE INDEX IF NOT EXISTS — безопасный re-run.
|
||||
-- - Backfill UPDATE ограничен `region_code IS NULL OR city IS NULL` —
|
||||
-- повторный прогон no-op (после первого раза условие уже false).
|
||||
--
|
||||
-- Dependencies: 002_core_tables.sql (deals, deals.region_code)
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE deals ADD COLUMN IF NOT EXISTS city text;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deals_city_deal_date_idx ON deals (city, deal_date);
|
||||
|
||||
-- Backfill: все существующие rosreestr-сделки импортированы старым EKB-only
|
||||
-- кодом (до этой миграции) => гарантированно Екатеринбург, region_code=66.
|
||||
UPDATE deals
|
||||
SET region_code = 66,
|
||||
city = 'Екатеринбург'
|
||||
WHERE source = 'rosreestr'
|
||||
AND (region_code IS NULL OR city IS NULL);
|
||||
|
||||
COMMIT;
|
||||
87
tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql
Normal file
87
tradein-mvp/backend/data/sql/178_deal_city_price_bands.sql
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
-- 178_deal_city_price_bands.sql
|
||||
-- Per-city ppm² plausible-deal guard-bands — снятие Екатеринбург-центричного
|
||||
-- глобального DEAL_MIN_PPM2=50_000 (estimator.py) для остальной Свердловской обл.
|
||||
--
|
||||
-- WHY:
|
||||
-- Миграция 177 открыла +47183 не-ЕКБ сделок (deals.city заполнен для всей
|
||||
-- области). Но estimator.py всё ещё режет ДКП-сделки абсолютным guard-band
|
||||
-- [DEAL_MIN_PPM2=50_000, DEAL_MAX_PPM2=800_000] ₽/м² — калиброванным
|
||||
-- ИСКЛЮЧИТЕЛЬНО под ЕКБ-вторичку (см. комментарий у DEAL_MIN_PPM2 в
|
||||
-- estimator.py). Для дешёвых городов области этот пол — не anti-outlier
|
||||
-- guard, а cut-off легитимного рынка:
|
||||
-- Североуральск median ≈ 21.7k ₽/м², Новоуральск ≈ 36k, Асбест ≈ 41k —
|
||||
-- ВСЕ ниже 50k → 46.6% не-ЕКБ сделок молча дропаются как «доля/ошибка»,
|
||||
-- хотя это нормальная цена для города.
|
||||
--
|
||||
-- WHAT:
|
||||
-- Таблица deal_city_price_bands — per-city [ppm2_min, ppm2_max] band,
|
||||
-- вычисленный из РЕАЛЬНЫХ rosreestr-сделок города (p1/p99 персентиль,
|
||||
-- зажатый абсолютными hard-floor/ceiling 8000/800000 — доли и опечатки
|
||||
-- режутся даже в самых дешёвых городах). Екатеринбург НАМЕРЕННО исключён
|
||||
-- из популяции — estimator.py при отсутствии строки в этой таблице
|
||||
-- fallback'ит на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 константы, т.е.
|
||||
-- ЕКБ-поведение byte-identical сегодняшнему (см. invariant в PR).
|
||||
--
|
||||
-- Города с < 30 сделок исключены (HAVING count(*) >= 30) — слишком тонкая
|
||||
-- выборка для устойчивого перцентиля, estimator fallback'ит на global band.
|
||||
--
|
||||
-- USAGE (estimator.py, тот же PR):
|
||||
-- - SQL enforcement (_fetch_dkp_corridor, ~line 1263): LEFT JOIN на
|
||||
-- deals.city = deal_city_price_bands.city, COALESCE(b.ppm2_min/max, global).
|
||||
-- - Python enforcement (_is_plausible_deal via _fetch_deals): bands dict,
|
||||
-- загружен один раз через _load_city_price_bands(db), .get(city, global).
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- CREATE TABLE IF NOT EXISTS — безопасный re-run. Populate — INSERT ...
|
||||
-- ON CONFLICT (city) DO UPDATE, т.е. повторный прогон РЕФРЕШИТ бэнды под
|
||||
-- свежие сделки (не no-op по дизайну — таблица предполагает периодический
|
||||
-- refresh по мере роста deals; scheduler для авто-рефреша НЕ входит в этот
|
||||
-- PR, см. follow-up в PR description).
|
||||
--
|
||||
-- Dependencies: 177_deals_city_region.sql (deals.city, backfill).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deal_city_price_bands (
|
||||
city text PRIMARY KEY,
|
||||
ppm2_min int NOT NULL,
|
||||
ppm2_max int NOT NULL,
|
||||
n_deals int NOT NULL,
|
||||
refreshed_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE deal_city_price_bands IS
|
||||
'Per-city ppm² plausible-deal guard-bands (estimator.py _is_plausible_deal / '
|
||||
'_fetch_dkp_corridor). Екатеринбург НЕ включён — estimator fallback на глобальные '
|
||||
'DEAL_MIN_PPM2=50000/DEAL_MAX_PPM2=800000 сохраняет ЕКБ-поведение byte-identical. '
|
||||
'Refresh: повторный прогон этого файла (ON CONFLICT DO UPDATE); авто-scheduler '
|
||||
'пока не реализован (см. issue follow-up).';
|
||||
|
||||
INSERT INTO deal_city_price_bands (city, ppm2_min, ppm2_max, n_deals, refreshed_at)
|
||||
SELECT
|
||||
city,
|
||||
GREATEST(round(percentile_cont(0.01) WITHIN GROUP (ORDER BY price_per_m2))::int, 8000)
|
||||
AS ppm2_min,
|
||||
LEAST(round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int, 800000)
|
||||
AS ppm2_max,
|
||||
count(*) AS n_deals,
|
||||
now() AS refreshed_at
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND price_per_m2 IS NOT NULL
|
||||
AND city IS NOT NULL
|
||||
AND city <> 'Екатеринбург'
|
||||
GROUP BY city
|
||||
HAVING count(*) >= 30
|
||||
-- Инвариант ppm2_max >= ppm2_min: если весь распределение города < 8000 ₽/м²
|
||||
-- (p99 < 8000), LEAST(p99,800000) < GREATEST(p1,8000) → band [8000, <8000] не
|
||||
-- матчит ничего → все сделки города молча дропались бы. Гейтим такие города
|
||||
-- (для жилой ДКП недостижимо, но airtight против мусорного города).
|
||||
AND round(percentile_cont(0.99) WITHIN GROUP (ORDER BY price_per_m2))::int >= 8000
|
||||
ON CONFLICT (city) DO UPDATE
|
||||
SET ppm2_min = EXCLUDED.ppm2_min,
|
||||
ppm2_max = EXCLUDED.ppm2_max,
|
||||
n_deals = EXCLUDED.n_deals,
|
||||
refreshed_at = EXCLUDED.refreshed_at;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
-- 179_scrape_schedules_seed_oblast_city_sweeps.sql
|
||||
-- Seed rows для oblast-wide city-sweep (Свердловская область, region 66) — B1 rollout:
|
||||
-- avito/cian/yandex city-sweep за пределами Екатеринбурга для 5 городов области.
|
||||
-- Domclick (BFF, city_id-based) — отдельный B2 rollout, сюда НЕ входит.
|
||||
--
|
||||
-- !!! DORMANT BY DESIGN !!! Все 15 строк ship enabled = false.
|
||||
-- Причина: в проксях-пуле сейчас только ОДИН здоровый прокси (см. scrape_proxies,
|
||||
-- 157/173) — параллельный/резкий rollout на 5 новых городов рискует забанить его.
|
||||
-- Capability полностью wired (pipeline.CITY_ANCHORS/get_city_anchors,
|
||||
-- scheduler._job_{avito,cian,yandex}_city_sweep читают default_params->>'city' и
|
||||
-- резолвят anchors города, wildcard-registry "*_city_sweep_*" в
|
||||
-- scraper_kit.orchestration.scheduler._default_kit_handlers), но schedule'ы
|
||||
-- намеренно ВЫКЛЮЧЕНЫ. Оператор включает ВРУЧНУЮ по одному городу за раз:
|
||||
-- UPDATE scrape_schedules SET enabled = true WHERE source = 'avito_city_sweep_nizhniy_tagil';
|
||||
--
|
||||
-- WHY отдельный source на город (а не один source + params.city со списком городов):
|
||||
-- scrape_schedules.source — UNIQUE (052_scrape_schedules.sql). Несколько строк с
|
||||
-- одним source='avito_city_sweep' невозможны (второй INSERT/UPDATE тем же source
|
||||
-- либо конфликтует, либо перезаписывает первую строку — по факту только один город
|
||||
-- был бы шедулирован). Поэтому каждый (source_type, город) — своя строка с source =
|
||||
-- '<source_type>_<city_slug>' (например 'cian_city_sweep_kamensk_uralskiy'), а
|
||||
-- `resolve_handler` в scraper_kit.orchestration.scheduler резолвит её на тот же
|
||||
-- handler, что и EKB source, через wildcard-запись "avito_city_sweep_*" (и т.п.,
|
||||
-- тот же механизм, что уже используется для "deactivate_stale_*"). Job читает
|
||||
-- default_params->>'city' и подставляет anchors города вместо EKB_ANCHORS.
|
||||
--
|
||||
-- default_params: source's текущие прод-дефолты (см. SELECT ниже) + {"city": "<slug>"}.
|
||||
-- Никакие другие поля (radius_m/pages_per_anchor/...) НЕ меняются в этой миграции —
|
||||
-- один anchor на город (pipeline.CITY_ANCHORS) может не покрывать город целиком на
|
||||
-- текущем radius_m (1500м у avito/cian) — ОПЕРАТОР должен пересмотреть radius_m перед
|
||||
-- enable (город крупнее одного 1500м-круга — напр. Н.Тагил/Каменск-Уральский).
|
||||
--
|
||||
-- window_start_hour/window_end_hour (UTC, 1-часовые окна) — расставлены НЕ пересекаясь
|
||||
-- ни друг с другом, ни с уже enabled EKB-окнами (avito 6-7, cian 2-5, yandex 16-17) —
|
||||
-- на случай если оператор включит несколько строк сразу (proxy-pool = 1, конкурентные
|
||||
-- sweep'ы разных источников делили бы тот же прокси).
|
||||
--
|
||||
-- ЗАВИСИМОСТИ: 052_scrape_schedules.sql (таблица + UNIQUE(source)).
|
||||
-- Idempotent: ON CONFLICT (source) DO NOTHING — каждый source в этой миграции уникален
|
||||
-- по построению (SELECT ниже подтверждает текущие прод-дефолты трёх EKB source'ов,
|
||||
-- НЕ вставляется повторно и не конфликтует с ними — city_sweep_<slug> != city_sweep).
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
next_run_at,
|
||||
default_params
|
||||
)
|
||||
VALUES
|
||||
-- ── avito_city_sweep_<city> (база: radius_m 1500, detail_top_n 20, enrich_houses true,
|
||||
-- pages_per_anchor 3, request_delay_sec 7 — прод-дефолты avito_city_sweep) ──────────
|
||||
(
|
||||
'avito_city_sweep_nizhniy_tagil',
|
||||
false,
|
||||
0,
|
||||
1,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 0)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "nizhniy_tagil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_kamensk_uralskiy',
|
||||
false,
|
||||
1,
|
||||
2,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 1)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "kamensk_uralskiy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_pervouralsk',
|
||||
false,
|
||||
5,
|
||||
6,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 5)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "pervouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_verkhnyaya_pyshma',
|
||||
false,
|
||||
7,
|
||||
8,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 7)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "verkhnyaya_pyshma"}'::jsonb
|
||||
),
|
||||
(
|
||||
'avito_city_sweep_serov',
|
||||
false,
|
||||
8,
|
||||
9,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 8)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "detail_top_n": 20, "request_delay_sec": 7.0, "enrich_houses": true, "radius_m": 1500, "city": "serov"}'::jsonb
|
||||
),
|
||||
-- ── cian_city_sweep_<city> (база: radius_m 1500, detail_top_n 10, enrich_houses true,
|
||||
-- pages_per_anchor 3, request_delay_sec 5 — прод-дефолты cian_city_sweep) ─────────
|
||||
(
|
||||
'cian_city_sweep_nizhniy_tagil',
|
||||
false,
|
||||
9,
|
||||
10,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "nizhniy_tagil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_kamensk_uralskiy',
|
||||
false,
|
||||
10,
|
||||
11,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 10)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "kamensk_uralskiy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_pervouralsk',
|
||||
false,
|
||||
11,
|
||||
12,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 11)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "pervouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_verkhnyaya_pyshma',
|
||||
false,
|
||||
12,
|
||||
13,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 12)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "verkhnyaya_pyshma"}'::jsonb
|
||||
),
|
||||
(
|
||||
'cian_city_sweep_serov',
|
||||
false,
|
||||
13,
|
||||
14,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 13)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 5, "radius_m": 1500, "detail_top_n": 10, "enrich_houses": true, "city": "serov"}'::jsonb
|
||||
),
|
||||
-- ── yandex_city_sweep_<city> (база: radius_m 25000, pages_per_anchor 3,
|
||||
-- request_delay_sec 9 — прод-дефолты yandex_city_sweep; radius_m большой ok —
|
||||
-- per 119_yandex_city_sweep_center_combos.sql это лишь fallback-фильтр, не
|
||||
-- query-ограничение, лишний охват не увеличивает load) ────────────────────────
|
||||
(
|
||||
'yandex_city_sweep_nizhniy_tagil',
|
||||
false,
|
||||
14,
|
||||
15,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 14)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "nizhniy_tagil"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_kamensk_uralskiy',
|
||||
false,
|
||||
15,
|
||||
16,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 15)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "kamensk_uralskiy"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_pervouralsk',
|
||||
false,
|
||||
17,
|
||||
18,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 17)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "pervouralsk"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_verkhnyaya_pyshma',
|
||||
false,
|
||||
18,
|
||||
19,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 18)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "verkhnyaya_pyshma"}'::jsonb
|
||||
),
|
||||
(
|
||||
'yandex_city_sweep_serov',
|
||||
false,
|
||||
19,
|
||||
20,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 19)) AT TIME ZONE 'UTC',
|
||||
'{"pages_per_anchor": 3, "request_delay_sec": 9, "radius_m": 25000, "city": "serov"}'::jsonb
|
||||
)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE scrape_schedules IS
|
||||
'In-app scheduler config (заменяет cron-script setup). '
|
||||
'Sources: avito_city_sweep, yandex_city_sweep, cian_city_sweep (EKB, enabled), '
|
||||
'avito_full_load, avito_full_load_exhaustive, cian_full_load, domclick_city_sweep, '
|
||||
'avito_city_sweep_<city>/cian_city_sweep_<city>/yandex_city_sweep_<city> '
|
||||
'(oblast B1 rollout — nizhniy_tagil/kamensk_uralskiy/pervouralsk/'
|
||||
'verkhnyaya_pyshma/serov, dormant, #179 — resolve_handler wildcard "*_city_sweep_*"), '
|
||||
'cian_history_backfill, rosreestr_dkp_import, listing_source_snapshot, '
|
||||
'asking_to_sold_ratio_refresh, refresh_search_matview, yandex_address_backfill, '
|
||||
'sber_index_pull, rosreestr_quarter_poll, deals_freshness_monitor, newbuilding_enrich, '
|
||||
'yandex_newbuilding_sweep, geoportal_coords_backfill, geocode_missing_listings, '
|
||||
'avito_detail_backfill, yandex_detail_backfill, cadastral_geo_match, '
|
||||
'osm_poi_ekb_refresh, house_imv_backfill, house_dedup_merge, proxy_healthcheck, '
|
||||
'deactivate_stale_avito/yandex/cian.';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
-- 180_seed_sber_freshness_monitor.sql
|
||||
-- Audit п.1 — seed scrape_schedules row for the daily СберИндекс data-freshness monitor.
|
||||
--
|
||||
-- Context (verified on prod 2026-07-12):
|
||||
-- estimator._load_sber_index_series (#794/#audit-5a) applies a СберИндекс time-adjustment
|
||||
-- to ДКП deals and only LOGS a per-estimate warning when the latest series month is older
|
||||
-- than settings.sber_index_max_age_days (35d). The `sber_index_pull` job runs monthly
|
||||
-- (enabled), but СберИндекс publishes with an inherent ~1-2 month lag, so
|
||||
-- sber_price_index.period_month drifts. Current max(period_month) for the oblast secondary
|
||||
-- segment = 2026-05-01 (~72d old). NOT a silent failure, but the staleness is buried in
|
||||
-- per-estimate warnings — invisible at monitor cadence.
|
||||
--
|
||||
-- Audit gap: ops has no monitor-frequency signal for СберИндекс staleness. This monitor
|
||||
-- watches max(period_month) directly and raises a per-day WARNING when the data is stale
|
||||
-- beyond the allowed publication lag.
|
||||
--
|
||||
-- Staleness threshold (app/tasks/sber_freshness_monitor.py::evaluate_sber_freshness):
|
||||
-- age_days = now() - max(period_month)
|
||||
-- max_age_days = settings.sber_index_max_age_days (35) + lag_allowance_days (25) = 60
|
||||
-- stale = age_days > max_age_days
|
||||
-- The +25 lag_allowance covers the INHERENT СберИндекс publication lag (source is 1-2mo
|
||||
-- behind; period_month is the first-of-month label; the monthly pull has not yet caught the
|
||||
-- newest period). Below 60d the latest period is treated as normally-lagging -> NO alert
|
||||
-- (avoids daily noise on штатный lag). Above 60d the data is stuck beyond ~2 months -> alert.
|
||||
-- At 2026-07-12: max=2026-05-01, age=72d > 60 -> alert=1 (surfaces the current drift).
|
||||
--
|
||||
-- Components deployed together:
|
||||
-- 1. app/tasks/sber_freshness_monitor.py — check_sber_freshness() + evaluate_sber_freshness()
|
||||
-- 2. app/services/product_handlers.py — kit Handler 'sber_freshness_monitor'
|
||||
-- 3. This migration — seeds the scrape_schedules row (enabled=true, daily window)
|
||||
--
|
||||
-- Schedule window 09:00-10:00 UTC:
|
||||
-- Daily cadence, placed AFTER deals_freshness_monitor (08:00-09:00 UTC) so the two
|
||||
-- data-freshness monitors do not overlap. Cheap: one SELECT max(period_month), no external
|
||||
-- HTTP — safe to run daily even though sber_index_pull itself is monthly.
|
||||
--
|
||||
-- default_params.lag_allowance_days = 25:
|
||||
-- Allowance for СберИндекс publication lag on top of the 35d per-estimate guard (-> 60d).
|
||||
--
|
||||
-- next_run_at bootstrapped to tomorrow 09:00 UTC — scheduler will not fire immediately
|
||||
-- on deploy (same pattern as 162/093/096/160).
|
||||
--
|
||||
-- Idempotent: ON CONFLICT (source) DO NOTHING — safe to re-apply.
|
||||
--
|
||||
-- Dependencies:
|
||||
-- 052_scrape_schedules.sql (table + UNIQUE(source))
|
||||
-- sber_price_index table (populated by app.tasks.sber_index_pull)
|
||||
-- app/services/product_handlers.py + tasks/sber_freshness_monitor.py deployed
|
||||
--
|
||||
-- Deploy order:
|
||||
-- Apply after deploying the product_handlers.py + task changes so the dispatch can resolve
|
||||
-- 'sber_freshness_monitor' correctly on first fire.
|
||||
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO scrape_schedules (
|
||||
source,
|
||||
enabled,
|
||||
window_start_hour,
|
||||
window_end_hour,
|
||||
next_run_at,
|
||||
default_params
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'sber_freshness_monitor',
|
||||
true, -- SAFE: single SELECT max(period_month), no ext calls
|
||||
9, -- window 09:00-10:00 UTC (after deals_freshness_monitor)
|
||||
10,
|
||||
((CURRENT_DATE + INTERVAL '1 day') + make_interval(hours => 9)) AT TIME ZONE 'UTC',
|
||||
'{"lag_allowance_days": 25}'::jsonb -- publication-lag allowance over the 35d per-estimate guard
|
||||
)
|
||||
ON CONFLICT (source) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
16
tradein-mvp/backend/data/sql/181_clamp_bad_listing_dates.sql
Normal file
16
tradein-mvp/backend/data/sql/181_clamp_bad_listing_dates.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
-- 181_clamp_bad_listing_dates.sql
|
||||
-- LOW audit R2 (#6): null out implausible listing_date/publish_date left by scrapers
|
||||
-- before the clamp guard landed (Avito 1970-epoch: ~698 rows; near-future: ~79 rows).
|
||||
-- Idempotent: re-running is a no-op once values are already NULL / in-window.
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE listings SET listing_date = NULL
|
||||
WHERE listing_date IS NOT NULL
|
||||
AND (listing_date < DATE '2010-01-01' OR listing_date > CURRENT_DATE + 2);
|
||||
|
||||
UPDATE listings SET publish_date = NULL
|
||||
WHERE publish_date IS NOT NULL
|
||||
AND (publish_date < DATE '2010-01-01' OR publish_date > CURRENT_DATE + 2);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
-- 182_trade_in_leads_consent_proof.sql
|
||||
-- LOW audit R2 (#9): durable 152-ФЗ proof-of-consent for trade_in_leads.
|
||||
--
|
||||
-- WHY:
|
||||
-- #2497 already computes client_ip (X-Forwarded-For, fallback direct peer)
|
||||
-- and pins _CONSENT_POLICY_VERSION = "2026-07" in app/api/v1/lead.py, but
|
||||
-- both were written ONLY to the audit log — trade_in_leads had no columns
|
||||
-- to hold them (explicit TODO at the INSERT in lead.py). A bare boolean
|
||||
-- `consent` column plus a log line is not durable proof: logs rotate/expire,
|
||||
-- the row itself carries no evidence of what was agreed to, under what
|
||||
-- policy version, or from what IP.
|
||||
--
|
||||
-- WHAT:
|
||||
-- Three nullable columns on trade_in_leads:
|
||||
-- - client_ip (inet) — same value already computed in
|
||||
-- create_trade_in_lead, now persisted
|
||||
-- per-row instead of log-only.
|
||||
-- - consent_policy_version (text) — snapshot of _CONSENT_POLICY_VERSION
|
||||
-- at the time this lead was captured.
|
||||
-- - consent_text_snapshot (text) — snapshot of the exact consent sentence
|
||||
-- shown to the user (_CONSENT_TEXT_SNAPSHOT
|
||||
-- in lead.py, same PR).
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- ADD COLUMN IF NOT EXISTS x3 — safe re-run. All nullable, no DEFAULT, no
|
||||
-- backfill: existing rows keep NULL (no consent proof was captured for them
|
||||
-- pre-migration — that's an honest reflection of what actually happened,
|
||||
-- not a schema gap). Purely additive: no existing reads/writes break.
|
||||
--
|
||||
-- Dependencies: 172_trade_in_leads.sql (trade_in_leads table).
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE trade_in_leads
|
||||
ADD COLUMN IF NOT EXISTS client_ip inet,
|
||||
ADD COLUMN IF NOT EXISTS consent_policy_version text,
|
||||
ADD COLUMN IF NOT EXISTS consent_text_snapshot text;
|
||||
|
||||
COMMENT ON COLUMN trade_in_leads.client_ip IS '152-ФЗ proof-of-consent: клиентский IP на момент согласия (ранее только в audit-логе, #2497 TODO).';
|
||||
COMMENT ON COLUMN trade_in_leads.consent_policy_version IS '152-ФЗ proof-of-consent: снимок _CONSENT_POLICY_VERSION на момент согласия.';
|
||||
COMMENT ON COLUMN trade_in_leads.consent_text_snapshot IS '152-ФЗ proof-of-consent: снимок текста согласия, показанного пользователю (_CONSENT_TEXT_SNAPSHOT).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
-- 183_reenable_deactivate_stale_domklik.sql
|
||||
--
|
||||
-- ПРОДУКТОВОЕ РЕШЕНИЕ (user, 2026-07-13): ВКЛЮЧАЕМ обратно TTL-деактивацию
|
||||
-- deactivate_stale_domklik (была отключена миграцией 163 «до возобновления
|
||||
-- регулярных domclick-свипов»).
|
||||
--
|
||||
-- Условие из 163 снято: свипы domclick_city_sweep + domclick_detail_backfill
|
||||
-- возобновлены и стабильно бегают ежедневно (scraped_at свежий, диапазон
|
||||
-- последней недели), т.е. причина отключения больше не действует.
|
||||
--
|
||||
-- Безопасность re-enable (LOW audit R2 #10):
|
||||
-- • Джоб ключуется на scraped_at (whitelist last_seen_at/scraped_at в
|
||||
-- deactivate_stale_avito.py::_ALLOWED_STALENESS_COLUMNS), НЕ на listing_date —
|
||||
-- исходная гипотеза «выпадет весь domklik по NULL listing_date» неприменима.
|
||||
-- • При живых свипах scraped_at < NOW()-14d только у реально протухших строк,
|
||||
-- а не у всего пула (в отличие от ситуации 163, где свипы стояли) —
|
||||
-- деактивация корректна, а не тотальна. Чинит завышенный freshness domklik.
|
||||
--
|
||||
-- Идемпотентно: повторный прогон — no-op (enabled уже true). Row создаётся
|
||||
-- миграцией 160; если отсутствует — UPDATE затронет 0 строк (безопасно).
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE scrape_schedules
|
||||
SET enabled = true
|
||||
WHERE source = 'deactivate_stale_domklik';
|
||||
|
||||
COMMIT;
|
||||
60
tradein-mvp/backend/data/sql/184_user_events.sql
Normal file
60
tradein-mvp/backend/data/sql/184_user_events.sql
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
-- 184_user_events.sql
|
||||
-- Foundation schema for Features 2 & 3: unified user-event tracking.
|
||||
--
|
||||
-- WHY:
|
||||
-- trade-in had no durable event log for login/IP audit or behavior
|
||||
-- analytics. A near-identical design already existed once — `audit_log`
|
||||
-- (event_type, ip_address inet, user_agent, estimate_id uuid, payload
|
||||
-- jsonb, created_at) was defined in 002_core_tables.sql and DROPPED in
|
||||
-- 095_dead_schema.sql as unused dead schema at the time. Product now
|
||||
-- needs exactly that shape again, so this migration resurrects the
|
||||
-- design under a new name, `user_events`, as the ONE unified
|
||||
-- append-only table serving:
|
||||
-- - login-audit (who logged in, from what IP/UA, when)
|
||||
-- - search-audit (estimate requests, listing lookups)
|
||||
-- - behavior-analytics (pdf_download, listing_click, page_view,
|
||||
-- drawer_open, and future event_type values)
|
||||
--
|
||||
-- WHAT:
|
||||
-- `user_events` — append-only, admin-read-only. No 152-ФЗ consent
|
||||
-- gating and no mandatory retention policy for MVP (product-owner
|
||||
-- decision): we log IP/UA/path/method/payload unconditionally for every
|
||||
-- tracked event. `estimate_id` is a plain uuid column with NO FK
|
||||
-- constraint on purpose — this keeps the log decoupled/append-only so
|
||||
-- estimate deletion (or any future estimate lifecycle change) never
|
||||
-- blocks or cascades into event rows.
|
||||
--
|
||||
-- IDEMPOTENCY / SAFETY:
|
||||
-- CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS throughout —
|
||||
-- safe re-run. Purely additive: no existing table/view/column is
|
||||
-- touched.
|
||||
--
|
||||
-- Dependencies: none (new standalone table). Auto-applied on deploy via
|
||||
-- _schema_migrations tracking (tradein-mvp/backend/data/sql convention).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_events (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
event_type text NOT NULL,
|
||||
username text NOT NULL,
|
||||
ip_address inet,
|
||||
user_agent text,
|
||||
path text,
|
||||
method text,
|
||||
estimate_id uuid,
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE user_events IS 'Unified append-only event log (login/IP audit + behavior analytics), admin-read-only. Resurrects the design of audit_log (002_core_tables.sql, dropped in 095_dead_schema.sql).';
|
||||
COMMENT ON COLUMN user_events.event_type IS 'e.g. login / estimate_request / pdf_download / listing_click / page_view / drawer_open.';
|
||||
COMMENT ON COLUMN user_events.username IS 'X-Authenticated-User value at the time of the event.';
|
||||
COMMENT ON COLUMN user_events.estimate_id IS 'No FK constraint by design — keeps the log decoupled/append-only from trade_in_estimates lifecycle.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_events_username_created_at_idx ON user_events (username, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS user_events_event_type_created_at_idx ON user_events (event_type, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS user_events_ip_address_idx ON user_events (ip_address);
|
||||
CREATE INDEX IF NOT EXISTS user_events_created_at_idx ON user_events (created_at DESC);
|
||||
|
||||
COMMIT;
|
||||
59
tradein-mvp/backend/data/sql/185_account_quota_overrides.sql
Normal file
59
tradein-mvp/backend/data/sql/185_account_quota_overrides.sql
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
-- Migration 185: account_quota_overrides — персональные месячные лимиты оценок
|
||||
--
|
||||
-- WHY:
|
||||
-- Бонусные попытки для платящих пилотов раздавались хаком через SQL-runbook:
|
||||
-- `UPDATE account_estimate_usage SET used = used - N` (negative used). Для user2
|
||||
-- (Брусника) это дало used=-35 при глобальном limit=15 → GET /quota отдавал
|
||||
-- {limit:15, used:-35, remaining:50} → фронт (EstimateForm.tsx) рендерил абсурд
|
||||
-- «Осталось 50 из 15». Правильное решение — персональный лимит, а не отрицательный
|
||||
-- счётчик использования.
|
||||
--
|
||||
-- WHAT:
|
||||
-- 1. Таблица account_quota_overrides(username PK, monthly_limit, note, updated_at) —
|
||||
-- per-user override глобального settings.estimate_quota_limit. Читается через
|
||||
-- app.services.account_quota.user_limit().
|
||||
-- 2. Seed: user2 (Брусника) → monthly_limit=50, пилотный грант 2026-07-13.
|
||||
-- 3. Сброс хака: account_estimate_usage.used < 0 → 0 (для user2, единственного
|
||||
-- затронутого аккаунта). После этой миграции user2: limit=50, used=0,
|
||||
-- remaining=50 — корректно вместо «50 из 15».
|
||||
--
|
||||
-- IDEMPOTENCY:
|
||||
-- CREATE TABLE IF NOT EXISTS + INSERT ... ON CONFLICT DO UPDATE (upsert, безопасен
|
||||
-- при повторном применении) + UPDATE ... WHERE used < 0 (после первого прогона
|
||||
-- used=0, условие больше не матчит — безопасный no-op при ре-apply).
|
||||
--
|
||||
-- Dependencies: 076_account_estimate_quota.sql (account_estimate_usage).
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_quota_overrides (
|
||||
username text PRIMARY KEY,
|
||||
monthly_limit integer NOT NULL,
|
||||
note text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE account_quota_overrides IS
|
||||
'Персональный override месячного лимита оценок trade-in (вместо глобального '
|
||||
'settings.estimate_quota_limit). Читается app.services.account_quota.user_limit().';
|
||||
|
||||
COMMENT ON COLUMN account_quota_overrides.monthly_limit IS
|
||||
'Персональный лимит успешных оценок в календарный месяц для username.';
|
||||
|
||||
COMMENT ON COLUMN account_quota_overrides.note IS
|
||||
'Причина override (grant / pilot / manual bump) — для аудита ручных изменений.';
|
||||
|
||||
INSERT INTO account_quota_overrides (username, monthly_limit, note)
|
||||
VALUES ('user2', 50, 'Брусника — пилот, грант 2026-07-13')
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
monthly_limit = EXCLUDED.monthly_limit,
|
||||
note = EXCLUDED.note,
|
||||
updated_at = now();
|
||||
|
||||
-- Сбрасываем прежний хак бонусных попыток (negative used), который ломал /quota:
|
||||
-- limit=15, used=-35 → remaining=50 → фронт рендерил «Осталось 50 из 15».
|
||||
UPDATE account_estimate_usage
|
||||
SET used = 0, updated_at = now()
|
||||
WHERE username = 'user2' AND used < 0;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -86,6 +86,9 @@ USAGE
|
|||
|
||||
# machine-readable:
|
||||
python -m scripts.backtest_estimator --json
|
||||
|
||||
# oblast D: per-city validation (exact deals.city name, not a slug):
|
||||
python -m scripts.backtest_estimator --city "Нижний Тагил" --sample 300
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -181,6 +184,14 @@ logger = logging.getLogger("backtest_estimator")
|
|||
# Price-per-m² sanity band — shared by the deal sample and the listings
|
||||
# subquery. Mirrors the estimator's working range for EKB вторичка and drops
|
||||
# obvious data-entry garbage / commercial outliers.
|
||||
# (oblast D) When --city is set, _load_sample sources a PER-CITY band from
|
||||
# deal_city_price_bands instead (see _resolve_city_ppm2_band) — these globals
|
||||
# remain the fallback (default/unscoped sample, and any city with no band row,
|
||||
# e.g. Екатеринбург — intentionally excluded from that table).
|
||||
# NB: the listings subquery (_CANDIDATES_SQL, asking-core engine only) still
|
||||
# always uses these globals — left global deliberately (not city-scoped) to
|
||||
# keep the change minimal; the asking-core engine is the legacy comparison
|
||||
# path, not the primary oblast-D target (the full engine, default).
|
||||
PPM2_MIN = 30_000
|
||||
PPM2_MAX = 600_000
|
||||
|
||||
|
|
@ -1043,22 +1054,115 @@ _CANDIDATES_SQL = text(
|
|||
"""
|
||||
)
|
||||
|
||||
# (oblast D) Per-city PPM2 sanity band — same table the estimator's
|
||||
# _fetch_dkp_corridor COALESCEs against (migration 178). Looked up only when
|
||||
# --city is set (see _resolve_city_ppm2_band); the default (city=None) path
|
||||
# never issues this query.
|
||||
_CITY_PPM2_BAND_SQL = text(
|
||||
"""
|
||||
SELECT ppm2_min, ppm2_max
|
||||
FROM deal_city_price_bands
|
||||
WHERE city = CAST(:city AS text)
|
||||
"""
|
||||
)
|
||||
|
||||
def _load_sample(db: Session, *, sample: int, since: str) -> list[DealSample]:
|
||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample]."""
|
||||
rows = (
|
||||
db.execute(
|
||||
_SAMPLE_SQL,
|
||||
{
|
||||
"ppm2_min": PPM2_MIN,
|
||||
"ppm2_max": PPM2_MAX,
|
||||
"since": since,
|
||||
"sample": sample,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
|
||||
def _sample_sql(city: str | None) -> Any:
|
||||
"""ДКП deal-sample SELECT — optionally scoped to one ``deals.city`` (oblast D).
|
||||
|
||||
``city is None`` (default) returns the SAME ``_SAMPLE_SQL`` object used
|
||||
before this change — literal identity, not just equal text — so the
|
||||
default CLI invocation (and the frozen EKB regression gate, which never
|
||||
calls this path at all) see byte-identical SQL.
|
||||
|
||||
When ``city`` is set, an extra ``AND city = CAST(:city AS text)`` predicate
|
||||
scopes the sample to that ``deals.city`` value — populated oblast-wide by
|
||||
the #C1 migration (368 cities, 96,974 deals). Pass the EXACT canonical
|
||||
Russian name as stored in ``deals.city`` (e.g. ``'Нижний Тагил'``), not a
|
||||
slug — see the ``--city`` CLI help for the naming decision.
|
||||
"""
|
||||
if city is None:
|
||||
return _SAMPLE_SQL
|
||||
return text(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
ST_X(geom::geometry) AS lon,
|
||||
ST_Y(geom::geometry) AS lat,
|
||||
rooms,
|
||||
price_per_m2 AS sold_ppm2,
|
||||
deal_date,
|
||||
area_m2,
|
||||
address,
|
||||
floor,
|
||||
total_floors,
|
||||
year_built,
|
||||
house_type
|
||||
FROM deals
|
||||
WHERE source = 'rosreestr'
|
||||
AND geom IS NOT NULL
|
||||
AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
|
||||
AND rooms IS NOT NULL
|
||||
AND area_m2 IS NOT NULL
|
||||
AND area_m2 > 0
|
||||
AND deal_date >= CAST(:since AS date)
|
||||
AND city = CAST(:city AS text)
|
||||
ORDER BY id DESC
|
||||
LIMIT CAST(:sample AS integer)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _resolve_city_ppm2_band(db: Session, city: str | None) -> tuple[float, float]:
|
||||
"""Per-city PPM2 sanity band (oblast D) — falls back to the module globals.
|
||||
|
||||
``city is None`` → ``(PPM2_MIN, PPM2_MAX)``, unchanged default behaviour,
|
||||
NO extra query. Otherwise looks up ``deal_city_price_bands`` for that
|
||||
city's own ``[ppm2_min, ppm2_max]`` — a region-66 town can have a
|
||||
materially different sane ₽/m² range than the EKB-tuned 30k..600k globals
|
||||
(e.g. Нижний Тагил: 16 955..108 175). No row for the city (e.g.
|
||||
Екатеринбург — intentionally excluded from the table, mirrors
|
||||
estimator._fetch_dkp_corridor's own COALESCE fallback) or any DB error →
|
||||
the globals; read-only best-effort, never raises.
|
||||
"""
|
||||
if city is None:
|
||||
return float(PPM2_MIN), float(PPM2_MAX)
|
||||
try:
|
||||
row = db.execute(_CITY_PPM2_BAND_SQL, {"city": city}).mappings().first()
|
||||
except Exception as exc: # pragma: no cover — defensive, read-only best-effort
|
||||
logger.warning("city PPM2 band lookup failed for %r (fallback to global): %s", city, exc)
|
||||
return float(PPM2_MIN), float(PPM2_MAX)
|
||||
if row is None or row["ppm2_min"] is None or row["ppm2_max"] is None:
|
||||
return float(PPM2_MIN), float(PPM2_MAX)
|
||||
return float(row["ppm2_min"]), float(row["ppm2_max"])
|
||||
|
||||
|
||||
def _load_sample(
|
||||
db: Session, *, sample: int, since: str, city: str | None = None
|
||||
) -> list[DealSample]:
|
||||
"""Run the held-out ДКП deal sampling SELECT → list[DealSample].
|
||||
|
||||
``city`` (oblast D, default None) scopes the sample to one ``deals.city``
|
||||
value via ``_sample_sql`` and sources the PPM2 sanity band from
|
||||
``deal_city_price_bands`` for that city (``_resolve_city_ppm2_band``,
|
||||
falls back to the module globals). Default None is byte-identical to the
|
||||
pre-oblast-D behaviour: same SQL object, same PPM2_MIN/PPM2_MAX globals,
|
||||
no extra query.
|
||||
"""
|
||||
if city is None:
|
||||
ppm2_min: float = PPM2_MIN
|
||||
ppm2_max: float = PPM2_MAX
|
||||
else:
|
||||
ppm2_min, ppm2_max = _resolve_city_ppm2_band(db, city)
|
||||
params: dict[str, Any] = {
|
||||
"ppm2_min": ppm2_min,
|
||||
"ppm2_max": ppm2_max,
|
||||
"since": since,
|
||||
"sample": sample,
|
||||
}
|
||||
if city is not None:
|
||||
params["city"] = city
|
||||
rows = db.execute(_sample_sql(city), params).mappings().all()
|
||||
out: list[DealSample] = []
|
||||
for r in rows:
|
||||
if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
|
||||
|
|
@ -1516,11 +1620,22 @@ def _predict_full_spine(
|
|||
)
|
||||
|
||||
# ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ───────
|
||||
dkp_raw = m._fetch_dkp_corridor(db, address=deal.address, rooms=deal.rooms, area=deal.area_m2)
|
||||
# (oblast C2 parity): estimate_quality now resolves a target city and passes
|
||||
# it to _fetch_dkp_corridor (estimator.py:3210-3220) — deals.city is
|
||||
# populated oblast-wide (368 cities), and an unscoped corridor lets
|
||||
# same-named streets in OTHER region-66 towns (e.g. "Ленина" exists in many)
|
||||
# contaminate the corridor. Mirror that resolve+pass here UNCONDITIONALLY
|
||||
# (independent of --city, which only scopes WHICH deals get sampled) so this
|
||||
# harness measures the SAME corridor prod actually computes today, not the
|
||||
# pre-C2 unscoped behaviour — else the backtest validates stale semantics.
|
||||
target_city = m._resolve_target_city(deal.address)
|
||||
dkp_raw = m._fetch_dkp_corridor(
|
||||
db, address=deal.address, rooms=deal.rooms, area=deal.area_m2, city=target_city
|
||||
)
|
||||
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
|
||||
# estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address
|
||||
# → ([], None) instead of an unconditional fetch.
|
||||
if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address:
|
||||
# estimate_quality — no-area / no-address → ([], None) instead of an
|
||||
# unconditional fetch.
|
||||
if deal.area_m2 and deal.address:
|
||||
# NB(#2002): kept at None deliberately — the deliverable scopes the
|
||||
# resolved-id threading to the Tier-S analog ladder + the house IMV anchor.
|
||||
# Prod ALSO resolves this same-building anchor (estimator.py:2782); flip to
|
||||
|
|
@ -1733,6 +1848,7 @@ def run_backtest(
|
|||
radius: int,
|
||||
rooms_tolerance: int,
|
||||
holdout_split: bool = False,
|
||||
city: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the full read-only backtest and return a metrics dict.
|
||||
|
||||
|
|
@ -1747,9 +1863,13 @@ def run_backtest(
|
|||
so its bias is near-zero by construction — it proves the MECHANISM, not
|
||||
out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
|
||||
to fit on even-id deals and evaluate on the odd-id half for an honest number.
|
||||
|
||||
``city`` (oblast D, default None) scopes the deal sample to one
|
||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||
(byte-identical to the pre-oblast-D behaviour).
|
||||
"""
|
||||
deals = _load_sample(db, sample=sample, since=since)
|
||||
logger.info("loaded sample: %d ДКП deals (since=%s)", len(deals), since)
|
||||
deals = _load_sample(db, sample=sample, since=since, city=city)
|
||||
logger.info("loaded sample: %d ДКП deals (since=%s, city=%s)", len(deals), since, city)
|
||||
|
||||
matched_rows: list[tuple[float, float, int]] = []
|
||||
matched_ids: list[int] = []
|
||||
|
|
@ -1809,6 +1929,7 @@ def run_backtest(
|
|||
"n_matched": len(matched_rows),
|
||||
"n_no_analogs": n_no_analogs,
|
||||
"holdout_split": holdout_split,
|
||||
"city": city,
|
||||
}
|
||||
return metrics
|
||||
|
||||
|
|
@ -1820,6 +1941,7 @@ def run_backtest_full(
|
|||
since: str,
|
||||
dump_fixture: str | None = None,
|
||||
resolve_house_id: bool = False,
|
||||
city: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
|
||||
|
||||
|
|
@ -1844,10 +1966,18 @@ def run_backtest_full(
|
|||
the Tier-S analog ladder + the house Avito-IMV anchor, mirroring prod, and a
|
||||
``house_id_resolution`` coverage block (resolved / total / imv_reachable) is
|
||||
attached to the returned metrics. Default False → byte-identical prior output.
|
||||
|
||||
``city`` (oblast D, default None) scopes the deal sample to one
|
||||
``deals.city`` value — see ``_load_sample``. Default None is unscoped
|
||||
(byte-identical to the pre-oblast-D behaviour). Independently of this flag,
|
||||
``_predict_full_spine`` ALWAYS resolves + passes a per-deal target city to
|
||||
``_fetch_dkp_corridor`` (oblast C2 parity fix) — see its docstring.
|
||||
"""
|
||||
est = _import_estimator_full()
|
||||
deals = _load_sample(db, sample=sample, since=since)
|
||||
logger.info("loaded sample: %d ДКП deals (since=%s) [full spine]", len(deals), since)
|
||||
deals = _load_sample(db, sample=sample, since=since, city=city)
|
||||
logger.info(
|
||||
"loaded sample: %d ДКП deals (since=%s, city=%s) [full spine]", len(deals), since, city
|
||||
)
|
||||
|
||||
predictions: list[Prediction] = []
|
||||
n_no_prediction = 0
|
||||
|
|
@ -1922,6 +2052,7 @@ def run_backtest_full(
|
|||
"n_matched": len(predictions),
|
||||
"n_no_prediction": n_no_prediction,
|
||||
"price_segments_ppm2": [list(seg) for seg in _price_segments()],
|
||||
"city": city,
|
||||
}
|
||||
|
||||
# #2002: house_id resolution coverage — the key Tier-S + IMV reach number.
|
||||
|
|
@ -2011,6 +2142,20 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||
default="2025-06-01",
|
||||
help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--city",
|
||||
default=None,
|
||||
metavar="NAME",
|
||||
help="Oblast D: scope the held-out ДКП deal sample to ONE deals.city value "
|
||||
"(region 66 is oblast-wide — deals.city covers 368 cities, 96,974 deals). "
|
||||
"Pass the EXACT canonical Russian name as stored in deals.city, e.g. "
|
||||
"'Нижний Тагил' or 'Каменск-Уральский' — NOT a slug/transliteration (run "
|
||||
"`SELECT DISTINCT city FROM deals` to check the exact spelling). Also "
|
||||
"sources the deal-sample PPM2 sanity band from deal_city_price_bands for "
|
||||
"that city (falls back to the global PPM2_MIN/PPM2_MAX when the city has "
|
||||
"no band row, e.g. Екатеринбург — intentionally excluded from that table). "
|
||||
"Default None → unscoped sample, byte-identical to the pre-oblast-D SQL.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--radius",
|
||||
type=int,
|
||||
|
|
@ -2115,7 +2260,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
|
||||
logger.info(
|
||||
"backtest start: engine=%s sample=%d since=%s radius=%dm "
|
||||
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s",
|
||||
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s resolve_house_id=%s city=%s",
|
||||
args.engine,
|
||||
args.sample,
|
||||
args.since,
|
||||
|
|
@ -2124,6 +2269,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
args.holdout_split,
|
||||
args.dump_fixture,
|
||||
args.resolve_house_id,
|
||||
args.city,
|
||||
)
|
||||
|
||||
db = _session()
|
||||
|
|
@ -2135,6 +2281,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
since=args.since,
|
||||
dump_fixture=args.dump_fixture,
|
||||
resolve_house_id=args.resolve_house_id,
|
||||
city=args.city,
|
||||
)
|
||||
else:
|
||||
metrics = run_backtest(
|
||||
|
|
@ -2144,6 +2291,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
radius=args.radius,
|
||||
rooms_tolerance=args.rooms_tolerance,
|
||||
holdout_split=args.holdout_split,
|
||||
city=args.city,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@
|
|||
"n_covered": 0
|
||||
},
|
||||
"low": {
|
||||
"coverage_pct": 81.82,
|
||||
"mape_pct": 13.23,
|
||||
"n": 275,
|
||||
"n_covered": 225
|
||||
"coverage_pct": 81.88,
|
||||
"mape_pct": 13.25,
|
||||
"n": 276,
|
||||
"n_covered": 226
|
||||
},
|
||||
"medium": {
|
||||
"coverage_pct": 100.0,
|
||||
"mape_pct": 14.64,
|
||||
"n": 2,
|
||||
"n_covered": 2
|
||||
"mape_pct": 6.99,
|
||||
"n": 1,
|
||||
"n_covered": 1
|
||||
}
|
||||
},
|
||||
"confidence_order": [
|
||||
|
|
@ -136,14 +136,14 @@
|
|||
"n_covered": 0
|
||||
},
|
||||
"low": {
|
||||
"coverage_pct": 81.82,
|
||||
"n": 275,
|
||||
"n_covered": 225
|
||||
"coverage_pct": 81.88,
|
||||
"n": 276,
|
||||
"n_covered": 226
|
||||
},
|
||||
"medium": {
|
||||
"coverage_pct": 100.0,
|
||||
"n": 2,
|
||||
"n_covered": 2
|
||||
"n": 1,
|
||||
"n_covered": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
60
tradein-mvp/backend/tests/scrapers/test_base_clamp_date.py
Normal file
60
tradein-mvp/backend/tests/scrapers/test_base_clamp_date.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""LOW audit R2 (#6) — `_clamp_plausible_date` в `scraper_kit.base`.
|
||||
|
||||
Choke point для `listing_date`/`publish_date` в `save_listings()` (единый для
|
||||
avito/cian/yandex/domclick). Проверено в БД: avito пишет 1970-01-01
|
||||
(epoch-sentinel, ~698 строк) и даты до +11 дней в будущем (~79 строк); у
|
||||
yandex — единичные аномально старые годы. Правдоподобный диапазон —
|
||||
[2010-01-01, today+2d]; всё остальное → None (колонка nullable).
|
||||
|
||||
Офлайн: без сети/curl_cffi/БД, только импорт + чистая функция.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.base import _clamp_plausible_date
|
||||
|
||||
|
||||
def test_avito_epoch_sentinel_rejected() -> None:
|
||||
"""1970-01-01 — классический Avito 0-epoch sentinel."""
|
||||
assert _clamp_plausible_date(date(1970, 1, 1)) is None
|
||||
|
||||
|
||||
def test_just_before_min_plausible_rejected() -> None:
|
||||
assert _clamp_plausible_date(date(2009, 12, 31)) is None
|
||||
|
||||
|
||||
def test_far_future_rejected() -> None:
|
||||
assert _clamp_plausible_date(date(2099, 1, 1)) is None
|
||||
|
||||
|
||||
def test_none_passes_through_as_none() -> None:
|
||||
assert _clamp_plausible_date(None) is None
|
||||
|
||||
|
||||
def test_normal_recent_date_passes_through() -> None:
|
||||
d = date(2024, 6, 1)
|
||||
assert _clamp_plausible_date(d) == d
|
||||
|
||||
|
||||
def test_today_passes() -> None:
|
||||
today = date.today()
|
||||
assert _clamp_plausible_date(today) == today
|
||||
|
||||
|
||||
def test_today_plus_one_day_passes() -> None:
|
||||
d = date.today() + timedelta(days=1)
|
||||
assert _clamp_plausible_date(d) == d
|
||||
|
||||
|
||||
def test_today_plus_three_days_rejected() -> None:
|
||||
assert _clamp_plausible_date(date.today() + timedelta(days=3)) is None
|
||||
|
||||
|
||||
def test_min_plausible_boundary_passes() -> None:
|
||||
"""2010-01-01 сам по себе — граница включительно (в окне)."""
|
||||
assert _clamp_plausible_date(date(2010, 1, 1)) == date(2010, 1, 1)
|
||||
|
|
@ -614,12 +614,12 @@ async def test_suggest_addresses_classifies_kind() -> None:
|
|||
results = await dadata.suggest_addresses("test")
|
||||
|
||||
assert len(results) == 6
|
||||
assert results[0].kind == "house" # 8
|
||||
assert results[0].kind == "house" # 8
|
||||
assert results[1].kind == "street" # 7
|
||||
assert results[2].kind == "city" # 4
|
||||
assert results[3].kind == "city" # 1
|
||||
assert results[4].kind == "house" # 9 (>=8)
|
||||
assert results[5].kind == "city" # missing → default
|
||||
assert results[2].kind == "city" # 4
|
||||
assert results[3].kind == "city" # 1
|
||||
assert results[4].kind == "house" # 9 (>=8)
|
||||
assert results[5].kind == "city" # missing → default
|
||||
|
||||
|
||||
async def test_suggest_addresses_sends_proper_request() -> None:
|
||||
|
|
@ -652,6 +652,55 @@ async def test_suggest_addresses_sends_proper_request() -> None:
|
|||
assert captured["x_secret"] is None
|
||||
|
||||
|
||||
async def test_suggest_addresses_region_body_shape_no_restrict_value() -> None:
|
||||
"""region= constrains `locations` by region, WITHOUT a restrict_value key.
|
||||
|
||||
`restrict_value` is a top-level DaData body param (not a per-location dict
|
||||
key) — putting it inside the locations object would be a silent no-op.
|
||||
The region constraint in `locations` is already a hard filter on its own.
|
||||
"""
|
||||
from app.services import dadata
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json as _json
|
||||
|
||||
captured["body"] = _json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with _patch_settings(token="my-token"), _patch_async_client(transport):
|
||||
await dadata.suggest_addresses("Ленина", limit=5, city=None, region="Свердловская область")
|
||||
|
||||
body = captured["body"]
|
||||
assert isinstance(body, dict)
|
||||
assert body["locations"] == [{"region": "Свердловская область"}]
|
||||
assert "restrict_value" not in body["locations"][0]
|
||||
assert "restrict_value" not in body
|
||||
|
||||
|
||||
async def test_suggest_addresses_region_overrides_city() -> None:
|
||||
"""Когда задан и `city`, и `region` — region побеждает, city игнорируется."""
|
||||
from app.services import dadata
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
import json as _json
|
||||
|
||||
captured["body"] = _json.loads(request.content.decode("utf-8"))
|
||||
return httpx.Response(200, json=SAMPLE_SUGGEST_PAYLOAD)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
await dadata.suggest_addresses("Ленина", city="Екатеринбург", region="Свердловская область")
|
||||
|
||||
body = captured["body"]
|
||||
assert isinstance(body, dict)
|
||||
assert body["locations"] == [{"region": "Свердловская область"}]
|
||||
|
||||
|
||||
async def test_suggest_addresses_clamps_limit() -> None:
|
||||
"""count должен быть clamped в [1, 20] — DaData ограничение."""
|
||||
from app.services import dadata
|
||||
|
|
@ -671,3 +720,168 @@ async def test_suggest_addresses_clamps_limit() -> None:
|
|||
body = captured["body"]
|
||||
assert isinstance(body, dict)
|
||||
assert body["count"] == 20 # clamped to max
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# #dadata-403 — clean feature-disabled log + enrich_address (clean → suggest fallback)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
import logging as _logging # noqa: E402
|
||||
|
||||
# Real prod 403 body when услуга CLEAN не подключена (не отклонённый токен).
|
||||
CLEAN_FEATURE_DISABLED_BODY = {
|
||||
"timestamp": "2026-07-12T16:37:54.585+00:00",
|
||||
"status": 403,
|
||||
"error": "Forbidden",
|
||||
"message": (
|
||||
"Feature 'CLEAN' disabled for token 'xxx'. "
|
||||
"See https://dadata.userecho.com/topics/7784 for help."
|
||||
),
|
||||
"path": "/api/v1/clean/address",
|
||||
}
|
||||
|
||||
# House-level suggest candidate carrying house_fias_id/kladr_id/qc_geo (что нужно fallback'у).
|
||||
SUGGEST_HOUSE_PAYLOAD = {
|
||||
"suggestions": [
|
||||
{
|
||||
"value": "г Екатеринбург, ул Малышева, д 4",
|
||||
"unrestricted_value": "620014, Свердловская обл, г Екатеринбург, ул Малышева, д 4",
|
||||
"data": {
|
||||
"geo_lat": "56.831463",
|
||||
"geo_lon": "60.580943",
|
||||
"fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
|
||||
"house_fias_id": "bfa7153e-fc05-40a4-a86d-779e65faaade",
|
||||
"kladr_id": "6600000100006480017",
|
||||
"qc_geo": "0",
|
||||
"fias_level": "8",
|
||||
"house": "4",
|
||||
"street": "Малышева",
|
||||
"city": "Екатеринбург",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _mock_enrich_transport(
|
||||
clean_status: int, clean_body: object, suggest_body: object
|
||||
) -> httpx.MockTransport:
|
||||
"""Routes /clean vs /suggest by URL — enrich_address зовёт оба через один AsyncClient."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
if "cleaner.dadata.ru" in url:
|
||||
return httpx.Response(clean_status, json=clean_body)
|
||||
if "suggestions.dadata.ru" in url:
|
||||
return httpx.Response(200, json=suggest_body)
|
||||
return httpx.Response(404, json={"error": "unexpected url"})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
async def test_clean_address_logs_feature_disabled_distinctly(caplog) -> None:
|
||||
"""403 «Feature CLEAN disabled» → None + сообщение про выключенную услугу (не про токен)."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_transport_returning(403, CLEAN_FEATURE_DISABLED_BODY)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
|
||||
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||
|
||||
assert result is None
|
||||
text = caplog.text
|
||||
assert "Стандартизация" in text or "выключена" in text
|
||||
# Не должны обвинять токен при feature-disabled.
|
||||
assert "auth/secret rejected" not in text
|
||||
|
||||
|
||||
async def test_clean_address_logs_real_auth_rejection_as_auth(caplog) -> None:
|
||||
"""401 (или 403 без 'disabled') → сообщение про креды."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_transport_returning(401, {"message": "Unauthorized"})
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
with caplog.at_level(_logging.ERROR, logger="app.services.dadata"):
|
||||
result = await dadata.clean_address("Екатеринбург, Малышева 4")
|
||||
|
||||
assert result is None
|
||||
assert "auth/secret rejected" in caplog.text
|
||||
|
||||
|
||||
async def test_suggest_addresses_captures_house_fias_kladr_qc() -> None:
|
||||
"""_parse_suggestion теперь тянет house_fias_id / kladr_id / qc_geo из data."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_suggest_transport(200, SUGGEST_HOUSE_PAYLOAD)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
results = await dadata.suggest_addresses("Малышева 4")
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
|
||||
assert results[0].kladr_id == "6600000100006480017"
|
||||
assert results[0].qc_geo == 0
|
||||
|
||||
|
||||
async def test_enrich_address_uses_clean_when_available() -> None:
|
||||
"""CLEAN 200 → берём его результат (canonical/cadnum), suggest не подменяет."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_enrich_transport(200, SAMPLE_OK_PAYLOAD, {"suggestions": []})
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 125")
|
||||
|
||||
assert result is not None
|
||||
assert result.canonical_address == "г Екатеринбург, ул Малышева, д 125"
|
||||
assert result.house_cadnum == "66:41:0704045:350"
|
||||
assert result.raw.get("_source") != "suggest_fallback"
|
||||
|
||||
|
||||
async def test_enrich_address_falls_back_to_suggest_when_clean_unavailable() -> None:
|
||||
"""CLEAN 403 disabled → suggest-fallback даёт house_fias_id/гео; canonical/cadnum=None."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, SUGGEST_HOUSE_PAYLOAD)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
result = await dadata.enrich_address("Екатеринбург, ул. Малышева, 4")
|
||||
|
||||
assert result is not None
|
||||
assert result.house_fias_id == "bfa7153e-fc05-40a4-a86d-779e65faaade"
|
||||
assert result.kladr_id == "6600000100006480017"
|
||||
assert result.qc_geo == 0
|
||||
assert result.lat is not None and abs(result.lat - 56.831463) < 1e-6
|
||||
# Честная деградация: без CLEAN нет canonical/cadnum — не выдумываем.
|
||||
assert result.canonical_address is None
|
||||
assert result.house_cadnum is None
|
||||
assert result.raw.get("_source") == "suggest_fallback"
|
||||
|
||||
|
||||
async def test_enrich_address_ignores_non_house_suggestions() -> None:
|
||||
"""Если suggest вернул только street/city (не house) → fallback не даёт house_fias_id → None."""
|
||||
from app.services import dadata
|
||||
|
||||
street_only = {
|
||||
"suggestions": [
|
||||
{
|
||||
"value": "г Екатеринбург, ул Малышева",
|
||||
"unrestricted_value": "Свердловская обл, г Екатеринбург, ул Малышева",
|
||||
"data": {"geo_lat": "56.83", "geo_lon": "60.58", "fias_id": "s", "fias_level": "7"},
|
||||
}
|
||||
]
|
||||
}
|
||||
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, street_only)
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
result = await dadata.enrich_address("Екатеринбург, ул. Малышева")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_enrich_address_returns_none_when_clean_and_suggest_both_fail() -> None:
|
||||
"""CLEAN недоступен + suggest пустой → None (graceful)."""
|
||||
from app.services import dadata
|
||||
|
||||
transport = _mock_enrich_transport(403, CLEAN_FEATURE_DISABLED_BODY, {"suggestions": []})
|
||||
with _patch_settings(), _patch_async_client(transport):
|
||||
result = await dadata.enrich_address("Малышева 4")
|
||||
|
||||
assert result is None
|
||||
|
|
|
|||
112
tradein-mvp/backend/tests/services/test_image_sanitizer.py
Normal file
112
tradein-mvp/backend/tests/services/test_image_sanitizer.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Unit tests for `app.services.image_sanitizer.sanitize_image`.
|
||||
|
||||
Coverage:
|
||||
- happy path: a small RGBA PNG re-encodes to a clean JPEG (RGB, EXIF stripped)
|
||||
- resize path: an image above _MAX_DIMENSION is thumbnailed down, still decodes
|
||||
- pixel-flood DoS: an image declaring > _MAX_PIXELS is rejected via img.size
|
||||
BEFORE img.load() is ever called (no full decode → no OOM) — #2214
|
||||
- byte-size backstop: content over _MAX_BYTES rejected before Image.open
|
||||
- Pillow's own bomb threshold (MAX_IMAGE_PIXELS) is lowered to our cap
|
||||
- garbage bytes still raise ImageSanitizationError (regression guard)
|
||||
|
||||
The oversized-dimension case is asserted via monkeypatch rather than a real
|
||||
100 MP buffer: allocating the bomb is exactly the OOM we are defending against,
|
||||
so the test proves img.load() is NOT reached.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
# DATABASE_URL required by config before any app import (см. sibling service tests).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.services import image_sanitizer
|
||||
from app.services.image_sanitizer import (
|
||||
SANITIZED_CONTENT_TYPE,
|
||||
ImageSanitizationError,
|
||||
sanitize_image,
|
||||
)
|
||||
|
||||
|
||||
def _png_bytes(width: int, height: int, mode: str = "RGBA") -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new(mode, (width, height), color=(120, 60, 30, 255)[: len(mode)]).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_small_image_passes_and_becomes_jpeg() -> None:
|
||||
out, ctype = sanitize_image(_png_bytes(100, 80))
|
||||
assert ctype == SANITIZED_CONTENT_TYPE
|
||||
# Output is a valid JPEG, decoded back in RGB (transparency dropped).
|
||||
reloaded = Image.open(io.BytesIO(out))
|
||||
assert reloaded.format == "JPEG"
|
||||
assert reloaded.mode == "RGB"
|
||||
assert reloaded.size == (100, 80)
|
||||
|
||||
|
||||
def test_oversized_but_under_pixel_cap_is_resized() -> None:
|
||||
# 3000x2000 = 6 MP (< 40 MP cap) but longest edge > _MAX_DIMENSION → thumbnailed.
|
||||
out, _ = sanitize_image(_png_bytes(3000, 2000, mode="RGB"))
|
||||
reloaded = Image.open(io.BytesIO(out))
|
||||
assert max(reloaded.size) == image_sanitizer._MAX_DIMENSION
|
||||
assert reloaded.size == (2400, 1600)
|
||||
|
||||
|
||||
def test_pixel_flood_rejected_without_decode(monkeypatch) -> None:
|
||||
"""Image declaring > cap pixels is rejected via img.size, never img.load()."""
|
||||
fake_img = MagicMock()
|
||||
fake_img.size = (10_000, 10_000) # 100 MP >> 40 MP cap
|
||||
fake_img.__enter__.return_value = fake_img
|
||||
fake_img.__exit__.return_value = False
|
||||
monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img)
|
||||
|
||||
with pytest.raises(ImageSanitizationError) as excinfo:
|
||||
sanitize_image(b"\x89PNG\r\n\x1a\n-tiny-header")
|
||||
|
||||
assert "exceeds" in str(excinfo.value)
|
||||
# The whole point of the fix: no full decode happened → no hundreds-of-MB buffer.
|
||||
fake_img.load.assert_not_called()
|
||||
|
||||
|
||||
def test_pixel_cap_boundary_allows_at_cap(monkeypatch) -> None:
|
||||
"""Exactly _MAX_PIXELS is allowed through to load() (strict '>' comparison)."""
|
||||
fake_img = MagicMock()
|
||||
fake_img.size = (image_sanitizer._MAX_PIXELS, 1) # width*height == cap
|
||||
fake_img.mode = "RGB"
|
||||
fake_img.__enter__.return_value = fake_img
|
||||
fake_img.__exit__.return_value = False
|
||||
monkeypatch.setattr(image_sanitizer.Image, "open", lambda *a, **k: fake_img)
|
||||
monkeypatch.setattr(image_sanitizer.ImageOps, "exif_transpose", lambda im: im)
|
||||
|
||||
sanitize_image(b"whatever")
|
||||
fake_img.load.assert_called_once()
|
||||
|
||||
|
||||
def test_byte_size_backstop_rejects_before_open(monkeypatch) -> None:
|
||||
called = {"open": False}
|
||||
|
||||
def _guard(*_a, **_k):
|
||||
called["open"] = True
|
||||
raise AssertionError("Image.open must not be called for oversized bytes")
|
||||
|
||||
monkeypatch.setattr(image_sanitizer.Image, "open", _guard)
|
||||
with pytest.raises(ImageSanitizationError):
|
||||
sanitize_image(b"\x00" * (image_sanitizer._MAX_BYTES + 1))
|
||||
assert called["open"] is False
|
||||
|
||||
|
||||
def test_pillow_bomb_threshold_lowered_to_cap() -> None:
|
||||
assert image_sanitizer._MAX_PIXELS == 40_000_000
|
||||
assert Image.MAX_IMAGE_PIXELS == image_sanitizer._MAX_PIXELS
|
||||
|
||||
|
||||
def test_garbage_bytes_rejected() -> None:
|
||||
with pytest.raises(ImageSanitizationError):
|
||||
sanitize_image(b"definitely not an image")
|
||||
|
|
@ -40,9 +40,21 @@ def test_category_weight_unknown_and_none_fall_back_to_default() -> None:
|
|||
|
||||
|
||||
def test_top7_weight_sum_matches_ptica() -> None:
|
||||
"""Same category set as Site Finder → identical top-7 normalization constant (31.5)."""
|
||||
"""Same category set as Site Finder → identical top-7 weight-sum constant (31.5)."""
|
||||
assert lc._TOP7_WEIGHT_SUM == 31.5
|
||||
assert abs(lc._MAX_STRAIGHT_SCORE - 0.315) < 1e-9
|
||||
|
||||
|
||||
def test_max_straight_score_normalized_at_reference_distance_not_zero() -> None:
|
||||
"""Audit R2 #7a: denominator uses _REF_DISTANCE_M (100m), not an unreachable d=0 max.
|
||||
|
||||
Old d=0 normalization was _TOP7_WEIGHT_SUM / 100 == 0.315 — the theoretical max with all
|
||||
top-7 categories AT the doorstep. That compressed realistic scores (~25-30/100 for even a
|
||||
strong central address) into the bottom third of the range. The new normalization halves
|
||||
the max (denominator distance+100 doubles from 100 to 200 at REF=100m), doubling realistic
|
||||
scores instead.
|
||||
"""
|
||||
assert lc._REF_DISTANCE_M == 100.0
|
||||
assert abs(lc._MAX_STRAIGHT_SCORE - 0.1575) < 1e-9
|
||||
|
||||
|
||||
def test_score_to_coef_bounds() -> None:
|
||||
|
|
@ -142,6 +154,45 @@ def test_compute_location_coef_weights_and_ranks_top_n() -> None:
|
|||
assert 0.95 <= result.coef <= 1.05
|
||||
|
||||
|
||||
def test_compute_location_coef_strong_central_fixture_lands_near_one() -> None:
|
||||
"""Audit R2 #7a: a realistic strong central-EKB POI profile now reads as ~1.0, not ~0.98.
|
||||
|
||||
Fixture mirrors a real strong central address audit: near transit/kindergarten/pharmacy,
|
||||
but school/metro/mall noticeably farther (a typical quarter profile, NOT everything at
|
||||
the doorstep). Under the OLD d=0 normalization this fixture scores ~28/100 → coef ~0.978
|
||||
(verified separately against the pre-fix formula). After re-centering on _REF_DISTANCE_M
|
||||
it should land close to 1.0 (within the documented ±0.01 calibration target).
|
||||
"""
|
||||
rows = [
|
||||
{"name": "Остановка", "category": "bus_stop", "distance_m": 80.0},
|
||||
{"name": "Аптека", "category": "pharmacy", "distance_m": 120.0},
|
||||
{"name": "Супермаркет", "category": "shop_supermarket", "distance_m": 180.0},
|
||||
{"name": "Детсад №5", "category": "kindergarten", "distance_m": 220.0},
|
||||
{"name": "Школа №10", "category": "school", "distance_m": 350.0},
|
||||
{"name": "Метро Геологическая", "category": "metro_stop", "distance_m": 500.0},
|
||||
{"name": "ТЦ Гринвич", "category": "shop_mall", "distance_m": 700.0},
|
||||
]
|
||||
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
|
||||
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
|
||||
|
||||
assert result.geo_source == "osm_poi_ekb"
|
||||
assert len(result.factors) == 7
|
||||
assert abs(result.coef - 1.0) <= 0.01
|
||||
|
||||
|
||||
def test_compute_location_coef_weak_fixture_stays_well_below_one() -> None:
|
||||
"""A sparse/poor location (only far, low-weight POI) must still stay well below 1.0."""
|
||||
rows = [
|
||||
{"name": "Магазинчик", "category": "shop_small", "distance_m": 950.0},
|
||||
{"name": "Прочее", "category": "some_unknown_tag", "distance_m": 1100.0},
|
||||
]
|
||||
db = _FakeDB([_FakeResult(scalar_value=len(rows)), _FakeResult(mapping_rows=rows)])
|
||||
result = lc.compute_location_coef(db, lat=56.838, lon=60.605, top_n=7)
|
||||
|
||||
assert result.geo_source == "osm_poi_ekb"
|
||||
assert result.coef < 0.98
|
||||
|
||||
|
||||
def test_compute_location_coef_limits_to_top_n() -> None:
|
||||
"""More than top_n candidates → only top_n factors surface in the response."""
|
||||
rows = [
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
|||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
|
||||
from app.schemas.trade_in import AggregatedEstimate, AnalogLot # noqa: E402
|
||||
from app.services.brand import Brand # noqa: E402
|
||||
from app.services.exporters import trade_in_pdf as mod # noqa: E402
|
||||
|
||||
|
|
@ -79,7 +79,16 @@ class _WellFormed(HTMLParser):
|
|||
"""Minimal balanced-tag check: every non-void tag that opens must close."""
|
||||
|
||||
_VOID: ClassVar[set[str]] = {
|
||||
"br", "img", "hr", "meta", "input", "rect", "path", "circle", "line", "polyline",
|
||||
"br",
|
||||
"img",
|
||||
"hr",
|
||||
"meta",
|
||||
"input",
|
||||
"rect",
|
||||
"path",
|
||||
"circle",
|
||||
"line",
|
||||
"polyline",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -224,7 +233,9 @@ def test_build_cover_with_sold_contains_dual_block() -> None:
|
|||
assert "Ожидаемая цена продажи" in html
|
||||
# existing sections untouched
|
||||
assert "Диапазон цен в объявлениях" in html
|
||||
assert "Диапазон цен по фактическим сделкам" in html
|
||||
# #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover
|
||||
# (остался на стр. 03) — cover ужат до одной A4-страницы.
|
||||
assert "Диапазон цен по фактическим сделкам" not in html
|
||||
|
||||
|
||||
def test_build_cover_without_sold_renders_only_asking() -> None:
|
||||
|
|
@ -234,4 +245,151 @@ def test_build_cover_without_sold_renders_only_asking() -> None:
|
|||
assert "Ожидаемая цена продажи" not in html
|
||||
# existing layout intact
|
||||
assert "Диапазон цен в объявлениях" in html
|
||||
assert "Диапазон цен по фактическим сделкам" in html
|
||||
# #pdf-empty-pages: дубль-бар «по фактическим сделкам» убран с cover (см. выше).
|
||||
assert "Диапазон цен по фактическим сделкам" not in html
|
||||
|
||||
|
||||
# ── _days_on_market_range — no fabricated (4, 118) corridor (#pdf-honesty) ─────
|
||||
|
||||
|
||||
def _lot(days_on_market: int | None, price_rub: int = 10_000_000) -> AnalogLot:
|
||||
return AnalogLot(
|
||||
address="Екатеринбург, ул. Ленина, 1",
|
||||
area_m2=50.0,
|
||||
rooms=2,
|
||||
floor=3,
|
||||
total_floors=9,
|
||||
price_rub=price_rub,
|
||||
price_per_m2=int(price_rub / 50),
|
||||
listing_date=None,
|
||||
days_on_market=days_on_market,
|
||||
)
|
||||
|
||||
|
||||
def test_days_range_none_when_no_lots() -> None:
|
||||
# Раньше возвращал хардкод (4, 118) — выдуманный срок экспозиции.
|
||||
assert mod._days_on_market_range([]) is None
|
||||
|
||||
|
||||
def test_days_range_none_when_all_days_missing() -> None:
|
||||
assert mod._days_on_market_range([_lot(None), _lot(None)]) is None
|
||||
|
||||
|
||||
def test_days_range_min_max_when_data_present() -> None:
|
||||
assert mod._days_on_market_range([_lot(12), _lot(None), _lot(88)]) == (12, 88)
|
||||
|
||||
|
||||
def test_cover_without_days_data_still_renders_range_chart() -> None:
|
||||
# Нет days_on_market → show_days=False, но ценовой бар всё равно строится.
|
||||
est = _estimate(analogs=[_lot(None)])
|
||||
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
|
||||
_assert_well_formed(html)
|
||||
assert "Диапазон цен в объявлениях" in html
|
||||
|
||||
|
||||
# ── _report_number — city-aware prefix, never hardcoded «EKБ» (#pdf-honesty) ───
|
||||
|
||||
|
||||
def test_report_number_ekb_prefix_for_ekb_address() -> None:
|
||||
est = _estimate(target_address="Екатеринбург, ул. Ленина, 1")
|
||||
assert mod._report_number(est).startswith("ЕКБ-")
|
||||
|
||||
|
||||
def test_report_number_city_prefix_for_non_ekb_address() -> None:
|
||||
assert mod._report_number(_estimate(target_address="Нижний Тагил, ул. Мира, 5")).startswith(
|
||||
"НТ-"
|
||||
)
|
||||
assert mod._report_number(_estimate(target_address="Серов, ул. Ленина, 3")).startswith("СЕР-")
|
||||
|
||||
|
||||
def test_report_number_neutral_prefix_when_city_unknown() -> None:
|
||||
# None адрес и адрес вне региона → нейтральный «МЕРА», НЕ ложный «EKБ».
|
||||
assert mod._report_number(_estimate(target_address=None)).startswith("МЕРА-")
|
||||
assert mod._report_number(_estimate(target_address="Москва, ул. Тверская, 1")).startswith(
|
||||
"МЕРА-"
|
||||
)
|
||||
|
||||
|
||||
def test_report_number_never_hardcodes_old_ekb_literal() -> None:
|
||||
# Старый баг: «EKБ-» (латинские EK + кириллическая Б) для ЛЮБОГО адреса.
|
||||
for addr in (None, "Серов, ул. Ленина, 3", "Москва, ул. Тверская, 1"):
|
||||
assert not mod._report_number(_estimate(target_address=addr)).startswith("EK")
|
||||
|
||||
|
||||
def test_report_number_falls_back_to_canonical_address() -> None:
|
||||
est = _estimate(target_address=None, canonical_address="Первоуральск, ул. Вайнера, 2")
|
||||
assert mod._report_number(est).startswith("ПРВ-")
|
||||
|
||||
|
||||
# ── Static «10–18%» haircut replaced by computed «−N%» (#pdf-honesty) ──────────
|
||||
|
||||
|
||||
def test_cover_advice_uses_computed_discount_not_static_range() -> None:
|
||||
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # −20%
|
||||
html = mod._build_cover(est, _SNAPSHOT, _BRAND)
|
||||
assert "10–18%" not in html
|
||||
assert "на 20%" in html
|
||||
|
||||
|
||||
def test_cover_advice_neutral_when_no_discount() -> None:
|
||||
html = mod._build_cover(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
|
||||
assert "10–18%" not in html
|
||||
assert "проходят ниже цен в объявлениях" in html
|
||||
|
||||
|
||||
def test_deals_banner_uses_computed_discount_not_static_range() -> None:
|
||||
est = _estimate(expected_sold_price_rub=8_000_000, asking_to_sold_ratio=0.80) # −20%
|
||||
html = mod._build_deals_page(est, _SNAPSHOT, _BRAND)
|
||||
_assert_well_formed(html)
|
||||
assert "10–18%" not in html
|
||||
# Ложный локейшн-хвост «(Екатеринбург, 2026)» удалён.
|
||||
assert "Екатеринбург, 2026" not in html
|
||||
assert "на 20%" in html
|
||||
|
||||
|
||||
def test_deals_banner_neutral_when_no_discount() -> None:
|
||||
html = mod._build_deals_page(_estimate(expected_sold_price_rub=None), _SNAPSHOT, _BRAND)
|
||||
_assert_well_formed(html)
|
||||
assert "10–18%" not in html
|
||||
assert "продаются дешевле, чем заявлено" in html
|
||||
|
||||
|
||||
# ── _build_offer_page — rent scales with room count, not a flat 1-room figure ──
|
||||
# (#pdf-honesty LOW audit R2 #5: «Общие финансовые потери» hardcoded a 1-room
|
||||
# 3-month rent for EVERY object, including 3-4-room ones.)
|
||||
|
||||
|
||||
def test_offer_page_rent_scales_up_for_larger_objects() -> None:
|
||||
est = _estimate()
|
||||
snapshot_1room = {**_SNAPSHOT, "rooms": 1}
|
||||
snapshot_3room = {**_SNAPSHOT, "rooms": 3}
|
||||
|
||||
html_1room = mod._build_offer_page(est, snapshot_1room, _BRAND)
|
||||
html_3room = mod._build_offer_page(est, snapshot_3room, _BRAND)
|
||||
_assert_well_formed(html_1room)
|
||||
_assert_well_formed(html_3room)
|
||||
|
||||
# Base (1-room) rent range unchanged: 84 000 – 135 000.
|
||||
assert mod._fmt_rub(84_000) in html_1room
|
||||
assert mod._fmt_rub(135_000) in html_1room
|
||||
# 3-room rent scaled up (×1.8 heuristic): 151 000 – 243 000.
|
||||
assert mod._fmt_rub(151_000) in html_3room
|
||||
assert mod._fmt_rub(243_000) in html_3room
|
||||
# The flat 1-room figure must NOT leak into the 3-room object's report.
|
||||
assert mod._fmt_rub(135_000) not in html_3room
|
||||
|
||||
|
||||
def test_offer_page_rent_subtitle_reflects_room_count() -> None:
|
||||
est = _estimate()
|
||||
|
||||
html_1room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 1}, _BRAND)
|
||||
assert "1-комн. квартиры на 3 месяца" in html_1room
|
||||
assert "однокомнатной квартиры на 3 месяца" not in html_1room
|
||||
|
||||
html_3room = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 3}, _BRAND)
|
||||
assert "3-комн. квартиры на 3 месяца" in html_3room
|
||||
|
||||
# Unknown room count (0/missing) → neutral subtitle, no fabricated room number.
|
||||
html_unknown = mod._build_offer_page(est, {**_SNAPSHOT, "rooms": 0}, _BRAND)
|
||||
assert "квартиры на 3 месяца" in html_unknown
|
||||
assert "-комн. квартиры на 3 месяца" not in html_unknown
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ def _run_qa_estimate(
|
|||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||
radius_analogs: list[dict[str, Any]] | None = None,
|
||||
) -> Any:
|
||||
from app.core.config import settings
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
from app.services.estimator import estimate_quality
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
|
@ -105,7 +104,6 @@ def _run_qa_estimate(
|
|||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch.object(settings, "estimate_same_building_anchor_enabled", True),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ Coverage:
|
|||
(c) increment растит used счётчик
|
||||
(d) get_status корректен для different сценариев
|
||||
(e) отсутствие заголовка X-Authenticated-User = unlimited (fail-open)
|
||||
(f) #747 — атомарно-условный increment (TOCTOU fix)
|
||||
(g) account_quota_overrides — персональный лимит вместо negative-used хака
|
||||
(h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
|
||||
|
||||
DB мокируется через MagicMock — реальная БД не требуется.
|
||||
"""
|
||||
|
|
@ -14,7 +17,9 @@ from __future__ import annotations
|
|||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
|
@ -29,6 +34,7 @@ import pytest # noqa: E402
|
|||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app.schemas.trade_in import AggregatedEstimate # noqa: E402
|
||||
from app.services.account_quota import ( # noqa: E402
|
||||
LIMIT_EXHAUSTED_MESSAGE,
|
||||
MONTHLY_LIMIT,
|
||||
|
|
@ -36,6 +42,7 @@ from app.services.account_quota import ( # noqa: E402
|
|||
get_status,
|
||||
increment,
|
||||
is_unlimited,
|
||||
user_limit,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -43,23 +50,54 @@ from app.services.account_quota import ( # noqa: E402
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _db_with_used(used: int) -> MagicMock:
|
||||
"""DB session mock whose execute().fetchone() returns row with .used = N."""
|
||||
row = MagicMock()
|
||||
row.used = used
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = row
|
||||
class _Row:
|
||||
"""Row stand-in поддерживающий и .used/.monthly_limit, и индексный доступ row[0]."""
|
||||
|
||||
def __init__(self, value: int) -> None:
|
||||
self.used = value
|
||||
self.monthly_limit = value
|
||||
self._t = (value,)
|
||||
|
||||
def __getitem__(self, i: int) -> int:
|
||||
return self._t[i]
|
||||
|
||||
|
||||
def _result(row: _Row | None) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.fetchone.return_value = row
|
||||
return result
|
||||
|
||||
|
||||
def _override_result(limit: int | None) -> MagicMock:
|
||||
"""Mock результата запроса account_quota_overrides.monthly_limit (user_limit())."""
|
||||
return _result(None if limit is None else _Row(limit))
|
||||
|
||||
|
||||
def _used_result(used: int | None) -> MagicMock:
|
||||
"""Mock результата запроса account_estimate_usage.used (или UPSERT RETURNING used)."""
|
||||
return _result(None if used is None else _Row(used))
|
||||
|
||||
|
||||
def _db_with_used(used: int, *, override_limit: int | None = None) -> MagicMock:
|
||||
"""DB session mock для get_status/check_and_raise: ровно 2 execute() —
|
||||
(1) user_limit() override lookup, (2) account_estimate_usage.used lookup."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value = execute_result
|
||||
db.execute.side_effect = [_override_result(override_limit), _used_result(used)]
|
||||
return db
|
||||
|
||||
|
||||
def _db_no_row() -> MagicMock:
|
||||
"""DB session mock where no row exists yet (first estimate of the month)."""
|
||||
execute_result = MagicMock()
|
||||
execute_result.fetchone.return_value = None
|
||||
def _db_no_row(*, override_limit: int | None = None) -> MagicMock:
|
||||
"""DB session mock где строки usage ещё нет (первая оценка месяца)."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value = execute_result
|
||||
db.execute.side_effect = [_override_result(override_limit), _used_result(None)]
|
||||
return db
|
||||
|
||||
|
||||
def _db_for_increment(*, override_limit: int | None, upsert_used: int | None) -> MagicMock:
|
||||
"""DB session mock для increment(): (1) user_limit() override lookup,
|
||||
(2) UPSERT ... RETURNING used (None если WHERE used < lim не матчит)."""
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = [_override_result(override_limit), _used_result(upsert_used)]
|
||||
return db
|
||||
|
||||
|
||||
|
|
@ -127,7 +165,7 @@ def test_check_and_raise_pilot_not_blocked_at_14() -> None:
|
|||
def test_check_and_raise_pilot_not_blocked_at_15_boundary() -> None:
|
||||
"""used=15 == MONTHLY_LIMIT → 429 (15-я — последняя допустимая, 16-я блокируется).
|
||||
|
||||
Логика: used >= MONTHLY_LIMIT → block. После 15-й успешной оценки
|
||||
Логика: used >= limit → block. После 15-й успешной оценки
|
||||
increment делает used=15, поэтому следующий запрос (16-й) блокируется.
|
||||
"""
|
||||
db = _db_with_used(MONTHLY_LIMIT)
|
||||
|
|
@ -153,7 +191,7 @@ def test_check_and_raise_pilot_blocked_exact_detail() -> None:
|
|||
|
||||
|
||||
def test_check_and_raise_pilot_blocked_over_limit() -> None:
|
||||
"""used=20 тоже блокируется."""
|
||||
"""used=20 тоже блокируется (нет override → глобальный лимит)."""
|
||||
db = _db_with_used(20)
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
|
@ -168,14 +206,15 @@ def test_check_and_raise_pilot_blocked_over_limit() -> None:
|
|||
|
||||
|
||||
def test_increment_pilot_calls_upsert() -> None:
|
||||
"""increment для pilot → выполняет db.execute (UPSERT) и db.commit."""
|
||||
db = MagicMock()
|
||||
increment(db, "user1")
|
||||
db.execute.assert_called_once()
|
||||
"""increment для pilot → выполняет user_limit lookup + UPSERT (2 execute), 1 commit."""
|
||||
db = _db_for_increment(override_limit=None, upsert_used=1)
|
||||
result = increment(db, "user1")
|
||||
assert result is True
|
||||
assert db.execute.call_count == 2
|
||||
db.commit.assert_called_once()
|
||||
|
||||
# Проверяем что SQL содержит ON CONFLICT ... DO UPDATE
|
||||
call_args = db.execute.call_args
|
||||
# Второй вызов — UPSERT; проверяем что SQL содержит ON CONFLICT ... DO UPDATE
|
||||
call_args = db.execute.call_args_list[1]
|
||||
sql_text = str(call_args[0][0]) # first positional arg — text() object
|
||||
assert "ON CONFLICT" in sql_text
|
||||
assert "used" in sql_text
|
||||
|
|
@ -190,9 +229,9 @@ def test_increment_none_username_is_noop() -> None:
|
|||
|
||||
def test_increment_pilot_first_estimate_of_month() -> None:
|
||||
"""Первый инкремент (нет строки в БД) — должен всё равно выполнить UPSERT."""
|
||||
db = MagicMock()
|
||||
db = _db_for_increment(override_limit=None, upsert_used=1)
|
||||
increment(db, "user5")
|
||||
db.execute.assert_called_once()
|
||||
assert db.execute.call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -212,7 +251,7 @@ def test_get_status_none_username() -> None:
|
|||
|
||||
|
||||
def test_get_status_admin() -> None:
|
||||
"""admin → unlimited, remaining=15 вне зависимости от used."""
|
||||
"""admin → unlimited, remaining=limit вне зависимости от used."""
|
||||
db = _db_with_used(7)
|
||||
status = get_status(db, "admin")
|
||||
assert status["unlimited"] is True
|
||||
|
|
@ -396,18 +435,7 @@ def test_estimate_no_header_not_blocked(estimate_app_exhausted: FastAPI) -> None
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _Row:
|
||||
"""Row stand-in поддерживающий и .used, и индексный доступ row[0]."""
|
||||
|
||||
def __init__(self, used: int) -> None:
|
||||
self.used = used
|
||||
self._t = (used,)
|
||||
|
||||
def __getitem__(self, i: int) -> int:
|
||||
return self._t[i]
|
||||
|
||||
|
||||
class _Result:
|
||||
class _AtomicResult:
|
||||
def __init__(self, row: _Row | None) -> None:
|
||||
self._row = row
|
||||
|
||||
|
|
@ -416,28 +444,33 @@ class _Result:
|
|||
|
||||
|
||||
class _AtomicQuotaFakeDB:
|
||||
"""In-memory fake, моделирующий атомарный conditional UPSERT (#747).
|
||||
|
||||
Воспроизводит семантику `INSERT ... ON CONFLICT DO UPDATE SET used=used+1
|
||||
WHERE used < :lim RETURNING used`:
|
||||
- строки ещё нет (used=None) → INSERT used=1, RETURNING row (WHERE не для INSERT);
|
||||
- used < lim → used+=1, RETURNING row;
|
||||
- used >= lim → конфликтная строка НЕ обновлена, RETURNING пуст (None).
|
||||
"""In-memory fake, воспроизводящий user_limit() override-lookup + атомарный
|
||||
conditional UPSERT (#747):
|
||||
- execute() без ключа "lim" в params → user_limit() override lookup —
|
||||
возвращает override_limit (или None → increment() падает на глобальный
|
||||
MONTHLY_LIMIT как :lim в следующем вызове).
|
||||
- execute() с ключом "lim" → UPSERT: строки ещё нет (used=None) → INSERT used=1,
|
||||
RETURNING row (WHERE не применяется к INSERT); used < lim → used+=1, RETURNING
|
||||
row; used >= lim → конфликтная строка НЕ обновлена, RETURNING пуст (None).
|
||||
"""
|
||||
|
||||
def __init__(self, *, used: int | None) -> None:
|
||||
def __init__(self, *, used: int | None, override_limit: int | None = None) -> None:
|
||||
self.used = used
|
||||
self.override_limit = override_limit
|
||||
self.commits = 0
|
||||
|
||||
def execute(self, _stmt: object, params: dict) -> _Result:
|
||||
def execute(self, _stmt: object, params: dict) -> _AtomicResult:
|
||||
if "lim" not in params:
|
||||
row = _Row(self.override_limit) if self.override_limit is not None else None
|
||||
return _AtomicResult(row)
|
||||
lim = params["lim"]
|
||||
if self.used is None:
|
||||
self.used = 1
|
||||
elif self.used < lim:
|
||||
self.used += 1
|
||||
else:
|
||||
return _Result(None)
|
||||
return _Result(_Row(self.used))
|
||||
return _AtomicResult(None)
|
||||
return _AtomicResult(_Row(self.used))
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
|
@ -472,3 +505,207 @@ def test_increment_atomic_unlimited_noop_returns_true() -> None:
|
|||
assert increment(db, "admin") is True
|
||||
assert db.commits == 0
|
||||
assert db.used == MONTHLY_LIMIT # не тронут
|
||||
|
||||
|
||||
def test_increment_atomic_respects_override_limit() -> None:
|
||||
"""override=50 (user2): used=49 → increment True (used→50); used=50 → False.
|
||||
Глобальный MONTHLY_LIMIT (15) НЕ применяется — используется персональный override."""
|
||||
db = _AtomicQuotaFakeDB(used=49, override_limit=50)
|
||||
assert increment(db, "user2") is True
|
||||
assert db.used == 50
|
||||
assert increment(db, "user2") is False
|
||||
assert db.used == 50 # не 51
|
||||
|
||||
|
||||
def test_increment_atomic_override_below_global_blocks_early() -> None:
|
||||
"""override=5 (ниже глобального 15): used=5 уже блокирует, хотя < MONTHLY_LIMIT.
|
||||
|
||||
Демонстрирует что per-user override заменяет глобальный лимит полностью —
|
||||
не является дополнительным потолком поверх него.
|
||||
"""
|
||||
db = _AtomicQuotaFakeDB(used=5, override_limit=5)
|
||||
assert increment(db, "user_low_override") is False
|
||||
assert db.used == 5 # не выросло
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (g) account_quota_overrides — персональный лимит (замена negative-used хака)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_user_limit_no_override_returns_global() -> None:
|
||||
"""Нет строки в account_quota_overrides → user_limit() возвращает MONTHLY_LIMIT."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _override_result(None)
|
||||
assert user_limit(db, "user1") == MONTHLY_LIMIT
|
||||
|
||||
|
||||
def test_user_limit_with_override_returns_override() -> None:
|
||||
"""Есть строка override → user_limit() возвращает monthly_limit из неё."""
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _override_result(50)
|
||||
assert user_limit(db, "user2") == 50
|
||||
|
||||
|
||||
def test_get_status_override_limit() -> None:
|
||||
"""user2 с override=50, used=0 (после сброса хака) → limit=50, remaining=50."""
|
||||
db = _db_with_used(0, override_limit=50)
|
||||
status = get_status(db, "user2")
|
||||
assert status["limit"] == 50
|
||||
assert status["remaining"] == 50
|
||||
assert status["used"] == 0
|
||||
|
||||
|
||||
def test_get_status_override_remaining_clamped_even_if_used_negative() -> None:
|
||||
"""Кламп: даже если used снова просочится отрицательным (regression прежнего
|
||||
negative-used хака), remaining НЕ превышает limit — не «Осталось 50 из 15»."""
|
||||
db = _db_with_used(-35, override_limit=50)
|
||||
status = get_status(db, "user2")
|
||||
assert status["limit"] == 50
|
||||
assert status["used"] == -35 # raw used не скрываем — диагностическая честность
|
||||
assert status["remaining"] == 50 # clamp: max(0, 50 - max(0, -35)) == 50
|
||||
assert status["remaining"] <= status["limit"]
|
||||
|
||||
|
||||
def test_check_and_raise_override_blocks_below_global_limit() -> None:
|
||||
"""override=5 (ниже глобального 15) — used=5 блокируется, хотя < MONTHLY_LIMIT."""
|
||||
db = _db_with_used(5, override_limit=5)
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
check_and_raise(db, "user_low_override")
|
||||
assert exc_info.value.status_code == 429
|
||||
|
||||
|
||||
def test_check_and_raise_override_allows_above_global_limit() -> None:
|
||||
"""override=50 — used=20 (> глобального 15) НЕ блокируется."""
|
||||
db = _db_with_used(20, override_limit=50)
|
||||
check_and_raise(db, "user2") # не должно поднять исключение
|
||||
|
||||
|
||||
def test_increment_override_blocks_at_override_not_global() -> None:
|
||||
"""increment уважает per-user override: used>=override → False, даже если
|
||||
used < MONTHLY_LIMIT (15)."""
|
||||
db = _db_for_increment(override_limit=5, upsert_used=None) # WHERE used<5 не матчит
|
||||
assert increment(db, "user_low_override") is False
|
||||
|
||||
|
||||
def test_increment_override_allows_above_global_limit() -> None:
|
||||
"""increment с override=50: used=20 (>15 глобального) успешно инкрементит."""
|
||||
db = _db_for_increment(override_limit=50, upsert_used=21)
|
||||
assert increment(db, "user2") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (h) insufficient_data результат НЕ инкрементит квоту (geocode-fail не сжигает слот)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _insufficient_estimate() -> AggregatedEstimate:
|
||||
"""Пустой результат — как _empty_estimate() в estimator.py: median=0 → #697
|
||||
computed_field insufficient_data=True."""
|
||||
return AggregatedEstimate(
|
||||
estimate_id=uuid4(),
|
||||
median_price_rub=0,
|
||||
range_low_rub=0,
|
||||
range_high_rub=0,
|
||||
median_price_per_m2=0,
|
||||
confidence="low",
|
||||
confidence_explanation="address_not_geocoded",
|
||||
n_analogs=0,
|
||||
period_months=24,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
|
||||
)
|
||||
|
||||
|
||||
def _real_estimate() -> AggregatedEstimate:
|
||||
"""Непустой результат — insufficient_data=False (median_price_rub > 0)."""
|
||||
return AggregatedEstimate(
|
||||
estimate_id=uuid4(),
|
||||
median_price_rub=5_000_000,
|
||||
range_low_rub=4_500_000,
|
||||
range_high_rub=5_500_000,
|
||||
median_price_per_m2=100_000,
|
||||
confidence="medium",
|
||||
n_analogs=8,
|
||||
period_months=24,
|
||||
analogs=[],
|
||||
actual_deals=[],
|
||||
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def estimate_app_ok() -> FastAPI:
|
||||
"""FastAPI app где check_and_raise проходит (used=0, без override) —
|
||||
estimate_quality мокается отдельно в каждом тесте."""
|
||||
from app.api.v1 import trade_in as trade_in_module
|
||||
from app.core.db import get_db
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
|
||||
|
||||
def _override_db():
|
||||
yield _db_no_row()
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
def test_estimate_insufficient_data_does_not_increment_quota(
|
||||
estimate_app_ok: FastAPI,
|
||||
) -> None:
|
||||
"""POST /estimate с insufficient_data=True результатом (нерезолвящийся адрес) →
|
||||
account_quota.increment НЕ вызывается (пустой результат не списывает платный слот)."""
|
||||
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.estimator.estimate_quality",
|
||||
new=AsyncMock(return_value=_insufficient_estimate()),
|
||||
),
|
||||
patch("app.services.account_quota.increment") as mock_increment,
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/estimate",
|
||||
json={
|
||||
"address": "г. Екатеринбург, несуществующий адрес xyz",
|
||||
"area_m2": 50.0,
|
||||
"rooms": 2,
|
||||
},
|
||||
headers={"X-Authenticated-User": "user1"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["median_price_rub"] == 0
|
||||
assert resp.json()["insufficient_data"] is True
|
||||
mock_increment.assert_not_called()
|
||||
|
||||
|
||||
def test_estimate_real_result_still_increments_quota(estimate_app_ok: FastAPI) -> None:
|
||||
"""Контрольный тест: непустой результат (insufficient_data=False) — increment
|
||||
вызывается как обычно (регресс-guard, не ломаем #747 TOCTOU-семантику)."""
|
||||
client = TestClient(estimate_app_ok, raise_server_exceptions=False)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.services.estimator.estimate_quality",
|
||||
new=AsyncMock(return_value=_real_estimate()),
|
||||
),
|
||||
patch("app.services.account_quota.increment", return_value=True) as mock_increment,
|
||||
):
|
||||
resp = client.post(
|
||||
"/api/v1/trade-in/estimate",
|
||||
json={
|
||||
"address": "г. Екатеринбург, ул. Малышева, 1",
|
||||
"area_m2": 50.0,
|
||||
"rooms": 2,
|
||||
},
|
||||
headers={"X-Authenticated-User": "user1"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["insufficient_data"] is False
|
||||
mock_increment.assert_called_once()
|
||||
|
|
|
|||
|
|
@ -113,6 +113,22 @@ def test_rederivation_cte_blocks_match_080() -> None:
|
|||
assert f"{cte} AS" in _REDERIVE_SQL, f"missing CTE {cte!r}"
|
||||
|
||||
|
||||
def test_rederivation_scopes_sold_side_to_asking_city() -> None:
|
||||
"""#C2: SOLD-сторона (deal_side + deal_global) скоупится на город asking-стороны (ЕКБ).
|
||||
|
||||
Миграция 177 залила ДКП по всей обл.66, а asking (listings) — только ЕКБ. Без скоупа
|
||||
sold-медиана смешивала дешёвую область → ratio 0.877→0.62, «выкупная» −29%. Оба
|
||||
deal-CTE (per-rooms + global) должны нести предикат; ask-стороны — НЕ трогаем.
|
||||
"""
|
||||
assert ratio_mod._ASKING_CITY_PATTERN == "%Екатеринбург%"
|
||||
# Оба deal-CTE (deal_side + deal_global) скоупятся — ровно 2 вхождения.
|
||||
assert _REDERIVE_SQL.count("AND city ILIKE :asking_city") == 2
|
||||
# ask-стороны (listings) НЕ фильтруются по городу (в listings нет колонки city).
|
||||
_a = _REDERIVE_SQL.index("ask_side AS")
|
||||
_b = _REDERIVE_SQL.index("per_bucket AS")
|
||||
assert "asking_city" not in _REDERIVE_SQL[_a:_b]
|
||||
|
||||
|
||||
def _strip_sql(s: str) -> str:
|
||||
"""Drop -- line comments and collapse whitespace — leaves only the executable SQL.
|
||||
|
||||
|
|
@ -159,9 +175,14 @@ def test_migration_080_derivation_is_subset_of_refresh_sql() -> None:
|
|||
s,
|
||||
)
|
||||
|
||||
assert _strip_sql(_normalise_ppm2(_drop_segment_guard(seed_derivation))) in _strip_sql(
|
||||
_normalise_ppm2(_drop_segment_guard(_REDERIVE_SQL))
|
||||
)
|
||||
def _drop_city_guard(s: str) -> str:
|
||||
"""Remove the #C2 EKB city-scope predicate on the SOLD side (absent in the 080 seed)."""
|
||||
return re.sub(r"AND\s+city\s+ILIKE\s+:asking_city", "", s)
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
return _strip_sql(_normalise_ppm2(_drop_city_guard(_drop_segment_guard(s))))
|
||||
|
||||
assert _norm(seed_derivation) in _norm(_REDERIVE_SQL)
|
||||
|
||||
|
||||
# ── Counters query ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
344
tradein-mvp/backend/tests/test_audit_api.py
Normal file
344
tradein-mvp/backend/tests/test_audit_api.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"""Tests for app.api.v1.audit — admin read API over `user_events` (Feature 2/3).
|
||||
|
||||
Coverage:
|
||||
(a) static SQL guard — CAST(:x AS type), never `:x::type` (psycopg v3 trap).
|
||||
(b) GET /audit/accounts — empty table → [] (never errors); populated → shape-valid
|
||||
AccountSummary rows pass through response_model unchanged.
|
||||
(c) GET /audit/accounts/{username} — empty table (incl. unknown username, NOT 404) →
|
||||
all 4 lists == []; populated → shape-valid drilldown rows.
|
||||
(d) GET /analytics — empty table → zeroed summary + empty lists (never errors);
|
||||
populated → shape-valid dashboard bundle, `days` query param clamped [1, 365].
|
||||
(e) optional real-Postgres round trip — inserts a couple of user_events rows and
|
||||
asserts aggregation through the real DB; self-SKIPS without a reachable,
|
||||
non-placeholder Postgres (mirrors tests/test_house_dedup_merge.py's live-DB
|
||||
pattern).
|
||||
|
||||
Router is tested in isolation on a minimal FastAPI app (mirrors tests/test_ratelimit.py
|
||||
and the quota_app fixture in tests/test_user_events.py) — no need to pull in the full
|
||||
app.main (sentry/scheduler/CORS/rate-limit wiring).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Any
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1 import audit as audit_module
|
||||
from app.core.db import get_db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (a) Static SQL guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AUDIT_SRC = inspect.getsource(audit_module)
|
||||
|
||||
|
||||
def test_no_psycopg_v3_doublecolon_cast() -> None:
|
||||
"""CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md)."""
|
||||
assert not re.search(r":\w+::\w", _AUDIT_SRC)
|
||||
|
||||
|
||||
def test_days_param_uses_cast_as_int() -> None:
|
||||
assert "CAST(:days AS int)" in _AUDIT_SRC
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes — mirror the mocked-DB convention used across tests/test_user_events.py etc.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeMappingResult:
|
||||
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def all(self) -> list[dict[str, Any]]:
|
||||
return list(self._rows)
|
||||
|
||||
def one(self) -> dict[str, Any]:
|
||||
return self._rows[0]
|
||||
|
||||
|
||||
class _FakeExecResult:
|
||||
def __init__(self, rows: list[dict[str, Any]]) -> None:
|
||||
self._rows = rows
|
||||
|
||||
def mappings(self) -> _FakeMappingResult:
|
||||
return _FakeMappingResult(self._rows)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Returns queued row-lists in the exact order audit.py issues db.execute() calls."""
|
||||
|
||||
def __init__(self, responses: list[list[dict[str, Any]]]) -> None:
|
||||
self._responses = list(responses)
|
||||
self._i = 0
|
||||
|
||||
def execute(self, _stmt: object, _params: dict[str, Any] | None = None) -> _FakeExecResult:
|
||||
rows = self._responses[self._i]
|
||||
self._i += 1
|
||||
return _FakeExecResult(rows)
|
||||
|
||||
|
||||
def _make_app(responses: list[list[dict[str, Any]]]) -> FastAPI:
|
||||
application = FastAPI()
|
||||
application.include_router(audit_module.router, prefix="/api/v1/admin")
|
||||
|
||||
def _override_db() -> Any:
|
||||
yield _FakeSession(responses)
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
return application
|
||||
|
||||
|
||||
_NOW = datetime(2026, 7, 13, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (b) GET /audit/accounts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_accounts_empty_table() -> None:
|
||||
app = _make_app(responses=[[]])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/audit/accounts")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
def test_list_accounts_populated_shape() -> None:
|
||||
row = {
|
||||
"username": "user1",
|
||||
"first_seen_at": _NOW,
|
||||
"last_seen_at": _NOW,
|
||||
"distinct_ips": 3,
|
||||
"distinct_devices": 2,
|
||||
"login_count": 5,
|
||||
"request_count": 40,
|
||||
"search_count": 7,
|
||||
}
|
||||
app = _make_app(responses=[[row]])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/audit/accounts")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["username"] == "user1"
|
||||
assert data[0]["distinct_ips"] == 3
|
||||
assert data[0]["login_count"] == 5
|
||||
assert data[0]["request_count"] == 40
|
||||
assert data[0]["search_count"] == 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (c) GET /audit/accounts/{username}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_account_drilldown_empty_table_unknown_user_not_404() -> None:
|
||||
"""Unknown username → 200 with all 4 lists empty, NOT a 404."""
|
||||
app = _make_app(responses=[[], [], [], []])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/audit/accounts/ghost_user_xyz")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"ips": [], "devices": [], "searches": [], "recent_activity": []}
|
||||
|
||||
|
||||
def test_account_drilldown_populated_shape() -> None:
|
||||
ips = [{"ip_address": "1.2.3.4", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}]
|
||||
devices = [
|
||||
{"user_agent": "pytest-agent", "event_count": 10, "first_seen": _NOW, "last_seen": _NOW}
|
||||
]
|
||||
searches = [
|
||||
{
|
||||
"address": "ул. Малышева, 1",
|
||||
"area_m2": "50.0",
|
||||
"rooms": "2",
|
||||
"estimate_id": str(uuid.uuid4()),
|
||||
"ip_address": "1.2.3.4",
|
||||
"created_at": _NOW,
|
||||
}
|
||||
]
|
||||
recent_activity = [
|
||||
{
|
||||
"event_type": "api_request",
|
||||
"path": "/api/v1/trade-in/estimate",
|
||||
"method": "POST",
|
||||
"ip_address": "1.2.3.4",
|
||||
"created_at": _NOW,
|
||||
}
|
||||
]
|
||||
app = _make_app(responses=[ips, devices, searches, recent_activity])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/audit/accounts/user1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["ips"][0]["ip_address"] == "1.2.3.4"
|
||||
assert data["ips"][0]["event_count"] == 10
|
||||
assert data["devices"][0]["user_agent"] == "pytest-agent"
|
||||
assert data["searches"][0]["address"] == "ул. Малышева, 1"
|
||||
assert data["searches"][0]["area_m2"] == "50.0"
|
||||
assert data["recent_activity"][0]["event_type"] == "api_request"
|
||||
assert data["recent_activity"][0]["path"] == "/api/v1/trade-in/estimate"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (d) GET /analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ZERO_SUMMARY = {
|
||||
"total_events": 0,
|
||||
"distinct_users": 0,
|
||||
"events_last_24h": 0,
|
||||
"active_users_last_24h": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_analytics_empty_table_never_errors() -> None:
|
||||
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/analytics")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["summary"] == _ZERO_SUMMARY
|
||||
assert data["daily"] == []
|
||||
assert data["top_searches"] == []
|
||||
assert data["top_paths"] == []
|
||||
assert data["by_account"] == []
|
||||
|
||||
|
||||
def test_analytics_populated_shape() -> None:
|
||||
summary = {
|
||||
"total_events": 120,
|
||||
"distinct_users": 4,
|
||||
"events_last_24h": 15,
|
||||
"active_users_last_24h": 2,
|
||||
}
|
||||
daily = [{"day": date(2026, 7, 13), "events": 15, "users": 2}]
|
||||
top_searches = [{"address": "ул. Малышева, 1", "n": 3}]
|
||||
top_paths = [{"path": "/api/v1/trade-in/estimate", "n": 40}]
|
||||
by_account = [{"username": "user1", "events": 60, "searches": 7, "last_seen": _NOW}]
|
||||
app = _make_app(responses=[[summary], daily, top_searches, top_paths, by_account])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/analytics")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["summary"]["total_events"] == 120
|
||||
assert data["daily"][0]["events"] == 15
|
||||
assert data["daily"][0]["day"] == "2026-07-13"
|
||||
assert data["top_searches"][0]["address"] == "ул. Малышева, 1"
|
||||
assert data["top_paths"][0]["n"] == 40
|
||||
assert data["by_account"][0]["username"] == "user1"
|
||||
|
||||
|
||||
def test_analytics_days_query_param_clamped() -> None:
|
||||
"""days=0 (below ge=1) → 422; days=9999 (above le=365) → 422."""
|
||||
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||
client = TestClient(app)
|
||||
assert client.get("/api/v1/admin/analytics?days=0").status_code == 422
|
||||
assert client.get("/api/v1/admin/analytics?days=9999").status_code == 422
|
||||
|
||||
|
||||
def test_analytics_days_query_param_default_30() -> None:
|
||||
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/analytics")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_analytics_days_query_param_accepted_in_range() -> None:
|
||||
app = _make_app(responses=[[_ZERO_SUMMARY], [], [], [], []])
|
||||
client = TestClient(app)
|
||||
resp = client.get("/api/v1/admin/analytics?days=7")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (e) Optional real-Postgres round trip (self-skips without a reachable DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _live_session() -> Any | None:
|
||||
"""Return a SQLAlchemy Session if a non-placeholder Postgres is reachable, else None."""
|
||||
try:
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import text as _t
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return None
|
||||
engine = create_engine(dsn, future=True)
|
||||
conn = engine.connect()
|
||||
conn.execute(_t("SELECT 1"))
|
||||
conn.close()
|
||||
return sessionmaker(bind=engine, future=True)()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||
def test_real_accounts_and_analytics_aggregate_inserted_rows() -> None:
|
||||
"""End-to-end on a real DB: insert 2 user_events rows for a throwaway test username,
|
||||
assert /audit/accounts, /audit/accounts/{username} and /analytics reflect them."""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db = _live_session()
|
||||
assert db is not None
|
||||
username = f"audit_test_{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
db.execute(
|
||||
_t(
|
||||
"""
|
||||
INSERT INTO user_events
|
||||
(event_type, username, ip_address, user_agent, path, method, payload)
|
||||
VALUES
|
||||
('login', :u, CAST('9.9.9.9' AS inet), 'pytest-ua', NULL, NULL,
|
||||
CAST('{}' AS jsonb)),
|
||||
('estimate_request', :u, CAST('9.9.9.9' AS inet), 'pytest-ua',
|
||||
'/api/v1/trade-in/estimate', 'POST',
|
||||
CAST(:payload AS jsonb))
|
||||
"""
|
||||
),
|
||||
{"u": username, "payload": '{"address": "ул. Тестовая, 1", "area_m2": "42.0"}'},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
application = FastAPI()
|
||||
application.include_router(audit_module.router, prefix="/api/v1/admin")
|
||||
|
||||
def _override_db() -> Any:
|
||||
yield SessionLocal()
|
||||
|
||||
application.dependency_overrides[get_db] = _override_db
|
||||
client = TestClient(application)
|
||||
|
||||
accounts = client.get("/api/v1/admin/audit/accounts").json()
|
||||
row = next(r for r in accounts if r["username"] == username)
|
||||
assert row["login_count"] == 1
|
||||
assert row["search_count"] == 1
|
||||
|
||||
drilldown = client.get(f"/api/v1/admin/audit/accounts/{username}").json()
|
||||
assert len(drilldown["searches"]) == 1
|
||||
assert drilldown["searches"][0]["address"] == "ул. Тестовая, 1"
|
||||
assert len(drilldown["recent_activity"]) == 2
|
||||
|
||||
dashboard = client.get("/api/v1/admin/analytics?days=1").json()
|
||||
assert dashboard["summary"]["total_events"] >= 2
|
||||
finally:
|
||||
db.rollback()
|
||||
db.execute(_t("DELETE FROM user_events WHERE username = :u"), {"u": username})
|
||||
db.commit()
|
||||
84
tradein-mvp/backend/tests/test_avito_serp_city_slug.py
Normal file
84
tradein-mvp/backend/tests/test_avito_serp_city_slug.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Avito SERP city-slug filter (#2487 oblast rollout).
|
||||
|
||||
`_parse_html` drops padding cards ("по всей России") when `avito_serp_ekb_only`
|
||||
is on. The kept city-slug used to be hardcoded `/ekaterinburg/`, so an oblast
|
||||
per-city sweep (`_job_avito_city_sweep` with a `city` param) discarded 100% of the
|
||||
target city's cards. The slug is now parameterized via `AvitoScraper.target_city_slug`
|
||||
(default → "ekaterinburg", i.e. ЕКБ behavior unchanged when no target is set).
|
||||
|
||||
No network / no DB — `_parse_html` runs on inline HTML with a stub config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||
|
||||
_BASE_URL = "https://www.avito.ru/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ?p=1"
|
||||
|
||||
|
||||
def _card(slug: str, item_id: str, price: int = 5_000_000) -> str:
|
||||
"""Minimal Avito SERP card whose per-card href carries `slug` as the city segment."""
|
||||
href = f"/{slug}/kvartiry/2k_kvartira_50_m_5_5et_{item_id}"
|
||||
return (
|
||||
f'<div data-marker="item" data-item-id="{item_id}">'
|
||||
f'<a data-marker="item-title" href="{href}">2-к. квартира, 50 м², 5/9 эт.</a>'
|
||||
f'<meta itemprop="price" content="{price}">'
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
def _html(*cards: str) -> str:
|
||||
return "<html><body>" + "".join(cards) + "</body></html>"
|
||||
|
||||
|
||||
def _ekb_only_cfg() -> SimpleNamespace:
|
||||
# _parse_html only reads config.avito_serp_ekb_only; a stub keeps the test
|
||||
# deterministic regardless of AVITO_SERP_EKB_ONLY env in the runner.
|
||||
return SimpleNamespace(avito_serp_ekb_only=True)
|
||||
|
||||
|
||||
def test_parse_keeps_target_oblast_city_drops_off_target_padding() -> None:
|
||||
"""Sweep targeting Н.Тагил keeps its cards; ЕКБ/Москва padding is dropped."""
|
||||
s = AvitoScraper(_ekb_only_cfg(), target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
|
||||
html = _html(
|
||||
_card("nizhniy_tagil", "nt1"), # target city → KEPT
|
||||
_card("ekaterinburg", "ekb1"), # off-target padding → DROPPED
|
||||
_card("moskva", "msk1"), # off-target padding → DROPPED
|
||||
)
|
||||
lots = s._parse_html(html, source_url_base=_BASE_URL)
|
||||
urls = [lot.source_url or "" for lot in lots]
|
||||
|
||||
assert len(lots) == 1, urls
|
||||
assert "/nizhniy_tagil/" in urls[0]
|
||||
assert all("/ekaterinburg/" not in u for u in urls)
|
||||
assert all("/moskva/" not in u for u in urls)
|
||||
|
||||
|
||||
def test_parse_default_target_is_ekb_and_drops_oblast_padding() -> None:
|
||||
"""No target (ЕКБ sweep/full-load): kept slug defaults to 'ekaterinburg' — behavior
|
||||
identical to the previous hardcode. ЕКБ card kept, oblast padding dropped."""
|
||||
s = AvitoScraper(_ekb_only_cfg(), target_city_slug=None) # type: ignore[arg-type]
|
||||
html = _html(
|
||||
_card("ekaterinburg", "e1"), # ЕКБ → KEPT
|
||||
_card("nizhniy_tagil", "nt1"), # off-target padding → DROPPED
|
||||
)
|
||||
lots = s._parse_html(html, source_url_base=_BASE_URL)
|
||||
urls = [lot.source_url or "" for lot in lots]
|
||||
|
||||
assert len(lots) == 1, urls
|
||||
assert "/ekaterinburg/" in urls[0]
|
||||
assert all("/nizhniy_tagil/" not in u for u in urls)
|
||||
|
||||
|
||||
def test_parse_ekb_only_disabled_keeps_all_cities() -> None:
|
||||
"""avito_serp_ekb_only=False → no city filter at all, every card is kept."""
|
||||
cfg = SimpleNamespace(avito_serp_ekb_only=False)
|
||||
s = AvitoScraper(cfg, target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
|
||||
html = _html(_card("nizhniy_tagil", "nt1"), _card("ekaterinburg", "e1"))
|
||||
lots = s._parse_html(html, source_url_base=_BASE_URL)
|
||||
assert len(lots) == 2
|
||||
|
|
@ -832,3 +832,177 @@ def test_render_full_table_handles_empty_sample() -> None:
|
|||
out = bt._render_full_table(m)
|
||||
assert "n/a" in out # None metrics render as n/a, no crash
|
||||
assert "BACKTEST" in out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Oblast D — `--city` deal-sample scoping (parse + SQL predicate + PPM2 band).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_argparse_city_defaults_none() -> None:
|
||||
assert bt._parse_args([]).city is None
|
||||
|
||||
|
||||
def test_argparse_city_override() -> None:
|
||||
ns = bt._parse_args(["--city", "Нижний Тагил"])
|
||||
assert ns.city == "Нижний Тагил"
|
||||
|
||||
|
||||
def test_sample_sql_default_is_unscoped_and_same_object() -> None:
|
||||
# city=None must return the SAME _SAMPLE_SQL object used before oblast D —
|
||||
# literal identity, not just equal text — so the default CLI invocation (and
|
||||
# the frozen EKB regression gate, which never calls this path at all) see a
|
||||
# byte-identical query.
|
||||
assert bt._sample_sql(None) is bt._SAMPLE_SQL
|
||||
|
||||
|
||||
def test_sample_sql_default_has_no_city_predicate() -> None:
|
||||
assert ":city" not in bt._SAMPLE_SQL.text
|
||||
|
||||
|
||||
def test_sample_sql_city_scoped_adds_city_predicate() -> None:
|
||||
sql = bt._sample_sql("Нижний Тагил")
|
||||
assert sql is not bt._SAMPLE_SQL
|
||||
built = sql.text
|
||||
assert "AND city = CAST(:city AS text)" in built
|
||||
# City-scoping is additive — the base predicates are still present.
|
||||
assert "source = 'rosreestr'" in built
|
||||
assert "deal_date >= CAST(:since AS date)" in built
|
||||
assert "ORDER BY id DESC" in built
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Oblast D — _resolve_city_ppm2_band (per-city PPM2 sanity band, no live DB).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _FakeBandResult:
|
||||
"""Minimal stand-in for a SQLAlchemy Result exposing .mappings().first()."""
|
||||
|
||||
def __init__(self, row: dict[str, object] | None) -> None:
|
||||
self._row = row
|
||||
|
||||
def mappings(self) -> "_FakeBandResult":
|
||||
return self
|
||||
|
||||
def first(self) -> dict[str, object] | None:
|
||||
return self._row
|
||||
|
||||
|
||||
class _FakeBandSession:
|
||||
"""Minimal stand-in for a Session — only .execute() is exercised here."""
|
||||
|
||||
def __init__(self, row: dict[str, object] | None = None, *, raise_exc: bool = False) -> None:
|
||||
self._row = row
|
||||
self._raise = raise_exc
|
||||
|
||||
def execute(self, *_args: object, **_kwargs: object) -> _FakeBandResult:
|
||||
if self._raise:
|
||||
raise RuntimeError("boom")
|
||||
return _FakeBandResult(self._row)
|
||||
|
||||
|
||||
def test_resolve_city_ppm2_band_none_city_returns_globals_no_query() -> None:
|
||||
# city=None must not even touch the DB (no _FakeBandSession.execute call).
|
||||
class _NoExecuteSession:
|
||||
def execute(self, *_a: object, **_kw: object) -> None:
|
||||
raise AssertionError("must not query DB when city is None")
|
||||
|
||||
assert bt._resolve_city_ppm2_band(_NoExecuteSession(), None) == (
|
||||
float(bt.PPM2_MIN),
|
||||
float(bt.PPM2_MAX),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_city_ppm2_band_found_row() -> None:
|
||||
db = _FakeBandSession({"ppm2_min": 16955, "ppm2_max": 108175})
|
||||
assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (16955.0, 108175.0)
|
||||
|
||||
|
||||
def test_resolve_city_ppm2_band_no_row_falls_back_to_globals() -> None:
|
||||
db = _FakeBandSession(None)
|
||||
assert bt._resolve_city_ppm2_band(db, "Екатеринбург") == (
|
||||
float(bt.PPM2_MIN),
|
||||
float(bt.PPM2_MAX),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_city_ppm2_band_db_error_falls_back_to_globals() -> None:
|
||||
db = _FakeBandSession(raise_exc=True)
|
||||
assert bt._resolve_city_ppm2_band(db, "Нижний Тагил") == (
|
||||
float(bt.PPM2_MIN),
|
||||
float(bt.PPM2_MAX),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Oblast C2 parity — _predict_full_spine must resolve + pass a target city to
|
||||
# _fetch_dkp_corridor, mirroring estimate_quality (estimator.py:3210-3220).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_predict_full_spine_passes_resolved_city_to_corridor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services import estimator as est_mod
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_resolve_target_city(address: str | None) -> str | None:
|
||||
return "нижний тагил"
|
||||
|
||||
def _fake_fetch_dkp_corridor(
|
||||
_db: object,
|
||||
*,
|
||||
address: object,
|
||||
rooms: object,
|
||||
area: object,
|
||||
city: object = None,
|
||||
**_kw: object,
|
||||
) -> None:
|
||||
captured["called"] = True
|
||||
captured["city"] = city
|
||||
return None
|
||||
|
||||
def _fake_price_from_inputs(**_kwargs: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
median_price=100_000.0,
|
||||
median_ppm2=100_000.0,
|
||||
confidence="low",
|
||||
anchor_tier=None,
|
||||
expected_sold_per_m2=95_000.0,
|
||||
expected_sold_price=4_750_000.0,
|
||||
expected_sold_range_low=4_000_000.0,
|
||||
expected_sold_range_high=5_500_000.0,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(est_mod, "_resolve_target_city", _fake_resolve_target_city)
|
||||
monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", _fake_fetch_dkp_corridor)
|
||||
monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None))
|
||||
monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None)
|
||||
monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs)
|
||||
monkeypatch.setattr(bt, "_select_analogs_full", lambda *a, **kw: ([], "W", False, False))
|
||||
|
||||
est = bt._import_estimator_full()
|
||||
deal = bt.DealSample(
|
||||
id=1,
|
||||
lon=60.6,
|
||||
lat=58.05,
|
||||
rooms=2,
|
||||
sold_ppm2=90_000.0,
|
||||
deal_date=None,
|
||||
area_m2=50.0,
|
||||
address="Нижний Тагил, ул. Ленина, 5",
|
||||
floor=3,
|
||||
total_floors=9,
|
||||
year_built=2000,
|
||||
house_type="панель",
|
||||
)
|
||||
|
||||
pred = bt._predict_full_spine(None, deal, est)
|
||||
|
||||
assert captured.get("called") is True
|
||||
assert captured.get("city") == "нижний тагил"
|
||||
assert pred is not None
|
||||
|
|
|
|||
443
tradein-mvp/backend/tests/test_domrf_kapremont_loader.py
Normal file
443
tradein-mvp/backend/tests/test_domrf_kapremont_loader.py
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
"""Тесты ДОМ.РФ капремонт loader'а (app/services/domrf_kapremont_loader.py, мигр. 176, #2013).
|
||||
|
||||
Coverage:
|
||||
- Чистые парс-хелперы: parse_decimal_comma / parse_int_field.
|
||||
- parse_kr11_csv / parse_kr12_wall_materials — фикстуры собраны из РЕАЛЬНЫХ строк
|
||||
региона 66 (export-kr1_1-66-20260701.csv / export-kr1_2-66-20260701.csv, дом
|
||||
2170017185 «Ломоносова 3» и 2170025270 «Ломоносова 4»): КР1.2 long-format,
|
||||
wall_material непустой только на строке construction_element_type='фасад'.
|
||||
- build_domrf_houses — join КР1.1+КР1.2 по mkd_code.
|
||||
- fetch_domrf_csvs — скачивание+распаковка zip, HTTP замокан через httpx.MockTransport
|
||||
(никакой живой сети).
|
||||
- Статические asserts по SQL: _UPSERT_SQL (ON CONFLICT, IS DISTINCT FROM, CAST),
|
||||
backfill houses (COALESCE-порядок year_built/domrf/zhkh_year, only-fill-NULL),
|
||||
propagate listings (только NULL listings.year_built).
|
||||
- backfill_houses_from_domrf / propagate_listings_year_from_houses — dry_run вызывает
|
||||
COUNT-SQL и не пишет, real-run суммирует rowcount обоих шагов (MagicMock db).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# settings/SessionLocal не импортируем, но пакет app.* на импорте может тронуть DSN — как в
|
||||
# sibling-тестах (test_zhkh_flats_loader) задаём заглушку (ядро статично/без реальной БД).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services import domrf_kapremont_loader as dkl
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Фикстуры: РЕАЛЬНЫЕ строки региона 66 (export-kr1_1/kr1_2-66-20260701.csv)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# КР1.1 house registry — 2 дома (Ломоносова 3 / Ломоносова 4, п. Арамиль).
|
||||
_KR11_HEADER = (
|
||||
"subject_rf;mun_obr_oktmo;mun_obr;mkd_code;houseguid;address;commission_year;"
|
||||
"architectural_monument_category;total_sq;total_rooms_amount;living_rooms_amount;"
|
||||
"total_rooms_sq;living_rooms_sq;total_ppl;number_floors_max;money_collecting_way;"
|
||||
"money_ppl_collected;money_ppl_collected_debts;overhaul_funds_spent_all;"
|
||||
"overhaul_funds_spent_subsidy;overhaul_fund_spent_other;overhaul_funds_balance;"
|
||||
"update_date_of_information;money_ppl_collected_date;owners_payment;energy_efficiency;"
|
||||
"alarm_document_date;exclude_date_from_program;inclusion_date_to_program;comment;"
|
||||
"last_update;house_id"
|
||||
)
|
||||
_KR11_ROW_3 = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170017185;04b98bcb-3232-4b26-b0fa-3c4a47adf394;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
|
||||
'1964;;399,90;;;366,60;333,30;20;2;"Счет регионального оператора";486,84407;72,59638;'
|
||||
"1580,29712;0,00000;0,00000;-1093,45305;2026-05-31;01.10.2014;18,81;"
|
||||
'"не присвоен";;;22.04.2014;;15.05.2026;8857551'
|
||||
)
|
||||
_KR11_ROW_4 = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170025270;15064d04-3aec-4266-985b-da26e9a5f392;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
|
||||
'1984;;906,40;;;;;;2;"Счет регионального оператора";1046,79431;204,75296;0,00000;'
|
||||
'0,00000;0,00000;1046,79431;2026-05-31;01.10.2014;18,81;"не присвоен";;;22.04.2014;;'
|
||||
"15.05.2026;8857552"
|
||||
)
|
||||
KR11_CSV = "\n".join([_KR11_HEADER, _KR11_ROW_3, _KR11_ROW_4]) + "\n"
|
||||
|
||||
# КР1.2 constructive elements — long-format: обычная строка (wall_material пуст) +
|
||||
# строка "фасад" (wall_material='кирпич') на каждый из тех же 2 домов.
|
||||
_KR12_HEADER = (
|
||||
"subject_rf;mun_obr_oktmo;mun_obr;mkd_code;address;construction_element_code;"
|
||||
"construction_element_type;system_type;lift_type;stops_count;commissioning_date;"
|
||||
"decommissioning_date;roof_type;roofing_area;basement_area;facade_type;facade_area;"
|
||||
"foundation_type;wall_material;comment;last_update"
|
||||
)
|
||||
_KR12_ROW_3_OTHER = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170017185;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
|
||||
'580000;"система дымоудаления и (или) противопожарной автоматики";;;;;;;;;;;;;;15.05.2026'
|
||||
)
|
||||
_KR12_ROW_3_FACADE = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170017185;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 3";'
|
||||
'60000;фасад;;;;;;;;;"облицованный камнем";480,00;;кирпич;;15.05.2026'
|
||||
)
|
||||
_KR12_ROW_4_OTHER = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170025270;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
|
||||
'100000;"инженерная система водоснабжения (ХВС)";центральная;;;;;;;;;;;;;15.05.2026'
|
||||
)
|
||||
_KR12_ROW_4_FACADE = (
|
||||
'"Свердловская область";65729000;"Арамильский городской округ Свердловской области";'
|
||||
"2170025270;"
|
||||
'"Арамильский городской округ Свердловской области, пос. Арамиль, ул. Ломоносова, д. 4";'
|
||||
"60000;фасад;;;;;;;;;;;;кирпич;;15.05.2026"
|
||||
)
|
||||
KR12_CSV = (
|
||||
"\n".join(
|
||||
[_KR12_HEADER, _KR12_ROW_3_OTHER, _KR12_ROW_3_FACADE, _KR12_ROW_4_OTHER, _KR12_ROW_4_FACADE]
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kr11_path(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "kr11.csv"
|
||||
p.write_text(KR11_CSV, encoding="utf-8-sig")
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kr12_path(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "kr12.csv"
|
||||
p.write_text(KR12_CSV, encoding="utf-8-sig")
|
||||
return p
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# parse_decimal_comma / parse_int_field
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_decimal_comma() -> None:
|
||||
assert dkl.parse_decimal_comma("909,80") == pytest.approx(909.80)
|
||||
assert dkl.parse_decimal_comma("399,90") == pytest.approx(399.90)
|
||||
assert dkl.parse_decimal_comma("100.5") == pytest.approx(100.5)
|
||||
assert dkl.parse_decimal_comma("") is None
|
||||
assert dkl.parse_decimal_comma(None) is None
|
||||
assert dkl.parse_decimal_comma("не число") is None
|
||||
|
||||
|
||||
def test_parse_int_field() -> None:
|
||||
assert dkl.parse_int_field("1964") == 1964
|
||||
assert dkl.parse_int_field("2") == 2
|
||||
assert dkl.parse_int_field("") is None
|
||||
assert dkl.parse_int_field(None) is None
|
||||
assert dkl.parse_int_field("x") is None
|
||||
assert dkl.parse_int_field("5,0") == 5 # decimal-фоллбек
|
||||
|
||||
|
||||
def test_parse_int_field_year_sanity_gate() -> None:
|
||||
# Fix A: значение вне [min_value, max_value] → None (мусорный год не в staging).
|
||||
ymax = dkl.plausible_year_max()
|
||||
assert dkl.parse_int_field("1841", min_value=1850, max_value=ymax) is None # слишком старый
|
||||
assert dkl.parse_int_field("0", min_value=1850, max_value=ymax) is None # ноль
|
||||
assert dkl.parse_int_field(str(ymax + 5), min_value=1850, max_value=ymax) is None # будущее
|
||||
assert dkl.parse_int_field("1964", min_value=1850, max_value=ymax) == 1964 # валидный
|
||||
assert dkl.parse_int_field("1850", min_value=1850, max_value=ymax) == 1850 # граница вкл.
|
||||
# Без границ (number_floors_max) — гейт не применяется.
|
||||
assert dkl.parse_int_field("1841") == 1841
|
||||
|
||||
|
||||
def test_parse_kr11_gates_impossible_commission_year(tmp_path: Path) -> None:
|
||||
# Fix A: строка КР1.1 с невозможным годом (1200) → commission_year=None.
|
||||
bad_row = _KR11_ROW_3.replace("1964;", "1200;", 1)
|
||||
csv_text = "\n".join([_KR11_HEADER, bad_row]) + "\n"
|
||||
p = tmp_path / "bad_year.csv"
|
||||
p.write_text(csv_text, encoding="utf-8-sig")
|
||||
rows = dkl.parse_kr11_csv(p)
|
||||
assert rows["2170017185"].commission_year is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# parse_kr11_csv — house registry
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_kr11_csv_real_rows(kr11_path: Path) -> None:
|
||||
rows = dkl.parse_kr11_csv(kr11_path)
|
||||
assert set(rows.keys()) == {"2170017185", "2170025270"}
|
||||
|
||||
house3 = rows["2170017185"]
|
||||
assert house3.houseguid == "04b98bcb-3232-4b26-b0fa-3c4a47adf394"
|
||||
assert house3.commission_year == 1964
|
||||
assert house3.number_floors_max == 2
|
||||
assert house3.total_sq == pytest.approx(399.90)
|
||||
assert "Ломоносова, д. 3" in (house3.address or "")
|
||||
|
||||
house4 = rows["2170025270"]
|
||||
assert house4.houseguid == "15064d04-3aec-4266-985b-da26e9a5f392"
|
||||
assert house4.commission_year == 1984
|
||||
assert house4.total_sq == pytest.approx(906.40)
|
||||
|
||||
|
||||
def test_parse_kr11_csv_skips_blank_mkd_code(tmp_path: Path) -> None:
|
||||
csv_text = _KR11_HEADER + "\n" + (";" * (_KR11_HEADER.count(";"))) + "\n"
|
||||
p = tmp_path / "blank.csv"
|
||||
p.write_text(csv_text, encoding="utf-8-sig")
|
||||
assert dkl.parse_kr11_csv(p) == {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# parse_kr12_wall_materials — long-format, wall_material только на строке 'фасад'
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_parse_kr12_wall_materials_extracts_facade_row_only(kr12_path: Path) -> None:
|
||||
materials = dkl.parse_kr12_wall_materials(kr12_path)
|
||||
# Обе non-facade строки (дымоудаление / ХВС) имели wall_material='' — исключены.
|
||||
assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"}
|
||||
|
||||
|
||||
def test_parse_kr12_wall_materials_empty_when_no_facade_row(tmp_path: Path) -> None:
|
||||
csv_text = "\n".join([_KR12_HEADER, _KR12_ROW_3_OTHER]) + "\n"
|
||||
p = tmp_path / "no_facade.csv"
|
||||
p.write_text(csv_text, encoding="utf-8-sig")
|
||||
assert dkl.parse_kr12_wall_materials(p) == {}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# build_domrf_houses — join КР1.1 + КР1.2(wall_material)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_build_domrf_houses_joins_wall_material(kr11_path: Path, kr12_path: Path) -> None:
|
||||
kr11 = dkl.parse_kr11_csv(kr11_path)
|
||||
walls = dkl.parse_kr12_wall_materials(kr12_path)
|
||||
houses = dkl.build_domrf_houses(kr11, walls)
|
||||
|
||||
by_code = {h.mkd_code: h for h in houses}
|
||||
assert len(houses) == 2
|
||||
assert by_code["2170017185"].wall_material == "кирпич"
|
||||
assert by_code["2170017185"].commission_year == 1964
|
||||
assert by_code["2170025270"].wall_material == "кирпич"
|
||||
|
||||
|
||||
# Дом без строки 'фасад' в КР1.2 → wall_material остаётся None (не KeyError/пустая строка).
|
||||
def test_wall_material_none_when_house_missing_from_kr12(tmp_path: Path, kr11_path: Path) -> None:
|
||||
kr11 = dkl.parse_kr11_csv(kr11_path)
|
||||
houses = dkl.build_domrf_houses(kr11, {}) # пустой КР1.2 lookup
|
||||
assert all(h.wall_material is None for h in houses)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# fetch_domrf_csvs — скачивание+распаковка zip, HTTP замокан (MockTransport)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def _zip_bytes(inner_name: str, content: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(inner_name, content.encode("utf-8-sig"))
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_fetch_domrf_csvs_downloads_and_extracts(tmp_path: Path) -> None:
|
||||
kr11_zip = _zip_bytes("export-kr1_1-66-20260701.csv", KR11_CSV)
|
||||
kr12_zip = _zip_bytes("export-kr1_2-66-20260701.csv", KR12_CSV)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
if url == dkl.KR11_URL:
|
||||
return httpx.Response(200, content=kr11_zip)
|
||||
if url == dkl.KR12_URL:
|
||||
return httpx.Response(200, content=kr12_zip)
|
||||
raise AssertionError(f"unexpected URL {url}")
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport) as client:
|
||||
kr11_out, kr12_out = dkl.fetch_domrf_csvs(tmp_path, client=client)
|
||||
|
||||
assert kr11_out.exists() and kr11_out.name.endswith(".csv")
|
||||
assert kr12_out.exists() and kr12_out.name.endswith(".csv")
|
||||
|
||||
# Круглый путь: распакованные файлы парсятся так же, как локальные фикстуры.
|
||||
rows = dkl.parse_kr11_csv(kr11_out)
|
||||
assert set(rows.keys()) == {"2170017185", "2170025270"}
|
||||
materials = dkl.parse_kr12_wall_materials(kr12_out)
|
||||
assert materials == {"2170017185": "кирпич", "2170025270": "кирпич"}
|
||||
|
||||
|
||||
def test_extract_csv_from_zip_raises_on_no_csv_entry(tmp_path: Path) -> None:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("readme.txt", "not a csv")
|
||||
with pytest.raises(ValueError, match="csv"):
|
||||
dkl._extract_csv_from_zip(buf.getvalue(), tmp_path)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Статические asserts по _UPSERT_SQL (psycopg v3, ON CONFLICT, идемпотентность)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_UPSERT_SQL = str(dkl._UPSERT_SQL.text)
|
||||
|
||||
|
||||
def test_upsert_sql_uses_psycopg_v3_cast_not_double_colon() -> None:
|
||||
assert not re.search(r":\w+::", _UPSERT_SQL)
|
||||
assert "CAST(:mkd_code AS text)" in _UPSERT_SQL
|
||||
assert "CAST(:commission_year AS smallint)" in _UPSERT_SQL
|
||||
assert "CAST(:total_sq AS numeric)" in _UPSERT_SQL
|
||||
|
||||
|
||||
def test_upsert_sql_idempotent_on_conflict() -> None:
|
||||
flat = re.sub(r"\s+", " ", _UPSERT_SQL)
|
||||
assert "INSERT INTO domrf_kapremont" in flat
|
||||
assert "ON CONFLICT (mkd_code) DO UPDATE SET" in flat
|
||||
assert "domrf_kapremont.houseguid IS DISTINCT FROM EXCLUDED.houseguid" in flat
|
||||
assert "domrf_kapremont.wall_material IS DISTINCT FROM EXCLUDED.wall_material" in flat
|
||||
|
||||
|
||||
def test_upsert_uses_savepoint_per_chunk() -> None:
|
||||
import inspect
|
||||
|
||||
src = inspect.getsource(dkl.upsert_domrf_kapremont)
|
||||
assert "with db.begin_nested():" in src
|
||||
assert "db.commit()" not in src # не коммитит сам (коммитит caller)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Статические asserts: backfill houses — COALESCE(year_built, domrf, zhkh_year)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_BACKFILL_DOMRF_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_FROM_DOMRF_SQL.text))
|
||||
_BACKFILL_ZHKH_SQL = re.sub(r"\s+", " ", str(dkl._BACKFILL_HOUSES_ZHKH_FALLBACK_SQL.text))
|
||||
|
||||
|
||||
def test_backfill_domrf_sql_year_priority_and_plausibility_guard() -> None:
|
||||
bound = "BETWEEN CAST(:ymin AS int) AND CAST(:ymax AS int)"
|
||||
# Fix A: year_built — валидное существующее (в границах :ymin..:ymax) выигрывает,
|
||||
# иначе (NULL/impossible) заменяется авторитетным commission_year → zhkh_year.
|
||||
assert f"WHEN h.year_built {bound}" in _BACKFILL_DOMRF_SQL
|
||||
assert "THEN h.year_built" in _BACKFILL_DOMRF_SQL
|
||||
# commission_year и zhkh_year тоже проходят плаузибилити-гейт перед записью.
|
||||
assert f"CASE WHEN d.commission_year {bound}" in _BACKFILL_DOMRF_SQL
|
||||
# Невозможный существующий год НЕ экранирует замену (WHERE ловит NOT BETWEEN).
|
||||
assert f"h.year_built NOT {bound}" in _BACKFILL_DOMRF_SQL
|
||||
# material_walls / total_floors — прежняя COALESCE-семантика (только заполнить NULL).
|
||||
assert "COALESCE(h.material_walls, d.wall_material)" in _BACKFILL_DOMRF_SQL
|
||||
assert "COALESCE(h.total_floors, d.number_floors_max, h.zhkh_floors)" in _BACKFILL_DOMRF_SQL
|
||||
|
||||
|
||||
def test_backfill_domrf_sql_matches_by_guid_coalesce_priority() -> None:
|
||||
# gar_house_guid первый (78% coverage), затем house_fias_id, затем zhkh_house_guid.
|
||||
assert (
|
||||
"d.houseguid = COALESCE(h.gar_house_guid, h.house_fias_id, h.zhkh_house_guid)"
|
||||
in _BACKFILL_DOMRF_SQL
|
||||
)
|
||||
|
||||
|
||||
def test_backfill_domrf_sql_only_fills_null_never_overwrites() -> None:
|
||||
# Гейт: каждый SET-таргет требует h.<col> IS NULL — COALESCE никогда не перезапишет
|
||||
# существующее non-null значение (SQL COALESCE тоже это гарантирует, но гейт защищает
|
||||
# от no-op UPDATE на уже заполненных строках).
|
||||
assert "h.year_built IS NULL" in _BACKFILL_DOMRF_SQL
|
||||
assert "h.material_walls IS NULL" in _BACKFILL_DOMRF_SQL
|
||||
assert "h.total_floors IS NULL" in _BACKFILL_DOMRF_SQL
|
||||
|
||||
|
||||
def test_backfill_zhkh_fallback_sql_folds_zhkh_for_unmatched_houses() -> None:
|
||||
# Шаг 2 — без JOIN на domrf_kapremont вообще: применяется к ЛЮБОМУ дому, включая
|
||||
# те, что не заматчились в шаге 1 (issue: "houses without a ДОМ.РФ match still benefit").
|
||||
assert "domrf_kapremont" not in _BACKFILL_ZHKH_SQL
|
||||
assert "COALESCE(year_built, zhkh_year)" in _BACKFILL_ZHKH_SQL
|
||||
assert "COALESCE(total_floors, zhkh_floors)" in _BACKFILL_ZHKH_SQL
|
||||
assert "year_built IS NULL AND zhkh_year IS NOT NULL" in _BACKFILL_ZHKH_SQL
|
||||
|
||||
|
||||
def test_backfill_sql_no_psycopg_v3_colon_colon_cast() -> None:
|
||||
assert not re.search(r":\w+::", _BACKFILL_DOMRF_SQL)
|
||||
assert not re.search(r":\w+::", _BACKFILL_ZHKH_SQL)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# backfill_houses_from_domrf — dry_run vs real-run (MagicMock db)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_backfill_houses_real_run_sums_both_steps_rowcount() -> None:
|
||||
db = MagicMock()
|
||||
results = [MagicMock(rowcount=3), MagicMock(rowcount=2)]
|
||||
db.execute.side_effect = results
|
||||
|
||||
out = dkl.backfill_houses_from_domrf(db, dry_run=False)
|
||||
|
||||
assert out == {"domrf_matched": 3, "zhkh_fallback": 2, "houses_updated": 5}
|
||||
assert db.execute.call_count == 2
|
||||
executed_sql = [str(call.args[0]) for call in db.execute.call_args_list]
|
||||
assert "UPDATE houses" in executed_sql[0]
|
||||
assert "UPDATE houses" in executed_sql[1]
|
||||
# Fix A: domrf-шаг прокидывает плаузибилити-границы года как bind-параметры.
|
||||
domrf_params = db.execute.call_args_list[0].args[1]
|
||||
assert domrf_params["ymin"] == dkl.YEAR_BUILT_MIN
|
||||
assert domrf_params["ymax"] == dkl.plausible_year_max()
|
||||
|
||||
|
||||
def test_backfill_houses_dry_run_only_counts_no_update() -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.scalar_one.side_effect = [7, 4]
|
||||
|
||||
out = dkl.backfill_houses_from_domrf(db, dry_run=True)
|
||||
|
||||
assert out == {"domrf_matched": 7, "zhkh_fallback": 4, "houses_updated": 0}
|
||||
for call in db.execute.call_args_list:
|
||||
sql_text = str(call.args[0]).upper()
|
||||
assert "UPDATE" not in sql_text
|
||||
assert "SELECT COUNT(*)" in sql_text
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Статические asserts + behavior: propagate_listings_year_from_houses
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_PROPAGATE_SQL = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_SQL.text))
|
||||
|
||||
|
||||
def test_propagate_sql_only_fills_null_listings_year() -> None:
|
||||
assert "SET year_built = h.year_built" in _PROPAGATE_SQL
|
||||
assert "l.year_built IS NULL" in _PROPAGATE_SQL
|
||||
assert "h.year_built IS NOT NULL" in _PROPAGATE_SQL
|
||||
assert "l.house_id_fk = h.id" in _PROPAGATE_SQL
|
||||
|
||||
|
||||
def test_propagate_sql_has_link_consistency_distance_guard() -> None:
|
||||
# Fix B: пропагация только если координаты объявления в пределах порога от дома
|
||||
# (ST_DWithin, geography → метры) — перепривязанный FK не впрыснет чужой год.
|
||||
assert "ST_DWithin(" in _PROPAGATE_SQL
|
||||
assert "CAST(l.geom AS geography)" in _PROPAGATE_SQL
|
||||
assert "CAST(h.geom AS geography)" in _PROPAGATE_SQL
|
||||
assert str(dkl.PROPAGATE_MAX_DISTANCE_M) in _PROPAGATE_SQL
|
||||
# Оба SQL (UPDATE и COUNT) несут один и тот же guard.
|
||||
count_sql = re.sub(r"\s+", " ", str(dkl._PROPAGATE_LISTINGS_YEAR_COUNT_SQL.text))
|
||||
assert "ST_DWithin(" in count_sql
|
||||
|
||||
|
||||
def test_propagate_sql_no_coords_falls_back_to_address_check() -> None:
|
||||
# Fix B: объявление без координат → консервативный address-фоллбек
|
||||
# (адрес объявления содержит short_address дома), не пропагируем «вслепую».
|
||||
assert "l.geom IS NULL" in _PROPAGATE_SQL
|
||||
assert "h.short_address IS NOT NULL" in _PROPAGATE_SQL
|
||||
assert "l.address ILIKE" in _PROPAGATE_SQL
|
||||
|
||||
|
||||
def test_propagate_listings_year_real_run_returns_rowcount() -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.rowcount = 42
|
||||
|
||||
out = dkl.propagate_listings_year_from_houses(db, dry_run=False)
|
||||
|
||||
assert out == {"listings_updated": 42}
|
||||
sql_text = str(db.execute.call_args[0][0])
|
||||
assert "UPDATE listings" in sql_text
|
||||
|
||||
|
||||
def test_propagate_listings_year_dry_run_uses_count_sql() -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.scalar_one.return_value = 99
|
||||
|
||||
out = dkl.propagate_listings_year_from_houses(db, dry_run=True)
|
||||
|
||||
assert out == {"listings_updated": 0, "would_update": 99}
|
||||
sql_text = str(db.execute.call_args[0][0]).upper()
|
||||
assert "UPDATE" not in sql_text
|
||||
|
|
@ -139,6 +139,9 @@ def _stub_precision_and_pdf():
|
|||
_fetch_price_trend=lambda *a, **k: None,
|
||||
_fetch_dkp_corridor=lambda *a, **k: None,
|
||||
_fetch_house_imv_anchor=lambda *a, **k: None,
|
||||
# (oblast C2): GET-rehydrate also resolves the target city (for
|
||||
# _fetch_dkp_corridor city-scoping) before calling the corridor helper.
|
||||
_resolve_target_city=lambda *a, **k: None,
|
||||
# #2043 (BE-1): GET-rehydrate also recomputes cv / source_counts from the
|
||||
# persisted analogs. Empty analogs in the fixture → None / {} (real behaviour).
|
||||
_cv_from_ppm2=lambda *a, **k: None,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
"""Display-consistency fix — показанные карточки аналогов согласованы с n_analogs
|
||||
и не содержат ни MAD-clip-отсечённых ценовых выбросов, ни кросс-source дублей
|
||||
одного физлота (live QA прод-смоук: «Екатеринбург, ул. Малышева, 51»).
|
||||
|
||||
Два независимых root cause (см. PR):
|
||||
|
||||
1. `n_analogs` считался ПОСЛЕ MAD-clip (_compute_same_building_anchor: n = len
|
||||
выживших ppm2), но отображаемые карточки строились из ИСХОДНОГО (пред-clip)
|
||||
anchor_comps — клип-отсечённый ценовой выброс оставался видимым в UI, даже
|
||||
когда он не участвовал в headline/n_analogs. Фикс: anchor_comps_used теперь =
|
||||
anchor["comps"] (тот же пост-clip пул, что дал n).
|
||||
|
||||
2. Статистический кросс-source дедуп (_dedup_cross_source) требует совпадения
|
||||
ЦЕНОВОГО бакета (price_bucket) — реальный кросс-пост одного физлота на разных
|
||||
площадках почти всегда имеет небольшой ценовой дрейф (перевыставили дешевле/
|
||||
дороже), который может увести пару в разные бакеты → дубль выживает в
|
||||
популяции. Фикс: отдельный display-only дедуп (_dedup_display_lots) — тот же
|
||||
физический ключ (building/floor/area), но БЕЗ price_bucket — ловит именно эти
|
||||
остаточные дубли ТОЛЬКО на уровне отображаемых карточек, не трогая
|
||||
n_analogs/median/cv (frozen backtest regression gate не задет — см.
|
||||
tests/test_backtest_regression_gate.py, который эту ветку кода не вызывает).
|
||||
|
||||
Test A воспроизводит буквальный прод-паттерн (кросс-source дубль-пара, которая
|
||||
ЦЕЛИКОМ является ценовым выбросом, — «Гоголя 18» из QA-смоука): MAD-clip убирает
|
||||
ОБЕ копии из anchor["comps"] → они пропадают из карточек естественным образом
|
||||
(fix #1 самодостаточен) → len(analogs) == n_analogs И нет дубль-адресов.
|
||||
|
||||
Test B покрывает менее тривиальный случай — дубль-пара НЕ является ценовым
|
||||
выбросом (обычная цена, просто с небольшим дрейфом между площадками) и
|
||||
переживает MAD-clip как 2 отдельные записи (её и ловит fix #2, display-only
|
||||
дедуп). Здесь len(analogs) < n_analogs ЧЕСТНО (статистика не трогается) — тест
|
||||
документирует именно это, а не искусственное равенство.
|
||||
|
||||
Полный estimate-путь со всеми I/O застаблен через harness _run_estimate из
|
||||
test_same_building_anchor.py (anchor_tier="A" → same-building anchor ветка, где
|
||||
живёт MAD-clip).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from app.services import estimator
|
||||
|
||||
_ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py"
|
||||
_spec = importlib.util.spec_from_file_location("_anchor_harness_display_consistency", _ANCHOR_TEST)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
_h = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_h)
|
||||
|
||||
|
||||
def _comp(
|
||||
*,
|
||||
source: str,
|
||||
address: str,
|
||||
ppm2: float,
|
||||
area_m2: float = 146.2,
|
||||
rooms: int = 4,
|
||||
scraped_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Минимальный same-building comp-dict (без floor/total_floors → нейтральный
|
||||
floor-вес; area_m2 по умолчанию = target площадь _make_payload(), area-вес ~1.0).
|
||||
"""
|
||||
return {
|
||||
"source": source,
|
||||
"source_url": f"https://{source}.example/offer/{address}/{ppm2}",
|
||||
"address": address,
|
||||
"area_m2": area_m2,
|
||||
"rooms": rooms,
|
||||
"price_per_m2": ppm2,
|
||||
"price_rub": ppm2 * area_m2,
|
||||
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
|
||||
"photo_urls": [],
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test A — outlier дубль-пара («Гоголя 18»-паттерн): MAD-clip убирает ОБЕ копии
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_outlier_duplicate_pair_excluded_from_both_count_and_cards() -> None:
|
||||
"""5 обычных комплов (разные дома, MAD-clip не трогает) + кросс-source
|
||||
дубль-пара одного физлота, которая ЦЕЛИКОМ ценовой выброс (~4.4× медианы,
|
||||
как «Гоголя 18» в QA-смоуке) → MAD-clip убирает ОБЕ копии из
|
||||
anchor["comps"]. Т.к. карточки теперь строятся из anchor["comps"] (не из
|
||||
исходного anchor_comps), выброс не просачивается в UI — количество карточек
|
||||
равно n_analogs, и обеих копий дубля нет ни в счётчике, ни в карточках.
|
||||
"""
|
||||
clean = [
|
||||
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
|
||||
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
|
||||
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
|
||||
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
|
||||
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 58", ppm2=215_000.0),
|
||||
]
|
||||
# Кросс-source дубль одного физлота — ~4.4-4.5× дороже остальных (целиком
|
||||
# ценовой выброс, как реальный прод-кейс «Гоголя 18»).
|
||||
outlier_dup = [
|
||||
_comp(source="avito", address="Екатеринбург, ул. Гоголя, 18", ppm2=900_000.0),
|
||||
_comp(source="domklik", address="Екатеринбург, ул. Гоголя, 18", ppm2=920_000.0),
|
||||
]
|
||||
|
||||
est = _h._run_estimate(anchor_comps=clean + outlier_dup, anchor_tier="A")
|
||||
|
||||
# Выброс не участвовал в headline/n_analogs (MAD-clip) — фикс #1 не менял эту
|
||||
# часть: сверяем, что n_analogs действительно = 5 (7 - 2 отсечённых).
|
||||
assert est.n_analogs == 5
|
||||
|
||||
# Главный инвариант display-consistency: показанные карточки == n_analogs.
|
||||
assert len(est.analogs) == est.n_analogs == 5
|
||||
|
||||
# Ни одна из копий выброса не попала в карточки.
|
||||
shown_addresses = {a.address for a in est.analogs}
|
||||
assert "Екатеринбург, ул. Гоголя, 18" not in shown_addresses
|
||||
shown_ppm2 = {a.price_per_m2 for a in est.analogs}
|
||||
assert 900_000 not in shown_ppm2
|
||||
assert 920_000 not in shown_ppm2
|
||||
|
||||
# Нет дублей адресов среди карточек.
|
||||
assert len(shown_addresses) == len(est.analogs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test B — non-outlier дубль-пара («Пушкина 9»-паттерн): переживает MAD-clip,
|
||||
# ловится ТОЛЬКО display-only дедупом (price_bucket-строгий стат-дедуп её
|
||||
# пропускает из-за ценового дрейфа, бакет-straddle).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None:
|
||||
"""4 обычных компла + кросс-source дубль-пара ОБЫЧНОЙ цены (небольшой дрейф
|
||||
2-25к, «Пушкина 9»-паттерн из QA-смоука) — НЕ выброс, переживает MAD-clip как
|
||||
2 отдельные записи (n_analogs их считает по отдельности, статистика не
|
||||
тронута). display-only дедуп (без price_bucket) схлопывает пару в ОДНУ
|
||||
карточку — карточек становится МЕНЬШЕ n_analogs (честно: это НЕ баг, а
|
||||
следствие того, что стат-пайплайн и display-дедуп используют разные ключи
|
||||
по дизайну — см. docstring _dedup_display_lots). Инвариант, который держит
|
||||
этот тест: адрес дубля встречается в карточках РОВНО один раз.
|
||||
"""
|
||||
clean = [
|
||||
_comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0),
|
||||
_comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0),
|
||||
_comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0),
|
||||
_comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0),
|
||||
]
|
||||
# Один физлот, кросс-source (yandex/domklik), лёгкий ценовой дрейф ~1.6% —
|
||||
# достаточно, чтобы price_bucket (_DEDUP_PRICE_BUCKET_RUB=100_000 руб.)
|
||||
# развёл пару по РАЗНЫМ бакетам (311 vs 316), но НЕ выброс (в пределах
|
||||
# MAD-clip диапазона остальных 4).
|
||||
dup_a = _comp(
|
||||
source="yandex",
|
||||
address="Екатеринбург, ул. Пушкина, 9",
|
||||
ppm2=219_000.0,
|
||||
area_m2=142.0,
|
||||
scraped_at=datetime(2026, 6, 1, tzinfo=UTC),
|
||||
)
|
||||
dup_b = _comp(
|
||||
source="domklik",
|
||||
address="Екатеринбург, ул. Пушкина, 9",
|
||||
ppm2=222_500.0,
|
||||
area_m2=142.0,
|
||||
scraped_at=datetime(2026, 6, 3, tzinfo=UTC), # свежее → представитель
|
||||
)
|
||||
|
||||
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
|
||||
# иначе тест проверял бы не то, что заявлено в docstring.
|
||||
assert estimator.settings.estimate_dedup_analogs_enabled is True
|
||||
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
|
||||
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"
|
||||
|
||||
est = _h._run_estimate(anchor_comps=[*clean, dup_a, dup_b], anchor_tier="A")
|
||||
|
||||
# Статистика ЧЕСТНО считает обе копии (стат-дедуп их не поймал, MAD-clip не
|
||||
# выброс) — n_analogs = 6.
|
||||
assert est.n_analogs == 6
|
||||
|
||||
# display-only дедуп схлопнул пару → карточек МЕНЬШЕ n_analogs.
|
||||
assert len(est.analogs) == 5
|
||||
assert len(est.analogs) < est.n_analogs
|
||||
|
||||
# Инвариант: адрес дубля встречается РОВНО один раз среди карточек.
|
||||
shown_addresses = [a.address for a in est.analogs]
|
||||
assert shown_addresses.count("Екатеринбург, ул. Пушкина, 9") == 1
|
||||
# Представитель — свежайший scraped_at (domklik, 222_500).
|
||||
pushkina_card = next(a for a in est.analogs if a.address == "Екатеринбург, ул. Пушкина, 9")
|
||||
assert pushkina_card.price_per_m2 == 222_500
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Unit-level: _dedup_display_lots — прямые проверки поведения helper'а
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _lot(
|
||||
*,
|
||||
source: str,
|
||||
address: str = "ул. Ленина, 5",
|
||||
area: float = 60.0,
|
||||
price: float = 12_000_000.0,
|
||||
floor: int | None = 5,
|
||||
scraped_at: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"source": source,
|
||||
"address": address,
|
||||
"area_m2": area,
|
||||
"price_rub": price,
|
||||
"price_per_m2": price / area if area else 0,
|
||||
"floor": floor,
|
||||
"scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC),
|
||||
"photo_urls": [],
|
||||
}
|
||||
|
||||
|
||||
def test_dedup_display_lots_collapses_price_drifted_cross_source_pair() -> None:
|
||||
# Тот же building/floor/area, РАЗНЫЙ source, цена отличается на 20% — стат-
|
||||
# дедуп (price_bucket) не поймал бы такой дрейф, display-дедуп игнорирует
|
||||
# цену вовсе.
|
||||
lots = [
|
||||
_lot(source="avito", price=12_000_000.0, scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
|
||||
_lot(source="cian", price=14_400_000.0, scraped_at=datetime(2026, 6, 5, tzinfo=UTC)),
|
||||
]
|
||||
out = estimator._dedup_display_lots(lots)
|
||||
assert len(out) == 1
|
||||
assert out[0]["source"] == "cian" # свежайший scraped_at
|
||||
|
||||
|
||||
def test_dedup_display_lots_same_source_pair_not_merged() -> None:
|
||||
# Тот же building/floor/area, ОДИНАКОВЫЙ source, разная цена — НЕ кросс-пост
|
||||
# (либо два разных реальных юнита, случайно совпавших по округлённой
|
||||
# площади/этажу), display-дедуп НЕ должен их схлопывать (require_diff_source
|
||||
# guard).
|
||||
lots = [
|
||||
_lot(source="cian", price=12_000_000.0),
|
||||
_lot(source="cian", price=13_000_000.0),
|
||||
]
|
||||
out = estimator._dedup_display_lots(lots)
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_display_lots_different_addresses_stay_distinct() -> None:
|
||||
lots = [
|
||||
_lot(source="avito", address="ул. Ленина, 5", price=12_000_000.0),
|
||||
_lot(source="cian", address="ул. Мира, 3", price=12_000_000.0),
|
||||
]
|
||||
out = estimator._dedup_display_lots(lots)
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_dedup_display_lots_noop_under_two_lots() -> None:
|
||||
lots = [_lot(source="avito")]
|
||||
out = estimator._dedup_display_lots(lots)
|
||||
assert out is lots
|
||||
|
|
@ -56,8 +56,8 @@ def test_fix1_low_conf_anchor_suppressed_by_gate() -> None:
|
|||
assert anchor_raw is not None, "Должен построить якорь без гейта"
|
||||
assert anchor_raw["confidence"] == "low", "Ожидаем low confidence при большом разбросе"
|
||||
|
||||
# Гейт: settings.estimate_sb_low_conf_gate_enabled=True
|
||||
# Это применяется в estimate_quality (выше уровня _compute_same_building_anchor),
|
||||
# Гейт low-confidence якоря применяется в estimate_quality (выше уровня
|
||||
# _compute_same_building_anchor),
|
||||
# поэтому тестируем сигнал: если confidence=low — гейт должен подавить.
|
||||
assert anchor_raw["confidence"] == "low"
|
||||
# Проверяем что гейт-условие срабатывает:
|
||||
|
|
@ -299,17 +299,15 @@ def test_fix4_floor_sigma_zero_does_not_crash() -> None:
|
|||
def test_fix4_premium_comp_survives_post_weight_clip() -> None:
|
||||
"""Видовой компл (высокий ppm²) с правильными весами не выкидывается.
|
||||
|
||||
Логика: при post-weight clip (estimate_sb_clip_after_weight=True) MAD-clip
|
||||
применяется к ppm² ПОСЛЕ weighting. Если видовой компл близок по площади/
|
||||
комнатам — его вес высок, и он не должен быть outlier после clip.
|
||||
Логика: post-weight MAD-clip применяется к ppm² ПОСЛЕ weighting. Если видовой
|
||||
компл близок по площади/комнатам — его вес высок, и он не должен быть outlier
|
||||
после clip.
|
||||
"""
|
||||
# 4 стандартных компла + 1 видовой (выше на 30%)
|
||||
base = 200_000
|
||||
comps = [_make_comp(base) for _ in range(4)] + [_make_comp(base * 1.3)]
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
# Активируем post-weight clip
|
||||
mock_settings.estimate_sb_clip_after_weight = True
|
||||
mock_settings.estimate_sb_mad_k_small_n = 2.5
|
||||
mock_settings.estimate_sb_small_n_threshold = 10
|
||||
mock_settings.avito_imv_thin_market_threshold = 10
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
"""Regression test for #2013 — avito NULL-year listings bypassing cohort filter.
|
||||
|
||||
Bug: the cohort/era WHERE clause carried an `OR year_built IS NULL` escape
|
||||
meant for avito's frequently-missing year_built field. In practice this let
|
||||
ANY-era avito listing (not just genuinely unknown-year ones) into the comp
|
||||
pool once cohort_year_min/max were set — pollution bug (#2013).
|
||||
|
||||
Fix: narrow the escape to `OR (year_built IS NULL AND source <> 'avito')` so
|
||||
avito NULL-year rows are held to the same year_built BETWEEN test as every
|
||||
other source, while non-avito NULL-year rows (e.g. cian/domklik legacy data
|
||||
without a parsed year) still pass through untouched.
|
||||
|
||||
The clause exists in TWO places that must stay byte-identical in intent:
|
||||
- `_COMMON_WHERE` (Tier S / Tier H, shared string constant)
|
||||
- the Tier W inline SQL copy (own CTE, per code comments "must stay in sync")
|
||||
|
||||
Wiring a real Postgres-backed integration test is impractical here (this
|
||||
suite's estimator tests run against a dummy DATABASE_URL with no live DB —
|
||||
see tests/test_estimator_pure_units.py / test_estimator_cohort.py). Instead
|
||||
we assert directly on the SQL source: both cohort blocks must contain the new
|
||||
guard, and the old unscoped `OR year_built IS NULL` escape must be gone from
|
||||
both (guarding against a partial fix that only patches one copy).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Settings requires DATABASE_URL at init time. Set dummy DSN before any app import.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services import estimator
|
||||
|
||||
ESTIMATOR_SRC = Path(estimator.__file__).read_text(encoding="utf-8")
|
||||
|
||||
GUARD = "OR (year_built IS NULL AND source <> 'avito')"
|
||||
|
||||
|
||||
def _cohort_block(anchor: str) -> str:
|
||||
"""Extract the `AND ( ... )` cohort-filter block that follows `anchor`.
|
||||
|
||||
Balances parens from the first `AND (` after `anchor` so we grab exactly
|
||||
the cohort predicate group, not neighbouring clauses.
|
||||
"""
|
||||
idx = ESTIMATOR_SRC.index(anchor)
|
||||
tail = ESTIMATOR_SRC[idx:]
|
||||
start = tail.index("AND (")
|
||||
open_idx = tail.index("(", start)
|
||||
depth = 0
|
||||
for i, ch in enumerate(tail[open_idx:], start=open_idx):
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return tail[start : i + 1]
|
||||
raise AssertionError(f"unbalanced parens extracting cohort block after {anchor!r}")
|
||||
|
||||
|
||||
def test_common_where_cohort_block_has_avito_guard() -> None:
|
||||
"""Tier S/H (_COMMON_WHERE): NULL-year avito rows must not bypass cohort."""
|
||||
block = _cohort_block("_COMMON_WHERE = ")
|
||||
assert "cohort_year_min" in block
|
||||
assert "cohort_year_max" in block
|
||||
assert GUARD in block
|
||||
|
||||
|
||||
def test_tier_w_inline_cohort_block_has_avito_guard() -> None:
|
||||
"""Tier W inline copy: same guard, kept in sync with _COMMON_WHERE."""
|
||||
block = _cohort_block("-- Когортный фильтр (PR 9)")
|
||||
assert "cohort_year_min" in block
|
||||
assert "cohort_year_max" in block
|
||||
assert GUARD in block
|
||||
|
||||
|
||||
def test_avito_guard_present_in_exactly_both_copies() -> None:
|
||||
"""Exactly two occurrences: _COMMON_WHERE + Tier W inline, kept in sync."""
|
||||
assert ESTIMATOR_SRC.count(GUARD) == 2
|
||||
|
||||
|
||||
def test_old_unscoped_null_escape_is_fully_removed() -> None:
|
||||
"""No leftover `OR year_built IS NULL` (unscoped) predicate anywhere.
|
||||
|
||||
Catches a partial fix that patches only one of the two WHERE copies.
|
||||
The one remaining textual mention of the bare phrase is a prose comment
|
||||
(ends in a period, not immediately followed by a newline like the SQL
|
||||
predicate form did), so it does not match this pattern.
|
||||
"""
|
||||
assert "OR year_built IS NULL\n" not in ESTIMATOR_SRC
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
"""Tests for the oblast-D deals-headline-fallback (non-EKB accuracy gap).
|
||||
|
||||
Context: `listings` coverage is ~40k EKB / ~0 non-EKB (Нижний Тагил / Серов /
|
||||
Каменск-Уральский). Before this fix, a non-EKB subject either:
|
||||
(a) leaked a DISTANT EKB listing median via the Tier S address-prefix
|
||||
fallback (no geo bound — see `_fetch_analogs` Tier S fix), or
|
||||
(b) surfaced an empty ("n/a") headline once (a) was fixed and the
|
||||
geo-bound radius tiers legitimately found 0 local listings.
|
||||
|
||||
This fallback builds the headline from the ДКП deal corridor (`dkp_raw`,
|
||||
already city+street-scoped via `_resolve_target_city`/`_fetch_dkp_corridor`)
|
||||
instead — honestly flagged 'low' confidence, deals-only.
|
||||
|
||||
These tests exercise `estimate_quality` end-to-end (real spine code, mocked
|
||||
DB-facing helpers only) — the same pattern as test_estimator_radius_floor.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import anyio
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_listing(*, price_per_m2: float, area_m2: float = 45.0) -> dict[str, Any]:
|
||||
"""An EKB analog — used only in the "EKB unaffected" control test."""
|
||||
return {
|
||||
"source": "cian",
|
||||
"source_url": "https://cian.ru/offer/1",
|
||||
"address": "ЕКБ, ул. Малышева, 30",
|
||||
"lat": 56.838,
|
||||
"lon": 60.595,
|
||||
"rooms": 2,
|
||||
"area_m2": area_m2,
|
||||
"floor": 5,
|
||||
"total_floors": 16,
|
||||
"price_rub": price_per_m2 * area_m2,
|
||||
"price_per_m2": price_per_m2,
|
||||
"listing_date": datetime(2026, 5, 1),
|
||||
"days_on_market": 10,
|
||||
"photo_urls": [],
|
||||
"scraped_at": datetime(2026, 5, 20, tzinfo=UTC),
|
||||
"distance_m": 150.0,
|
||||
"relevance_score": 0.1,
|
||||
}
|
||||
|
||||
|
||||
def _make_geo_tagil():
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
return GeocodeResult(
|
||||
lat=57.9094,
|
||||
lon=59.9789,
|
||||
full_address="Свердловская обл., Нижний Тагил, ул. Ленина, 5",
|
||||
provider="nominatim",
|
||||
)
|
||||
|
||||
|
||||
def _make_payload_tagil():
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
|
||||
return TradeInEstimateInput(
|
||||
address="Нижний Тагил, ул. Ленина, 5",
|
||||
area_m2=45.0,
|
||||
rooms=2,
|
||||
floor=5,
|
||||
total_floors=9,
|
||||
)
|
||||
|
||||
|
||||
def _run_estimate(
|
||||
*,
|
||||
analogs: list[dict[str, Any]],
|
||||
dkp_raw: dict[str, Any] | None,
|
||||
geo: Any,
|
||||
payload: Any,
|
||||
) -> Any:
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
# Post Part-1 (geo-bound Tier S) reality for a non-EKB town: the
|
||||
# radius/tier ladder legitimately returns NOTHING — tier='W' is
|
||||
# what the always-executed final fallback tier returns.
|
||||
return_value=(list(analogs), False, "W"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
return anyio.run(_run)
|
||||
|
||||
|
||||
# ── Нижний Тагил: no local listings, deal corridor present ───────────────────
|
||||
|
||||
|
||||
def test_non_ekb_empty_listings_uses_deals_headline() -> None:
|
||||
"""0 local listings + a usable ДКП corridor → headline comes from deals.
|
||||
|
||||
Mirrors the reported Нижний Тагил gap: deal_median ≈ 85 911 ₽/м² (accurate)
|
||||
vs the old EKB-leaked asking headline ≈ 186 461 (~6x over). After the fix,
|
||||
the headline must equal the deal corridor's median — nowhere near the
|
||||
EKB-range figure — and confidence must be honestly 'low' (deals-only, zero
|
||||
scraped analogs).
|
||||
"""
|
||||
dkp_raw = {
|
||||
"count": 12,
|
||||
"low_ppm2": 70_000,
|
||||
"median_ppm2": 85_911,
|
||||
"high_ppm2": 100_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
est = _run_estimate(
|
||||
analogs=[], # 0 listings — the honest post-geo-bound-fix reality
|
||||
dkp_raw=dkp_raw,
|
||||
geo=_make_geo_tagil(),
|
||||
payload=_make_payload_tagil(),
|
||||
)
|
||||
|
||||
assert est.median_price_per_m2 == 85_911, (
|
||||
f"headline={est.median_price_per_m2} must equal the deal corridor "
|
||||
f"median, not 0/n-a and nowhere near an EKB-range figure (~186k)"
|
||||
)
|
||||
assert est.median_price_per_m2 < 120_000, "must NOT be EKB-leaked (~186k)"
|
||||
assert est.n_analogs == 0, "honest: zero scraped-listing analogs were used"
|
||||
assert est.confidence == "low", "deals-only headline must be honestly low-confidence"
|
||||
assert est.median_price_rub == round(85_911 * 45.0)
|
||||
# Range should bracket the corridor's P10/P90, not collapse to a point.
|
||||
assert est.range_low_rub <= est.median_price_rub <= est.range_high_rub
|
||||
|
||||
|
||||
def test_non_ekb_empty_listings_no_deals_stays_insufficient() -> None:
|
||||
"""0 listings + NO deal corridor either → stays honest n/a (median=0).
|
||||
|
||||
Guards against the fallback inventing a number when there is truly no
|
||||
signal at all (e.g. Каменск-Уральский with an unresolvable street).
|
||||
"""
|
||||
est = _run_estimate(
|
||||
analogs=[],
|
||||
dkp_raw=None,
|
||||
geo=_make_geo_tagil(),
|
||||
payload=_make_payload_tagil(),
|
||||
)
|
||||
assert est.median_price_per_m2 == 0
|
||||
assert est.n_analogs == 0
|
||||
assert est.confidence == "low"
|
||||
|
||||
|
||||
def test_non_ekb_thin_deal_corridor_below_min_n_stays_insufficient() -> None:
|
||||
"""Deal corridor exists but below DEALS_HEADLINE_FALLBACK_MIN_N → no fallback.
|
||||
|
||||
A single stale sold price should not become the town's headline.
|
||||
"""
|
||||
dkp_raw = {
|
||||
"count": 1,
|
||||
"low_ppm2": 70_000,
|
||||
"median_ppm2": 85_911,
|
||||
"high_ppm2": 100_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
est = _run_estimate(
|
||||
analogs=[],
|
||||
dkp_raw=dkp_raw,
|
||||
geo=_make_geo_tagil(),
|
||||
payload=_make_payload_tagil(),
|
||||
)
|
||||
assert est.median_price_per_m2 == 0
|
||||
assert est.n_analogs == 0
|
||||
|
||||
|
||||
# ── EKB control: dense local listings → deals-fallback must NOT engage ───────
|
||||
|
||||
|
||||
def test_ekb_with_dense_listings_ignores_deals_fallback() -> None:
|
||||
"""EKB has plenty of local listings — the radius-path headline must win,
|
||||
NOT the deal corridor, even though dkp_raw is present (byte-green guard:
|
||||
EKB must stay on the existing listings-median path unconditionally).
|
||||
"""
|
||||
from app.schemas.trade_in import TradeInEstimateInput
|
||||
from app.services.geocoder import GeocodeResult
|
||||
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=140_000.0),
|
||||
_make_listing(price_per_m2=145_000.0),
|
||||
_make_listing(price_per_m2=150_000.0),
|
||||
]
|
||||
dkp_raw = {
|
||||
"count": 20,
|
||||
"low_ppm2": 120_000,
|
||||
"median_ppm2": 144_000,
|
||||
"high_ppm2": 160_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
geo = GeocodeResult(
|
||||
lat=56.838,
|
||||
lon=60.595,
|
||||
full_address="Свердловская обл., Екатеринбург, ул. Малышева, 30",
|
||||
provider="nominatim",
|
||||
)
|
||||
payload = TradeInEstimateInput(
|
||||
address="ЕКБ, ул. Малышева, 30", area_m2=45.0, rooms=2, floor=5, total_floors=16
|
||||
)
|
||||
est = _run_estimate(analogs=analogs, dkp_raw=dkp_raw, geo=geo, payload=payload)
|
||||
|
||||
# Headline built from the LISTINGS median (~145k), not silently replaced —
|
||||
# n_analogs must reflect the real listing count (deals-fallback never ran).
|
||||
assert est.n_analogs == len(analogs)
|
||||
assert 140_000 <= est.median_price_per_m2 <= 150_000
|
||||
|
|
@ -313,7 +313,6 @@ def _run_estimate_anchor_only(
|
|||
anchor_tier: str | None = "A",
|
||||
):
|
||||
"""estimate_quality: пустой радиус (listings_clean=[]), якорь задаёт median_price."""
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
|
|
@ -322,7 +321,6 @@ def _run_estimate_anchor_only(
|
|||
|
||||
async def _run():
|
||||
with (
|
||||
patch.object(settings, "estimate_same_building_anchor_enabled", True),
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_fake_geo())),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
|
|
|
|||
|
|
@ -5,22 +5,19 @@
|
|||
blend, rehydrate, миграция), который наполнит median/sources_used без единого
|
||||
реального аналога ("ghost-anchor", напр. legacy-строка 5fcc1e99: n=0, high,
|
||||
38.45M, yandex_valuation). Mainline уже честен — это belt-and-suspenders.
|
||||
|
||||
Гард за флагом settings.estimate_confidence_floor_no_analogs (дефолт True).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# pydantic Settings требует DATABASE_URL при инициализации.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
# WeasyPrint stubbed in CI.
|
||||
sys.modules.setdefault("weasyprint", MagicMock())
|
||||
|
||||
import app.services.estimator as est # noqa: E402
|
||||
from app.services.estimator import _enforce_zero_analog_low # noqa: E402
|
||||
|
||||
# Suffix-маркер из гарда — проверяем его наличие/отсутствие, не дублирование.
|
||||
|
|
@ -108,23 +105,8 @@ def test_none_explanation_returns_empty_string_not_none() -> None:
|
|||
assert isinstance(explanation, str)
|
||||
|
||||
|
||||
def test_flag_default_true() -> None:
|
||||
"""estimate_confidence_floor_no_analogs по умолчанию True (гард активен)."""
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.estimate_confidence_floor_no_analogs is True
|
||||
|
||||
|
||||
def test_flag_off_does_not_downgrade() -> None:
|
||||
"""Флаг OFF: n_analogs=0 + confidence='high' → guard пропускается, high сохраняется.
|
||||
|
||||
Проверяет escape-hatch: при estimate_confidence_floor_no_analogs=False
|
||||
_enforce_zero_analog_low не вызывается (вызов обёрнут в if-гейт в estimator.py).
|
||||
Тест патчит settings через patch.object, вызывает функцию напрямую — убеждается
|
||||
что сам гард всё ещё понижает (без флага); интеграционный эффект флага проверяется
|
||||
через patch в estimate_quality (ниже).
|
||||
"""
|
||||
# Напрямую: _enforce_zero_analog_low всегда понижает (флаг только в if-гейте caller'а)
|
||||
def test_enforce_zero_analog_low_always_downgrades() -> None:
|
||||
"""_enforce_zero_analog_low всегда понижает high→low при n_analogs=0 (гард всегда ON)."""
|
||||
confidence, _ = _enforce_zero_analog_low(
|
||||
"high",
|
||||
0,
|
||||
|
|
@ -133,19 +115,3 @@ def test_flag_off_does_not_downgrade() -> None:
|
|||
sources_used=["yandex_valuation"],
|
||||
)
|
||||
assert confidence == "low" # сама функция не смотрит на settings
|
||||
|
||||
# Через патч: гейт в estimate_quality при флаге OFF пропускает вызов.
|
||||
with patch.object(est.settings, "estimate_confidence_floor_no_analogs", False):
|
||||
# _enforce_zero_analog_low не должна быть вызвана —
|
||||
# мокаем её и убеждаемся что мок не тронут.
|
||||
mock_guard = MagicMock(return_value=("high", "unchanged"))
|
||||
with patch.object(est, "_enforce_zero_analog_low", mock_guard):
|
||||
# Вызываем блок кода напрямую: повторяем if-гейт как в estimate_quality.
|
||||
confidence_in = "high"
|
||||
n = 0
|
||||
expl = "test"
|
||||
if est.settings.estimate_confidence_floor_no_analogs:
|
||||
confidence_in, expl = est._enforce_zero_analog_low(
|
||||
confidence_in, n, expl, median_price=0, sources_used=[]
|
||||
)
|
||||
mock_guard.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@
|
|||
- комбинированный случай (несколько причин сразу);
|
||||
- all-clear (нет причин → recommended False);
|
||||
- граничные значения порогов (>= high_value, >= wide_ratio);
|
||||
- div-guard на диапазон (range_low None/0 → без деления на ноль);
|
||||
- выключенный флаг estimate_manual_review_enabled → (False, []).
|
||||
- div-guard на диапазон (range_low None/0 → без деления на ноль).
|
||||
|
||||
Плюс smoke-проверка дефолтов AggregatedEstimate (флаг — метаданные: сериализация
|
||||
без изменений, когда ручная оценка не рекомендована).
|
||||
|
|
@ -36,14 +35,12 @@ ELITE_REASON = (
|
|||
|
||||
def _settings(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
high_value: int = 20_000_000,
|
||||
wide_ratio: float = 1.9,
|
||||
elite_ppm2: int = 250_000,
|
||||
) -> SimpleNamespace:
|
||||
"""Duck-typed заглушка Settings — helper читает только эти поля."""
|
||||
return SimpleNamespace(
|
||||
estimate_manual_review_enabled=enabled,
|
||||
estimate_manual_review_high_value_rub=high_value,
|
||||
estimate_manual_review_wide_range_ratio=wide_ratio,
|
||||
estimate_manual_review_elite_ppm2=elite_ppm2,
|
||||
|
|
@ -320,23 +317,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
|
|||
assert WIDE_RANGE_REASON not in reasons
|
||||
|
||||
|
||||
# ── Выключенный флаг ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_disabled_flag_suppresses_all() -> None:
|
||||
"""estimate_manual_review_enabled=False → (False, []) даже когда всё триггерит."""
|
||||
rec, reasons = _manual_review(
|
||||
premium_building=True,
|
||||
expected_sold_price=99_000_000,
|
||||
confidence="low",
|
||||
range_low=10_000_000,
|
||||
range_high=99_000_000,
|
||||
settings=_settings(enabled=False),
|
||||
)
|
||||
assert rec is False
|
||||
assert reasons == []
|
||||
|
||||
|
||||
# ── invariant recommended == bool(reasons) ───────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Mera-audit fix-3: cross-source dedup в _fetch_price_trend.
|
||||
|
||||
Проверяет что при dedup_enabled=True один объект на avito_imv + yandex_valuation
|
||||
с разными ext_item_id не double-count'ится в месячной медиане.
|
||||
Проверяет что один объект на avito_imv + yandex_valuation с разными ext_item_id
|
||||
не double-count'ится в месячной медиане (dedup всегда включён).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -40,19 +40,11 @@ def _make_mock_db(source1_rows: list[dict], source2_rows: list[dict]) -> MagicMo
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dedup_enabled_flag_is_true_by_default() -> None:
|
||||
"""estimate_price_trend_dedup_enabled дефолт True."""
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.estimate_price_trend_dedup_enabled is True
|
||||
|
||||
|
||||
def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
|
||||
"""Когда dedup включён, SQL-запрос к house_placement_history содержит DISTINCT ON."""
|
||||
def test_price_trend_dedup_sql_uses_distinct_on() -> None:
|
||||
"""SQL-запрос к house_placement_history всегда содержит DISTINCT ON (dedup)."""
|
||||
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
||||
|
|
@ -64,24 +56,8 @@ def test_price_trend_dedup_sql_uses_distinct_on_when_enabled() -> None:
|
|||
assert "DISTINCT ON" in second_sql.upper() or "distinct on" in second_sql.lower()
|
||||
|
||||
|
||||
def test_price_trend_dedup_disabled_no_distinct_on() -> None:
|
||||
"""Когда dedup выключен, SQL НЕ содержит DISTINCT ON (legacy path)."""
|
||||
mock_db = _make_mock_db([], [{"month": "2026-01", "ppm2": 150_000}])
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = False
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
_fetch_price_trend(mock_db, target_house_id=42, min_points=1)
|
||||
|
||||
calls = mock_db.execute.call_args_list
|
||||
assert len(calls) >= 2
|
||||
second_sql = str(calls[1].args[0].text) if calls[1].args else ""
|
||||
assert "distinct on" not in second_sql.lower()
|
||||
|
||||
|
||||
def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
||||
"""Функция корректно возвращает точки тренда при dedup_enabled=True."""
|
||||
def test_price_trend_returns_result_with_dedup() -> None:
|
||||
"""Функция корректно возвращает точки тренда (dedup включён)."""
|
||||
rows = [
|
||||
{"month": "2025-11", "ppm2": 148_000},
|
||||
{"month": "2025-12", "ppm2": 152_000},
|
||||
|
|
@ -90,7 +66,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
|||
mock_db = _make_mock_db([], rows)
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||
|
|
@ -101,25 +76,6 @@ def test_price_trend_returns_result_with_dedup_enabled() -> None:
|
|||
assert result[0]["ppm2"] == 148_000
|
||||
|
||||
|
||||
def test_price_trend_dedup_disabled_returns_same_points() -> None:
|
||||
"""Флаг OFF не ломает возврат данных (backward-compat)."""
|
||||
rows = [
|
||||
{"month": "2025-11", "ppm2": 148_000},
|
||||
{"month": "2025-12", "ppm2": 152_000},
|
||||
{"month": "2026-01", "ppm2": 155_000},
|
||||
]
|
||||
mock_db = _make_mock_db([], rows)
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = False
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
||||
"""Если Source1 (houses_price_dynamics) вернул ≥ min_points → Source2 не вызывается."""
|
||||
source1_rows = [
|
||||
|
|
@ -130,7 +86,6 @@ def test_price_trend_source1_preferred_over_dedup_path() -> None:
|
|||
mock_db = _make_mock_db(source1_rows, [])
|
||||
|
||||
with patch("app.services.estimator.settings") as mock_settings:
|
||||
mock_settings.estimate_price_trend_dedup_enabled = True
|
||||
mock_settings.estimate_price_trend_max_age_months = 6
|
||||
|
||||
result = _fetch_price_trend(mock_db, target_house_id=42, min_points=3)
|
||||
|
|
|
|||
|
|
@ -181,18 +181,49 @@ def test_confidence_high_with_7_unique_addresses_and_tight_iqr() -> None:
|
|||
|
||||
|
||||
def test_confidence_medium_via_4_unique_addresses() -> None:
|
||||
# 4 unique addresses → medium branch (independent of IQR).
|
||||
# Wide IQR ensures it is NOT "high". avg = 4/4 = 1.0 (no downgrade).
|
||||
# #R2-H2: 4 unique addresses AND IQR/median < 0.35 → medium. avg = 4/4 = 1.0 (no downgrade).
|
||||
listings = _addr_lots(["a", "b", "c", "d"])
|
||||
level, _ = estimator._compute_confidence(
|
||||
n_analogs=4,
|
||||
median_ppm2=100,
|
||||
q1=90,
|
||||
q3=115, # IQR/median = 0.25 < 0.35 → medium
|
||||
fallback_radius_used=False,
|
||||
listings=listings,
|
||||
)
|
||||
assert level == "medium"
|
||||
|
||||
|
||||
def test_confidence_4_addresses_wide_iqr_now_low() -> None:
|
||||
# #R2-H2: 4 unique addresses but IQR/median = 0.60 (huge dispersion) → low, NOT medium.
|
||||
# Dispersion ceiling: the badge must not contradict a ±30% spread in its own explanation.
|
||||
listings = _addr_lots(["a", "b", "c", "d"])
|
||||
level, _ = estimator._compute_confidence(
|
||||
n_analogs=4,
|
||||
median_ppm2=100,
|
||||
q1=70,
|
||||
q3=130, # IQR/median = 0.60 → not high
|
||||
q3=130, # IQR/median = 0.60
|
||||
fallback_radius_used=False,
|
||||
listings=listings,
|
||||
)
|
||||
assert level == "medium"
|
||||
assert level == "low"
|
||||
|
||||
|
||||
def test_confidence_force_low_on_radius_widen_with_dispersion() -> None:
|
||||
# #R2-H2: a pool that would be medium (4 addr, IQR 0.32 < 0.35) is FORCED low when it
|
||||
# was radius-widened due to sparse data AND spread > 0.30 — badge can't say "medium"
|
||||
# while the explanation admits "расширили радиус … из-за нехватки данных".
|
||||
listings = _addr_lots(["a", "b", "c", "d"])
|
||||
level, expl = estimator._compute_confidence(
|
||||
n_analogs=4,
|
||||
median_ppm2=100,
|
||||
q1=84,
|
||||
q3=116, # IQR/median = 0.32 → medium base, then forced low
|
||||
fallback_radius_used=True,
|
||||
listings=listings,
|
||||
)
|
||||
assert level == "low"
|
||||
assert "расширили радиус" in expl
|
||||
|
||||
|
||||
def test_confidence_medium_via_2_unique_addresses_and_tight_iqr() -> None:
|
||||
|
|
@ -313,7 +344,6 @@ def _clamp(
|
|||
tier: str | None,
|
||||
slack: float = 0.25,
|
||||
min_n: int = 10,
|
||||
enabled: bool = True,
|
||||
median_price: int = 0,
|
||||
range_low: int = 0,
|
||||
range_high: int = 0,
|
||||
|
|
@ -328,7 +358,6 @@ def _clamp(
|
|||
anchor_tier=tier,
|
||||
slack=slack,
|
||||
min_n=min_n,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -374,15 +403,6 @@ def test_corridor_clamp_low_n_is_noop() -> None:
|
|||
assert new_ppm2 == 296_000
|
||||
|
||||
|
||||
def test_corridor_clamp_disabled_flag_is_noop() -> None:
|
||||
# enabled=False → точно старое поведение.
|
||||
new_ppm2, _, _, _, clamped = _clamp(
|
||||
median_ppm2=296_000, corridor_high=138_000, count=20, tier="C", enabled=False
|
||||
)
|
||||
assert clamped is False
|
||||
assert new_ppm2 == 296_000
|
||||
|
||||
|
||||
def test_corridor_clamp_tier_none_clamps() -> None:
|
||||
# anchor не сработал (tier=None, чистый радиус) — клампим как и Tier C.
|
||||
new_ppm2, _, _, _, clamped = _clamp(
|
||||
|
|
|
|||
|
|
@ -379,7 +379,6 @@ def _run_estimate_qi(
|
|||
dadata_cadnum: str | None,
|
||||
qi_lookup_result: tuple[float, int] | None,
|
||||
*,
|
||||
flag_enabled: bool = True,
|
||||
anchor_tier_override: str | None = None,
|
||||
):
|
||||
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate."""
|
||||
|
|
@ -442,10 +441,6 @@ def _run_estimate_qi(
|
|||
"app.services.estimator._lookup_quarter_indexes",
|
||||
side_effect=_fake_lookup_indexes,
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.settings.estimate_quarter_index_enabled",
|
||||
flag_enabled,
|
||||
),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
|
|
@ -488,7 +483,6 @@ def test_quarter_index_correction_applied() -> None:
|
|||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.2, 30),
|
||||
flag_enabled=True,
|
||||
)
|
||||
expected_median = round(base_median * 1.2)
|
||||
assert est.median_price_rub == expected_median
|
||||
|
|
@ -497,21 +491,6 @@ def test_quarter_index_correction_applied() -> None:
|
|||
assert "квартал" in est.confidence_explanation.lower()
|
||||
|
||||
|
||||
def test_quarter_index_flag_off_exact_old_behavior() -> None:
|
||||
"""При estimate_quarter_index_enabled=False — точное старое поведение."""
|
||||
analogs_no_cadnum = [
|
||||
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
|
||||
]
|
||||
est = _run_estimate_qi(
|
||||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.2, 30),
|
||||
flag_enabled=False,
|
||||
)
|
||||
base_median = round(_BASE_PPM2 * _AREA)
|
||||
assert est.median_price_rub == base_median
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Guard-2: >0.6 аналогов в целевом квартале → no-op
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -534,7 +513,6 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
|
|||
analogs=analogs,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
|
||||
flag_enabled=True,
|
||||
)
|
||||
# Медиана НЕ должна изменяться
|
||||
assert est.median_price_rub == base_median
|
||||
|
|
@ -555,7 +533,6 @@ def test_sparse_fallback_no_row_noop() -> None:
|
|||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=None, # sparse
|
||||
flag_enabled=True,
|
||||
)
|
||||
assert est.median_price_rub == base_median
|
||||
|
||||
|
|
@ -575,7 +552,6 @@ def test_bimodal_guard_skips_high_index_small_n() -> None:
|
|||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
|
||||
flag_enabled=True,
|
||||
)
|
||||
assert est.median_price_rub == base_median
|
||||
|
||||
|
|
@ -594,7 +570,6 @@ def test_bimodal_guard_allows_high_index_large_n() -> None:
|
|||
analogs=analogs_no_cadnum,
|
||||
dadata_cadnum=f"{_TARGET_QUARTER}:350",
|
||||
qi_lookup_result=(2.5, 60), # index>2.0 но n=60>=50 → bimodal guard не срабатывает
|
||||
flag_enabled=True,
|
||||
)
|
||||
# Коррекция применена: медиана != base_median (bimodal guard не заблокировал).
|
||||
# factor=2.5 > max_factor=1.8 → зажат до 1.8 (#859).
|
||||
|
|
@ -620,6 +595,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None:
|
|||
payload = _make_payload_qi()
|
||||
dadata_obj = _make_fake_dadata(f"{_TARGET_QUARTER}:350")
|
||||
|
||||
fake_comps = [
|
||||
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
|
||||
for _ in range(3)
|
||||
]
|
||||
fake_anchor = {
|
||||
"anchor_ppm2": _BASE_PPM2 * 1.5,
|
||||
"anchor_sold_ppm2": _BASE_PPM2 * 1.4,
|
||||
|
|
@ -631,11 +610,10 @@ def test_guard1_anchor_tier_prevents_correction() -> None:
|
|||
"comp_max_ppm2": _BASE_PPM2 * 1.7,
|
||||
"used_uplift": False,
|
||||
"haircut": 0.05,
|
||||
# display-consistency fix: contract now requires "comps" — the final
|
||||
# post-MAD-clip survivors (see _compute_same_building_anchor docstring).
|
||||
"comps": fake_comps,
|
||||
}
|
||||
fake_comps = [
|
||||
_make_listing_qi(price_per_m2=_BASE_PPM2 * 1.5, building_cadastral_number=None)
|
||||
for _ in range(3)
|
||||
]
|
||||
|
||||
analogs_no_cadnum = [
|
||||
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None) for _ in range(3)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
|
|||
(source, source_id)-дубли делят один address и выживают на разных rn_addr рангах →
|
||||
раздувают n_analogs. Anchor-путь дедупит по (source, source_id); radius — нет.
|
||||
|
||||
Эти тесты проверяют SQL-фрагмент (rn_dup-окно всегда; `AND rn_dup = 1` фильтр за
|
||||
флагом) во ВСЕХ четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
|
||||
Эти тесты проверяют SQL-фрагмент (rn_dup-окно + `AND rn_dup = 1` фильтр) во ВСЕХ
|
||||
четырёх тирах (S-canonical, S-fallback, H, W) + дедупную семантику
|
||||
окна на уровне Python-симуляции (freshest scraped_at, namespaced ключ, NULL-guard).
|
||||
Полный radius-путь требует PostGIS+БД, поэтому SQL проверяется на сгенерированном
|
||||
тексте (как test_estimator_price_trend_dedup.py).
|
||||
|
|
@ -14,7 +14,7 @@ Radius-путь кэпит только per-address (rn_addr <= MAX_ANALOGS_PER_
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ import pytest
|
|||
import app.services.estimator as est
|
||||
|
||||
|
||||
def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]:
|
||||
def _capture_tier_sql() -> list[str]:
|
||||
"""Прогоняет _fetch_analogs с mock db, возвращает SQL-текст каждого тира.
|
||||
|
||||
target_house_id + short_addr + year/floors заданы так, чтобы SQL ВСЕХ четырёх
|
||||
|
|
@ -40,63 +40,42 @@ def _capture_tier_sql(*, dedup_enabled: bool) -> list[str]:
|
|||
|
||||
db.execute.side_effect = side_effect
|
||||
|
||||
with patch.object(est.settings, "estimate_radius_dedup_enabled", dedup_enabled):
|
||||
est._fetch_analogs(
|
||||
db,
|
||||
lat=56.83,
|
||||
lon=60.6,
|
||||
rooms=2,
|
||||
area=50.0,
|
||||
radius_m=2000,
|
||||
full_address="г Екатеринбург, ул Малышева, д 30",
|
||||
year_built=2010,
|
||||
house_type="монолит",
|
||||
total_floors=20,
|
||||
target_house_id=123,
|
||||
)
|
||||
est._fetch_analogs(
|
||||
db,
|
||||
lat=56.83,
|
||||
lon=60.6,
|
||||
rooms=2,
|
||||
area=50.0,
|
||||
radius_m=2000,
|
||||
full_address="г Екатеринбург, ул Малышева, д 30",
|
||||
year_built=2010,
|
||||
house_type="монолит",
|
||||
total_floors=20,
|
||||
target_house_id=123,
|
||||
)
|
||||
return captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Флаг по умолчанию
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_radius_dedup_flag_default_true() -> None:
|
||||
from app.core.config import settings
|
||||
|
||||
assert settings.estimate_radius_dedup_enabled is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL-фрагмент во всех тирах
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_all_four_tiers_render() -> None:
|
||||
sqls = _capture_tier_sql(dedup_enabled=True)
|
||||
sqls = _capture_tier_sql()
|
||||
assert len(sqls) == 4, "ожидаем S-canonical, S-fallback, H, W"
|
||||
|
||||
|
||||
def test_rn_dup_window_present_in_every_tier_when_enabled() -> None:
|
||||
sqls = _capture_tier_sql(dedup_enabled=True)
|
||||
def test_rn_dup_window_present_in_every_tier() -> None:
|
||||
sqls = _capture_tier_sql()
|
||||
for i, sql in enumerate(sqls):
|
||||
assert "AS rn_dup" in sql, f"tier#{i} без rn_dup-окна"
|
||||
assert "rn_dup = 1" in sql, f"tier#{i} без `AND rn_dup = 1` фильтра"
|
||||
|
||||
|
||||
def test_dup_filter_dropped_when_disabled_but_window_stays() -> None:
|
||||
"""Флаг OFF: фильтр `AND rn_dup = 1` убран, окно остаётся (безвредно)."""
|
||||
sqls = _capture_tier_sql(dedup_enabled=False)
|
||||
assert len(sqls) == 4
|
||||
for i, sql in enumerate(sqls):
|
||||
assert "AS rn_dup" in sql, f"tier#{i}: окно должно оставаться даже при OFF"
|
||||
assert "rn_dup = 1" not in sql, f"tier#{i}: фильтр должен исчезнуть при OFF"
|
||||
|
||||
|
||||
def test_rn_dup_key_namespaced_and_freshest() -> None:
|
||||
"""Ключ окна namespaced (id:/url:/ctid:) + ORDER BY scraped_at DESC."""
|
||||
sql = _capture_tier_sql(dedup_enabled=True)[0]
|
||||
sql = _capture_tier_sql()[0]
|
||||
assert "PARTITION BY source" in sql
|
||||
assert "'id:' || source_id::text" in sql
|
||||
assert "'url:' || source_url" in sql
|
||||
|
|
@ -117,7 +96,7 @@ def test_no_bind_param_double_colon_cast() -> None:
|
|||
import re
|
||||
|
||||
bad = re.compile(r":[a-z_]+::[a-z]")
|
||||
for i, sql in enumerate(_capture_tier_sql(dedup_enabled=True)):
|
||||
for i, sql in enumerate(_capture_tier_sql()):
|
||||
assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
- radius median выше dkp_low × factor → no-op (медиана не изменена)
|
||||
- dkp_raw is None → no-op (нет базы для floor)
|
||||
- anchor-путь (anchor_tier != None) → не затронут floor'ом
|
||||
- флаг estimate_radius_floor_enabled=False → no-op
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -75,7 +74,6 @@ def _run_estimate(
|
|||
analogs: list[dict[str, Any]],
|
||||
dkp_raw: dict[str, Any] | None,
|
||||
*,
|
||||
radius_floor_enabled: bool = True,
|
||||
radius_floor_factor: float = 0.8,
|
||||
) -> Any:
|
||||
from app.core.config import settings
|
||||
|
|
@ -109,7 +107,6 @@ def _run_estimate(
|
|||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
patch.object(settings, "estimate_radius_floor_enabled", radius_floor_enabled),
|
||||
patch.object(settings, "estimate_radius_floor_factor", radius_floor_factor),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
|
@ -188,62 +185,3 @@ def test_no_dkp_raw_no_floor() -> None:
|
|||
assert (
|
||||
est.median_price_per_m2 < 100_000
|
||||
), f"median_ppm2={est.median_price_per_m2} без dkp_raw не должна расти"
|
||||
|
||||
|
||||
# ── тест 4: флаг OFF → no-op ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_radius_floor_disabled_no_op() -> None:
|
||||
"""estimate_radius_floor_enabled=False → floor не применяется."""
|
||||
analogs = [
|
||||
_make_listing(price_per_m2=75_000.0),
|
||||
_make_listing(price_per_m2=80_000.0),
|
||||
_make_listing(price_per_m2=85_000.0),
|
||||
]
|
||||
dkp_raw = {
|
||||
"count": 15,
|
||||
"low_ppm2": 150_000,
|
||||
"median_ppm2": 180_000,
|
||||
"high_ppm2": 220_000,
|
||||
"period_months": 12,
|
||||
}
|
||||
# Без флага — median не должна подняться
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
payload = _make_payload()
|
||||
|
||||
async def _run() -> Any:
|
||||
with (
|
||||
patch("app.services.estimator.geocode", new=AsyncMock(return_value=_make_geo())),
|
||||
patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)),
|
||||
patch("app.services.estimator.match_house_readonly", return_value=None),
|
||||
patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)),
|
||||
patch(
|
||||
"app.services.estimator._fetch_analogs",
|
||||
return_value=(list(analogs), False, "S"),
|
||||
),
|
||||
patch("app.services.estimator._fetch_deals", return_value=[]),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_imv_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator._get_or_fetch_yandex_valuation_cached",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch(
|
||||
"app.services.estimator.estimate_via_cian_valuation",
|
||||
new=AsyncMock(return_value=None),
|
||||
),
|
||||
patch("app.services.estimator._fetch_dkp_corridor", return_value=dkp_raw),
|
||||
patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)),
|
||||
patch.object(settings, "estimate_radius_floor_enabled", False),
|
||||
):
|
||||
return await estimate_quality(payload, db)
|
||||
|
||||
est = anyio.run(_run)
|
||||
assert (
|
||||
est.median_price_per_m2 < 100_000
|
||||
), f"Флаг OFF: median_ppm2={est.median_price_per_m2} не должна расти"
|
||||
|
|
|
|||
|
|
@ -47,11 +47,8 @@ def _payload(): # type: ignore[no-untyped-def]
|
|||
return _h._make_payload(area=60.0, rooms=2)
|
||||
|
||||
|
||||
def _run(*, flag: bool, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
|
||||
with (
|
||||
patch.object(settings, "estimate_wide_corridor_disclosure_enabled", flag),
|
||||
patch.object(settings, "estimate_wide_corridor_threshold", threshold),
|
||||
):
|
||||
def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
|
||||
with patch.object(settings, "estimate_wide_corridor_threshold", threshold):
|
||||
return _h._run_estimate(
|
||||
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
|
||||
anchor_tier=anchor_tier,
|
||||
|
|
@ -63,15 +60,10 @@ _DISCLOSURE_MARKER = "разбит на секции"
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Флаг по умолчанию
|
||||
# Порог по умолчанию
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_disclosure_flag_default_on() -> None:
|
||||
# Включено после валидации порога на prod-данных (corridor_pct median≈0.48).
|
||||
assert settings.estimate_wide_corridor_disclosure_enabled is True
|
||||
|
||||
|
||||
def test_threshold_default_1_2() -> None:
|
||||
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
|
||||
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
|
||||
|
|
@ -83,21 +75,21 @@ def test_threshold_default_1_2() -> None:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tier_a_wide_corridor_flag_on_downgrades_and_discloses() -> None:
|
||||
"""Tier A + corridor_pct > threshold + флаг ON → confidence понижен + disclosure."""
|
||||
on = _run(flag=True, anchor_tier="A")
|
||||
def test_tier_a_wide_corridor_downgrades_and_discloses() -> None:
|
||||
"""Tier A + corridor_pct > threshold → confidence понижен + disclosure."""
|
||||
on = _run(anchor_tier="A")
|
||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||
assert pct > 0.6, "fixture должен давать коридор > 60%"
|
||||
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
||||
# confidence понижен относительно flag-OFF baseline (medium → low).
|
||||
off = _run(flag=False, anchor_tier="A")
|
||||
# confidence понижен относительно no-fire baseline (порог выше коридора).
|
||||
off = _run(anchor_tier="A", threshold=99.0)
|
||||
assert _downgrade_confidence(off.confidence) == on.confidence
|
||||
assert on.confidence != off.confidence
|
||||
|
||||
|
||||
def test_flag_off_no_change() -> None:
|
||||
"""Флаг OFF → ничего не делает (ни confidence, ни explanation)."""
|
||||
off = _run(flag=False, anchor_tier="A")
|
||||
def test_threshold_above_corridor_no_change() -> None:
|
||||
"""Порог выше фактического коридора → ничего не делает (ни confidence, ни explanation)."""
|
||||
off = _run(anchor_tier="A", threshold=99.0)
|
||||
assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "")
|
||||
|
||||
|
||||
|
|
@ -108,7 +100,7 @@ def test_flag_off_no_change() -> None:
|
|||
|
||||
def test_corridor_fires_when_above_threshold() -> None:
|
||||
"""corridor_pct (≈1.045) > threshold (1.0) → disclosure фаерит (механизм работает)."""
|
||||
on = _run(flag=True, anchor_tier="A", threshold=1.0)
|
||||
on = _run(anchor_tier="A", threshold=1.0)
|
||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||
assert pct > 1.0
|
||||
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
|
||||
|
|
@ -117,7 +109,7 @@ def test_corridor_fires_when_above_threshold() -> None:
|
|||
def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
|
||||
"""Прод-дефолт 1.2: умеренный коридор (0.6<pct<1.2) НЕ фаерит — нет ложной
|
||||
атрибуции split-дома на ~31% оценок, которую дал бы старый порог 0.6."""
|
||||
on = _run(flag=True, anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
|
||||
on = _run(anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
|
||||
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
|
||||
assert 0.6 < pct < 1.2, "moderate-fixture должен быть между 0.6 и 1.2"
|
||||
assert _DISCLOSURE_MARKER not in (on.confidence_explanation or "")
|
||||
|
|
@ -125,8 +117,8 @@ def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
|
|||
|
||||
def test_does_not_touch_point_median_range() -> None:
|
||||
"""Disclosure НЕ трогает point/median/range — только confidence+explanation."""
|
||||
off = _run(flag=False, anchor_tier="A")
|
||||
on = _run(flag=True, anchor_tier="A")
|
||||
off = _run(anchor_tier="A", threshold=99.0)
|
||||
on = _run(anchor_tier="A")
|
||||
assert on.median_price_rub == off.median_price_rub
|
||||
assert on.range_low_rub == off.range_low_rub
|
||||
assert on.range_high_rub == off.range_high_rub
|
||||
|
|
@ -134,13 +126,13 @@ def test_does_not_touch_point_median_range() -> None:
|
|||
|
||||
def test_below_threshold_no_disclosure() -> None:
|
||||
"""corridor_pct ниже порога → без изменений (порог поднят выше фактического)."""
|
||||
high_threshold = _run(flag=True, anchor_tier="A", threshold=5.0)
|
||||
high_threshold = _run(anchor_tier="A", threshold=5.0)
|
||||
assert _DISCLOSURE_MARKER not in (high_threshold.confidence_explanation or "")
|
||||
|
||||
|
||||
def test_non_tier_a_not_affected() -> None:
|
||||
"""Не-Tier-A (Tier C) → disclosure не применяется (даже с флагом ON)."""
|
||||
c = _run(flag=True, anchor_tier="C")
|
||||
"""Не-Tier-A (Tier C) → disclosure не применяется."""
|
||||
c = _run(anchor_tier="C")
|
||||
assert _DISCLOSURE_MARKER not in (c.confidence_explanation or "")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,33 @@
|
|||
"""Тесты для ЕКБ bbox-хелперов geocoder (#1871).
|
||||
"""Тесты для ЕКБ/oblast66 bbox-хелперов geocoder (#1871, oblast-66 expansion).
|
||||
|
||||
Покрывают:
|
||||
- is_within_ekb_bbox_wide — ingest-guard (avito_detail координаты);
|
||||
- is_within_ekb_bbox — geocoder tight-фильтр (поведение НЕ изменилось после DRY-рефактора);
|
||||
- инвариант WIDE ⊇ TIGHT.
|
||||
- инвариант WIDE ⊇ TIGHT;
|
||||
- OBLAST66_BBOX / is_within_oblast66_bbox — генеральный bbox всей области (region 66),
|
||||
инвариант OBLAST66 ⊇ TIGHT, дальние города области, известное ограничение (Тюмень
|
||||
внутри bbox координатно — компенсируется region cross-check на accept-сайтах);
|
||||
- _has_oblast_marker — word-boundary matching (не substring);
|
||||
- _nominatim_query / _yandex_lookup accept-сайты — two-pass tie-break (tight ЕКБ
|
||||
приоритетнее) + region cross-check (отсекает Тюмень и т.п. даже внутри bbox).
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.geocoder import (
|
||||
EKB_BBOX_TIGHT,
|
||||
EKB_BBOX_WIDE,
|
||||
OBLAST66_BBOX,
|
||||
_has_oblast_marker,
|
||||
_names_non_ekb_city,
|
||||
_nominatim_query,
|
||||
_yandex_lookup,
|
||||
is_within_ekb_bbox,
|
||||
is_within_ekb_bbox_wide,
|
||||
is_within_oblast66_bbox,
|
||||
)
|
||||
|
||||
# Центр ЕКБ (Плотинка) — внутри обоих bbox.
|
||||
|
|
@ -86,3 +101,225 @@ def test_wide_strictly_contains_tight() -> None:
|
|||
for lon in (t_lon_min, t_lon_max):
|
||||
assert is_within_ekb_bbox(lat, lon) is True
|
||||
assert is_within_ekb_bbox_wide(lat, lon) is True
|
||||
|
||||
|
||||
# ── OBLAST66_BBOX (region 66 expansion) ─────────────────────────────────────
|
||||
|
||||
|
||||
def test_oblast66_strictly_contains_tight() -> None:
|
||||
"""Инвариант: OBLAST66_BBOX ⊇ EKB_BBOX_TIGHT — генерализация не режет ЕКБ.
|
||||
|
||||
Мирроор test_wide_strictly_contains_tight, но для областного bbox.
|
||||
"""
|
||||
t_lat_min, t_lat_max, t_lon_min, t_lon_max = EKB_BBOX_TIGHT
|
||||
o_lat_min, o_lat_max, o_lon_min, o_lon_max = OBLAST66_BBOX
|
||||
assert o_lat_min <= t_lat_min
|
||||
assert o_lat_max >= t_lat_max
|
||||
assert o_lon_min <= t_lon_min
|
||||
assert o_lon_max >= t_lon_max
|
||||
for lat in (t_lat_min, t_lat_max):
|
||||
for lon in (t_lon_min, t_lon_max):
|
||||
assert is_within_ekb_bbox(lat, lon) is True
|
||||
assert is_within_oblast66_bbox(lat, lon) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"lat,lon,label",
|
||||
[
|
||||
(60.6935, 60.4235, "Ивдель"),
|
||||
(59.6297, 60.5541, "Серов"),
|
||||
(58.0378, 65.2753, "Тавда"),
|
||||
(56.6152, 57.7666, "Красноуфимск"),
|
||||
],
|
||||
)
|
||||
def test_oblast66_accepts_far_towns(lat: float, lon: float, label: str) -> None:
|
||||
"""Дальние города области (вне EKB tight/wide) — приняты oblast66-bbox."""
|
||||
assert is_within_oblast66_bbox(lat, lon) is True, f"{label} должен быть внутри OBLAST66_BBOX"
|
||||
|
||||
|
||||
def test_oblast66_bbox_admits_tyumen_by_design() -> None:
|
||||
"""OBLAST66_BBOX — генеральный (щедрый) bbox, координатно admits Тюмень.
|
||||
|
||||
Это ЗНАЕМОЕ и намеренное ограничение самого bbox-хелпера (дешевле
|
||||
false-positive у границы, чем false-negative на корректном адресе области).
|
||||
Реальное отсечение Тюмени происходит на accept-сайтах через region
|
||||
cross-check — см. test_nominatim_query_rejects_out_of_region_only_candidate /
|
||||
test_yandex_lookup_rejects_out_of_region_only_candidate ниже.
|
||||
"""
|
||||
assert is_within_oblast66_bbox(57.1530, 65.5343) is True
|
||||
|
||||
|
||||
# ── _has_oblast_marker — word/phrase-boundary matching (не substring) ───────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text,expected",
|
||||
[
|
||||
("екатеринбург, малышева 30", True),
|
||||
("нижний тагил, ленина 10", True),
|
||||
("свердловская область, серова 27", True),
|
||||
("каменск-уральский, мира 1", True),
|
||||
("заречный, мира 5", True), # ЗАТО-город (без district-префикса)
|
||||
# false-positives, которые substring-match ловил ошибочно (#oblast66 bug):
|
||||
("серова 27", False), # НЕ город "серов"
|
||||
("ирбитская 5", False), # НЕ город "ирбит"
|
||||
("асбестовский пер., 3", False), # НЕ город "асбест"
|
||||
("невьянский пер., 10", False), # НЕ город "невьянск"
|
||||
("богдановича, 22", False), # НЕ город "богданович"
|
||||
("мкр заречный, мира 5", False), # район внутри города, не город-ЗАТО
|
||||
("микрорайон заречный, мира 5", False),
|
||||
("малышева 30", False), # обычный ЕКБ bare-адрес
|
||||
],
|
||||
)
|
||||
def test_has_oblast_marker(text: str, expected: bool) -> None:
|
||||
assert _has_oblast_marker(text) is expected
|
||||
|
||||
|
||||
# ── _names_non_ekb_city — gate для EKB-only локальных tier'ов (#11) ─────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address,expected",
|
||||
[
|
||||
("Нижний Тагил, проспект Ленина, 1", True),
|
||||
("г. Екатеринбург, проспект Ленина, 1", False),
|
||||
("проспект Ленина, 1", False),
|
||||
("Серова 27", False), # word-boundary — НЕ город "Серов"
|
||||
("мкр Заречный, ул. Ленина 5", False), # район внутри города, не ЗАТО Заречный
|
||||
("Каменск-Уральский, ул. Ленина 1", True),
|
||||
# явное "Екатеринбург" перевешивает омоним соседнего города в названии
|
||||
# ЖК/района внутри города (реальный false-positive из прод-кэша, #11):
|
||||
("24, опалихинская улица, заречный, Верх-Исетский район, Екатеринбург", False),
|
||||
# word-boundary на "екатеринбург" — "Екатеринбургское шоссе" НЕ город
|
||||
# Екатеринбург (реальная улица в городах-спутниках, напр. Первоуральске):
|
||||
("Первоуральск, Екатеринбургское шоссе, 5", True),
|
||||
# расширенный gazetteer (deep-review fast-follow):
|
||||
("Верхняя Пышма, ул. Ленина 5", True),
|
||||
("Сухой Лог, ул. Мира 3", True),
|
||||
],
|
||||
)
|
||||
def test_names_non_ekb_city(address: str, expected: bool) -> None:
|
||||
assert _names_non_ekb_city(address) is expected
|
||||
|
||||
|
||||
# ── Accept-site two-pass tie-break + region cross-check ─────────────────────
|
||||
|
||||
|
||||
async def test_nominatim_query_prefers_tight_ekb_over_oblast_rank0() -> None:
|
||||
"""rank0 — Серов (внутри OBLAST66, вне tight), rank1 — ЕКБ (tight).
|
||||
|
||||
Two-pass tie-break должен выбрать rank1 (tight), а не rank0, несмотря на то
|
||||
что Nominatim ранжировал его вторым.
|
||||
"""
|
||||
payload = [
|
||||
{"lat": "59.6297", "lon": "60.5541", "address": {"state": "Свердловская область"}},
|
||||
{"lat": "56.838", "lon": "60.605", "address": {"state": "Свердловская область"}},
|
||||
]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
item = await _nominatim_query(client, "Серова 27")
|
||||
|
||||
assert item is not None
|
||||
assert float(item["lat"]) == pytest.approx(56.838)
|
||||
assert float(item["lon"]) == pytest.approx(60.605)
|
||||
|
||||
|
||||
async def test_nominatim_query_rejects_out_of_region_only_candidate() -> None:
|
||||
"""Единственный кандидат — Тюмень (координатно внутри OBLAST66, но регион другой)
|
||||
→ region cross-check отсекает его → None (не просто "первый как есть")."""
|
||||
payload = [{"lat": "57.1522", "lon": "65.5272", "address": {"state": "Тюменская область"}}]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
item = await _nominatim_query(client, "какой-то мусорный адрес")
|
||||
|
||||
assert item is None
|
||||
|
||||
|
||||
async def test_nominatim_query_accepts_oblast_wide_when_region_absent() -> None:
|
||||
"""Нет tight-совпадения, `address.state` отсутствует (Nominatim не всегда его
|
||||
отдаёт) — fallback на oblast-bbox (Серов), region cross-check не блокирует."""
|
||||
payload = [{"lat": "59.6297", "lon": "60.5541"}]
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(transport=transport) as client:
|
||||
item = await _nominatim_query(client, "Серов, Ленина 1")
|
||||
|
||||
assert item is not None
|
||||
assert float(item["lat"]) == pytest.approx(59.6297)
|
||||
|
||||
|
||||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||
|
||||
|
||||
def _yandex_client_factory(transport: httpx.MockTransport):
|
||||
"""Drop-in replacement для httpx.AsyncClient внутри geocoder._yandex_lookup."""
|
||||
|
||||
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||||
return _REAL_ASYNC_CLIENT(transport=transport)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
def _yandex_geo_object(lat: str, lon: str, admin_area: str | None) -> dict:
|
||||
meta: dict = {"precision": "exact", "text": "тест"}
|
||||
if admin_area is not None:
|
||||
meta["AddressDetails"] = {
|
||||
"Country": {"AdministrativeArea": {"AdministrativeAreaName": admin_area}}
|
||||
}
|
||||
return {
|
||||
"GeoObject": {
|
||||
"Point": {"pos": f"{lon} {lat}"},
|
||||
"metaDataProperty": {"GeocoderMetaData": meta},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _yandex_payload(members: list[dict]) -> dict:
|
||||
return {"response": {"GeoObjectCollection": {"featureMember": members}}}
|
||||
|
||||
|
||||
async def test_yandex_lookup_prefers_tight_ekb_over_oblast_rank0() -> None:
|
||||
"""Тот же tie-break сценарий, что и для Nominatim, но для Yandex top-5."""
|
||||
members = [
|
||||
_yandex_geo_object("59.6297", "60.5541", "Свердловская область"), # Серов, rank0
|
||||
_yandex_geo_object("56.838", "60.605", "Свердловская область"), # ЕКБ tight, rank1
|
||||
]
|
||||
payload = _yandex_payload(members)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
|
||||
result = await _yandex_lookup("Серова 27", "fake-key")
|
||||
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(56.838)
|
||||
assert result.lon == pytest.approx(60.605)
|
||||
|
||||
|
||||
async def test_yandex_lookup_rejects_out_of_region_only_candidate() -> None:
|
||||
"""Единственный кандидат — Тюмень (bbox admits координатно, регион другой) →
|
||||
region cross-check отсекает даже в ultimate as-is fallback → None."""
|
||||
members = [_yandex_geo_object("57.1522", "65.5272", "Тюменская область")]
|
||||
payload = _yandex_payload(members)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=payload)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with patch("app.services.geocoder.httpx.AsyncClient", _yandex_client_factory(transport)):
|
||||
result = await _yandex_lookup("какой-то мусорный адрес", "fake-key")
|
||||
|
||||
assert result is None
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ sys.modules.setdefault("weasyprint", _wp_mock)
|
|||
|
||||
|
||||
from app.services.geocoder import ( # noqa: E402
|
||||
GeocodeResult,
|
||||
GeocodeSuggestion,
|
||||
_cadastral_house_match,
|
||||
_parse_street_house,
|
||||
|
|
@ -264,6 +265,50 @@ async def test_geocode_skips_house_match_when_parse_fails() -> None:
|
|||
mock_forward.assert_called_once()
|
||||
|
||||
|
||||
# ── geocode() non-EKB city gate (#11) ────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_geocode_skips_ekb_local_tiers_for_non_ekb_city() -> None:
|
||||
"""Non-EKB oblast-адрес (Нижний Тагил) → EKB-only локальные тиры (geoportal/
|
||||
cadastral) НЕ вызываются, результат идёт от oblast-aware Nominatim (#11).
|
||||
|
||||
Без гейта «проспект Ленина, 1» коллизирует с одноимённым ЕКБ-домом через
|
||||
geoportal/cadastral и снапается в ЕКБ вместо Нижнего Тагила.
|
||||
"""
|
||||
db = MagicMock()
|
||||
nominatim_result = GeocodeResult(
|
||||
lat=57.905,
|
||||
lon=59.950,
|
||||
full_address="проспект Ленина, 1, Нижний Тагил",
|
||||
provider="nominatim",
|
||||
confidence="approximate",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cache_get", return_value=None),
|
||||
patch("app.services.geocoder._geoportal_house_match") as mock_geoportal,
|
||||
patch("app.services.geocoder._cadastral_house_match") as mock_house,
|
||||
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
|
||||
patch("app.services.geocoder._cache_put"),
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_lookup",
|
||||
new_callable=AsyncMock,
|
||||
return_value=nominatim_result,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
result = await geocode("Нижний Тагил, проспект Ленина, 1", db)
|
||||
|
||||
assert result is not None
|
||||
assert result.lat == pytest.approx(57.905)
|
||||
assert result.lon == pytest.approx(59.950)
|
||||
mock_geoportal.assert_not_called()
|
||||
mock_house.assert_not_called()
|
||||
mock_forward.assert_not_called()
|
||||
mock_nominatim.assert_called_once()
|
||||
|
||||
|
||||
# ── suggest() wiring ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -328,3 +373,39 @@ async def test_suggest_falls_back_to_legacy_forward_when_house_match_misses() ->
|
|||
mock_house.assert_called_once()
|
||||
mock_forward.assert_called_once()
|
||||
mock_yandex.assert_not_called()
|
||||
|
||||
|
||||
async def test_suggest_skips_ekb_local_tier_for_non_ekb_city() -> None:
|
||||
"""Non-EKB oblast query (Нижний Тагил) → EKB-only cadastral Tier 1
|
||||
(`_cadastral_house_match` / `_cadastral_forward_sync`) NOT called; falls
|
||||
through to external tiers, same root cause as `geocode()` (#11)."""
|
||||
db = MagicMock()
|
||||
nominatim_hit = [
|
||||
GeocodeSuggestion(
|
||||
label="проспект Ленина, 1",
|
||||
full_address="проспект Ленина, 1, Нижний Тагил",
|
||||
lat=57.905,
|
||||
lon=59.950,
|
||||
kind="house",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
patch("app.services.geocoder._cadastral_house_match") as mock_house,
|
||||
patch("app.services.geocoder._cadastral_forward_sync") as mock_forward,
|
||||
patch("app.services.geocoder.settings") as mock_settings,
|
||||
patch(
|
||||
"app.services.geocoder._nominatim_suggest",
|
||||
new_callable=AsyncMock,
|
||||
return_value=nominatim_hit,
|
||||
) as mock_nominatim,
|
||||
):
|
||||
mock_settings.dadata_api_token = None
|
||||
mock_settings.yandex_geocoder_api_key = None
|
||||
results = await suggest("Нижний Тагил, проспект Ленина, 1", db=db, limit=8)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].lat == pytest.approx(57.905)
|
||||
mock_house.assert_not_called()
|
||||
mock_forward.assert_not_called()
|
||||
mock_nominatim.assert_called_once()
|
||||
|
|
|
|||
|
|
@ -16,10 +16,13 @@ from app.services.matching.conflict_resolution import (
|
|||
update_canonical_fields,
|
||||
)
|
||||
from app.services.matching.normalize import (
|
||||
EKB_CITY_TOKEN,
|
||||
address_fingerprint,
|
||||
has_city_token,
|
||||
has_house_number,
|
||||
house_number_token,
|
||||
normalize_address,
|
||||
resolve_city_token,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -168,6 +171,68 @@ def test_fingerprint_all_none():
|
|||
assert len(fp) == 32
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# has_city_token — обл.66 city-token detection (Tier-2b oblast guard)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_has_city_token_detects_ekb():
|
||||
assert has_city_token("екатеринбург улица ленина 5") is True
|
||||
|
||||
|
||||
def test_has_city_token_detects_multiword_oblast_city():
|
||||
assert has_city_token("нижний тагил улица ленина 100") is True
|
||||
assert has_city_token("каменск уральский улица мира 7") is True
|
||||
|
||||
|
||||
def test_has_city_token_bare_street_is_false():
|
||||
# The collision-prone case: a bare street+number carries no city token.
|
||||
assert has_city_token("улица ленина 100") is False
|
||||
assert has_city_token("уральская улица 62к1") is False
|
||||
|
||||
|
||||
def test_has_city_token_no_false_positive_on_street_named_after_city():
|
||||
# 'улица серова' must NOT match the city token 'серов' (word-bounded, not substring).
|
||||
assert has_city_token("улица серова 5") is False
|
||||
assert has_city_token("первоуральская улица 3") is False
|
||||
|
||||
|
||||
def test_has_city_token_handles_empty():
|
||||
assert has_city_token("") is False
|
||||
assert has_city_token(None) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_city_token — which обл.66 city (or None); drives the Tier-2a oblast guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_city_token_returns_ekb():
|
||||
assert resolve_city_token("екатеринбург улица ленина 5") == EKB_CITY_TOKEN
|
||||
|
||||
|
||||
def test_resolve_city_token_returns_non_ekb_city():
|
||||
assert resolve_city_token("нижний тагил улица ленина 100") == "нижний тагил"
|
||||
assert resolve_city_token("свердловская область каменск уральский улица мира 7") == (
|
||||
"каменск уральский"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_city_token_bare_street_is_none():
|
||||
# The dominant Avito SERP format — no city token → None (guard stays dormant).
|
||||
assert resolve_city_token("улица ленина 100") is None
|
||||
|
||||
|
||||
def test_resolve_city_token_no_false_positive_on_street_named_after_city():
|
||||
assert resolve_city_token("улица серова 5") is None
|
||||
assert resolve_city_token("первоуральская улица 3") is None
|
||||
|
||||
|
||||
def test_resolve_city_token_handles_empty():
|
||||
assert resolve_city_token("") is None
|
||||
assert resolve_city_token(None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# match_or_create_house — mock DB tier routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -622,6 +687,204 @@ def test_insert_alias_executes_when_house_number_present():
|
|||
db.execute.assert_called_once()
|
||||
|
||||
|
||||
def test_insert_alias_conflict_only_rebinds_same_house():
|
||||
"""Oblast guard (#2): ON CONFLICT refreshes fingerprint/source ONLY for the same
|
||||
house. A different house colliding on a bare common-street normalized_address must
|
||||
not rewrite the owner's fingerprint (that would downgrade a coord-bearing alias to a
|
||||
coord-less one and let the next no-coord card mis-bucket via Tier 2a)."""
|
||||
from app.services.matching.houses import _insert_alias
|
||||
|
||||
db = MagicMock()
|
||||
_insert_alias(db, house_id=1, address="улица Ленина 100", fp="c" * 32, source="avito")
|
||||
sql = " ".join(str(db.execute.call_args.args[0]).split())
|
||||
assert "ON CONFLICT (normalized_address) DO UPDATE" in sql
|
||||
# fingerprint/source updated only when the existing owner == the incoming house.
|
||||
assert "house_address_aliases.house_id = EXCLUDED.house_id" in sql
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# match_or_create_house — Tier-2b oblast geo/city guard (bug #2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tier2b_coord_present_uses_geo_guard_and_matches_within_radius():
|
||||
"""Coords present → Tier-2b runs the ST_DWithin JOIN guard; a house within the
|
||||
radius is accepted as a fingerprint match (0.9)."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss (Tier 1)
|
||||
None, # fingerprint miss (Tier 2a)
|
||||
{"house_id": 77}, # Tier 2b JOIN+ST_DWithin hit (same place, within radius)
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias
|
||||
]
|
||||
)
|
||||
house_id, conf, method = match_or_create_house(
|
||||
db, "avito", "ext-2b-geo", address="улица Ленина 100", lat=57.910, lon=59.980
|
||||
)
|
||||
assert (house_id, conf, method) == (77, 0.9, "fingerprint")
|
||||
# The Tier-2b lookup must be the geo-guarded JOIN, not a bare normalized_address SELECT.
|
||||
tier2b_sql = _executed_sqls(db)[3]
|
||||
assert "ST_DWithin" in tier2b_sql
|
||||
assert "JOIN houses" in tier2b_sql
|
||||
|
||||
|
||||
def test_tier2b_coord_present_cross_city_falls_through_to_new():
|
||||
"""A bare street+number oblast card WITH coords whose only same-street alias is a
|
||||
far-away (cross-city) house: the ST_DWithin guard filters it out (JOIN miss) → the
|
||||
card falls through to a New house instead of mis-bucketing into the ЕКБ house."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss
|
||||
None, # fingerprint miss (Tier 2a)
|
||||
None, # Tier 2b JOIN+ST_DWithin miss (candidate house is >3km away)
|
||||
None, # geo miss (Tier 3 — coords present)
|
||||
{"id": 909}, # INSERT RETURNING id (New house)
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias
|
||||
]
|
||||
)
|
||||
house_id, conf, method = match_or_create_house(
|
||||
db, "avito", "ext-2b-xcity", address="улица Ленина 100", lat=57.910, lon=59.980
|
||||
)
|
||||
assert (house_id, conf, method) == (909, 1.0, "new")
|
||||
|
||||
|
||||
def test_tier2b_no_coords_no_city_token_skips_and_creates_new():
|
||||
"""No coords AND a bare common-street (no city token) → Tier-2b is SKIPPED entirely
|
||||
(no normalized_address lookup). The card falls through to a New house — conservative:
|
||||
do not mis-bucket a bare 'улица ленина 100' into a same-named ЕКБ alias."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss
|
||||
None, # fingerprint miss (Tier 2a); Tier 2b SKIPPED (no coords, no city token)
|
||||
{"id": 910}, # INSERT RETURNING id (New house) — no Tier 3 (no coords)
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias
|
||||
]
|
||||
)
|
||||
house_id, conf, method = match_or_create_house(
|
||||
db, "avito", "ext-2b-bare", address="улица Ленина 100"
|
||||
)
|
||||
assert (house_id, conf, method) == (910, 1.0, "new")
|
||||
assert not any(
|
||||
"normalized_address = :na" in s for s in _executed_sqls(db)
|
||||
), "bare common-street with no coords/city must not run a Tier-2b lookup"
|
||||
|
||||
|
||||
def test_tier2b_no_coords_with_city_token_matches():
|
||||
"""No coords but the normalized_address carries a city token → self-disambiguating,
|
||||
so Tier-2b matches on normalized_address alone (0.9). ЕКБ cross-source dedup for
|
||||
city-qualified addresses is preserved."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss
|
||||
None, # fingerprint miss (Tier 2a)
|
||||
{"house_id": 33}, # Tier 2b normalized_address hit (city token present)
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias
|
||||
]
|
||||
)
|
||||
house_id, conf, method = match_or_create_house(
|
||||
db, "avito", "ext-2b-city", address="Екатеринбург, улица Ленина, 100"
|
||||
)
|
||||
assert (house_id, conf, method) == (33, 0.9, "fingerprint")
|
||||
tier2b_sql = _executed_sqls(db)[3]
|
||||
assert "normalized_address = :na" in tier2b_sql
|
||||
assert "ST_DWithin" not in tier2b_sql
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# match_or_create_house — Tier-2a coord-less oblast city guard (#2500 follow-up)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tier2a_coord_less_non_ekb_city_skips_both_lookups_and_creates_new():
|
||||
"""A coord-less card whose address resolves a NON-ЕКБ city (Н.Тагил) must NOT match a
|
||||
globally-unique alias by fingerprint OR normalized_address (either would corrupt ЕКБ
|
||||
house data). The guard skips both alias lookups → New house."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss (Tier 1)
|
||||
# Tier 2a (fingerprint) SKIPPED, Tier 2b (normalized_address) SKIPPED,
|
||||
# Tier 3 SKIPPED (no coords) — guard forces fall-through to New.
|
||||
{"id": 701}, # INSERT RETURNING id (New house)
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias (address carries a house number → executes)
|
||||
]
|
||||
)
|
||||
house_id, conf, method = match_or_create_house(
|
||||
db, "avito", "ext-2a-nt", address="Нижний Тагил, ул. Ленина, 100"
|
||||
)
|
||||
assert (house_id, conf, method) == (701, 1.0, "new")
|
||||
sqls = _executed_sqls(db)
|
||||
assert not any(
|
||||
"fingerprint = :fp" in s for s in sqls
|
||||
), "coord-less non-ЕКБ card must NOT run the Tier-2a fingerprint lookup"
|
||||
assert not any(
|
||||
"normalized_address = :na" in s for s in sqls
|
||||
), "coord-less non-ЕКБ card must NOT run the Tier-2b normalized_address lookup"
|
||||
|
||||
|
||||
def test_tier2a_coord_less_ekb_city_still_matches():
|
||||
"""EKB happy-path: a coord-less card that resolves ЕКБ runs Tier-2a EXACTLY as before —
|
||||
the guard does not fire (екатеринбург == EKB_CITY_TOKEN)."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss
|
||||
{"house_id": 88}, # Tier 2a fingerprint HIT (guard did NOT skip it)
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias
|
||||
]
|
||||
)
|
||||
house_id, conf, method = match_or_create_house(
|
||||
db, "avito", "ext-2a-ekb", address="Екатеринбург, ул. Ленина, 5"
|
||||
)
|
||||
assert (house_id, conf, method) == (88, 0.9, "fingerprint")
|
||||
assert any(
|
||||
"fingerprint = :fp" in s for s in _executed_sqls(db)
|
||||
), "ЕКБ coord-less card must still run the Tier-2a fingerprint lookup"
|
||||
|
||||
|
||||
def test_tier2a_coord_less_bare_street_still_runs_tier2a():
|
||||
"""EKB happy-path (dominant): a coord-less BARE card (no city token — today's Avito SERP
|
||||
format) resolves to None → guard dormant → Tier-2a fingerprint dedup UNCHANGED."""
|
||||
from app.services.matching.houses import match_or_create_house
|
||||
|
||||
db = _make_db(
|
||||
[
|
||||
None, # pg_advisory_xact_lock
|
||||
None, # house_sources miss
|
||||
{"house_id": 99}, # Tier 2a fingerprint HIT (bare card → guard did not skip)
|
||||
None, # _upsert_house_source
|
||||
None, # _insert_alias
|
||||
]
|
||||
)
|
||||
house_id, conf, method = match_or_create_house(
|
||||
db, "avito", "ext-2a-bare", address="ул. Ленина, 5"
|
||||
)
|
||||
assert (house_id, conf, method) == (99, 0.9, "fingerprint")
|
||||
assert any("fingerprint = :fp" in s for s in _executed_sqls(db))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# match_or_create_listing — mock DB tier routing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -122,7 +122,10 @@ def test_insufficient_data_page_contains_address_and_report_num() -> None:
|
|||
est = _zero_estimate()
|
||||
html = mod._build_insufficient_data_page(est, _SNAPSHOT, _GENERIC)
|
||||
assert "Ленина" in html # address
|
||||
assert "EKБ-" in html # report number prefix
|
||||
# #pdf-honesty: № отчёта city-aware — у оценки без target_address город не распознан
|
||||
# → нейтральный префикс «МЕРА» (раньше был захардкожен «EKБ» для любого объекта).
|
||||
assert "МЕРА-" in html
|
||||
assert "EKБ-" not in html # старый mixed-script хардкод убран
|
||||
|
||||
|
||||
def test_generate_pdf_insufficient_skips_offer_page() -> None:
|
||||
|
|
@ -197,20 +200,23 @@ def test_cover_disclaimer_absent_when_none() -> None:
|
|||
|
||||
|
||||
def test_cover_renders_logo_img_when_valid_url() -> None:
|
||||
est = _estimate()
|
||||
html = mod._build_cover(est, _SNAPSHOT, _PRAKTIKA)
|
||||
assert "<img" in html
|
||||
assert "gk-praktika.ru" in html
|
||||
# #4-pages-fix: _page_header больше не встроен в _build_cover — он рендерится
|
||||
# ОДИН раз в generate_trade_in_pdf как running-header (position:running(...),
|
||||
# печатается WeasyPrint'ом в @top-center margin-box на каждой странице), см.
|
||||
# trade_in_pdf.py::generate_trade_in_pdf. Логотип/wordmark проверяем напрямую
|
||||
# через _page_header, не через _build_cover (симметрично уже существовавшему
|
||||
# паттерну в test_cover_falls_back_to_name_when_logo_none ниже).
|
||||
header_html = mod._page_header(_PRAKTIKA, "EKБ-0001-0000000001", datetime.now(UTC).date())
|
||||
assert "<img" in header_html
|
||||
assert "gk-praktika.ru" in header_html
|
||||
|
||||
|
||||
def test_cover_falls_back_to_name_when_logo_none() -> None:
|
||||
est = _estimate()
|
||||
html = mod._build_cover(est, _SNAPSHOT, _GENERIC)
|
||||
assert "TRADE-IN" in html.upper()
|
||||
# Logo-specific check via _page_header directly — не проверяем отсутствие <img> во
|
||||
# всей cover-странице, т.к. с #2327-follow-up диапазоны цен рендерятся как
|
||||
# matplotlib SVG <img> (см. _price_range_chart_svg), не связанные с логотипом бренда.
|
||||
header_html = mod._page_header(_GENERIC, "EKБ-0001-0000000001", datetime.now(UTC).date())
|
||||
assert "TRADE-IN" in header_html.upper()
|
||||
assert "<img" not in header_html
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -278,3 +278,45 @@ async def test_fetch_serp_html_routes_through_browser() -> None:
|
|||
|
||||
assert html == "<html><body>real serp listing</body></html>"
|
||||
scraper._browser.fetch.assert_awaited_once()
|
||||
|
||||
|
||||
# ── 6. SERP browser sidecar timeout → transient retry, not bare exception ────
|
||||
# #2472: BrowserFetcher.fetch (browser_fetcher.py) retries once internally on
|
||||
# httpx.HTTPError/TransportError, then re-raises. Before this fix, non-
|
||||
# HTTPStatusError exceptions (httpx.TimeoutException/TransportError — e.g.
|
||||
# ReadTimeout/ConnectError/RemoteProtocolError) propagated unhandled through
|
||||
# _fetch_serp_html_browser straight into pipeline.run_avito_full_load's generic
|
||||
# `except Exception` → mark_failed(str(exc)) (str(ReadTimeout())=="" → empty
|
||||
# error, and mark_failed does NOT persist done_buckets, unlike mark_banned).
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_serp_html_browser_timeout_routes_transient_not_bare_exception() -> None:
|
||||
"""httpx.ReadTimeout from browser.fetch() must NOT propagate as an unhandled
|
||||
exception. It's classified transient (status=0, no soft-ban keyword match)
|
||||
and, once the bounded retry budget is exhausted, surfaces as
|
||||
AvitoRateLimitedError — the same graceful banned-path a sidecar
|
||||
HTTPStatusError takes — never as a bare/generic exception."""
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
from scraper_kit.avito_exceptions import AvitoRateLimitedError
|
||||
from scraper_kit.providers.avito import serp as serp_module
|
||||
from scraper_kit.providers.avito.serp import AvitoScraper
|
||||
|
||||
from app.services.scraper_adapters import RealScraperConfig
|
||||
|
||||
scraper = AvitoScraper(RealScraperConfig())
|
||||
assert scraper._cffi is None
|
||||
scraper._browser = AsyncMock()
|
||||
scraper._browser.fetch = AsyncMock(side_effect=httpx.ReadTimeout(""))
|
||||
|
||||
with patch.object(serp_module.asyncio, "sleep", AsyncMock()):
|
||||
with pytest.raises(AvitoRateLimitedError):
|
||||
await scraper._fetch_serp_html(
|
||||
"https://www.avito.ru/ekaterinburg/kvartiry/prodam", page=1
|
||||
)
|
||||
|
||||
# 1 initial attempt + 2 transient retries (_AVITO_SIDECAR_TRANSIENT_RETRIES) = 3,
|
||||
# no IP rotation involved (RealScraperConfig has no rotate_url configured in tests).
|
||||
assert scraper._browser.fetch.await_count == 3
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ def _reset_auth_cache() -> None:
|
|||
|
||||
_ADMIN_API_RE = re.compile(r"^/api/v1/admin/")
|
||||
_PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"})
|
||||
# MIRROR of app.main (#R2-H3): keep in sync with the real rbac_guard.
|
||||
_EXTERNAL_PREFIX = "/trade-in"
|
||||
_RBAC_BOOTSTRAP_EXEMPT = ("/api/v1/me", "/api/v1/brand")
|
||||
|
||||
|
||||
def _build_test_app() -> FastAPI:
|
||||
|
|
@ -76,6 +79,18 @@ def _build_test_app() -> FastAPI:
|
|||
status_code=403,
|
||||
content={"detail": "admin only"},
|
||||
)
|
||||
# #R2-H3 mirror: enforce roles.yaml scope on non-bootstrap paths.
|
||||
if not path.startswith(_RBAC_BOOTSTRAP_EXEMPT):
|
||||
external_path = _EXTERNAL_PREFIX + path
|
||||
try:
|
||||
allowed = auth_mod.is_path_allowed(role, external_path)
|
||||
except Exception:
|
||||
allowed = True
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "forbidden for role"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(me_router.router, prefix="/api/v1", tags=["me"])
|
||||
|
|
@ -84,6 +99,18 @@ def _build_test_app() -> FastAPI:
|
|||
async def admin_dummy() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/v1/search")
|
||||
async def search_dummy() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/v1/trade-in/dummy")
|
||||
async def tradein_dummy() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/v1/brand/dummy")
|
||||
async def brand_dummy() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
|
@ -105,9 +132,7 @@ def test_get_role_known_users() -> None:
|
|||
assert auth_mod.get_role("admin") == "admin"
|
||||
assert auth_mod.get_role("kopylov") == "pilot"
|
||||
for n in range(1, 11):
|
||||
# user2 = «Брусника»: пробный доступ закрыт 2026-07-09 (roles.yaml)
|
||||
expected = "expired" if n == 2 else "pilot"
|
||||
assert auth_mod.get_role(f"user{n}") == expected
|
||||
assert auth_mod.get_role(f"user{n}") == "pilot"
|
||||
|
||||
|
||||
def test_get_role_unknown_user_raises() -> None:
|
||||
|
|
@ -288,6 +313,50 @@ def test_rbac_guard_unknown_user_blocked_on_non_admin_path(client: TestClient) -
|
|||
assert "not in roles config" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
# --- #R2-H3: roles.yaml scope enforced on ALL non-admin paths (not just /admin/*) ---
|
||||
|
||||
|
||||
def test_rbac_expired_denied_on_non_admin_api(client: TestClient) -> None:
|
||||
"""#R2-H3: revoked (role=expired, paths:[] deny:/**) НЕ достаёт non-admin API.
|
||||
Раньше rbac_guard гейтил только /admin/* → expired имел полный non-admin доступ
|
||||
(POST /search экспорт листингов и т.д.). Теперь scope-blocked → 403."""
|
||||
for user in ("praktika",): # role=expired (trial отозван; user2 восстановлен 2026-07-13)
|
||||
for path in ("/api/v1/search", "/api/v1/trade-in/dummy"):
|
||||
resp = client.get(path, headers={"X-Authenticated-User": user})
|
||||
assert resp.status_code == 403, f"{user} {path}: {resp.status_code}"
|
||||
assert "forbidden for role" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_rbac_expired_allowed_on_bootstrap(client: TestClient) -> None:
|
||||
"""expired ДОЛЖЕН достучаться до /me (получить role=expired → trial-экран) и
|
||||
/brand/* (брендинг trial-экрана) — иначе UX сломан. Bootstrap-исключение."""
|
||||
resp_me = client.get("/api/v1/me", headers={"X-Authenticated-User": "praktika"})
|
||||
assert resp_me.status_code == 200, resp_me.text
|
||||
assert resp_me.json()["role"] == "expired"
|
||||
resp_brand = client.get("/api/v1/brand/dummy", headers={"X-Authenticated-User": "praktika"})
|
||||
assert resp_brand.status_code == 200, resp_brand.text
|
||||
|
||||
|
||||
def test_rbac_pilot_allowed_on_tradein_api(client: TestClient) -> None:
|
||||
"""pilot по roles.yaml имеет /trade-in/api/v1/** → доступ к trade-in/search сохраняется."""
|
||||
for path in ("/api/v1/search", "/api/v1/trade-in/dummy"):
|
||||
resp = client.get(path, headers={"X-Authenticated-User": "kopylov"})
|
||||
assert resp.status_code == 200, f"pilot {path}: {resp.text}"
|
||||
|
||||
|
||||
def test_rbac_admin_allowed_everywhere(client: TestClient) -> None:
|
||||
for path in ("/api/v1/search", "/api/v1/trade-in/dummy", "/api/v1/admin/dummy"):
|
||||
resp = client.get(path, headers={"X-Authenticated-User": "admin"})
|
||||
assert resp.status_code == 200, f"admin {path}: {resp.text}"
|
||||
|
||||
|
||||
def test_rbac_analyst_allowed_non_admin_denied_admin(client: TestClient) -> None:
|
||||
ok = client.get("/api/v1/search", headers={"X-Authenticated-User": "analysttest"})
|
||||
assert ok.status_code == 200, ok.text
|
||||
denied = client.get("/api/v1/admin/dummy", headers={"X-Authenticated-User": "analysttest"})
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
def test_rbac_guard_no_header_on_non_admin_path_returns_401(client: TestClient) -> None:
|
||||
"""Любой non-public path без X-Authenticated-User → 401."""
|
||||
resp = client.get("/api/v1/me")
|
||||
|
|
|
|||
140
tradein-mvp/backend/tests/test_request_audit.py
Normal file
140
tradein-mvp/backend/tests/test_request_audit.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"""Tests for RequestAuditMiddleware (app/core/request_audit.py).
|
||||
|
||||
Тестируем middleware в изоляции на минимальном FastAPI-приложении (как
|
||||
tests/test_ratelimit.py) — не тянем тяжёлый app.main. schedule_event
|
||||
и should_log_login мокируются, реальная запись в user_events не требуется
|
||||
(она уже покрыта tests/test_user_events.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.request_audit import RequestAuditMiddleware
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.add_middleware(RequestAuditMiddleware)
|
||||
|
||||
@app.get("/api/v1/ping")
|
||||
def ping() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_authenticated_api_request_schedules_api_request_event(client: TestClient) -> None:
|
||||
with (
|
||||
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||
patch("app.core.request_audit.should_log_login", return_value=False),
|
||||
):
|
||||
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_schedule.assert_called_once()
|
||||
kwargs = mock_schedule.call_args.kwargs
|
||||
assert kwargs["event_type"] == "api_request"
|
||||
assert kwargs["username"] == "alice"
|
||||
assert kwargs["path"] == "/api/v1/ping"
|
||||
assert kwargs["method"] == "GET"
|
||||
|
||||
|
||||
def test_login_event_scheduled_when_should_log_login_true(client: TestClient) -> None:
|
||||
with (
|
||||
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||
patch("app.core.request_audit.should_log_login", return_value=True),
|
||||
):
|
||||
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
|
||||
|
||||
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
|
||||
assert event_types == ["api_request", "login"]
|
||||
|
||||
|
||||
def test_login_event_not_scheduled_when_should_log_login_false(client: TestClient) -> None:
|
||||
with (
|
||||
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||
patch("app.core.request_audit.should_log_login", return_value=False),
|
||||
):
|
||||
client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
|
||||
|
||||
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
|
||||
assert event_types == ["api_request"]
|
||||
|
||||
|
||||
def test_no_username_header_skips_audit(client: TestClient) -> None:
|
||||
with patch("app.core.request_audit.schedule_event") as mock_schedule:
|
||||
resp = client.get("/api/v1/ping")
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_schedule.assert_not_called()
|
||||
|
||||
|
||||
def test_public_path_skips_audit_even_with_username(client: TestClient) -> None:
|
||||
with patch("app.core.request_audit.schedule_event") as mock_schedule:
|
||||
resp = client.get("/health", headers={"X-Authenticated-User": "alice"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_schedule.assert_not_called()
|
||||
|
||||
|
||||
def test_non_api_path_skips_audit(client: TestClient) -> None:
|
||||
"""Path not under /api/ (and not one of the FastAPI-generated docs paths) is skipped."""
|
||||
app = FastAPI()
|
||||
app.add_middleware(RequestAuditMiddleware)
|
||||
|
||||
@app.get("/other")
|
||||
def other() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
with patch("app.core.request_audit.schedule_event") as mock_schedule:
|
||||
resp = TestClient(app).get("/other", headers={"X-Authenticated-User": "alice"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_schedule.assert_not_called()
|
||||
|
||||
|
||||
def test_audit_failure_does_not_break_response(client: TestClient) -> None:
|
||||
"""A raising schedule_event must never turn a 200 into a 500 (dispatch swallows it)."""
|
||||
with patch("app.core.request_audit.schedule_event", side_effect=RuntimeError("boom")):
|
||||
resp = client.get("/api/v1/ping", headers={"X-Authenticated-User": "alice"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_admin_path_excluded_from_api_request_but_login_kept() -> None:
|
||||
"""/api/v1/admin/* — ops-действия (просмотр дашбордов аудита/аналитики), не
|
||||
поведение пилота: api_request НЕ пишем (иначе дашборд зашумляет активность),
|
||||
а login (вход админа с IP) остаётся валидным аудитом."""
|
||||
app = FastAPI()
|
||||
app.add_middleware(RequestAuditMiddleware)
|
||||
|
||||
@app.get("/api/v1/admin/analytics")
|
||||
def analytics() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
with (
|
||||
patch("app.core.request_audit.schedule_event") as mock_schedule,
|
||||
patch("app.core.request_audit.should_log_login", return_value=True),
|
||||
):
|
||||
resp = TestClient(app).get(
|
||||
"/api/v1/admin/analytics", headers={"X-Authenticated-User": "admin"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
event_types = [c.kwargs["event_type"] for c in mock_schedule.call_args_list]
|
||||
assert event_types == ["login"]
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
Чисто-юнит: ассертим SQL-текст, эмитируемый import_rosreestr_dkp (через inspect.getsource),
|
||||
плюс содержимое backfill-миграции 077 — без живого FDW / DB-фикстуры.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
|
@ -45,9 +46,67 @@ def test_import_populates_source_id() -> None:
|
|||
assert '"source_id": str(row["source_id_src"])' in _IMPORT_SRC
|
||||
|
||||
|
||||
def test_import_keeps_on_conflict_dedup_hash() -> None:
|
||||
"""Дедуп по-прежнему через ON CONFLICT (dedup_hash) DO NOTHING."""
|
||||
assert "ON CONFLICT (dedup_hash) DO NOTHING" in _IMPORT_SRC
|
||||
def test_import_uses_on_conflict_do_update_for_corrections() -> None:
|
||||
"""Fix D: дедуп по dedup_hash, но ON CONFLICT DO UPDATE (не DO NOTHING) —
|
||||
|
||||
исправленный/переопубликованный квартал обновляет сырые факты Росреестра.
|
||||
"""
|
||||
assert "ON CONFLICT (dedup_hash) DO UPDATE SET" in _IMPORT_SRC
|
||||
assert "DO NOTHING" not in _IMPORT_BODY
|
||||
# Обновляются изменяемые сырые факты...
|
||||
for col in (
|
||||
"address = EXCLUDED.address",
|
||||
"region_code = EXCLUDED.region_code",
|
||||
"city = EXCLUDED.city",
|
||||
"rooms = EXCLUDED.rooms",
|
||||
"area_m2 = EXCLUDED.area_m2",
|
||||
"floor = EXCLUDED.floor",
|
||||
"year_built = EXCLUDED.year_built",
|
||||
"price_rub = EXCLUDED.price_rub",
|
||||
"price_per_m2 = EXCLUDED.price_per_m2",
|
||||
"deal_date = EXCLUDED.deal_date",
|
||||
):
|
||||
assert col in _IMPORT_SRC, f"missing DO UPDATE SET column: {col!r}"
|
||||
# ...но идемпотентность через IS DISTINCT FROM (неизменные строки не трогаются).
|
||||
assert "IS DISTINCT FROM EXCLUDED.price_rub" in _IMPORT_SRC
|
||||
# xmax-дискриминатор insert vs update.
|
||||
assert "RETURNING (xmax = 0)" in _IMPORT_SRC
|
||||
|
||||
|
||||
def test_import_do_update_never_overwrites_enrichment_columns() -> None:
|
||||
"""Fix D: обогащение (lat/lon/geom/geocode/cadastral_number...) не в SET-списке.
|
||||
|
||||
Эти колонки заполняются пост-импортом (geocode_deals_*) — повторный импорт не
|
||||
должен их затирать. Отсутствие в SET DO UPDATE гарантирует сохранность.
|
||||
"""
|
||||
conflict_clause = _IMPORT_SRC.split("ON CONFLICT (dedup_hash) DO UPDATE SET", 1)[1]
|
||||
set_and_where = conflict_clause.split("RETURNING", 1)[0]
|
||||
for enrich in (
|
||||
"lat =",
|
||||
"lon =",
|
||||
"geom =",
|
||||
"geocode_tried_at =",
|
||||
"cadastral_number =",
|
||||
"total_floors =",
|
||||
"house_type =",
|
||||
):
|
||||
assert enrich not in set_and_where, f"enrichment column leaked into UPDATE: {enrich!r}"
|
||||
|
||||
|
||||
def test_import_separates_errors_from_dedup_skips() -> None:
|
||||
"""Fix C: INSERT-ошибки считаются отдельным счётчиком, не как dedup-skip."""
|
||||
assert '"rows_errored": 0' in _IMPORT_SRC
|
||||
assert '"rows_updated": 0' in _IMPORT_SRC
|
||||
assert "batch_errored += 1" in _IMPORT_SRC
|
||||
# Per-row except-ветка (после лога "INSERT failed", до конца батча) инкрементит
|
||||
# ТОЛЬКО errored — больше НЕ skip (это был баг silent-green: ошибки маскировались
|
||||
# под дедуп).
|
||||
per_row_except = _IMPORT_SRC.split("INSERT failed", 1)[1].split("last_id = batch_max_id", 1)[0]
|
||||
assert "batch_errored += 1" in per_row_except
|
||||
assert "batch_skipped += 1" not in per_row_except
|
||||
# Порог ошибок → run failed, а не silent-green.
|
||||
assert "DKP_IMPORT_ERROR_RATE_THRESHOLD" in _IMPORT_SRC
|
||||
assert "marking run failed" in _IMPORT_SRC
|
||||
|
||||
|
||||
def test_import_uses_psycopg_v3_casts_not_double_colon() -> None:
|
||||
|
|
@ -89,12 +148,18 @@ def test_migration_077_converts_md5_to_plain_key() -> None:
|
|||
assert "BEGIN;" in sql and "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_077_filter_matches_live_import() -> None:
|
||||
"""Фильтр src CTE байт-в-байт совпадает с живым импортом → все строки сконвертируются."""
|
||||
def test_migration_077_shared_filter_matches_live_import() -> None:
|
||||
"""Дедуп-релевантные фильтры src CTE миграции 077 совпадают с живым импортом.
|
||||
|
||||
Исключение — city ILIKE (см. test_live_import_dropped_ekb_city_filter ниже):
|
||||
077 backfill'ил ЕКБ-строки под EKB-only scope того времени; живой импорт расширен
|
||||
на всю Свердловскую область (region_code=66, все города), поэтому city-фильтр из
|
||||
живого импорта СНЯТ намеренно. Остальные клозы обязаны совпадать байт-в-байт,
|
||||
иначе 077 конвертировал бы не тот набор строк.
|
||||
"""
|
||||
sql = _MIGRATION_077.read_text("utf-8")
|
||||
for clause in (
|
||||
"region_code = 66",
|
||||
"city ILIKE '%катеринбург%'",
|
||||
"realestate_type_code = '002001003000'",
|
||||
"area BETWEEN 18 AND 200",
|
||||
"deal_price BETWEEN 1000000 AND 100000000",
|
||||
|
|
@ -103,3 +168,17 @@ def test_migration_077_filter_matches_live_import() -> None:
|
|||
):
|
||||
assert clause in sql, f"missing filter clause in migration: {clause!r}"
|
||||
assert clause in _IMPORT_SRC, f"missing filter clause in import: {clause!r}"
|
||||
|
||||
|
||||
def test_live_import_dropped_ekb_city_filter() -> None:
|
||||
"""Живой импорт БОЛЬШЕ не фильтрует по городу — Mera расширена на всю обл. 66.
|
||||
|
||||
Историческая миграция 077 (уже применена на прод, трогать нельзя) сохраняет свой
|
||||
EKB-only city-фильтр; живой импорт — нет. Guard против случайного возврата
|
||||
ЕКБ-скоупа, который снова отрезал бы +47k не-ЕКБ сделок.
|
||||
"""
|
||||
sql = _MIGRATION_077.read_text("utf-8")
|
||||
# 077 — исторический EKB-scope, city-фильтр там остаётся неизменным.
|
||||
assert "city ILIKE '%катеринбург%'" in sql
|
||||
# Живой импорт city-фильтр снял намеренно (region_code=66 = вся область).
|
||||
assert "ILIKE '%катеринбург%'" not in _IMPORT_SRC
|
||||
|
|
|
|||
|
|
@ -3,10 +3,17 @@
|
|||
Coverage:
|
||||
(a) _next_quarter: boundary cases (Q4 → Q1 of next year, mid-year).
|
||||
(b) latest_loaded_quarter: derives correct (year, quarter) from a known deal_date.
|
||||
(c) poll_rosreestr_new_quarter available=True path (mocked httpx + fake DB).
|
||||
(d) poll_rosreestr_new_quarter available=False path.
|
||||
(e) Network error → available=False (never raises into caller).
|
||||
(f) No DB data → fallback baseline 2025Q4, checks 2026Q1.
|
||||
(c) rosreestr_dataset_url: cp1251 percent-encoding matches the real portal URL.
|
||||
(d) check_new_quarter_available — new /data-sets/ autoindex flow:
|
||||
- real application/zip + large Content-Length → True
|
||||
- soft-404 regression: file found but Content-Type text/html → False
|
||||
(this is the exact bug that broke the old /opendata/ flat-URL poller)
|
||||
- tiny Content-Length (placeholder/error file) → False
|
||||
- quarter folder missing from autoindex → False (not yet published)
|
||||
- dataset file missing from quarter folder → False
|
||||
- network error / timeout → False, never raises
|
||||
(e) poll_rosreestr_new_quarter orchestration: available=True/False end-to-end,
|
||||
no DB data fallback, network error propagation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -15,6 +22,7 @@ import os
|
|||
import sys
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import quote
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
|
|
@ -33,6 +41,46 @@ from app.services.rosreestr_poll import ( # noqa: E402
|
|||
rosreestr_dataset_url,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cp1251 href fixtures — built the same way the real portal encodes folder/file
|
||||
# names (percent-encoded cp1251 bytes), so tests exercise the actual decode path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cp1251_href(name: str) -> str:
|
||||
return quote(name.encode("cp1251"), safe="")
|
||||
|
||||
|
||||
def _autoindex_html(*folder_names: str) -> str:
|
||||
"""Build a fake Apache-autoindex-style HTML listing for /data-sets/."""
|
||||
links = ['<a href="../">../</a>']
|
||||
for name in folder_names:
|
||||
href = _cp1251_href(name) + "/"
|
||||
links.append(f'<a href="{href}">{name}/</a>')
|
||||
return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"
|
||||
|
||||
|
||||
def _folder_html(*file_names: str) -> str:
|
||||
"""Build a fake Apache-autoindex-style HTML listing for a quarter folder."""
|
||||
links = ['<a href="../">../</a>']
|
||||
for name in file_names:
|
||||
href = _cp1251_href(name)
|
||||
links.append(f'<a href="{href}">{name}</a>')
|
||||
return "<html><body><pre>" + "\n".join(links) + "</pre></body></html>"
|
||||
|
||||
|
||||
_SDELKI_FILE_2026_Q1 = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_1.csv.zip"
|
||||
_KADASTR_FILE_2026_Q1 = "dataset_КАДАСТРСТОИМОСТЬ_r-r_01-92_y_2026_q_1.csv.zip"
|
||||
|
||||
|
||||
def _mock_response(*, status_code: int = 200, text: str = "", headers: dict | None = None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.text = text
|
||||
resp.headers = headers or {}
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (a) _next_quarter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -62,10 +110,25 @@ def test_period_start_to_quarter_from_string() -> None:
|
|||
assert _period_start_to_quarter("2026-07-01") == (2026, 3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (c) rosreestr_dataset_url — cp1251 percent-encoding must match the real portal.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rosreestr_dataset_url_matches_verified_real_encoding() -> None:
|
||||
"""Encoding must byte-match the live-verified rosreestr.gov.ru/data-sets/ URL."""
|
||||
url = rosreestr_dataset_url(2026, 1)
|
||||
assert url == (
|
||||
"https://rosreestr.gov.ru/data-sets/"
|
||||
"1%20%EA%E2%E0%F0%F2%E0%EB%202026%E3./"
|
||||
"dataset_%D1%C4%C5%CB%CA%C8_r-r_01-92_y_2026_q_1.csv.zip"
|
||||
)
|
||||
|
||||
|
||||
def test_rosreestr_dataset_url_contains_year_quarter() -> None:
|
||||
url = rosreestr_dataset_url(2026, 2)
|
||||
assert "2026" in url
|
||||
assert "_2" in url or "q_2" in url # quarter embedded
|
||||
assert "q_2" in url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -125,22 +188,181 @@ def test_latest_loaded_quarter_returns_none_when_empty() -> None:
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (c) poll_rosreestr_new_quarter — available=True
|
||||
# (d) check_new_quarter_available — /data-sets/ autoindex flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_available_true_returns_correct_next_quarter() -> None:
|
||||
"""If latest loaded is Q1 2026 and head returns 200, returns available=True for Q2 2026."""
|
||||
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
async def test_check_available_true_for_real_zip() -> None:
|
||||
"""Folder found, file found, HEAD returns application/zip + large Content-Length → True."""
|
||||
index_html = _autoindex_html("1 квартал 2026г.", "4 квартал 2025г.")
|
||||
folder_html = _folder_html(_SDELKI_FILE_2026_Q1, _KADASTR_FILE_2026_Q1)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.head.return_value = mock_response
|
||||
mock_client.get.side_effect = [
|
||||
_mock_response(text=index_html),
|
||||
_mock_response(text=folder_html),
|
||||
]
|
||||
mock_client.head.return_value = _mock_response(
|
||||
headers={"content-type": "application/zip", "content-length": "9500000"}
|
||||
)
|
||||
|
||||
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result is True
|
||||
assert mock_client.get.call_count == 2
|
||||
assert mock_client.head.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_available_false_soft_404_text_html() -> None:
|
||||
"""Regression guard: HTTP 200 with Content-Type text/html (Bitrix soft-404) → False.
|
||||
|
||||
This is the exact failure mode that broke the old /opendata/ flat-URL poller —
|
||||
it treated bare HTTP 200 as availability. Even if a matching file href is found,
|
||||
a non-zip Content-Type must never be treated as available.
|
||||
"""
|
||||
index_html = _autoindex_html("1 квартал 2026г.")
|
||||
folder_html = _folder_html(_SDELKI_FILE_2026_Q1)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = [
|
||||
_mock_response(text=index_html),
|
||||
_mock_response(text=folder_html),
|
||||
]
|
||||
mock_client.head.return_value = _mock_response(
|
||||
status_code=200,
|
||||
headers={"content-type": "text/html; charset=utf-8", "content-length": "1234"},
|
||||
)
|
||||
|
||||
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_available_false_tiny_content_length() -> None:
|
||||
"""application/zip but tiny Content-Length (placeholder/error file) → False."""
|
||||
index_html = _autoindex_html("1 квартал 2026г.")
|
||||
folder_html = _folder_html(_SDELKI_FILE_2026_Q1)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = [
|
||||
_mock_response(text=index_html),
|
||||
_mock_response(text=folder_html),
|
||||
]
|
||||
mock_client.head.return_value = _mock_response(
|
||||
headers={"content-type": "application/zip", "content-length": "512"}
|
||||
)
|
||||
|
||||
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_available_false_quarter_folder_missing() -> None:
|
||||
"""Autoindex doesn't have the target quarter folder yet → False, no further GET/HEAD."""
|
||||
index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not published yet
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = _mock_response(text=index_html)
|
||||
|
||||
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result is False
|
||||
assert mock_client.get.call_count == 1 # never fetched a (non-existent) folder
|
||||
mock_client.head.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_available_false_dataset_file_missing_in_folder() -> None:
|
||||
"""Quarter folder exists but has no dataset_СДЕЛКИ file (only КАДАСТРСТОИМОСТЬ) → False."""
|
||||
index_html = _autoindex_html("1 квартал 2026г.")
|
||||
folder_html = _folder_html(_KADASTR_FILE_2026_Q1) # no СДЕЛКИ file
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = [
|
||||
_mock_response(text=index_html),
|
||||
_mock_response(text=folder_html),
|
||||
]
|
||||
|
||||
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result is False
|
||||
mock_client.head.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_available_false_on_index_404() -> None:
|
||||
"""Unexpected non-200 on the base autoindex → False, no raise."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = _mock_response(status_code=404, text="")
|
||||
|
||||
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_new_quarter_available_timeout_returns_false() -> None:
|
||||
"""Timeout → returns False, no exception."""
|
||||
import httpx
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = httpx.TimeoutException("timeout")
|
||||
|
||||
result = await check_new_quarter_available(mock_client, 2026, 2) # type: ignore[arg-type]
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_new_quarter_available_network_error_returns_false() -> None:
|
||||
"""Network error mid-flow (folder GET) → returns False, no exception."""
|
||||
import httpx
|
||||
|
||||
index_html = _autoindex_html("1 квартал 2026г.")
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = [
|
||||
_mock_response(text=index_html),
|
||||
httpx.ConnectError("simulated network error"),
|
||||
]
|
||||
|
||||
result = await check_new_quarter_available(mock_client, 2026, 1) # type: ignore[arg-type]
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (e) poll_rosreestr_new_quarter — orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _patched_client_for(index_html: str, folder_html: str, head_response) -> AsyncMock:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = [
|
||||
_mock_response(text=index_html),
|
||||
_mock_response(text=folder_html),
|
||||
]
|
||||
mock_client.head.return_value = head_response
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_available_true_returns_correct_next_quarter() -> None:
|
||||
"""If latest loaded is Q1 2026 and the Q2 2026 dataset is a real zip, available=True."""
|
||||
db = _FakeDB(date(2026, 3, 31)) # latest = Q1 2026 → next = Q2 2026
|
||||
|
||||
index_html = _autoindex_html("2 квартал 2026г.")
|
||||
file_name = "dataset_СДЕЛКИ_r-r_01-92_y_2026_q_2.csv.zip"
|
||||
folder_html = _folder_html(file_name)
|
||||
head_response = _mock_response(
|
||||
headers={"content-type": "application/zip", "content-length": "9500000"}
|
||||
)
|
||||
|
||||
mock_client = _patched_client_for(index_html, folder_html, head_response)
|
||||
|
||||
with patch("app.services.rosreestr_poll.httpx.AsyncClient", return_value=mock_client):
|
||||
result = await poll_rosreestr_new_quarter(db) # type: ignore[arg-type]
|
||||
|
|
@ -152,21 +374,15 @@ async def test_poll_available_true_returns_correct_next_quarter() -> None:
|
|||
assert result["latest_loaded_quarter"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (d) poll_rosreestr_new_quarter — available=False (404)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_available_false_on_404() -> None:
|
||||
"""404 from server → available=False."""
|
||||
db = _FakeDB(date(2026, 3, 15)) # Q1 2026
|
||||
async def test_poll_available_false_when_folder_not_published() -> None:
|
||||
"""Quarter folder not yet published (autoindex has no matching entry) → available=False."""
|
||||
db = _FakeDB(date(2026, 3, 15)) # Q1 2026 → next = Q2 2026
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
index_html = _autoindex_html("1 квартал 2026г.") # Q2 2026 missing
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.head.return_value = mock_response
|
||||
mock_client.get.return_value = _mock_response(text=index_html)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
|
|
@ -178,11 +394,6 @@ async def test_poll_available_false_on_404() -> None:
|
|||
assert result["quarter"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (e) Network error → available=False (never raises into caller)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_poll_network_error_returns_false_no_raise() -> None:
|
||||
"""Network error → available=False, no exception propagated."""
|
||||
|
|
@ -191,7 +402,7 @@ async def test_poll_network_error_returns_false_no_raise() -> None:
|
|||
db = _FakeDB(date(2026, 1, 20)) # Q1 2026
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.head.side_effect = httpx.ConnectError("simulated network error")
|
||||
mock_client.get.side_effect = httpx.ConnectError("simulated network error")
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
|
|
@ -202,18 +413,6 @@ async def test_poll_network_error_returns_false_no_raise() -> None:
|
|||
assert result["available"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_new_quarter_available_timeout_returns_false() -> None:
|
||||
"""Timeout → returns False, no exception."""
|
||||
import httpx
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.head.side_effect = httpx.TimeoutException("timeout")
|
||||
|
||||
result = await check_new_quarter_available(mock_client, 2026, 2) # type: ignore[arg-type]
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (f) No DB data → fallback baseline checks 2026Q1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -224,11 +423,10 @@ async def test_poll_fallback_when_no_db_data() -> None:
|
|||
"""No rosreestr deals in DB → fallback 2025Q4 baseline, checks 2026Q1."""
|
||||
db = _FakeDB(None)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404 # not yet available
|
||||
index_html = _autoindex_html("4 квартал 2025г.") # 2026 Q1 not yet published
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.head.return_value = mock_response
|
||||
mock_client.get.return_value = _mock_response(text=index_html)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
|
|
@ -238,6 +436,7 @@ async def test_poll_fallback_when_no_db_data() -> None:
|
|||
# Fallback: 2025Q4 → check 2026Q1
|
||||
assert result["year"] == 2026
|
||||
assert result["quarter"] == 1
|
||||
assert result["available"] is False
|
||||
# latest_loaded_year/quarter should be None (no DB data)
|
||||
assert result["latest_loaded_year"] is None
|
||||
assert result["latest_loaded_quarter"] is None
|
||||
|
|
|
|||
|
|
@ -426,7 +426,6 @@ def _run_estimate(
|
|||
*,
|
||||
anchor_comps: list[dict[str, Any]],
|
||||
anchor_tier: str | None,
|
||||
flag_enabled: bool = True,
|
||||
ratio_tuple: tuple[float | None, str | None] = (0.92, "per_rooms"),
|
||||
payload=None,
|
||||
radius_analogs: list[dict[str, Any]] | None = None,
|
||||
|
|
@ -439,7 +438,6 @@ def _run_estimate(
|
|||
ST_DWithin не нашёл радиусных аналогов (median=0) — якорь обязан сработать
|
||||
по same-building комплам независимо.
|
||||
"""
|
||||
from app.core.config import settings
|
||||
from app.services.estimator import estimate_quality
|
||||
|
||||
db = MagicMock()
|
||||
|
|
@ -448,7 +446,6 @@ def _run_estimate(
|
|||
|
||||
async def _run():
|
||||
with (
|
||||
patch.object(settings, "estimate_same_building_anchor_enabled", flag_enabled),
|
||||
patch(
|
||||
"app.services.estimator.geocode",
|
||||
new=AsyncMock(return_value=geo_result or _make_fake_geo()),
|
||||
|
|
@ -546,14 +543,6 @@ def test_estimate_economy_no_regression() -> None:
|
|||
assert est.median_price_per_m2 >= 100_000
|
||||
|
||||
|
||||
def test_estimate_flag_off_unchanged_radius_result() -> None:
|
||||
"""(e) Флаг OFF ⇒ headline = радиусная медиана (210k×146.2), якорь не трогает."""
|
||||
est_off = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A", flag_enabled=False)
|
||||
# Радиусная медиана из _RADIUS_ANALOGS = median(200,210,220)=210k.
|
||||
assert est_off.median_price_per_m2 == 210_000
|
||||
assert est_off.median_price_rub == int(210_000 * 146.2)
|
||||
|
||||
|
||||
def test_estimate_expected_sold_distinct_after_anchor() -> None:
|
||||
"""(f) При сработавшем якоре headline = ASKING (anchor_ppm2, pre-haircut), а
|
||||
expected_sold = headline × per-rooms ratio → DISTINCT, строго ниже median.
|
||||
|
|
|
|||
227
tradein-mvp/backend/tests/test_sber_freshness_monitor.py
Normal file
227
tradein-mvp/backend/tests/test_sber_freshness_monitor.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Freshness-монитор данных СберИндекса по max(period_month) — audit п.1.
|
||||
|
||||
Покрывает:
|
||||
1. Чистую логику evaluate_sber_freshness (frozen now, без БД):
|
||||
- fresh: age <= max_age_days (алерта нет);
|
||||
- stale: age > max_age_days (алерт);
|
||||
- граница порога (== max_age_days → нет алерта; +1 день → алерт).
|
||||
2. check_sber_freshness с FakeDB (fresh / stale / empty→mark_failed / кастомный lag).
|
||||
3. Свойства миграции 180 (по образцу test_deals_freshness_monitor).
|
||||
4. Регистрацию в kit product_handlers (registry остаётся зелёным).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.product_handlers import build_product_handlers
|
||||
from app.tasks import sber_freshness_monitor as mon
|
||||
|
||||
_SQL_DIR = Path(__file__).resolve().parents[1] / "data" / "sql"
|
||||
_MIGRATION_180 = _SQL_DIR / "180_seed_sber_freshness_monitor.sql"
|
||||
|
||||
# max(period_month) вторичного сегмента = 2026-05-01 (проверено на проде 2026-07-12).
|
||||
_MAY_2026 = date(2026, 5, 1)
|
||||
|
||||
|
||||
# ── evaluate_sber_freshness (чистая логика, frozen now) ───────────────────────
|
||||
|
||||
|
||||
def _now(y: int, m: int, d: int) -> datetime:
|
||||
return datetime(y, m, d, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_fresh_within_max_age() -> None:
|
||||
"""age=30 ≤ max_age_days=60 — свежий, алерта нет."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 5, 31), max_age_days=60)
|
||||
assert v.stale is False
|
||||
assert v.age_days == 30
|
||||
assert v.latest_period == _MAY_2026
|
||||
|
||||
|
||||
def test_stale_beyond_max_age() -> None:
|
||||
"""Прод-состояние: 2026-05-01 @ 2026-07-12 — age=72 > 60 → алерт."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 12), max_age_days=60)
|
||||
assert v.stale is True
|
||||
assert v.age_days == 72
|
||||
|
||||
|
||||
def test_threshold_boundary_exact_no_alert() -> None:
|
||||
"""Ровно на пороге (age == max_age_days) алерта ещё нет (строгое >)."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 6, 30), max_age_days=60)
|
||||
assert v.age_days == 60
|
||||
assert v.stale is False
|
||||
|
||||
|
||||
def test_threshold_boundary_next_day_alert() -> None:
|
||||
"""Порог + 1 день (age=61) — первый алерт."""
|
||||
v = mon.evaluate_sber_freshness(_MAY_2026, _now(2026, 7, 1), max_age_days=60)
|
||||
assert v.age_days == 61
|
||||
assert v.stale is True
|
||||
|
||||
|
||||
# ── check_sber_freshness (FakeDB) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class _Row:
|
||||
def __init__(self, latest: date | None) -> None:
|
||||
self.latest = latest
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, latest: date | None) -> None:
|
||||
self._latest = latest
|
||||
|
||||
def first(self) -> _Row:
|
||||
return _Row(self._latest)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, latest: date | None) -> None:
|
||||
self._latest = latest
|
||||
self.rolled_back = False
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||
return _FakeResult(self._latest)
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rolled_back = True
|
||||
|
||||
|
||||
def _patch_runs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
||||
calls: dict[str, Any] = {"done": None, "failed": None, "heartbeat": 0}
|
||||
monkeypatch.setattr(
|
||||
mon.runs_mod,
|
||||
"update_heartbeat",
|
||||
lambda *a, **k: calls.__setitem__("heartbeat", calls["heartbeat"] + 1),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mon.runs_mod,
|
||||
"mark_done",
|
||||
lambda _db, run_id, counters: calls.__setitem__("done", dict(counters)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mon.runs_mod,
|
||||
"mark_failed",
|
||||
lambda _db, run_id, err, counters: calls.__setitem__("failed", err),
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
def test_check_fresh_marks_done(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = _patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
# @2026-05-31: age=30 ≤ 35+25=60 → нет алерта.
|
||||
out = mon.check_sber_freshness(db, run_id=1, params={}, now=_now(2026, 5, 31)) # type: ignore[arg-type]
|
||||
assert out == {"latest_year": 2026, "latest_month": 5, "age_days": 30, "alert": 0}
|
||||
assert calls["done"] == out
|
||||
assert calls["failed"] is None
|
||||
|
||||
|
||||
def test_check_stale_marks_done_with_alert(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = _patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
# @2026-07-12 (прод): age=72 > 60 → алерт.
|
||||
out = mon.check_sber_freshness(db, run_id=2, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
|
||||
assert out["alert"] == 1
|
||||
assert out["age_days"] == 72
|
||||
assert out["latest_month"] == 5
|
||||
# Монитор НЕ падает при алерте — прогон done, а не failed.
|
||||
assert calls["done"] == out
|
||||
assert calls["failed"] is None
|
||||
|
||||
|
||||
def test_check_empty_index_marks_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls = _patch_runs(monkeypatch)
|
||||
db = _FakeDB(None)
|
||||
out = mon.check_sber_freshness(db, run_id=3, params={}, now=_now(2026, 7, 12)) # type: ignore[arg-type]
|
||||
assert out["alert"] == 0
|
||||
assert calls["done"] is None
|
||||
assert calls["failed"] is not None
|
||||
|
||||
|
||||
def test_check_reads_lag_from_params(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
# lag=0 → порог = sber_index_max_age_days (35) → @2026-07-12 (age=72) просрочено.
|
||||
out = mon.check_sber_freshness(
|
||||
db, run_id=4, params={"lag_allowance_days": 0}, now=_now(2026, 7, 12)
|
||||
) # type: ignore[arg-type]
|
||||
assert out["alert"] == 1
|
||||
|
||||
|
||||
def test_check_default_threshold_uses_setting_plus_lag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Дефолтный порог = sber_index_max_age_days + DEFAULT_LAG_ALLOWANCE_DAYS."""
|
||||
_patch_runs(monkeypatch)
|
||||
db = _FakeDB(_MAY_2026)
|
||||
threshold = settings.sber_index_max_age_days + mon.DEFAULT_LAG_ALLOWANCE_DAYS
|
||||
# Ровно на пороге (age == threshold) — алерта нет; +1 день — алерт.
|
||||
exact = datetime(2026, 5, 1, tzinfo=UTC) + timedelta(days=threshold)
|
||||
out_exact = mon.check_sber_freshness(db, run_id=5, params={}, now=exact) # type: ignore[arg-type]
|
||||
assert out_exact["age_days"] == threshold
|
||||
assert out_exact["alert"] == 0
|
||||
out_over = mon.check_sber_freshness(db, run_id=6, params={}, now=exact + timedelta(days=1)) # type: ignore[arg-type]
|
||||
assert out_over["alert"] == 1
|
||||
|
||||
|
||||
# ── Миграция 180 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_migration_180_exists() -> None:
|
||||
assert _MIGRATION_180.is_file(), f"missing migration: {_MIGRATION_180}"
|
||||
|
||||
|
||||
def test_migration_180_seeds_source() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert "'sber_freshness_monitor'" in sql
|
||||
|
||||
|
||||
def test_migration_180_enabled_true() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert "true" in sql
|
||||
|
||||
|
||||
def test_migration_180_is_idempotent() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert "ON CONFLICT (source) DO NOTHING" in sql
|
||||
|
||||
|
||||
def test_migration_180_is_transactional() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert "BEGIN;" in sql
|
||||
assert "COMMIT;" in sql
|
||||
|
||||
|
||||
def test_migration_180_window_9_to_10_utc() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert re.search(r"\b9\b", sql), "window_start_hour 9 missing"
|
||||
assert re.search(r"\b10\b", sql), "window_end_hour 10 missing"
|
||||
|
||||
|
||||
def test_migration_180_lag_allowance_25() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert "lag_allowance_days" in sql
|
||||
assert "25" in sql
|
||||
|
||||
|
||||
def test_migration_180_no_psycopg_trap() -> None:
|
||||
sql = _MIGRATION_180.read_text("utf-8")
|
||||
assert not re.search(r":\w+::", sql)
|
||||
|
||||
|
||||
# ── Регистрация в kit registry ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_kit_product_handler_registered() -> None:
|
||||
# ctx не нужен для сборки dict ключей — build_product_handlers его не замыкает.
|
||||
handlers = build_product_handlers(ctx=None) # type: ignore[arg-type]
|
||||
assert "sber_freshness_monitor" in handlers
|
||||
|
|
@ -28,11 +28,15 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
|
||||
|
||||
from scraper_kit.orchestration import scheduler as kit_sched
|
||||
from scraper_kit.orchestration.pipeline import CITY_ANCHORS
|
||||
from scraper_kit.orchestration.scheduler import (
|
||||
Handler,
|
||||
SchedulerContext,
|
||||
_claim_run,
|
||||
_dispatch,
|
||||
_job_avito_city_sweep,
|
||||
_job_cian_city_sweep,
|
||||
_job_yandex_city_sweep,
|
||||
build_registry,
|
||||
reap_zombies,
|
||||
reschedule_after_minutes,
|
||||
|
|
@ -74,14 +78,20 @@ _PRODUCT_SOURCES: set[str] = {
|
|||
"proxy_healthcheck",
|
||||
}
|
||||
|
||||
# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно
|
||||
# kit-native (тело в scraper_kit.orchestration.pipeline) — регистрируются встроенно.
|
||||
# "*_city_sweep_*" — wildcard-семья oblast B1 rollout (region 66, dormant, #179):
|
||||
# per-city source (avito_city_sweep_nizhniy_tagil и т.п.) резолвится на тот же
|
||||
# handler, что EKB source, через resolve_handler-префикс (см. _default_kit_handlers).
|
||||
_KIT_NATIVE_SOURCES = {
|
||||
"avito_city_sweep",
|
||||
"avito_city_sweep_*",
|
||||
"avito_full_load",
|
||||
"avito_full_load_exhaustive",
|
||||
"avito_newbuilding_sweep",
|
||||
"yandex_city_sweep",
|
||||
"yandex_city_sweep_*",
|
||||
"cian_city_sweep",
|
||||
"cian_city_sweep_*",
|
||||
"cian_full_load",
|
||||
"domclick_city_sweep",
|
||||
}
|
||||
|
|
@ -156,7 +166,10 @@ def test_real_build_product_handlers_covers_all_scheduled_sources() -> None:
|
|||
|
||||
|
||||
def test_kit_native_handler_set() -> None:
|
||||
"""Ровно 8 kit-native sweep-обработчиков зарегистрированы встроенно."""
|
||||
"""Ровно 11 kit-native sweep-обработчиков зарегистрированы встроенно (8 + 3 oblast
|
||||
|
||||
"*_city_sweep_*" wildcard, #179).
|
||||
"""
|
||||
registry = build_registry()
|
||||
assert set(registry) == _KIT_NATIVE_SOURCES
|
||||
for src in _KIT_NATIVE_SOURCES:
|
||||
|
|
@ -434,3 +447,87 @@ async def test_dispatch_post_claim_hook_runs() -> None:
|
|||
await asyncio.gather(*list(ctx._inflight_tasks))
|
||||
post.assert_called_once()
|
||||
assert post.call_args[0][1] == 300 # run_id
|
||||
|
||||
|
||||
# ── 6. B1 oblast rollout — city param → city anchors, wildcard dispatch ──────────
|
||||
|
||||
|
||||
def _oblast_ctx() -> SchedulerContext:
|
||||
return SchedulerContext(
|
||||
config=MagicMock(),
|
||||
matcher=MagicMock(),
|
||||
enrichment=MagicMock(),
|
||||
session_factory=MagicMock(),
|
||||
runs=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
async def test_job_avito_city_sweep_with_city_param_uses_city_anchors() -> None:
|
||||
"""params={"city": "nizhniy_tagil"} → Н.Тагил anchors переданы в run_avito_city_sweep."""
|
||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
||||
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nizhniy_tagil"}, _oblast_ctx())
|
||||
|
||||
mock_run.assert_awaited_once()
|
||||
_args, kwargs = mock_run.call_args
|
||||
assert kwargs["anchors"] == CITY_ANCHORS["nizhniy_tagil"]
|
||||
assert kwargs["anchors"] == [(57.910, 59.980, "Н.Тагил центр")]
|
||||
|
||||
|
||||
async def test_job_cian_city_sweep_with_city_param_uses_city_anchors() -> None:
|
||||
"""params={"city": "kamensk_uralskiy"} → anchors города переданы в run_cian_city_sweep."""
|
||||
with patch.object(kit_sched, "run_cian_city_sweep", AsyncMock()) as mock_run:
|
||||
await _job_cian_city_sweep(MagicMock(), 1, {"city": "kamensk_uralskiy"}, _oblast_ctx())
|
||||
|
||||
mock_run.assert_awaited_once()
|
||||
_args, kwargs = mock_run.call_args
|
||||
assert kwargs["anchors"] == CITY_ANCHORS["kamensk_uralskiy"]
|
||||
|
||||
|
||||
async def test_job_yandex_city_sweep_with_city_param_uses_city_anchors() -> None:
|
||||
"""params={"city": "serov"} → anchors города переданы в run_yandex_city_sweep."""
|
||||
with patch.object(kit_sched, "run_yandex_city_sweep", AsyncMock()) as mock_run:
|
||||
await _job_yandex_city_sweep(MagicMock(), 1, {"city": "serov"}, _oblast_ctx())
|
||||
|
||||
mock_run.assert_awaited_once()
|
||||
_args, kwargs = mock_run.call_args
|
||||
assert kwargs["anchors"] == CITY_ANCHORS["serov"]
|
||||
|
||||
|
||||
async def test_job_avito_city_sweep_without_city_param_falls_back_to_none() -> None:
|
||||
"""Без city (EKB-schedule, back-compat) — anchors=None передан (run_* сам берёт EKB_ANCHORS)."""
|
||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
||||
await _job_avito_city_sweep(MagicMock(), 1, {}, _oblast_ctx())
|
||||
|
||||
mock_run.assert_awaited_once()
|
||||
_args, kwargs = mock_run.call_args
|
||||
assert kwargs["anchors"] is None
|
||||
|
||||
|
||||
async def test_job_avito_city_sweep_unknown_city_falls_back_to_none() -> None:
|
||||
"""Неизвестный city slug (typo/будущий город без CITY_ANCHORS-записи) → anchors=None,
|
||||
|
||||
НЕ падает с KeyError.
|
||||
"""
|
||||
with patch.object(kit_sched, "run_avito_city_sweep", AsyncMock()) as mock_run:
|
||||
await _job_avito_city_sweep(MagicMock(), 1, {"city": "nonexistent_town"}, _oblast_ctx())
|
||||
|
||||
mock_run.assert_awaited_once()
|
||||
_args, kwargs = mock_run.call_args
|
||||
assert kwargs["anchors"] is None
|
||||
|
||||
|
||||
def test_wildcard_resolves_oblast_city_sweep_sources() -> None:
|
||||
"""resolve_handler матчит per-city oblast source на wildcard "*_city_sweep_*"."""
|
||||
registry = build_registry()
|
||||
assert (
|
||||
resolve_handler("avito_city_sweep_nizhniy_tagil", registry)
|
||||
is registry["avito_city_sweep_*"]
|
||||
)
|
||||
assert resolve_handler("cian_city_sweep_serov", registry) is registry["cian_city_sweep_*"]
|
||||
assert (
|
||||
resolve_handler("yandex_city_sweep_pervouralsk", registry)
|
||||
is registry["yandex_city_sweep_*"]
|
||||
)
|
||||
# EKB source (no city) — по-прежнему резолвится на exact non-wildcard entry.
|
||||
assert resolve_handler("avito_city_sweep", registry) is registry["avito_city_sweep"]
|
||||
assert registry["avito_city_sweep"] is not registry["avito_city_sweep_*"]
|
||||
|
|
|
|||
|
|
@ -67,16 +67,13 @@ def test_tier_a_anchor_gated_python_side_not_sql_guard() -> None:
|
|||
relaxation (впускает novostroyki только если в доме есть вторичка/NULL).
|
||||
|
||||
Assert: (a) Tier C всё ещё несёт канон-guard в SQL, (b) Tier A делает
|
||||
Python-side gating — источник содержит и флаг настройки, и hardened
|
||||
secondary_count/primary_count gate (#1774, после code-review).
|
||||
Python-side gating — источник содержит hardened secondary_count/primary_count
|
||||
gate (#1774, после code-review).
|
||||
"""
|
||||
src = inspect.getsource(est_mod._fetch_anchor_comps)
|
||||
# (a) Tier C SQL guard сохранён (поиск находит единственное вхождение — Tier C).
|
||||
assert _GUARD_RE.search(src), "Tier C anchor SQL lost the canonical novostroyki guard"
|
||||
# (b) Tier A теперь gated Python-side (#1774).
|
||||
assert (
|
||||
"estimate_sb_tier_a_allow_primary_if_secondary_present" in src
|
||||
), "Tier A no longer references the #1774 gating flag — gated relaxation missing"
|
||||
# Hardened gate: secondary_count ≥ primary_count (заменил has_secondary).
|
||||
assert "secondary_count" in src, "Tier A lacks secondary_count Python-side gate (#1774)"
|
||||
assert "primary_count" in src, "Tier A lacks primary_count comparison in hardened gate (#1774)"
|
||||
|
|
|
|||
104
tradein-mvp/backend/tests/test_serp_city_location_urls.py
Normal file
104
tradein-mvp/backend/tests/test_serp_city_location_urls.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Per-city SERP location URL builders (#12 fix EKB-leak в oblast city-sweep).
|
||||
|
||||
Проблема: avito/cian/yandex провайдеры хардкодили ЕКБ-локацию (путь /ekaterinburg/,
|
||||
CIAN_EKB_REGION_ID, _EKB_RGID) мимо anchor'а oblast-города → city-sweep целевого
|
||||
города (напр. Н.Тагил) фактически скрейпил ЕКБ, n_analogs=0. Фикс — новые
|
||||
kw-параметры (avito: target_city_slug уже был #2487, теперь и в ПУТИ URL;
|
||||
cian: city_region_id; yandex: city_rgid), все дефолт None → ЕКБ-поведение
|
||||
byte-identical прежнему (back-compat critical).
|
||||
|
||||
No network / no DB — чистые URL-билдеры на stub-конфиге.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from scraper_kit.providers.avito.serp import NOVOSTROYKA_SLUG, AvitoScraper
|
||||
from scraper_kit.providers.cian.serp import CIAN_EKB_REGION_ID, CianScraper
|
||||
from scraper_kit.providers.yandex.serp import _EKB_RGID, YandexRealtyScraper
|
||||
|
||||
_STUB_CFG = SimpleNamespace()
|
||||
|
||||
|
||||
def _query(url: str) -> dict[str, list[str]]:
|
||||
return parse_qs(urlparse(url).query)
|
||||
|
||||
|
||||
# ── Avito: city-slug в ПУТЬ URL (не только SERP-фильтр) ────────────────────────
|
||||
|
||||
|
||||
def test_avito_web_url_uses_target_city_slug_in_path() -> None:
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug="nizhniy_tagil") # type: ignore[arg-type]
|
||||
url = s._build_web_url(57.910, 59.980, radius_km=2, page=1)
|
||||
assert "/nizhniy_tagil/" in url
|
||||
assert "/ekaterinburg/" not in url
|
||||
|
||||
|
||||
def test_avito_citywide_url_uses_target_city_slug_in_path() -> None:
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug="kamensk-uralskiy") # type: ignore[arg-type]
|
||||
url = s._build_citywide_url(page=2)
|
||||
assert "/kamensk-uralskiy/" in url
|
||||
assert "/ekaterinburg/" not in url
|
||||
|
||||
|
||||
def test_avito_newbuilding_url_uses_target_city_slug_in_path() -> None:
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug="verhnyaya_pyshma") # type: ignore[arg-type]
|
||||
url = s._build_newbuilding_url(page=1)
|
||||
assert "/verhnyaya_pyshma/" in url
|
||||
assert "/ekaterinburg/" not in url
|
||||
assert NOVOSTROYKA_SLUG in url
|
||||
|
||||
|
||||
def test_avito_rooms_url_uses_target_city_slug_in_path() -> None:
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug="serov") # type: ignore[arg-type]
|
||||
url = s._build_rooms_url("2k", page=1)
|
||||
assert "/serov/" in url
|
||||
assert "/ekaterinburg/" not in url
|
||||
|
||||
|
||||
def test_avito_urls_default_to_ekaterinburg_when_no_target_city_slug() -> None:
|
||||
"""Back-compat: target_city_slug=None → путь /ekaterinburg/ (ЕКБ full_load/pipeline)."""
|
||||
s = AvitoScraper(_STUB_CFG, target_city_slug=None) # type: ignore[arg-type]
|
||||
assert "/ekaterinburg/" in s._build_web_url(56.84, 60.60, radius_km=1, page=1)
|
||||
assert "/ekaterinburg/" in s._build_citywide_url(page=1)
|
||||
assert "/ekaterinburg/" in s._build_newbuilding_url(page=1)
|
||||
assert "/ekaterinburg/" in s._build_rooms_url("2k", page=1)
|
||||
|
||||
|
||||
# ── Cian: city_region_id → region= (subdomain НЕ трогается) ────────────────────
|
||||
|
||||
|
||||
def test_cian_build_url_uses_city_region_id() -> None:
|
||||
s = CianScraper(_STUB_CFG, city_region_id=4886) # type: ignore[arg-type]
|
||||
url = s._build_url()
|
||||
assert _query(url)["region"] == ["4886"]
|
||||
assert urlparse(url).hostname == "ekb.cian.ru"
|
||||
|
||||
|
||||
def test_cian_build_url_defaults_to_ekb_region_when_no_city_region_id() -> None:
|
||||
"""Back-compat: city_region_id=None → region=CIAN_EKB_REGION_ID (4743)."""
|
||||
s = CianScraper(_STUB_CFG, city_region_id=None) # type: ignore[arg-type]
|
||||
url = s._build_url()
|
||||
assert _query(url)["region"] == [str(CIAN_EKB_REGION_ID)]
|
||||
assert urlparse(url).hostname == "ekb.cian.ru"
|
||||
|
||||
|
||||
# ── Yandex: city_rgid → rgid= ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_yandex_build_url_uses_city_rgid() -> None:
|
||||
s = YandexRealtyScraper(_STUB_CFG, city_rgid=328286) # type: ignore[arg-type]
|
||||
url = s._build_url()
|
||||
assert _query(url)["rgid"] == ["328286"]
|
||||
|
||||
|
||||
def test_yandex_build_url_defaults_to_ekb_rgid_when_no_city_rgid() -> None:
|
||||
"""Back-compat: city_rgid=None → rgid=_EKB_RGID (559132)."""
|
||||
s = YandexRealtyScraper(_STUB_CFG, city_rgid=None) # type: ignore[arg-type]
|
||||
url = s._build_url()
|
||||
assert _query(url)["rgid"] == [str(_EKB_RGID)]
|
||||
|
|
@ -105,12 +105,7 @@ def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
|
|||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
|
||||
|
|
@ -135,12 +130,7 @@ def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
|
|||
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
|
||||
assert tier is None
|
||||
|
|
@ -162,42 +152,13 @@ def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
|
|||
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
|
||||
assert tier is None
|
||||
assert comps == []
|
||||
|
||||
|
||||
def test_tier_a_flag_off_excludes_novostroyki_even_with_secondary() -> None:
|
||||
"""Flag OFF → оригинальное поведение #1186: novostroyki ВСЕГДА исключены в Tier A,
|
||||
даже если в доме есть вторичка. Остаются только 4 vtorichka/NULL."""
|
||||
rows = [
|
||||
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
|
||||
_row(source="avito", source_id="a2", listing_segment=None),
|
||||
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
|
||||
_row(source="yandex", source_id="y2", listing_segment=None),
|
||||
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
|
||||
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", False
|
||||
),
|
||||
):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# novostroyki отброшены → только 4 вторички.
|
||||
assert len(comps) == 4
|
||||
|
||||
|
||||
def test_tier_a_dedup_same_source_id_collapses() -> None:
|
||||
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
|
||||
одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу
|
||||
|
|
@ -216,12 +177,7 @@ def test_tier_a_dedup_same_source_id_collapses() -> None:
|
|||
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
|
||||
|
|
@ -257,12 +213,7 @@ def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
|
|||
),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# source_id-primary схлопывает несмотря на разные url → 5 comps.
|
||||
|
|
@ -281,12 +232,7 @@ def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
|
|||
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
|
||||
]
|
||||
db = _db_mock(rows)
|
||||
with (
|
||||
patch.object(est_mod.settings, "estimate_sb_min_comps", 4),
|
||||
patch.object(
|
||||
est_mod.settings, "estimate_sb_tier_a_allow_primary_if_secondary_present", True
|
||||
),
|
||||
):
|
||||
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
|
||||
comps, tier = _fetch(db)
|
||||
assert tier == "A"
|
||||
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
- happy path: consent=True + валидный телефон -> 200, INSERT + commit
|
||||
- consent=False -> 422 (Literal[True] guard)
|
||||
- невалидный формат телефона -> 422
|
||||
- телефон без цифр / слишком мало цифр -> 422 (digit-guard, #2376 hardening)
|
||||
- source="landing" (мёртвая воронка) -> 422 (литерал убран из схемы)
|
||||
- estimate_id, которого нет в trade_in_estimates -> 404
|
||||
"""
|
||||
|
||||
|
|
@ -53,12 +55,15 @@ def _insert_result(lead_id: str) -> MagicMock:
|
|||
|
||||
|
||||
def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
|
||||
from app.api.v1 import lead as lead_module
|
||||
|
||||
lead_id = str(uuid4())
|
||||
db.execute.return_value = _insert_result(lead_id)
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/trade-in/lead",
|
||||
json={"phone": "+7 (912) 345-67-89", "consent": True},
|
||||
headers={"x-forwarded-for": "203.0.113.7, 10.0.0.1"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
|
|
@ -71,6 +76,27 @@ def test_lead_happy_path(client: TestClient, db: MagicMock) -> None:
|
|||
assert params["consent"] is True
|
||||
assert params["source"] == "result"
|
||||
assert params["estimate_id"] is None
|
||||
# 152-ФЗ proof-of-consent (migration 182): client_ip / policy version / text
|
||||
# snapshot must reach the INSERT params, not just the audit log (#2497 TODO).
|
||||
assert params["client_ip"] == "203.0.113.7" # first hop of X-Forwarded-For
|
||||
assert params["consent_policy_version"] == lead_module._CONSENT_POLICY_VERSION
|
||||
assert params["consent_text_snapshot"] == lead_module._CONSENT_TEXT_SNAPSHOT
|
||||
|
||||
|
||||
def test_lead_client_ip_falls_back_to_peer_when_no_xff(client: TestClient, db: MagicMock) -> None:
|
||||
# No X-Forwarded-For header -> falls back to request.client.host (TestClient
|
||||
# reports "testclient"), still persisted rather than silently None.
|
||||
lead_id = str(uuid4())
|
||||
db.execute.return_value = _insert_result(lead_id)
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/trade-in/lead",
|
||||
json={"phone": "+79123456789", "consent": True},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
params = db.execute.call_args.args[1]
|
||||
assert params["client_ip"] == "testclient"
|
||||
|
||||
|
||||
def test_lead_consent_false_422(client: TestClient, db: MagicMock) -> None:
|
||||
|
|
@ -121,10 +147,41 @@ def test_lead_with_known_estimate_id_200(client: TestClient, db: MagicMock) -> N
|
|||
"phone": "+79123456789",
|
||||
"consent": True,
|
||||
"estimate_id": estimate_id,
|
||||
"source": "landing",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
params = db.execute.call_args.args[1]
|
||||
assert params["estimate_id"] == estimate_id
|
||||
assert params["source"] == "landing"
|
||||
assert params["source"] == "result"
|
||||
|
||||
|
||||
def test_lead_digit_free_phone_422(client: TestClient, db: MagicMock) -> None:
|
||||
# "(()) -- .." проходит regex-маску (только +/скобки/дефисы/точки/пробелы),
|
||||
# но содержит 0 цифр -> должно отклоняться digit-валидатором.
|
||||
r = client.post(
|
||||
"/api/v1/trade-in/lead",
|
||||
json={"phone": "(()) -- ..", "consent": True},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
assert not db.execute.called
|
||||
|
||||
|
||||
def test_lead_too_few_digits_phone_422(client: TestClient, db: MagicMock) -> None:
|
||||
# 6 цифр < минимума 10 -> 422 (маску проходит по длине, digit-guard режет).
|
||||
r = client.post(
|
||||
"/api/v1/trade-in/lead",
|
||||
json={"phone": "123-456", "consent": True},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
assert not db.execute.called
|
||||
|
||||
|
||||
def test_lead_source_landing_rejected_422(client: TestClient, db: MagicMock) -> None:
|
||||
# "landing"-воронка недостижима (rbac_guard закрывает /lead) — литерал убран
|
||||
# из схемы, поэтому явный source="landing" теперь 422.
|
||||
r = client.post(
|
||||
"/api/v1/trade-in/lead",
|
||||
json={"phone": "+79123456789", "consent": True, "source": "landing"},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
assert not db.execute.called
|
||||
|
|
|
|||
292
tradein-mvp/backend/tests/test_user_events.py
Normal file
292
tradein-mvp/backend/tests/test_user_events.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""Tests for app.services.user_events — record_event / schedule_event / should_log_login.
|
||||
|
||||
Coverage:
|
||||
(a) record_event inserts exactly one row via a mocked SessionLocal, commits, and the
|
||||
SQL uses CAST(:x AS type) (never the psycopg-v3-broken `:x::type` form).
|
||||
(b) record_event swallows exceptions — a DB failure (connect or execute) never raises.
|
||||
(c) schedule_event in a sync context (no running event loop) calls record_event inline.
|
||||
(d) schedule_event in an async context schedules a non-blocking background task that
|
||||
still calls through, and a failure inside it is logged, not raised.
|
||||
(e) should_log_login: True once per (user, ip, ua, day), False on repeat; independent
|
||||
per user / ip / user_agent; prunes the in-process set past capacity.
|
||||
(f) optional real-DB round trip against app.core.db.SessionLocal (the exact session
|
||||
record_event uses) — self-skips without a reachable, non-placeholder Postgres,
|
||||
mirroring tests/test_house_dedup_merge.py's live-DB pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
# psycopg v3 driver required; stub DATABASE_URL before any app import (mirrors other tests).
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import user_events as ue
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def fetchone(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""SessionLocal() stand-in supporting the `with SessionLocal() as db:` pattern."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.executed: list[tuple[str, dict[str, Any] | None]] = []
|
||||
self.committed = False
|
||||
|
||||
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||
self.executed.append((str(stmt), params))
|
||||
return _FakeResult()
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed = True
|
||||
|
||||
def __enter__(self) -> _FakeSession:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (a) record_event — successful insert
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_record_event_inserts_and_commits() -> None:
|
||||
fake = _FakeSession()
|
||||
with patch.object(ue, "SessionLocal", return_value=fake):
|
||||
ue.record_event(
|
||||
event_type="estimate_request",
|
||||
username="user1",
|
||||
ip="1.2.3.4",
|
||||
user_agent="pytest-agent",
|
||||
path="/api/v1/trade-in/estimate",
|
||||
method="POST",
|
||||
estimate_id="11111111-1111-1111-1111-111111111111",
|
||||
payload={"address": "ул. Ленина, 1", "area_m2": 50.0, "rooms": 2},
|
||||
)
|
||||
|
||||
assert fake.committed is True
|
||||
assert len(fake.executed) == 1
|
||||
sql, params = fake.executed[0]
|
||||
assert params is not None
|
||||
assert "INSERT INTO user_events" in sql
|
||||
assert params["event_type"] == "estimate_request"
|
||||
assert params["username"] == "user1"
|
||||
assert params["ip"] == "1.2.3.4"
|
||||
assert params["estimate_id"] == "11111111-1111-1111-1111-111111111111"
|
||||
# payload сериализован в JSON-строку (CAST(:payload AS jsonb) на стороне SQL).
|
||||
assert isinstance(params["payload"], str)
|
||||
assert "Ленина" in params["payload"]
|
||||
|
||||
|
||||
def test_record_event_cast_not_doublecolon() -> None:
|
||||
"""CAST(:x AS type) — НИКОГДА `:x::type` (psycopg v3 trap, .claude/rules/backend.md)."""
|
||||
fake = _FakeSession()
|
||||
with patch.object(ue, "SessionLocal", return_value=fake):
|
||||
ue.record_event(event_type="login", username="user1")
|
||||
|
||||
sql, _ = fake.executed[0]
|
||||
assert not re.search(r":\w+::\w", sql)
|
||||
assert "CAST(:ip AS inet)" in sql
|
||||
assert "CAST(:estimate_id AS uuid)" in sql
|
||||
assert "CAST(:payload AS jsonb)" in sql
|
||||
|
||||
|
||||
def test_record_event_default_payload_is_empty_dict() -> None:
|
||||
fake = _FakeSession()
|
||||
with patch.object(ue, "SessionLocal", return_value=fake):
|
||||
ue.record_event(event_type="login", username="user2")
|
||||
|
||||
_, params = fake.executed[0]
|
||||
assert params is not None
|
||||
assert params["payload"] == "{}"
|
||||
assert params["ip"] is None
|
||||
assert params["estimate_id"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (b) record_event never raises
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_record_event_swallows_session_open_errors() -> None:
|
||||
class _BrokenSession:
|
||||
def __enter__(self) -> _BrokenSession:
|
||||
raise RuntimeError("db unreachable")
|
||||
|
||||
def __exit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
with patch.object(ue, "SessionLocal", return_value=_BrokenSession()):
|
||||
# Should NOT raise.
|
||||
ue.record_event(event_type="login", username="user1")
|
||||
|
||||
|
||||
def test_record_event_swallows_execute_errors() -> None:
|
||||
class _ExplodingSession(_FakeSession):
|
||||
def execute(self, stmt: object, params: dict[str, Any] | None = None) -> _FakeResult:
|
||||
raise RuntimeError("syntax error at or near ':'")
|
||||
|
||||
with patch.object(ue, "SessionLocal", return_value=_ExplodingSession()):
|
||||
ue.record_event(event_type="login", username="user1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (c)/(d) schedule_event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_schedule_event_sync_context_calls_record_event_inline() -> None:
|
||||
"""No running loop (plain sync call, e.g. from a script/test) → inline call."""
|
||||
with patch.object(ue, "record_event") as mock_record:
|
||||
ue.schedule_event(event_type="login", username="user1", ip="9.9.9.9")
|
||||
|
||||
mock_record.assert_called_once_with(event_type="login", username="user1", ip="9.9.9.9")
|
||||
|
||||
|
||||
async def test_schedule_event_async_context_is_non_blocking_and_calls_through() -> None:
|
||||
"""Running loop present → backgrounded via asyncio.to_thread; does not block the
|
||||
calling coroutine and eventually calls record_event."""
|
||||
with patch.object(ue, "record_event") as mock_record:
|
||||
ue.schedule_event(event_type="api_request", username="user1", path="/api/v1/x")
|
||||
# schedule_event must return immediately without awaiting the background task.
|
||||
# Give the scheduled task a chance to run before asserting.
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
mock_record.assert_called_once_with(
|
||||
event_type="api_request", username="user1", path="/api/v1/x"
|
||||
)
|
||||
|
||||
|
||||
async def test_schedule_event_background_failure_is_logged_not_raised() -> None:
|
||||
"""A record_event failure inside the background task must not propagate/crash the
|
||||
caller — the done-callback swallows it (logs a warning)."""
|
||||
with patch.object(ue, "record_event", side_effect=RuntimeError("boom")):
|
||||
# Must not raise synchronously (the task runs in the background).
|
||||
ue.schedule_event(event_type="login", username="user1")
|
||||
await asyncio.sleep(0.05)
|
||||
# Reaching here without an unhandled-exception bubbling up confirms the
|
||||
# done-callback caught it.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (e) should_log_login dedup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_should_log_login_true_once_then_false_same_day() -> None:
|
||||
ue._LOGIN_DEDUP_SEEN.clear()
|
||||
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
|
||||
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is False
|
||||
|
||||
|
||||
def test_should_log_login_independent_per_ip() -> None:
|
||||
ue._LOGIN_DEDUP_SEEN.clear()
|
||||
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
|
||||
assert ue.should_log_login("alice", "2.2.2.2", "chrome") is True
|
||||
|
||||
|
||||
def test_should_log_login_independent_per_user() -> None:
|
||||
ue._LOGIN_DEDUP_SEEN.clear()
|
||||
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
|
||||
assert ue.should_log_login("bob", "1.1.1.1", "chrome") is True
|
||||
|
||||
|
||||
def test_should_log_login_independent_per_user_agent() -> None:
|
||||
ue._LOGIN_DEDUP_SEEN.clear()
|
||||
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
|
||||
assert ue.should_log_login("alice", "1.1.1.1", "firefox") is True
|
||||
|
||||
|
||||
def test_should_log_login_none_ip_and_ua_are_valid_keys() -> None:
|
||||
ue._LOGIN_DEDUP_SEEN.clear()
|
||||
assert ue.should_log_login("alice", None, None) is True
|
||||
assert ue.should_log_login("alice", None, None) is False
|
||||
|
||||
|
||||
def test_should_log_login_prunes_when_over_capacity() -> None:
|
||||
ue._LOGIN_DEDUP_SEEN.clear()
|
||||
for i in range(ue._LOGIN_DEDUP_MAX):
|
||||
ue._LOGIN_DEDUP_SEEN.add(f"filler-{i}")
|
||||
assert len(ue._LOGIN_DEDUP_SEEN) == ue._LOGIN_DEDUP_MAX
|
||||
|
||||
# Next call must prune (clear the whole set) before adding, not grow unbounded.
|
||||
assert ue.should_log_login("alice", "1.1.1.1", "chrome") is True
|
||||
assert len(ue._LOGIN_DEDUP_SEEN) == 1
|
||||
ue._LOGIN_DEDUP_SEEN.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# (f) optional real-DB round trip (self-skips without a reachable test DB)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _db_reachable() -> bool:
|
||||
"""True if app.core.db's engine (the one record_event actually uses) points at a
|
||||
real, non-placeholder Postgres and is reachable — mirrors the live-DB skip pattern
|
||||
in tests/test_house_dedup_merge.py."""
|
||||
dsn = os.environ.get("DATABASE_URL", "")
|
||||
if not dsn or "localhost:5432/test" in dsn:
|
||||
return False
|
||||
try:
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
from app.core.db import engine
|
||||
|
||||
with engine.connect() as conn:
|
||||
conn.execute(_t("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _db_reachable(), reason="no reachable Postgres test DB")
|
||||
def test_real_record_event_inserts_row() -> None:
|
||||
"""End-to-end on the real DB record_event() is bound to (app.core.db.SessionLocal):
|
||||
persists exactly the given fields and is queryable back."""
|
||||
from sqlalchemy import text as _t
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
ue.record_event(
|
||||
event_type="pytest_probe",
|
||||
username="pytest_user_events_test",
|
||||
ip="203.0.113.7",
|
||||
user_agent="pytest-real-db",
|
||||
path="/api/v1/trade-in/estimate",
|
||||
method="POST",
|
||||
payload={"probe": True},
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
try:
|
||||
row = db.execute(
|
||||
_t(
|
||||
"SELECT event_type, username, ip_address, user_agent, payload "
|
||||
"FROM user_events WHERE username = :u ORDER BY id DESC LIMIT 1"
|
||||
),
|
||||
{"u": "pytest_user_events_test"},
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row.event_type == "pytest_probe"
|
||||
assert str(row.ip_address) == "203.0.113.7"
|
||||
assert row.payload == {"probe": True}
|
||||
finally:
|
||||
db.execute(
|
||||
_t("DELETE FROM user_events WHERE username = :u"),
|
||||
{"u": "pytest_user_events_test"},
|
||||
)
|
||||
db.commit()
|
||||
|
|
@ -27,7 +27,8 @@ docker exec "$DST_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -c "
|
|||
-- запуск упал между CREATE и финальным DROP под старым кодом (без source_id).
|
||||
DROP TABLE IF EXISTS deals_ros_staging;
|
||||
CREATE TABLE deals_ros_staging (
|
||||
dedup_hash text PRIMARY KEY, source_id text, address text, rooms int, area_m2 numeric,
|
||||
dedup_hash text PRIMARY KEY, source_id text, address text, region_code int, city text,
|
||||
rooms int, area_m2 numeric,
|
||||
floor int, year_built int, price_rub bigint, price_per_m2 int, deal_date date
|
||||
);
|
||||
"
|
||||
|
|
@ -38,7 +39,9 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
|
|||
SELECT
|
||||
'ros:dkp:' || id::text AS dedup_hash,
|
||||
id::text AS source_id,
|
||||
'Екатеринбург, ' || trim(street) AS address,
|
||||
trim(city) || ', ' || trim(street) AS address,
|
||||
region_code AS region_code,
|
||||
trim(city) AS city,
|
||||
CASE
|
||||
WHEN area < 30 THEN 0 WHEN area < 44 THEN 1
|
||||
WHEN area < 62 THEN 2 WHEN area < 85 THEN 3
|
||||
|
|
@ -51,7 +54,7 @@ docker exec "$SRC_PG" psql -U "$SRC_USER" -d "$SRC_DB" -v ON_ERROR_STOP=on -c "
|
|||
period_start_date AS deal_date
|
||||
FROM rosreestr_deals
|
||||
WHERE region_code = 66
|
||||
AND city ILIKE '%катеринбург%'
|
||||
AND city IS NOT NULL AND trim(city) <> ''
|
||||
AND realestate_type_code = '002001003000'
|
||||
AND area BETWEEN 18 AND 200
|
||||
AND deal_price BETWEEN 1000000 AND 100000000
|
||||
|
|
@ -71,10 +74,10 @@ docker exec "$DST_PG" psql -U tradein -d tradein -v ON_ERROR_STOP=on -c "
|
|||
DELETE FROM deals WHERE address = 'Екатеринбург, реальная сделка';
|
||||
|
||||
INSERT INTO deals (
|
||||
source, dedup_hash, source_id, address, rooms, area_m2, floor,
|
||||
source, dedup_hash, source_id, address, region_code, city, rooms, area_m2, floor,
|
||||
year_built, price_rub, price_per_m2, deal_date
|
||||
)
|
||||
SELECT 'rosreestr', dedup_hash, source_id, address, rooms, area_m2, floor,
|
||||
SELECT 'rosreestr', dedup_hash, source_id, address, region_code, city, rooms, area_m2, floor,
|
||||
year_built, price_rub, price_per_m2, deal_date
|
||||
FROM deals_ros_staging
|
||||
ON CONFLICT (dedup_hash) DO NOTHING;
|
||||
|
|
|
|||
252
tradein-mvp/frontend/src/app/admin/analytics/page.tsx
Normal file
252
tradein-mvp/frontend/src/app/admin/analytics/page.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Активность (Feature 3) — поведенческая аналитика по user_events: сколько
|
||||
* событий, кто активен, что чаще ищут/дёргают. Названо «Активность», не
|
||||
* «Аналитика» — этот label уже занят market-analytics overlay (АНАЛИТИКА ДОМА,
|
||||
* v2/AnalyticsView.tsx) внутри самой оценки, здесь другой домен (юзер-активность).
|
||||
*
|
||||
* Admin-only — тот же паттерн gating, что и /admin/audit (см. комментарий там).
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import "@/components/trade-in/trade-in.css";
|
||||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||||
import { Topbar } from "@/components/trade-in/Topbar";
|
||||
import { HTTPError } from "@/lib/api";
|
||||
import { useUserAnalytics } from "@/lib/admin-audit-api";
|
||||
|
||||
const DAYS_OPTIONS = [7, 30, 90] as const;
|
||||
type DaysOption = (typeof DAYS_OPTIONS)[number];
|
||||
|
||||
function formatDay(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function KpiCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="admin-kpi-card">
|
||||
<div className="admin-kpi-label">{label}</div>
|
||||
<div className="admin-kpi-value">{value.toLocaleString("ru-RU")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DailyChartPoint {
|
||||
day: string;
|
||||
events: number;
|
||||
users: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export default function AdminAnalyticsPage() {
|
||||
const [days, setDays] = useState<DaysOption>(30);
|
||||
const analyticsQ = useUserAnalytics(days);
|
||||
|
||||
const chartData = useMemo<DailyChartPoint[]>(
|
||||
() => (analyticsQ.data?.daily ?? []).map((p) => ({ ...p, label: formatDay(p.day) })),
|
||||
[analyticsQ.data],
|
||||
);
|
||||
|
||||
if (analyticsQ.error instanceof HTTPError && analyticsQ.error.status === 403) {
|
||||
return <NoAccessScreen variant="user" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar active="analytics" />
|
||||
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
|
||||
<h1 className="scraper-h1">Активность</h1>
|
||||
<p className="scraper-subtitle">
|
||||
Поведенческая аналитика по всем аккаунтам: события, активные
|
||||
пользователи, частые поиски и API-пути.
|
||||
</p>
|
||||
|
||||
<div className="admin-days-filter">
|
||||
{DAYS_OPTIONS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={days === d ? "is-active" : undefined}
|
||||
onClick={() => setDays(d)}
|
||||
>
|
||||
{d} дн.
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{analyticsQ.isPending && <p className="scraper-hint">Загрузка…</p>}
|
||||
{analyticsQ.isError && (
|
||||
<p className="scraper-result scraper-result--error">
|
||||
Ошибка загрузки: {analyticsQ.error.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{analyticsQ.data && (
|
||||
<>
|
||||
<div className="admin-kpi-grid">
|
||||
<KpiCard label="Всего событий" value={analyticsQ.data.summary.total_events} />
|
||||
<KpiCard
|
||||
label="Уникальных пользователей"
|
||||
value={analyticsQ.data.summary.distinct_users}
|
||||
/>
|
||||
<KpiCard label="События за 24ч" value={analyticsQ.data.summary.events_last_24h} />
|
||||
<KpiCard
|
||||
label="Активны за 24ч"
|
||||
value={analyticsQ.data.summary.active_users_last_24h}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="scraper-section">
|
||||
<h2>Динамика по дням</h2>
|
||||
<p className="scraper-hint">События и уникальные пользователи в день.</p>
|
||||
{chartData.length < 2 ? (
|
||||
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||
Недостаточно данных для графика — нужно минимум 2 дня наблюдений.
|
||||
</p>
|
||||
) : (
|
||||
<div aria-hidden="true" style={{ width: "100%", height: 260 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={chartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
|
||||
<CartesianGrid stroke="var(--border-card, #e6e8ec)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip labelFormatter={(label: string) => label} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="events"
|
||||
stroke="var(--accent, #1d4ed8)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2 }}
|
||||
name="События"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="users"
|
||||
stroke="var(--success, #0a7a3a)"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2 }}
|
||||
name="Пользователи"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="admin-two-col">
|
||||
<section className="scraper-section">
|
||||
<h2>Топ поисковых адресов</h2>
|
||||
{analyticsQ.data.top_searches.length === 0 ? (
|
||||
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||
Пока нет данных.
|
||||
</p>
|
||||
) : (
|
||||
<table className="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Адрес</th>
|
||||
<th scope="col">Поисков</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analyticsQ.data.top_searches.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{row.address ?? "—"}</td>
|
||||
<td>{row.n}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="scraper-section">
|
||||
<h2>Топ API-путей</h2>
|
||||
{analyticsQ.data.top_paths.length === 0 ? (
|
||||
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||
Пока нет данных.
|
||||
</p>
|
||||
) : (
|
||||
<table className="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Путь</th>
|
||||
<th scope="col">Запросов</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analyticsQ.data.top_paths.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{row.path ?? "—"}</td>
|
||||
<td>{row.n}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="scraper-section">
|
||||
<h2>Активность по аккаунтам</h2>
|
||||
{analyticsQ.data.by_account.length === 0 ? (
|
||||
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||
Пока нет данных.
|
||||
</p>
|
||||
) : (
|
||||
<table className="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Аккаунт</th>
|
||||
<th scope="col">События</th>
|
||||
<th scope="col">Поиски</th>
|
||||
<th scope="col">Последняя активность</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{analyticsQ.data.by_account.map((row) => (
|
||||
<tr key={row.username}>
|
||||
<td>{row.username}</td>
|
||||
<td>{row.events}</td>
|
||||
<td>{row.searches}</td>
|
||||
<td className="run-muted">{formatDateTime(row.last_seen)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
248
tradein-mvp/frontend/src/app/admin/audit/page.tsx
Normal file
248
tradein-mvp/frontend/src/app/admin/audit/page.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Аудит доступа (Feature 2) — с каких IP/устройств заходят пилотные аккаунты и
|
||||
* что они ищут. Admin-only: nav entry в Topbar скрыт для не-admin ролей (see
|
||||
* NAV_ITEMS scopePath), backend 403-ит non-admin регардлесс — обрабатываем
|
||||
* это здесь, а не крашимся (RouteGuard сам по себе НЕ блокирует прямой заход
|
||||
* на /admin/audit для pilot, см. комментарий в Topbar.tsx про /scrapers —
|
||||
* тот же паттерн).
|
||||
*/
|
||||
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
|
||||
import "@/components/trade-in/trade-in.css";
|
||||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||||
import { Topbar } from "@/components/trade-in/Topbar";
|
||||
import { HTTPError } from "@/lib/api";
|
||||
import {
|
||||
useAccountDrilldown,
|
||||
useAuditAccounts,
|
||||
type AccountDrilldown,
|
||||
} from "@/lib/admin-audit-api";
|
||||
|
||||
function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function DrilldownSection<T>({
|
||||
title,
|
||||
rows,
|
||||
render,
|
||||
}: {
|
||||
title: string;
|
||||
rows: T[];
|
||||
render: (row: T, i: number) => ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h4>
|
||||
{title} ({rows.length})
|
||||
</h4>
|
||||
{rows.length === 0 ? (
|
||||
<p className="scraper-hint" style={{ margin: 0 }}>
|
||||
Нет данных.
|
||||
</p>
|
||||
) : (
|
||||
<table className="runs-table">
|
||||
<tbody>{rows.map(render)}</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountDrilldownPanel({ username }: { username: string }) {
|
||||
const drilldownQ = useAccountDrilldown(username);
|
||||
|
||||
if (drilldownQ.isError) {
|
||||
return (
|
||||
<p className="scraper-result scraper-result--error" style={{ margin: "12px 6px" }}>
|
||||
Не удалось загрузить детали аккаунта: {drilldownQ.error.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const d: AccountDrilldown | undefined = drilldownQ.data;
|
||||
if (!d) {
|
||||
return (
|
||||
<p className="scraper-hint" style={{ margin: "12px 6px" }}>
|
||||
Загрузка деталей…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyData =
|
||||
d.ips.length > 0 ||
|
||||
d.devices.length > 0 ||
|
||||
d.searches.length > 0 ||
|
||||
d.recent_activity.length > 0;
|
||||
|
||||
if (!hasAnyData) {
|
||||
return (
|
||||
<p className="scraper-hint" style={{ margin: "12px 6px" }}>
|
||||
Пока нет данных по этому аккаунту.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-drilldown">
|
||||
<DrilldownSection
|
||||
title="IP-адреса"
|
||||
rows={d.ips}
|
||||
render={(row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{row.ip_address ?? "—"}</td>
|
||||
<td className="run-muted">{row.event_count} событ.</td>
|
||||
<td className="run-muted">
|
||||
{formatDateTime(row.first_seen)} → {formatDateTime(row.last_seen)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
/>
|
||||
<DrilldownSection
|
||||
title="Устройства"
|
||||
rows={d.devices}
|
||||
render={(row, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{row.user_agent ?? "—"}
|
||||
</td>
|
||||
<td className="run-muted">{row.event_count} событ.</td>
|
||||
<td className="run-muted">{formatDateTime(row.last_seen)}</td>
|
||||
</tr>
|
||||
)}
|
||||
/>
|
||||
<DrilldownSection
|
||||
title="Поиски"
|
||||
rows={d.searches}
|
||||
render={(row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{row.address ?? "—"}</td>
|
||||
<td className="run-muted">
|
||||
{row.rooms ?? "?"}к · {row.area_m2 ?? "?"} м²
|
||||
</td>
|
||||
<td className="run-muted">
|
||||
{formatDateTime(row.created_at)} · {row.ip_address ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
/>
|
||||
<DrilldownSection
|
||||
title="Недавняя активность"
|
||||
rows={d.recent_activity}
|
||||
render={(row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{row.event_type}</td>
|
||||
<td className="run-muted">
|
||||
{row.method ?? ""} {row.path ?? "—"}
|
||||
</td>
|
||||
<td className="run-muted">{formatDateTime(row.created_at)}</td>
|
||||
</tr>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminAuditPage() {
|
||||
const accountsQ = useAuditAccounts();
|
||||
const [expandedUser, setExpandedUser] = useState<string | null>(null);
|
||||
|
||||
// Backend 403-ит non-admin регардлесс (nav уже прячет ссылку) — прямой заход
|
||||
// на URL не должен крашить страницу, показываем тот же экран, что RouteGuard
|
||||
// рендерит для path-deny.
|
||||
if (accountsQ.error instanceof HTTPError && accountsQ.error.status === 403) {
|
||||
return <NoAccessScreen variant="user" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Topbar active="audit" />
|
||||
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
|
||||
<h1 className="scraper-h1">Аудит доступа</h1>
|
||||
<p className="scraper-subtitle">
|
||||
С каких IP-адресов и устройств заходят аккаунты и что они ищут. Клик по
|
||||
аккаунту раскрывает детали.
|
||||
</p>
|
||||
|
||||
<section className="scraper-section">
|
||||
{accountsQ.isPending && <p className="scraper-hint">Загрузка…</p>}
|
||||
{accountsQ.isError && (
|
||||
<p className="scraper-result scraper-result--error">
|
||||
Ошибка загрузки: {accountsQ.error.message}
|
||||
</p>
|
||||
)}
|
||||
{accountsQ.data && accountsQ.data.length === 0 && (
|
||||
<p className="scraper-hint">
|
||||
Пока нет данных — событий аудита ещё не собрано.
|
||||
</p>
|
||||
)}
|
||||
{accountsQ.data && accountsQ.data.length > 0 && (
|
||||
<table className="runs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Аккаунт</th>
|
||||
<th scope="col">Последний визит</th>
|
||||
<th scope="col">Первый визит</th>
|
||||
<th scope="col">IP</th>
|
||||
<th scope="col">Устройства</th>
|
||||
<th scope="col">Входы</th>
|
||||
<th scope="col">Запросы</th>
|
||||
<th scope="col">Поиски</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{accountsQ.data.map((a) => {
|
||||
const isOpen = expandedUser === a.username;
|
||||
return (
|
||||
<Fragment key={a.username}>
|
||||
<tr>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-row-toggle"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setExpandedUser(isOpen ? null : a.username)}
|
||||
>
|
||||
{isOpen ? "▾" : "▸"} {a.username}
|
||||
</button>
|
||||
</td>
|
||||
<td className="run-muted">{formatDateTime(a.last_seen_at)}</td>
|
||||
<td className="run-muted">{formatDateTime(a.first_seen_at)}</td>
|
||||
<td>{a.distinct_ips}</td>
|
||||
<td>{a.distinct_devices}</td>
|
||||
<td>{a.login_count}</td>
|
||||
<td>{a.request_count}</td>
|
||||
<td>{a.search_count}</td>
|
||||
</tr>
|
||||
{isOpen && (
|
||||
<tr>
|
||||
<td colSpan={8} style={{ background: "var(--bg-card-alt, #fafbfc)" }}>
|
||||
<AccountDrilldownPanel username={a.username} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -448,10 +448,17 @@ export default function TradeInV2Page() {
|
|||
// The rest of the dashboard (ParamsPanel / ResultPanel / overlays) stays
|
||||
// inside the scaled artboard — smaller on phones, but no longer overflowing.
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
// Below this width the fixed 1536px artboard scales down to an unreadable
|
||||
// ~25% and there is no responsive layout yet. Rather than ship that broken
|
||||
// scaled-down HUD, render an honest "open on desktop" notice instead (see the
|
||||
// early return below). Starts false so SSR + first client render match the
|
||||
// desktop tree; the effect flips it post-hydration → no hydration mismatch.
|
||||
const [isSmallViewport, setIsSmallViewport] = useState(false);
|
||||
useEffect(() => {
|
||||
const compute = () => {
|
||||
setArtboardScale(Math.min(1, window.innerWidth / 1536));
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
setIsSmallViewport(window.innerWidth < 1024);
|
||||
};
|
||||
compute();
|
||||
window.addEventListener("resize", compute);
|
||||
|
|
@ -766,6 +773,48 @@ export default function TradeInV2Page() {
|
|||
statusMessage = "Оценка готова";
|
||||
}
|
||||
|
||||
// Small-viewport guard: the HUD is a fixed 1536px artboard with no responsive
|
||||
// layout — under ~1024px it scales to an unreadable ~25%. Show an honest
|
||||
// desktop-only notice instead of the broken scaled-down copy. Placed after all
|
||||
// hooks so the early return never changes hook order. Desktop (≥1024) is
|
||||
// untouched.
|
||||
if (isSmallViewport) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
padding: "40px 24px",
|
||||
boxSizing: "border-box",
|
||||
gap: 14,
|
||||
fontFamily: tokens.font.sans,
|
||||
color: tokens.ink,
|
||||
background: tokens.gradientBg,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0, letterSpacing: 0.3 }}>
|
||||
Мера оптимизирована для десктопа
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
lineHeight: 1.6,
|
||||
margin: 0,
|
||||
maxWidth: 360,
|
||||
color: tokens.body,
|
||||
}}
|
||||
>
|
||||
Откройте оценку на компьютере или расширьте окно браузера до 1024 px
|
||||
и шире — на узком экране интерфейс отчёта нечитаем.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -974,6 +1023,7 @@ export default function TradeInV2Page() {
|
|||
analytics={analyticsViewData}
|
||||
sources={sourcesData}
|
||||
cache={cacheData}
|
||||
estimate={estimate}
|
||||
onSelectEstimate={(id) => {
|
||||
// #2420 — same ?id= restore-by-id shape as handleSubmit/handleNew
|
||||
// above (line ~649); this file uses replace() consistently for
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
import { logout } from "@/lib/logout";
|
||||
|
||||
interface NoAccessScreenProps {
|
||||
variant: "user" | "path" | "session" | "trial";
|
||||
variant: "user" | "path" | "session" | "trial" | "error";
|
||||
path?: string;
|
||||
}
|
||||
|
||||
|
|
@ -22,16 +22,20 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
|
|||
? "Сессия истекла"
|
||||
: variant === "trial"
|
||||
? "Пробный доступ закончился"
|
||||
: "Доступа нет";
|
||||
: variant === "error"
|
||||
? "Не удалось проверить доступ"
|
||||
: "Доступа нет";
|
||||
|
||||
const subtitle =
|
||||
variant === "session"
|
||||
? "Сессия истекла или вы не авторизованы. Обновите страницу, чтобы войти снова."
|
||||
: variant === "user"
|
||||
? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov."
|
||||
: variant === "path"
|
||||
? "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору."
|
||||
: null;
|
||||
? "Сессия истекла или вы не авторизованы. Нажмите «Войти снова», чтобы пройти авторизацию заново."
|
||||
: variant === "error"
|
||||
? "Возникла техническая проблема при проверке доступа. Проверьте подключение к интернету и обновите страницу. Если ошибка повторяется — обратитесь к администратору GenDesign."
|
||||
: variant === "user"
|
||||
? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov."
|
||||
: variant === "path"
|
||||
? "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main
|
||||
|
|
@ -118,26 +122,51 @@ export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
|
|||
{path}
|
||||
</p>
|
||||
) : null}
|
||||
{/* Always-available logout — иначе locked/no-access юзер заперт:
|
||||
UserMenu (где обычно «Выйти») на этом экране не рендерится. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout()}
|
||||
style={{
|
||||
marginTop: 20,
|
||||
background: "var(--accent)",
|
||||
color: "#FFFFFF",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
padding: "10px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
{/* Always-available primary action — иначе locked/no-access юзер заперт:
|
||||
UserMenu (где обычно «Выйти») на этом экране не рендерится.
|
||||
session/error — сбой временный (истёкшая сессия / сетевой сбой),
|
||||
поэтому CTA — повторить попытку через reload, а не logout.
|
||||
basic_auth Caddy: reload на top-level navigation триггерит
|
||||
повторный auth-challenge браузера при отклонённых creds. */}
|
||||
{variant === "session" || variant === "error" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
marginTop: 20,
|
||||
background: "var(--accent)",
|
||||
color: "#FFFFFF",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
padding: "10px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{variant === "session" ? "Войти снова" : "Обновить страницу"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => logout()}
|
||||
style={{
|
||||
marginTop: 20,
|
||||
background: "var(--accent)",
|
||||
color: "#FFFFFF",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
padding: "10px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -58,7 +58,10 @@ export function RouteGuard({ children }: RouteGuardProps) {
|
|||
}
|
||||
|
||||
if (error) {
|
||||
return <NoAccessScreen variant="user" />;
|
||||
// Не 401/403 — 500 / сетевой сбой / таймаут. НЕ переиспользуем variant="user"
|
||||
// (текст «аккаунт не привязан к роли» вводит в заблуждение при техническом
|
||||
// сбое) — отдельный экран с честной формулировкой + reload CTA.
|
||||
return <NoAccessScreen variant="error" />;
|
||||
}
|
||||
|
||||
if (!data) return null;
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@
|
|||
* покажется заново.
|
||||
*/
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { API_BASE_URL } from "@/lib/api";
|
||||
import { logout } from "@/lib/logout";
|
||||
|
||||
import { useMe } from "@/lib/useMe";
|
||||
|
|
@ -46,6 +48,69 @@ function LogOutIcon() {
|
|||
);
|
||||
}
|
||||
|
||||
/** Inline Shield icon (lucide-react `Shield` SVG path, stroke 1.5) — для
|
||||
admin-ссылки «Аудит доступа». Та же inline-SVG конвенция, что LogOutIcon
|
||||
(tradein-mvp не тянет lucide-react в deps). */
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline BarChart icon (lucide-react `BarChart3` SVG path, stroke 1.5) —
|
||||
для admin-ссылки «Активность». */
|
||||
function BarChartIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 3v16a2 2 0 0 0 2 2h16" />
|
||||
<path d="M18 17V9" />
|
||||
<path d="M13 17V5" />
|
||||
<path d="M8 17v-3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Общий inline-стиль для admin-nav-пунктов дропдауна (Аудит/Активность) —
|
||||
тот же паттерн, что «Выйти», но нейтральный цвет вместо var(--danger). */
|
||||
const adminMenuItemStyle: CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: "4px 0",
|
||||
cursor: "pointer",
|
||||
color: "var(--fg-primary)",
|
||||
fontWeight: 500,
|
||||
fontSize: 14,
|
||||
textAlign: "left",
|
||||
fontFamily: "inherit",
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
export function UserMenu() {
|
||||
const { data, isLoading, error } = useMe();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -159,6 +224,35 @@ export function UserMenu() {
|
|||
>
|
||||
{roleLabel}
|
||||
</div>
|
||||
{data.role === "admin" && (
|
||||
<>
|
||||
<hr
|
||||
style={{
|
||||
margin: "12px 0",
|
||||
border: "none",
|
||||
borderTop: "1px solid var(--border-soft)",
|
||||
}}
|
||||
/>
|
||||
<a
|
||||
role="menuitem"
|
||||
href={`${API_BASE_URL}/admin/audit`}
|
||||
onClick={() => setOpen(false)}
|
||||
style={adminMenuItemStyle}
|
||||
>
|
||||
<ShieldIcon />
|
||||
Аудит доступа
|
||||
</a>
|
||||
<a
|
||||
role="menuitem"
|
||||
href={`${API_BASE_URL}/admin/analytics`}
|
||||
onClick={() => setOpen(false)}
|
||||
style={{ ...adminMenuItemStyle, marginTop: 4 }}
|
||||
>
|
||||
<BarChartIcon />
|
||||
Активность
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<hr
|
||||
style={{
|
||||
margin: "12px 0",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,58 @@ import { safeUrl } from "@/lib/safeUrl";
|
|||
import { useBrand } from "@/lib/useBrand";
|
||||
import { useMe } from "@/lib/useMe";
|
||||
|
||||
/** Telegram-канал обратной связи для пилота. Build-time env — пусто до тех
|
||||
пор, пока devops не настроит реальную ссылку (см. FeedbackButton ниже). */
|
||||
const FEEDBACK_TG_URL = process.env.NEXT_PUBLIC_FEEDBACK_TG_URL ?? "";
|
||||
|
||||
/** Inline Send icon (lucide-react `Send` SVG path, stroke 1.5) — та же
|
||||
конвенция, что и LogOutIcon в UserMenu.tsx: tradein-mvp не тянет
|
||||
lucide-react в deps, поэтому иконка — inline SVG. */
|
||||
function TelegramIcon() {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка обратной связи в Telegram — рендерится для ВСЕХ авторизованных
|
||||
* пользователей (не зависит от RBAC allowed_paths, в отличие от NAV_ITEMS).
|
||||
*
|
||||
* Env-gated: пока `NEXT_PUBLIC_FEEDBACK_TG_URL` не задан на билде, кнопка не
|
||||
* рендерится вообще — безопасно шипить до появления реальной Telegram-ссылки.
|
||||
* safeUrl() дополнительно блокирует не-http(s) схемы (#766-class XSS).
|
||||
*/
|
||||
function FeedbackButton() {
|
||||
const href = safeUrl(FEEDBACK_TG_URL);
|
||||
if (!href) return null;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="top-cta"
|
||||
aria-label="Обратная связь в Telegram"
|
||||
>
|
||||
<TelegramIcon />
|
||||
Обратная связь
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** «Мера» mark — буква М над размерной линией («мера» = измерение).
|
||||
Глиф в currentColor; тёмный фон даёт CSS-бейдж .brand-mark. */
|
||||
function MeraMark() {
|
||||
|
|
@ -30,7 +82,9 @@ export type ActiveTab =
|
|||
| "scrapers"
|
||||
| "avito"
|
||||
| "cian"
|
||||
| "yandex";
|
||||
| "yandex"
|
||||
| "audit"
|
||||
| "analytics";
|
||||
|
||||
interface TopbarProps {
|
||||
active: ActiveTab;
|
||||
|
|
@ -93,6 +147,21 @@ const NAV_ITEMS: Array<{
|
|||
scopePath: "/trade-in/api/v1/admin/scrapers/yandex",
|
||||
label: "Яндекс",
|
||||
},
|
||||
// Аудит доступа (Feature 2) + Активность (Feature 3) — admin-only read API
|
||||
// над user_events (см. app/api/v1/audit.py). Тот же admin-scopePath паттерн,
|
||||
// что и скрапперы выше: pilot.deny покрывает /trade-in/api/v1/admin/**.
|
||||
{
|
||||
key: "audit",
|
||||
href: "/admin/audit",
|
||||
scopePath: "/trade-in/api/v1/admin/audit",
|
||||
label: "Аудит",
|
||||
},
|
||||
{
|
||||
key: "analytics",
|
||||
href: "/admin/analytics",
|
||||
scopePath: "/trade-in/api/v1/admin/analytics",
|
||||
label: "Активность",
|
||||
},
|
||||
];
|
||||
|
||||
export function Topbar({ active }: TopbarProps) {
|
||||
|
|
@ -156,6 +225,10 @@ export function Topbar({ active }: TopbarProps) {
|
|||
{/* UserMenu — личный кабинет справа от nav. В dev (401) или до /me
|
||||
UserMenu сам рендерит null. */}
|
||||
<UserMenu />
|
||||
{/* FeedbackButton — вне items.map/RBAC: видна всем авторизованным
|
||||
пользователям независимо от allowed_paths. Сама себя прячет,
|
||||
пока NEXT_PUBLIC_FEEDBACK_TG_URL не задан. */}
|
||||
<FeedbackButton />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -2732,3 +2732,91 @@ html, body { overflow-x: clip; }
|
|||
.ss-hist { height: 76px; }
|
||||
.ss-map, .ss-map-empty { height: 360px; }
|
||||
}
|
||||
|
||||
/* ── Admin dashboards: audit (Feature 2) + activity (Feature 3), #2521 ── */
|
||||
|
||||
.admin-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-kpi-card {
|
||||
background: var(--bg-card, #ffffff);
|
||||
border: 1px solid var(--border-card, #e6e8ec);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.admin-kpi-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-secondary, #5b6066);
|
||||
}
|
||||
|
||||
.admin-kpi-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-primary, #111111);
|
||||
margin-top: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-days-filter {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-days-filter button {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-card, #e6e8ec);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
background: var(--bg-card, #fff);
|
||||
color: var(--fg-secondary, #5b6066);
|
||||
}
|
||||
|
||||
.admin-days-filter button.is-active {
|
||||
background: var(--accent, #1d4ed8);
|
||||
border-color: var(--accent, #1d4ed8);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-row-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent, #1d4ed8);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-row-toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.admin-drilldown {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 14px 6px;
|
||||
}
|
||||
|
||||
.admin-drilldown h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--fg-primary, #111111);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.admin-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { tokens } from "./tokens";
|
||||
import { analytics as ANALYTICS_FIXTURE } from "./fixtures";
|
||||
import { phYearX as histYearX } from "./mappers";
|
||||
import { phYearX as histYearX, PH_GRID_Y } from "./mappers";
|
||||
import type { Analytics, SellTimeTier } from "./types";
|
||||
|
||||
interface AnalyticsViewProps {
|
||||
|
|
@ -115,9 +115,18 @@ function sellTimeTierN(tier: SellTimeTier): number {
|
|||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** A tier is trustworthy only with a real point estimate AND ≥ SELLTIME_MIN_N lots. */
|
||||
/**
|
||||
* A tier is trustworthy only with a real point estimate, ≥ SELLTIME_MIN_N lots,
|
||||
* AND the backend not having flagged it as insufficient (V3) — a bucket the
|
||||
* backend itself calls too thin is dropped like any other untrustworthy tile,
|
||||
* never shown as a confident value.
|
||||
*/
|
||||
function sellTimeTierValid(tier: SellTimeTier): boolean {
|
||||
return tier.days !== "—" && sellTimeTierN(tier) >= SELLTIME_MIN_N;
|
||||
return (
|
||||
!tier.insufficientData &&
|
||||
tier.days !== "—" &&
|
||||
sellTimeTierN(tier) >= SELLTIME_MIN_N
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnalyticsView({
|
||||
|
|
@ -318,357 +327,402 @@ export default function AnalyticsView({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* история цен */}
|
||||
<div style={{ position: "relative", ...cardStyle }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
||||
История цен в этом доме
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||
{historyNote}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
|
||||
{avitoPts.length > 0 ? (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ width: 14, height: 2, background: tokens.accent }}
|
||||
/>
|
||||
Avito
|
||||
</span>
|
||||
) : null}
|
||||
{yandexPts.length > 0 ? (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ width: 14, height: 2, background: tokens.gold }}
|
||||
/>
|
||||
Яндекс
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 900 220"
|
||||
style={{ width: "100%", height: 200, marginTop: 12 }}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс"
|
||||
>
|
||||
<title>История цен в этом доме — медиана ₽/м² по годам</title>
|
||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||
<line x1={40} y1={20} x2={900} y2={20} />
|
||||
<line x1={40} y1={65} x2={900} y2={65} />
|
||||
<line x1={40} y1={110} x2={900} y2={110} />
|
||||
<line x1={40} y1={155} x2={900} y2={155} />
|
||||
<line x1={40} y1={195} x2={900} y2={195} />
|
||||
</g>
|
||||
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
|
||||
{data.priceHistory.yTicks.map((t) => (
|
||||
<text key={t.label} x={0} y={t.y + 4}>
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
<g
|
||||
fontFamily={tokens.font.mono}
|
||||
fontSize={11}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{data.priceHistory.years.map((yr, i) => (
|
||||
<text
|
||||
key={yr}
|
||||
x={histYearX(i, data.priceHistory.years.length)}
|
||||
y={214}
|
||||
>
|
||||
{yr}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
<polyline
|
||||
points={data.priceHistory.avito}
|
||||
fill="none"
|
||||
stroke={tokens.accent}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<polyline
|
||||
points={data.priceHistory.yandex}
|
||||
fill="none"
|
||||
stroke={tokens.gold}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
{/* white dot fill on an accent ring — documented one-off, no token (design l.568) */}
|
||||
<g
|
||||
fill="#fff"
|
||||
stroke={tokens.accent}
|
||||
strokeWidth={2}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<title>Архивные объявления Avito · нажмите, чтобы открыть</title>
|
||||
{avitoPts.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
|
||||
))}
|
||||
</g>
|
||||
<g
|
||||
fill={tokens.gold}
|
||||
stroke={tokens.gold}
|
||||
strokeWidth={2}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{yandexDots.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
|
||||
))}
|
||||
</g>
|
||||
<g
|
||||
fill="transparent"
|
||||
style={{ cursor: "pointer" }}
|
||||
onMouseEnter={() => setHoverHist(true)}
|
||||
onMouseLeave={() => setHoverHist(false)}
|
||||
onClick={() => onNavigate?.(2)}
|
||||
>
|
||||
{hoverPts.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={13} />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
{hoverHist ? (
|
||||
// Dark navy tooltip surface + white text — documented one-off literals
|
||||
// matching the design (l.572); no token equivalent.
|
||||
{/* история цен + цена × срок продажи — side by side so both fit the
|
||||
fixed-height v2 artboard panel without scrolling (reclaims ~194px
|
||||
of vertical space vs. stacking). Both SVGs share height: 260 (viewBox
|
||||
heights differ — 220 vs 300 — but preserveAspectRatio="none" stretches
|
||||
them to match) so the two cards render at equal height. */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 16,
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
{/* история цен */}
|
||||
<div style={{ position: "relative", ...cardStyle }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 64,
|
||||
top: 54,
|
||||
background: "rgba(23,38,58,.96)",
|
||||
color: "#fff",
|
||||
borderRadius: 7,
|
||||
padding: "9px 13px",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
zIndex: 6,
|
||||
pointerEvents: "none",
|
||||
boxShadow: "0 10px 26px rgba(20,40,70,.28)",
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
Архивные объявления этого дома
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
||||
История цен в этом доме
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||
{historyNote}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: tokens.barMid, fontSize: 10, marginTop: 2 }}>
|
||||
Нажмите, чтобы открыть список →
|
||||
<div style={{ display: "flex", gap: 16, fontSize: 10 }}>
|
||||
{avitoPts.length > 0 ? (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ width: 14, height: 2, background: tokens.accent }}
|
||||
/>
|
||||
Avito
|
||||
</span>
|
||||
) : null}
|
||||
{yandexPts.length > 0 ? (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{ width: 14, height: 2, background: tokens.gold }}
|
||||
/>
|
||||
Яндекс
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* цена × срок продажи (детально) */}
|
||||
<div style={cardStyle}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
||||
Цена × срок продажи
|
||||
<svg
|
||||
viewBox="0 0 900 220"
|
||||
style={{ width: "100%", height: 260, marginTop: 12 }}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="График истории цен в этом доме: медиана ₽/м² по годам, серии Avito и Яндекс"
|
||||
>
|
||||
<title>История цен в этом доме — медиана ₽/м² по годам</title>
|
||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||
{PH_GRID_Y.map((y) => (
|
||||
<line key={y} x1={40} y1={y} x2={900} y2={y} />
|
||||
))}
|
||||
</g>
|
||||
<g fontFamily={tokens.font.mono} fontSize={11} fill={tokens.muted2}>
|
||||
{data.priceHistory.yTicks.map((t) => (
|
||||
<text key={t.label} x={0} y={t.y + 4}>
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
<g
|
||||
fontFamily={tokens.font.mono}
|
||||
fontSize={11}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{data.priceHistory.years.map((yr, i) => (
|
||||
<text
|
||||
key={yr}
|
||||
x={histYearX(i, data.priceHistory.years.length)}
|
||||
y={214}
|
||||
>
|
||||
{yr}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
<polyline
|
||||
points={data.priceHistory.avito}
|
||||
fill="none"
|
||||
stroke={tokens.accent}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
<polyline
|
||||
points={data.priceHistory.yandex}
|
||||
fill="none"
|
||||
stroke={tokens.gold}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
{/* white dot fill on an accent ring — documented one-off, no token (design l.568) */}
|
||||
<g
|
||||
fill="#fff"
|
||||
stroke={tokens.accent}
|
||||
strokeWidth={2}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<title>Архивные объявления Avito · нажмите, чтобы открыть</title>
|
||||
{avitoPts.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
|
||||
))}
|
||||
</g>
|
||||
<g
|
||||
fill={tokens.gold}
|
||||
stroke={tokens.gold}
|
||||
strokeWidth={2}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{yandexDots.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={3.5} />
|
||||
))}
|
||||
</g>
|
||||
{/* WCAG 2.1.1 (#4): the chart's "open archived listings" affordance
|
||||
was a mouse-only <g onClick>. Expose it as a real focusable
|
||||
button (role + tabIndex + Enter/Space) so keyboard/SR users can
|
||||
open the «Аналогичные объявления» overlay too — the transparent
|
||||
hover circles stay as the pointer target. Only interactive when
|
||||
there are points to click (no empty tab stop on a data-less
|
||||
chart). onFocus/onBlur mirror hover so the hint tooltip shows on
|
||||
keyboard focus as well. */}
|
||||
<g
|
||||
fill="transparent"
|
||||
role={hoverPts.length > 0 ? "button" : undefined}
|
||||
tabIndex={hoverPts.length > 0 ? 0 : undefined}
|
||||
aria-label={
|
||||
hoverPts.length > 0
|
||||
? "Открыть архивные объявления этого дома"
|
||||
: undefined
|
||||
}
|
||||
style={{ cursor: "pointer" }}
|
||||
onMouseEnter={() => setHoverHist(true)}
|
||||
onMouseLeave={() => setHoverHist(false)}
|
||||
onFocus={() => setHoverHist(true)}
|
||||
onBlur={() => setHoverHist(false)}
|
||||
onClick={() => onNavigate?.(2)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onNavigate?.(2);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hoverPts.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={13} />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
{hoverHist ? (
|
||||
// Dark navy tooltip surface + white text — documented one-off literals
|
||||
// matching the design (l.572); no token equivalent.
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 64,
|
||||
top: 54,
|
||||
background: "rgba(23,38,58,.96)",
|
||||
color: "#fff",
|
||||
borderRadius: 7,
|
||||
padding: "9px 13px",
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
zIndex: 6,
|
||||
pointerEvents: "none",
|
||||
boxShadow: "0 10px 26px rgba(20,40,70,.28)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
Архивные объявления этого дома
|
||||
</div>
|
||||
<div style={{ color: tokens.barMid, fontSize: 10, marginTop: 2 }}>
|
||||
Нажмите, чтобы открыть список →
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||
Зависимость цены от срока экспозиции · {data.scatterDetail.note}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: tokens.accent,
|
||||
}}
|
||||
/>
|
||||
аналоги
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: tokens.scatterDeal,
|
||||
}}
|
||||
/>
|
||||
сделки
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: tokens.ink,
|
||||
}}
|
||||
/>
|
||||
объект
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 900 300"
|
||||
style={{ width: "100%", height: 280, marginTop: 12 }}
|
||||
role="img"
|
||||
aria-label="Точечный график зависимости цены продажи от срока экспозиции: аналоги, сделки и ваш объект"
|
||||
>
|
||||
<title>Цена × срок продажи</title>
|
||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||
<line x1={60} y1={20} x2={880} y2={20} />
|
||||
<line x1={60} y1={77} x2={880} y2={77} />
|
||||
<line x1={60} y1={134} x2={880} y2={134} />
|
||||
<line x1={60} y1={191} x2={880} y2={191} />
|
||||
<line x1={60} y1={250} x2={880} y2={250} />
|
||||
<line x1={265} y1={20} x2={265} y2={250} />
|
||||
<line x1={470} y1={20} x2={470} y2={250} />
|
||||
<line x1={675} y1={20} x2={675} y2={250} />
|
||||
</g>
|
||||
<line
|
||||
x1={60}
|
||||
y1={20}
|
||||
x2={60}
|
||||
y2={250}
|
||||
stroke={tokens.line3}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<line
|
||||
x1={60}
|
||||
y1={250}
|
||||
x2={880}
|
||||
y2={250}
|
||||
stroke={tokens.line3}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<line
|
||||
x1={100}
|
||||
y1={226}
|
||||
x2={820}
|
||||
y2={55}
|
||||
stroke={tokens.accent}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="5 5"
|
||||
opacity={0.45}
|
||||
/>
|
||||
<g fill={tokens.scatterDeal}>
|
||||
{data.scatterDetail.deals.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
||||
))}
|
||||
</g>
|
||||
<g fill={tokens.accent}>
|
||||
{data.scatterDetail.analogs.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
||||
))}
|
||||
</g>
|
||||
<circle
|
||||
cx={data.scatterDetail.subject.x}
|
||||
cy={data.scatterDetail.subject.y}
|
||||
r={data.scatterDetail.subject.r}
|
||||
fill={tokens.ink}
|
||||
/>
|
||||
<circle
|
||||
cx={data.scatterDetail.subject.x}
|
||||
cy={data.scatterDetail.subject.y}
|
||||
r={11}
|
||||
fill="none"
|
||||
stroke={tokens.ink}
|
||||
strokeWidth={1.2}
|
||||
opacity={0.4}
|
||||
/>
|
||||
<g
|
||||
fontFamily={tokens.font.mono}
|
||||
fontSize={11}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="end"
|
||||
|
||||
{/* цена × срок продажи (детально) */}
|
||||
<div style={cardStyle}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{data.scatterDetail.yTicks.map((t) => (
|
||||
<text key={t.label} x={52} y={t.y}>
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
<g
|
||||
fontFamily={tokens.font.mono}
|
||||
fontSize={11}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="middle"
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: tokens.ink2 }}>
|
||||
Цена × срок на рынке
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: tokens.muted2, marginTop: 4 }}>
|
||||
{/* Honesty (#2): active analogs carry «дней на рынке так далеко»
|
||||
(today − listing_date, нижняя граница), not realized
|
||||
time-to-sell — the axis reads «срок на рынке», not «срок
|
||||
продажи», so an ещё-продающийся лот isn't shown as a
|
||||
measured sale time. */}
|
||||
Цена в зависимости от срока на рынке · {data.scatterDetail.note}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 14, fontSize: 10 }}>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: tokens.accent,
|
||||
}}
|
||||
/>
|
||||
аналоги
|
||||
</span>
|
||||
{data.scatterDetail.deals.length > 0 ? (
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: tokens.scatterDeal,
|
||||
}}
|
||||
/>
|
||||
сделки
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: tokens.ink,
|
||||
}}
|
||||
/>
|
||||
объект
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
viewBox="0 0 900 300"
|
||||
style={{ width: "100%", height: 260, marginTop: 12 }}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label="Точечный график зависимости цены от срока лота на рынке: аналоги, сделки и ваш объект"
|
||||
>
|
||||
{data.scatterDetail.xTicks.map((t) => (
|
||||
<text key={t.label} x={t.x} y={268}>
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
<text
|
||||
x={470}
|
||||
y={288}
|
||||
fontSize={10}
|
||||
letterSpacing={1}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="middle"
|
||||
>
|
||||
СРОК ПРОДАЖИ, ДН
|
||||
</text>
|
||||
<text
|
||||
x={18}
|
||||
y={135}
|
||||
fontSize={10}
|
||||
letterSpacing={1}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="middle"
|
||||
transform="rotate(-90 18 135)"
|
||||
>
|
||||
ЦЕНА ПРОДАЖИ, ₽
|
||||
</text>
|
||||
</svg>
|
||||
<title>Цена × срок на рынке</title>
|
||||
<g stroke={tokens.lineSoft2} strokeWidth={1}>
|
||||
<line x1={60} y1={20} x2={880} y2={20} />
|
||||
<line x1={60} y1={77} x2={880} y2={77} />
|
||||
<line x1={60} y1={134} x2={880} y2={134} />
|
||||
<line x1={60} y1={191} x2={880} y2={191} />
|
||||
<line x1={60} y1={250} x2={880} y2={250} />
|
||||
<line x1={265} y1={20} x2={265} y2={250} />
|
||||
<line x1={470} y1={20} x2={470} y2={250} />
|
||||
<line x1={675} y1={20} x2={675} y2={250} />
|
||||
</g>
|
||||
<line
|
||||
x1={60}
|
||||
y1={20}
|
||||
x2={60}
|
||||
y2={250}
|
||||
stroke={tokens.line3}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<line
|
||||
x1={60}
|
||||
y1={250}
|
||||
x2={880}
|
||||
y2={250}
|
||||
stroke={tokens.line3}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<g fill={tokens.scatterDeal}>
|
||||
{data.scatterDetail.deals.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
||||
))}
|
||||
</g>
|
||||
<g fill={tokens.accent}>
|
||||
{data.scatterDetail.analogs.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r={p.r} />
|
||||
))}
|
||||
</g>
|
||||
<g opacity={data.scatterDetail.subjectApprox ? 0.55 : 1}>
|
||||
{/* V4: subjectApprox marks a fallback x (est_days_on_market unknown) —
|
||||
dashed ring + title flag it as an estimate, not a measurement. */}
|
||||
{data.scatterDetail.subjectApprox ? (
|
||||
<title>
|
||||
Срок продажи — оценка (~90 дн.), точных данных нет
|
||||
</title>
|
||||
) : null}
|
||||
<circle
|
||||
cx={data.scatterDetail.subject.x}
|
||||
cy={data.scatterDetail.subject.y}
|
||||
r={data.scatterDetail.subject.r}
|
||||
fill={tokens.ink}
|
||||
/>
|
||||
<circle
|
||||
cx={data.scatterDetail.subject.x}
|
||||
cy={data.scatterDetail.subject.y}
|
||||
r={11}
|
||||
fill="none"
|
||||
stroke={tokens.ink}
|
||||
strokeWidth={1.2}
|
||||
strokeDasharray={
|
||||
data.scatterDetail.subjectApprox ? "3 3" : undefined
|
||||
}
|
||||
opacity={0.4}
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
fontFamily={tokens.font.mono}
|
||||
fontSize={11}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="end"
|
||||
>
|
||||
{data.scatterDetail.yTicks.map((t) => (
|
||||
<text key={t.label} x={52} y={t.y}>
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
<g
|
||||
fontFamily={tokens.font.mono}
|
||||
fontSize={11}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{data.scatterDetail.xTicks.map((t) => (
|
||||
<text key={t.label} x={t.x} y={268}>
|
||||
{t.label}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
<text
|
||||
x={470}
|
||||
y={288}
|
||||
fontSize={10}
|
||||
letterSpacing={1}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="middle"
|
||||
>
|
||||
СРОК НА РЫНКЕ, ДН
|
||||
</text>
|
||||
<text
|
||||
x={18}
|
||||
y={135}
|
||||
fontSize={10}
|
||||
letterSpacing={1}
|
||||
fill={tokens.muted2}
|
||||
textAnchor="middle"
|
||||
transform="rotate(-90 18 135)"
|
||||
>
|
||||
ЦЕНА ПРОДАЖИ, ₽
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -406,23 +406,31 @@ export default function HeroBar({
|
|||
{data.object.city}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
background: tokens.lineSoft,
|
||||
margin: "8px 0",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 9,
|
||||
color: tokens.muted,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{data.object.streetView}
|
||||
</div>
|
||||
{/* streetView — Fix #4 (audit): always "" (TODO BE-3, mappers.ts
|
||||
mapObject), so this slot + its divider are hidden together
|
||||
rather than showing an empty caption row. Leaves the single
|
||||
divider below to separate the address block from the coef row. */}
|
||||
{data.object.streetView && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
background: tokens.lineSoft,
|
||||
margin: "8px 0",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 9,
|
||||
color: tokens.muted,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
{data.object.streetView}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
|
|
@ -520,114 +528,52 @@ export default function HeroBar({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* compass */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
top: 14,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
color: tokens.accent,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
|
||||
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
|
||||
</svg>
|
||||
<span
|
||||
{/* compass — Fix #4 (audit): compass bearing is always "" (TODO BE-3,
|
||||
mappers.ts mapObject), so the icon+label are hidden rather than
|
||||
showing a compass that never actually points anywhere. Mirrors the
|
||||
locationCoef "нет данных" graceful-fallback pattern already used
|
||||
in this file. */}
|
||||
{data.object.compass && (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 8,
|
||||
letterSpacing: ".5px",
|
||||
color: tokens.muted,
|
||||
whiteSpace: "nowrap",
|
||||
position: "absolute",
|
||||
right: 16,
|
||||
top: 14,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
color: tokens.accent,
|
||||
}}
|
||||
>
|
||||
{data.object.compass}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
|
||||
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 8,
|
||||
letterSpacing: ".5px",
|
||||
color: tokens.muted,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{data.object.compass}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* distance scale */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
bottom: 16,
|
||||
width: 170,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
height: 8,
|
||||
borderBottom: "1px solid rgba(46,139,255,.55)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 1,
|
||||
height: 8,
|
||||
background: "rgba(46,139,255,.55)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: 1,
|
||||
height: 5,
|
||||
background: "rgba(46,139,255,.45)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: 1,
|
||||
height: 5,
|
||||
background: "rgba(46,139,255,.45)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: 1,
|
||||
height: 5,
|
||||
background: "rgba(46,139,255,.45)",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: 1,
|
||||
height: 8,
|
||||
background: "rgba(46,139,255,.55)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: "7.5px",
|
||||
color: tokens.muted,
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
<span>0</span>
|
||||
<span>25</span>
|
||||
<span>50</span>
|
||||
<span>75</span>
|
||||
<span>100м</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Fix #3 (audit) — the "0/25/50/75/100м" distance ruler here was a
|
||||
hardcoded tick scale over a generic stock photo with no real
|
||||
measurement behind it (not tied to any actual distance/scale
|
||||
value). Removed rather than fabricating a scale. */}
|
||||
|
||||
{/* corner brackets */}
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -358,6 +358,19 @@ export function LocationDrawer({
|
|||
{data.resultLabel}
|
||||
</span>
|
||||
</div>
|
||||
{/* Fix #7c (audit): the estimator never reads this coefficient — it's
|
||||
illustrative/reference only. Without this line the ₽-vs-₽ layout
|
||||
above reads as if it changes the quoted price. */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
fontSize: 10.5,
|
||||
lineHeight: 1.5,
|
||||
color: tokens.muted3,
|
||||
}}
|
||||
>
|
||||
Справочно: поправка на локацию не влияет на итоговую оценку.
|
||||
</div>
|
||||
{data.factors.length > 0 ? (
|
||||
<ul
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -273,6 +273,11 @@ export function ObjectSummary({
|
|||
</div>
|
||||
|
||||
{/* КАЧЕСТВО ДАННЫХ */}
|
||||
{/* Fix #2 (audit) — the donut ring here used to be a hardcoded
|
||||
strokeDasharray/strokeDashoffset (~43%, never changed): no composite
|
||||
data-quality SCORE exists anywhere in the mapper to back a ring
|
||||
fraction, so it always lied. Removed rather than inventing a metric;
|
||||
the three real rows below (Источников/Достоверность/Разброс) stay. */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
|
|
@ -280,34 +285,9 @@ export function ObjectSummary({
|
|||
border: `1px solid ${tokens.infoSoftBorder}`,
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
display: "flex",
|
||||
gap: 12,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" aria-hidden="true">
|
||||
<circle
|
||||
cx="25"
|
||||
cy="25"
|
||||
r="20"
|
||||
stroke={tokens.track}
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<circle
|
||||
cx="25"
|
||||
cy="25"
|
||||
r="20"
|
||||
stroke={tokens.accent}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="126"
|
||||
strokeDashoffset="72"
|
||||
transform="rotate(-90 25 25)"
|
||||
/>
|
||||
<circle cx="25" cy="25" r="11" stroke="#cdddec" strokeWidth="1" />
|
||||
<circle cx="25" cy="25" r="1.6" fill={tokens.accent} />
|
||||
</svg>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9.5,
|
||||
|
|
|
|||
|
|
@ -1078,6 +1078,23 @@ export default function ParamsPanel({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Fix #7 (audit) — the SVG above is decorative (grid + fake streets +
|
||||
radius rings), not a real map, and the rings themselves aren't drawn
|
||||
to scale (outerRingR is a clamped best-effort, not a true geometric
|
||||
projection of РАДИУС). Same honesty-caption tone as SourcesMap's
|
||||
footer disclosure. */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: tokens.hint,
|
||||
lineHeight: 1.4,
|
||||
marginTop: 6,
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
Схематично · не географическая карта · радиус не в масштабе
|
||||
</div>
|
||||
|
||||
{/* radius row */}
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -1634,41 +1651,47 @@ export default function ParamsPanel({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* cache status */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginTop: 11,
|
||||
flex: "0 0 auto",
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
color: tokens.muted2,
|
||||
}}
|
||||
>
|
||||
<span>ДАННЫЕ АКТУАЛЬНЫ</span>
|
||||
<span
|
||||
{/* cache status — Fix #6 (audit): was static, so it showed "fresh" before
|
||||
any estimate existed. Gated on hasEstimate (same signal that already
|
||||
swaps the CTA to «СКАЧАТЬ PDF-ОТЧЁТ» above) — the 24 Ч TTL itself is a
|
||||
real cache constant (ParamsPanel/mappers.ts cacheStatus), only the
|
||||
"always visible" part was dishonest. */}
|
||||
{hasEstimate && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.success,
|
||||
justifyContent: "space-between",
|
||||
marginTop: 11,
|
||||
flex: "0 0 auto",
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.5,
|
||||
color: tokens.muted2,
|
||||
}}
|
||||
>
|
||||
<span>ДАННЫЕ АКТУАЛЬНЫ</span>
|
||||
<span
|
||||
className="pp-dot"
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: tokens.success,
|
||||
boxShadow: `0 0 6px ${tokens.success}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
color: tokens.success,
|
||||
}}
|
||||
/>
|
||||
ДЕЙСТВИТЕЛЕН 24 Ч
|
||||
</span>
|
||||
</div>
|
||||
>
|
||||
<span
|
||||
className="pp-dot"
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
background: tokens.success,
|
||||
boxShadow: `0 0 6px ${tokens.success}`,
|
||||
}}
|
||||
/>
|
||||
ДЕЙСТВИТЕЛЕН 24 Ч
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -789,6 +789,14 @@ export default function ResultPanel({
|
|||
Подробнее <span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* Fix #1 (audit) — the tile counts below are summed from the top-10
|
||||
persisted sample, which can be less than builtOn's full n_analogs
|
||||
total (footer, below) for large result sets. */}
|
||||
{data.meta.sourcesNote && (
|
||||
<div style={{ fontSize: 9.5, color: hint, marginBottom: 8 }}>
|
||||
{data.meta.sourcesNote}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue