diff --git a/tradein-mvp/backend/app/services/dadata.py b/tradein-mvp/backend/app/services/dadata.py index 4e7da7c5..a14626e5 100644 --- a/tradein-mvp/backend/app/services/dadata.py +++ b/tradein-mvp/backend/app/services/dadata.py @@ -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 diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index a81b18fa..3a29a137 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -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 diff --git a/tradein-mvp/backend/tests/services/test_dadata.py b/tradein-mvp/backend/tests/services/test_dadata.py index 5cb7489e..4b9a7464 100644 --- a/tradein-mvp/backend/tests/services/test_dadata.py +++ b/tradein-mvp/backend/tests/services/test_dadata.py @@ -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