arm-core/api/tests/test_counterparty_resolve.py
Andrey Epifancev eaee3ec5f9 feat(model): DBML v5.16 — counterparty_match_kind, миграция 0004, шаг резолва в маппинге
Enum counterparty_match_kind (INN, NAME, ALIAS, MANUAL, PROPAGATED) и nullable-колонка osv_entry.counterparty_match_kind
(NULL <=> counterparty_id NULL). Миграция 0004_dbml_v5_16: enum, колонка, backfill тремя UPDATE по уже смапленным
строкам (ИНН без пробелов -> INN, trim(имя) -> NAME, остальные -> ALIAS), откат удаляет колонку и тип.
CounterpartyIndex.resolve возвращает Resolution(entity_id, kind) (определение в domain/osv_mapping, R-4);
маппинг пишет kind; EntryOut.match_kind. DBML_VERSION 5.16, Makefile/dbml_parity/CLAUDE/README/config.yaml.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 03:23:02 +04:00

165 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Индекс и резолв контрагентов (§3 п. 4; spec osv-result-mapping «Резолв контрагента»; design D-8; Review Focus 3).
Интеграционные тесты на `db_session` с фабриками: загрузка индекса двумя запросами по всему справочнику (без policy),
ИНН → точное имя → нормализованное имя/алиас, неоднозначность → `None`, `owner_names` для `counterparty_check`.
"""
import datetime
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.aliases import normalize_alias
from src.models.directories import LegalEntityAlias
from src.models.enums import CounterpartyMatchKind
from src.repositories.counterparties import CounterpartyIndex, Resolution
from tests.factories import make_legal_entity
pytestmark = pytest.mark.integration
def _entity(resolution: Resolution | None) -> uuid.UUID | None:
"""Идентификатор ЮЛ из результата резолва (шаг резолва проверяют отдельные тесты)."""
return resolution.entity_id if resolution is not None else None
async def _alias(session: AsyncSession, entity_id, alias: str) -> None:
session.add(LegalEntityAlias(legal_entity_id=entity_id, alias=alias, normalized_alias=normalize_alias(alias)))
await session.flush()
async def test_inn_alias_and_ambiguous(db_session: AsyncSession) -> None:
"""Сценарий спеки: по ИНН, по алиасу, неоднозначно."""
by_inn = await make_legal_entity(db_session, name="АО «Хлебозавод Ломоносов»", inn="3528052718")
by_alias = await make_legal_entity(db_session, name="ООО «РБ Холдинг»")
await _alias(db_session, by_alias.id, "Русский бисквит ООО")
await make_legal_entity(db_session, name="Альфаомега")
await make_legal_entity(db_session, name="АО «Альфаомега»")
index = await CounterpartyIndex.load(db_session)
assert _entity(index.resolve("3528052718", "что угодно")) == by_inn.id
assert _entity(index.resolve(None, "Русский бисквит")) == by_alias.id
# точного имени нет, два ЮЛ с нормализованным именем `альфаомега`
assert index.resolve(None, "ООО Альфаомега") is None
async def test_inn_is_compared_without_whitespace(db_session: AsyncSession) -> None:
entity = await make_legal_entity(db_session, name="ООО Пробел", inn="7701234567")
index = await CounterpartyIndex.load(db_session)
assert _entity(index.resolve(" 7701 234 567 ", None)) == entity.id
assert index.resolve("7701234568", None) is None
assert index.resolve("", "") is None
assert index.resolve(None, None) is None
assert index.resolve(None, " ") is None
async def test_unknown_inn_falls_through_to_alias_of_other_entity(db_session: AsyncSession) -> None:
"""Review Focus 3: ИНН строки нет в справочнике — это не блокирует поиск по имени/алиасу другого ЮЛ."""
owner = await make_legal_entity(db_session, name="ООО Совсем Другое", inn="1111111111")
await _alias(db_session, owner.id, "Пекарня Счастливочная")
index = await CounterpartyIndex.load(db_session)
assert _entity(index.resolve("9999999999", "Пекарня Счастливочная ООО")) == owner.id
async def test_exact_name_unique_and_ambiguous(db_session: AsyncSession) -> None:
unique = await make_legal_entity(db_session, name="ООО Точное Имя Один")
await make_legal_entity(db_session, name="ООО Дубль Имени", inn="2222222221")
await make_legal_entity(db_session, name="ООО Дубль Имени", inn="2222222222")
index = await CounterpartyIndex.load(db_session)
assert _entity(index.resolve(None, " ООО Точное Имя Один ")) == unique.id
# >1 кандидата на шаге точного имени → None, без перехода дальше
assert index.resolve(None, "ООО Дубль Имени") is None
async def test_unique_exact_name_wins_over_ambiguous_normalized(db_session: AsyncSession) -> None:
"""Приоритет шагов: единственное точное имя побеждает неоднозначность нормализованного (оба ЮЛ нормализуются в
одно имя, но точное совпадение у одного)."""
ooo = await make_legal_entity(db_session, name="ООО Альфа Приоритет")
await make_legal_entity(db_session, name="АО Альфа Приоритет")
index = await CounterpartyIndex.load(db_session)
assert _entity(index.resolve(None, "ООО Альфа Приоритет")) == ooo.id
# без точного совпадения нормализованное имя неоднозначно → None
assert index.resolve(None, "Альфа Приоритет") is None
async def test_exact_name_zero_candidates_goes_to_normalized(db_session: AsyncSession) -> None:
entity = await make_legal_entity(db_session, name="ООО «Нормализуемое Имя»")
index = await CounterpartyIndex.load(db_session)
assert _entity(index.resolve(None, "Нормализуемое имя, ООО")) == entity.id
async def test_alias_ambiguity_between_name_and_alias_of_different_entities(db_session: AsyncSession) -> None:
first = await make_legal_entity(db_session, name="ООО Гамма Сервис")
second = await make_legal_entity(db_session, name="ООО Дельта")
await _alias(db_session, second.id, "Гамма сервис")
index = await CounterpartyIndex.load(db_session)
assert first.id != second.id
assert index.resolve(None, "Гамма Сервис") is None
async def test_same_entity_by_name_and_alias_is_not_ambiguous(db_session: AsyncSession) -> None:
entity = await make_legal_entity(db_session, name="ООО Эпсилон")
await _alias(db_session, entity.id, "Эпсилон")
index = await CounterpartyIndex.load(db_session)
assert _entity(index.resolve(None, "эпсилон ооо")) == entity.id
async def test_closed_entity_is_resolved(db_session: AsyncSession) -> None:
closed = await make_legal_entity(
db_session, name="ООО Закрытое Зета", inn="3333333333", close_date=datetime.date(2023, 1, 1)
)
index = await CounterpartyIndex.load(db_session)
assert _entity(index.resolve("3333333333", None)) == closed.id
assert _entity(index.resolve(None, "закрытое зета, ООО")) == closed.id
async def test_owner_names(db_session: AsyncSession) -> None:
owner = await make_legal_entity(db_session, name="ООО «Пекарня Счастливочная»")
await _alias(db_session, owner.id, "Счастливочная пекарня")
other = await make_legal_entity(db_session, name="ООО Чужое")
await _alias(db_session, other.id, "Чужой алиас")
index = await CounterpartyIndex.load(db_session)
assert index.owner_names(owner) == frozenset({"пекарня счастливочная", "счастливочная пекарня"})
assert index.owner_names(other) == frozenset({"чужое", "чужой алиас"})
async def test_owner_names_without_aliases_and_empty_normalization(db_session: AsyncSession) -> None:
only_form = await make_legal_entity(db_session, name="ООО")
plain = await make_legal_entity(db_session, name="ИП Иванов")
index = await CounterpartyIndex.load(db_session)
assert index.owner_names(only_form) == frozenset()
assert index.owner_names(plain) == frozenset({"иванов"})
# нормализация в пустую строку не даёт совпадений: пустой ключ в индексе не хранится
assert index.resolve(None, "АО") is None
async def test_resolution_kind_per_step(db_session: AsyncSession) -> None:
"""v5.16: результат резолва несёт шаг — ИНН → `INN`, точное имя → `NAME`, нормализованное имя/алиас → `ALIAS`."""
by_inn = await make_legal_entity(db_session, name="ООО Шаг Инн", inn="4401000001")
by_name = await make_legal_entity(db_session, name="ООО Шаг Имя", inn="4401000002")
by_normalized = await make_legal_entity(db_session, name="ООО «Шаг Нормализация»", inn="4401000003")
by_alias = await make_legal_entity(db_session, name="ООО Совсем Другое Имя", inn="4401000004")
await _alias(db_session, by_alias.id, "Шаг Алиас")
index = await CounterpartyIndex.load(db_session)
assert index.resolve("4401000001", "ООО Шаг Имя") == Resolution(by_inn.id, CounterpartyMatchKind.INN)
assert index.resolve(None, " ООО Шаг Имя ") == Resolution(by_name.id, CounterpartyMatchKind.NAME)
# ИНН не найден, точного имени нет, нормализованное — единственное
assert index.resolve("9999999999", "Шаг нормализация, ООО") == Resolution(
by_normalized.id, CounterpartyMatchKind.ALIAS
)
assert index.resolve(None, "шаг алиас") == Resolution(by_alias.id, CounterpartyMatchKind.ALIAS)
assert index.resolve(None, "Нет такого") is None