Merge pull request 'fix(tradein/dadata): suggest-fallback для house_fias_id когда CLEAN выключен' (#2486) from fix/tradein-dadata-clean-fallback-suggest into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 51s
Deploy Trade-In / build-backend (push) Successful in 1m5s
Deploy Trade-In / deploy (push) Successful in 52s

This commit is contained in:
bot-backend 2026-07-12 16:59:29 +00:00
commit 74017b39c4
3 changed files with 252 additions and 5 deletions

View file

@ -167,9 +167,26 @@ 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):
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 %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
"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:
@ -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,6 +318,9 @@ 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")),
)
@ -401,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

View file

@ -64,7 +64,11 @@ from app.schemas.trade_in import (
TradeInEstimateInput,
)
from app.services.dadata import DadataAddressResult
from app.services.dadata import clean_address as dadata_clean_address
# enrich_address = /clean с graceful fallback на /suggest (#dadata-403: на проде услуга
# CLEAN выключена → house_fias_id/гео берём из token-only /suggest). Локальный алиас
# оставлен как dadata_clean_address — это точка патча в 11 тестах (без churn).
from app.services.dadata import enrich_address as dadata_clean_address
from app.services.geocoder import GeocodeResult, geocode
from app.services.house_metadata import get_house_metadata
from app.services.matching.houses import match_house_readonly, match_or_create_house

View file

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