fix(tradein/dadata): suggest-fallback для house_fias_id когда CLEAN выключен #2486
3 changed files with 252 additions and 5 deletions
|
|
@ -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,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue