arm-core/api/tests/test_counterparty_resolve.py
Andrey Epifancev 7dcffbd10d Резолв контрагента: алиас приоритетнее ЮЛ-тёзки по нормализованному имени (#57)
CounterpartyIndex.resolve: порядок шагов ИНН -> точное имя -> алиас -> нормализованное
имя (OQ-128, §3 п. 4). Алиас уникален (ALIAS_TAKEN), поэтому владелец алиаса берётся
с match_kind = ALIAS, даже если есть ЮЛ-тёзки по нормализованному имени; NULL остаётся
при нескольких совпадениях только по имени. Правило общее для маппинга (Срез 2c) и
дорезолва свода на чтение (§3 п. 7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-08 00:21:15 +04:00

205 lines
12 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_wins_over_namesake_by_normalized_name(db_session: AsyncSession) -> None:
"""OQ-128 (§3 п. 4): ЮЛ-тёзка по нормализованному имени не мешает владельцу алиаса — алиас приоритетнее имени."""
namesake = await make_legal_entity(db_session, name="ООО Гамма Сервис")
owner = await make_legal_entity(db_session, name="ООО Дельта")
await _alias(db_session, owner.id, "Гамма сервис")
index = await CounterpartyIndex.load(db_session)
assert namesake.id != owner.id
assert index.resolve(None, "Гамма Сервис") == Resolution(owner.id, CounterpartyMatchKind.ALIAS)
# нормализованное имя строки ОПФ-вариант — тот же результат: шаг алиаса идёт до нормализованного имени
assert index.resolve(None, "гамма сервис, АО") == Resolution(owner.id, CounterpartyMatchKind.ALIAS)
async def test_alias_wins_over_several_namesakes(db_session: AsyncSession) -> None:
"""Несколько тёзок по имени без алиаса дали бы NULL; владелец алиаса однозначен и побеждает."""
await make_legal_entity(db_session, name="ООО Омега Плюс", inn="5501000001")
await make_legal_entity(db_session, name="АО Омега Плюс", inn="5501000002")
owner = await make_legal_entity(db_session, name="ООО Совсем Другое Имя", inn="5501000003")
index = await CounterpartyIndex.load(db_session)
assert index.resolve(None, "Омега Плюс") is None # без алиаса — как раньше, ручное сопоставление
await _alias(db_session, owner.id, "Омега плюс")
index = await CounterpartyIndex.load(db_session)
assert index.resolve(None, "Омега Плюс") == Resolution(owner.id, CounterpartyMatchKind.ALIAS)
async def test_exact_name_still_precedes_alias(db_session: AsyncSession) -> None:
"""Порядок шагов: ИНН → точное имя → алиас → нормализованное имя; единственное точное имя идёт раньше алиаса."""
exact = await make_legal_entity(db_session, name="ООО Сигма Точная")
owner = await make_legal_entity(db_session, name="ООО Тау")
await _alias(db_session, owner.id, "Сигма точная")
index = await CounterpartyIndex.load(db_session)
assert index.resolve(None, "ООО Сигма Точная") == Resolution(exact.id, CounterpartyMatchKind.NAME)
assert index.resolve(None, "Сигма Точная") == Resolution(owner.id, CounterpartyMatchKind.ALIAS)
async def test_alias_with_several_owners_is_ambiguous(db_session: AsyncSession) -> None:
"""Глобальная уникальность алиаса — правило приложения (`ALIAS_TAKEN`), а не БД; при нарушении (данные импорта)
владельцев несколько → `None`, без молчаливого выбора."""
first = await make_legal_entity(db_session, name="ООО Первый Владелец", inn="5502000001")
second = await make_legal_entity(db_session, name="ООО Второй Владелец", inn="5502000002")
await _alias(db_session, first.id, "Общий алиас")
await _alias(db_session, second.id, "Общий алиас")
index = await CounterpartyIndex.load(db_session)
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