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
205 lines
12 KiB
Python
205 lines
12 KiB
Python
"""Индекс и резолв контрагентов (§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
|