From 9658f809e759fb3ba82513f0877530835c0f0b50 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 22:27:42 +0400 Subject: [PATCH] =?UTF-8?q?feat(mapping):=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=20=D0=B8=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BE=20=D0=98=D0=9D=D0=9D/=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8/=D0=B0=D0=BB=D0=B8=D0=B0=D1=81=D1=83=20(=C2=A73=20?= =?UTF-8?q?=D0=BF.=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/counterparties.py | 78 +++++++++++ api/tests/test_counterparty_resolve.py | 126 ++++++++++++++++++ .../plans/2026-10-05-slice-2c-osv-ingest.md | 6 +- openspec/changes/slice-2c-osv-ingest/tasks.md | 2 +- 4 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 api/src/repositories/counterparties.py create mode 100644 api/tests/test_counterparty_resolve.py diff --git a/api/src/repositories/counterparties.py b/api/src/repositories/counterparties.py new file mode 100644 index 0000000..638d0f1 --- /dev/null +++ b/api/src/repositories/counterparties.py @@ -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) diff --git a/api/tests/test_counterparty_resolve.py b/api/tests/test_counterparty_resolve.py new file mode 100644 index 0000000..1153dd0 --- /dev/null +++ b/api/tests/test_counterparty_resolve.py @@ -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 diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index d4616c3..c94a3f2 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -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)`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index d74fd8e..fe6bd91 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -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`