fix(tradein/dadata): suggest-fallback для house_fias_id когда CLEAN выключен
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 56s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 56s
На проде у DaData-аккаунта выключена услуга CLEAN (Стандартизация) → /clean/address отдаёт HTTP 403 «Feature CLEAN disabled», из-за чего house_fias_id (ключ join с houses/ДОМ.РФ) и гео терялись, хотя /suggest их отдаёт бесплатно (token-only). - enrich_address(): /clean → graceful fallback на /suggest (house-level кандидат); canonical_address/house_cadnum честно None (их даёт только CLEAN — не выдумываем). - clean_address(): 403 «feature disabled» логируется отдельно от auth-rejection (токен валиден — refresh не поможет; чинить = включить услугу либо fallback). - DadataSuggestion + _parse_suggestion: тянут house_fias_id/kladr_id/qc_geo. - estimator: enrichment-вызов → enrich_address (алиас dadata_clean_address сохранён, это точка патча в 11 тестах — без churn). - Тесты: feature-disabled лог, enrich clean-path/suggest-fallback/both-fail, новые поля. Проверено вживую на прод-кредах: clean→403, enrich→house_fias_id восстановлен.
This commit is contained in:
parent
b0b2c94ece
commit
e250869c9d
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?)")
|
logger.warning("dadata: HTTP 429 — quota exceeded (100/день demo limit?)")
|
||||||
return None
|
return None
|
||||||
if status in (401, 403):
|
if status in (401, 403):
|
||||||
logger.error(
|
body_preview = (response.text or "")[:200]
|
||||||
"dadata: HTTP %d — auth/secret rejected. Проверь DADATA_API_TOKEN/SECRET.",
|
# 403 «Feature 'CLEAN' disabled for token …» ≠ отклонённый токен: токен валиден,
|
||||||
status,
|
# но услуга «Стандартизация» (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
|
return None
|
||||||
if status >= 500:
|
if status >= 500:
|
||||||
logger.warning("dadata: HTTP %d — transient server error", status)
|
logger.warning("dadata: HTTP %d — transient server error", status)
|
||||||
|
|
@ -246,6 +263,12 @@ class DadataSuggestion:
|
||||||
street: str | None
|
street: str | None
|
||||||
city: str | None
|
city: str | None
|
||||||
kind: str
|
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:
|
def _classify_kind(fias_level: Any) -> str:
|
||||||
|
|
@ -295,6 +318,9 @@ def _parse_suggestion(item: dict[str, Any]) -> DadataSuggestion | None:
|
||||||
street=data.get("street"),
|
street=data.get("street"),
|
||||||
city=data.get("city"),
|
city=data.get("city"),
|
||||||
kind=_classify_kind(data.get("fias_level")),
|
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))
|
logger.info("dadata suggest: %r → %d вариантов", query[:60], len(out))
|
||||||
return 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,
|
TradeInEstimateInput,
|
||||||
)
|
)
|
||||||
from app.services.dadata import DadataAddressResult
|
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.geocoder import GeocodeResult, geocode
|
||||||
from app.services.house_metadata import get_house_metadata
|
from app.services.house_metadata import get_house_metadata
|
||||||
from app.services.matching.houses import match_house_readonly, match_or_create_house
|
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"]
|
body = captured["body"]
|
||||||
assert isinstance(body, dict)
|
assert isinstance(body, dict)
|
||||||
assert body["count"] == 20 # clamped to max
|
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