feat(mapping): индекс и резолв контрагентов по ИНН/имени/алиасу (§3 п. 4)

CounterpartyIndex.load — два запроса по всему справочнику без policy (включая закрытые ЮЛ);
resolve — ИНН, затем точное имя, затем normalize_alias по именам и алиасам, >1 кандидата → None;
неизвестный ИНН не блокирует поиск по имени. owner_names — для counterparty_check.
Отмечены шаги Task 6 и пункт 3.1 tasks.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 22:27:42 +04:00
parent 4e9253204c
commit 9658f809e7
4 changed files with 208 additions and 4 deletions

View File

@ -0,0 +1,78 @@
"""Индекс контрагентов для резолва `osv_entry.counterparty_id` (§3 п. 4; spec osv-result-mapping «Резолв контрагента»;
design D-8).
Индекс строится двумя запросами на файл (`legal_entity`, `legal_entity_alias`) по всему справочнику — без policy
видимости, включая закрытые ЮЛ (`close_date`): контрагент может быть организацией другого филиала. `resolve` — чистый
метод без обращений к БД. Функции не делают `commit`.
"""
import uuid
from collections import defaultdict
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.aliases import normalize_alias
from src.domain.inn import normalize_inn
from src.models.directories import LegalEntity, LegalEntityAlias
@dataclass
class CounterpartyIndex:
"""Словари поиска контрагента; значения-множества позволяют отличить «ровно один» от «неоднозначно»."""
by_inn: dict[str, uuid.UUID]
by_exact_name: dict[str, set[uuid.UUID]] # ключ — `name.strip()`
by_normalized: dict[str, set[uuid.UUID]] # normalize_alias(name) ∪ normalized_alias алиасов; без пустого ключа
aliases_by_entity: dict[uuid.UUID, set[str]] = field(default_factory=dict) # для `owner_names`
@classmethod
async def load(cls, session: AsyncSession) -> "CounterpartyIndex":
"""Два запроса: `legal_entity(id, inn, name)` и `legal_entity_alias(legal_entity_id, normalized_alias)`."""
by_inn: dict[str, uuid.UUID] = {}
by_exact_name: dict[str, set[uuid.UUID]] = defaultdict(set)
by_normalized: dict[str, set[uuid.UUID]] = defaultdict(set)
aliases_by_entity: dict[uuid.UUID, set[str]] = defaultdict(set)
for entity_id, inn, name in await session.execute(select(LegalEntity.id, LegalEntity.inn, LegalEntity.name)):
by_inn[normalize_inn(inn)] = entity_id # `legal_entity.inn` уникален
by_exact_name[name.strip()].add(entity_id)
if normalized := normalize_alias(name):
by_normalized[normalized].add(entity_id)
alias_rows = await session.execute(select(LegalEntityAlias.legal_entity_id, LegalEntityAlias.normalized_alias))
for entity_id, normalized_alias in alias_rows:
if normalized_alias:
by_normalized[normalized_alias].add(entity_id)
aliases_by_entity[entity_id].add(normalized_alias)
return cls(
by_inn=by_inn,
by_exact_name=dict(by_exact_name),
by_normalized=dict(by_normalized),
aliases_by_entity=dict(aliases_by_entity),
)
def resolve(self, inn: str | None, name: str | None) -> uuid.UUID | None:
"""§3 п. 4: ИНН → точное имя → нормализованное имя/алиас; больше одного кандидата на шаге → `None`.
ИНН, которого нет в справочнике, не блокирует поиск по имени (Review Focus 3). Ноль кандидатов на шаге —
переход к следующему; больше одного — ручное сопоставление, дальше не идём.
"""
if inn and (found := self.by_inn.get(normalize_inn(inn))) is not None:
return found
if not name or not (stripped := name.strip()):
return None
if candidates := self.by_exact_name.get(stripped):
return next(iter(candidates)) if len(candidates) == 1 else None
if (normalized := normalize_alias(stripped)) and (candidates := self.by_normalized.get(normalized)):
return next(iter(candidates)) if len(candidates) == 1 else None
return None
def owner_names(self, entity: LegalEntity) -> frozenset[str]:
"""Нормализованные имя и алиасы ЮЛ — для `counterparty_check` (D-8); пустая нормализация отбрасывается."""
names = set(self.aliases_by_entity.get(entity.id, ()))
if normalized := normalize_alias(entity.name):
names.add(normalized)
return frozenset(names)

View File

@ -0,0 +1,126 @@
"""Индекс и резолв контрагентов (§3 п. 4; spec osv-result-mapping «Резолв контрагента»; design D-8; Review Focus 3).
Интеграционные тесты на `db_session` с фабриками: загрузка индекса двумя запросами по всему справочнику (без policy),
ИНН → точное имя → нормализованное имя/алиас, неоднозначность → `None`, `owner_names` для `counterparty_check`.
"""
import datetime
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.aliases import normalize_alias
from src.models.directories import LegalEntityAlias
from src.repositories.counterparties import CounterpartyIndex
from tests.factories import make_legal_entity
pytestmark = pytest.mark.integration
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 index.resolve("3528052718", "что угодно") == by_inn.id
assert 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 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 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 index.resolve(None, " ООО Точное Имя Один ") == unique.id
# >1 кандидата на шаге точного имени → 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 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 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 index.resolve("3333333333", None) == closed.id
assert 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

View File

@ -194,9 +194,9 @@ class CounterpartyIndex:
def owner_names(self, entity: LegalEntity) -> frozenset[str] # normalize_alias(name) ∪ его алиасы — для counterparty_check
```
- [ ] **Step 1: Тесты** (integration на `db_session` с фабриками `make_legal_entity` + алиасы через `LegalEntityAlias`): сценарий спеки «По ИНН, по алиасу, неоднозначно»; ИНН не найден, имя совпадает с алиасом другого ЮЛ → по алиасу (Review Focus 3); закрытое ЮЛ (`close_date`) тоже резолвится; `owner_names`.
- [ ] **Step 2–4:** падает → реализация → тесты, `make check`.
- [ ] **Step 5: Commit** `feat(mapping): индекс и резолв контрагентов по ИНН/имени/алиасу (§3 п. 4)`.
- [x] **Step 1: Тесты** (integration на `db_session` с фабриками `make_legal_entity` + алиасы через `LegalEntityAlias`): сценарий спеки «По ИНН, по алиасу, неоднозначно»; ИНН не найден, имя совпадает с алиасом другого ЮЛ → по алиасу (Review Focus 3); закрытое ЮЛ (`close_date`) тоже резолвится; `owner_names`.
- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
- [x] **Step 5: Commit** `feat(mapping): индекс и резолв контрагентов по ИНН/имени/алиасу (§3 п. 4)`.
---

View File

@ -30,7 +30,7 @@
## 3. Маппинг
- [ ] 3.1 **Домен маппинга.** `domain/osv_mapping.py`: `normalize_account_code`, `account_root`, `EntryDraft`, `build_entries(doc,
- [x] 3.1 **Домен маппинга.** `domain/osv_mapping.py`: `normalize_account_code`, `account_root`, `EntryDraft`, `build_entries(doc,
chart, resolve)` (пропуск TOTAL, parent по row_number, `account_id`), `counterparty_check(header, owner)`, `document_status(...)`;
`repositories/counterparties.py:CounterpartyIndex` + `resolve(row) -> uuid | None` (ИНН → имя → алиас, >1 → None).
Unit-тесты: нормализация (`60.1`, `76.7.1`, `76.АВ`, `МЦ.04` → root None), дерево и TOTAL на `result-osv-minimal.json`