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:
parent
4e9253204c
commit
9658f809e7
78
api/src/repositories/counterparties.py
Normal file
78
api/src/repositories/counterparties.py
Normal 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)
|
||||||
126
api/tests/test_counterparty_resolve.py
Normal file
126
api/tests/test_counterparty_resolve.py
Normal 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
|
||||||
@ -194,9 +194,9 @@ class CounterpartyIndex:
|
|||||||
def owner_names(self, entity: LegalEntity) -> frozenset[str] # normalize_alias(name) ∪ его алиасы — для counterparty_check
|
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`.
|
- [x] **Step 1: Тесты** (integration на `db_session` с фабриками `make_legal_entity` + алиасы через `LegalEntityAlias`): сценарий спеки «По ИНН, по алиасу, неоднозначно»; ИНН не найден, имя совпадает с алиасом другого ЮЛ → по алиасу (Review Focus 3); закрытое ЮЛ (`close_date`) тоже резолвится; `owner_names`.
|
||||||
- [ ] **Step 2–4:** падает → реализация → тесты, `make check`.
|
- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
|
||||||
- [ ] **Step 5: Commit** `feat(mapping): индекс и резолв контрагентов по ИНН/имени/алиасу (§3 п. 4)`.
|
- [x] **Step 5: Commit** `feat(mapping): индекс и резолв контрагентов по ИНН/имени/алиасу (§3 п. 4)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
## 3. Маппинг
|
## 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(...)`;
|
chart, resolve)` (пропуск TOTAL, parent по row_number, `account_id`), `counterparty_check(header, owner)`, `document_status(...)`;
|
||||||
`repositories/counterparties.py:CounterpartyIndex` + `resolve(row) -> uuid | None` (ИНН → имя → алиас, >1 → None).
|
`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`
|
Unit-тесты: нормализация (`60.1`, `76.7.1`, `76.АВ`, `МЦ.04` → root None), дерево и TOTAL на `result-osv-minimal.json`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user