From 7dcffbd10de3dbb72d02cb249833a7760e20241a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 00:21:15 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A0=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=B0:?= =?UTF-8?q?=20=D0=B0=D0=BB=D0=B8=D0=B0=D1=81=20=D0=BF=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D0=B5=D1=82=D0=BD=D0=B5=D0=B5=20=D0=AE=D0=9B?= =?UTF-8?q?-=D1=82=D1=91=D0=B7=D0=BA=D0=B8=20=D0=BF=D0=BE=20=D0=BD=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=BC=D1=83=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B8=20(#5?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CounterpartyIndex.resolve: порядок шагов ИНН -> точное имя -> алиас -> нормализованное имя (OQ-128, §3 п. 4). Алиас уникален (ALIAS_TAKEN), поэтому владелец алиаса берётся с match_kind = ALIAS, даже если есть ЮЛ-тёзки по нормализованному имени; NULL остаётся при нескольких совпадениях только по имени. Правило общее для маппинга (Срез 2c) и дорезолва свода на чтение (§3 п. 7). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 2 +- api/src/repositories/counterparties.py | 23 ++++++++-- api/tests/test_api_consolidated_osv.py | 37 +++++++++++++++ api/tests/test_api_counterparty_mapping.py | 10 ++++- api/tests/test_counterparty_resolve.py | 52 +++++++++++++++++++--- openspec/specs/consolidated-osv/spec.md | 5 +++ openspec/specs/osv-result-mapping/spec.md | 5 +++ 7 files changed, 121 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7f83147..efe80d7 100644 --- a/README.md +++ b/README.md @@ -722,7 +722,7 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` склеиваются по ключу (счёт, имя контрагента, ИНН): сальдо начала — из Q1, обороты — сумма, сальдо конца — из Qn, отсутствующая в квартале строка — нули; нарастающий и квартальные вместе — нарастающий. Нет квартала — **пробел** (`gaps[]` по паре, строк нет). Q1-эквивалентность ОСВ — см. «Привязка документов». -- Контрагент строки: хранимый `osv_entry.counterparty_id` либо резолв на лету (ИНН → имя → алиас; в БД не пишется, +- Контрагент строки: хранимый `osv_entry.counterparty_id` либо резолв на лету (ИНН → точное имя → алиас → нормализованное имя; алиас приоритетнее ЮЛ-тёзок, OQ-128; в БД не пишется, `match_stored = false`); `match_kind` — `INN`/`NAME`/`ALIAS` (шаг резолва), `MANUAL`, `PROPAGATED`. Тип контрагента: `GSK` (член периметра), `GK` (та же ГК), иначе `EXTERNAL`. - Фильтры: `legal_entity_id`, `account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q` diff --git a/api/src/repositories/counterparties.py b/api/src/repositories/counterparties.py index 9f9a4ec..e659fe9 100644 --- a/api/src/repositories/counterparties.py +++ b/api/src/repositories/counterparties.py @@ -29,6 +29,8 @@ 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 алиасов; без пустого ключа + by_normalized_name: dict[str, set[uuid.UUID]] = field(default_factory=dict) # только normalize_alias(name) + by_alias: dict[str, set[uuid.UUID]] = field(default_factory=dict) # только normalized_alias алиасов (владельцы) aliases_by_entity: dict[uuid.UUID, set[str]] = field(default_factory=dict) # для `owner_names` @classmethod @@ -37,6 +39,8 @@ class CounterpartyIndex: 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) + by_normalized_name: dict[str, set[uuid.UUID]] = defaultdict(set) + by_alias: 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)): @@ -44,27 +48,35 @@ class CounterpartyIndex: by_exact_name[name.strip()].add(entity_id) if normalized := normalize_alias(name): by_normalized[normalized].add(entity_id) + by_normalized_name[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) + by_alias[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), + by_normalized_name=dict(by_normalized_name), + by_alias=dict(by_alias), aliases_by_entity=dict(aliases_by_entity), ) def resolve(self, inn: str | None, name: str | None) -> Resolution | None: - """§3 п. 4: ИНН → точное имя → нормализованное имя/алиас; больше одного кандидата на шаге → `None`. + """§3 п. 4: ИНН → точное имя → алиас → нормализованное имя; больше одного кандидата на шаге → `None`. - Шаг попадает в результат: ИНН → `INN`, точное имя → `NAME`, нормализованное имя ЮЛ или алиас → `ALIAS`. + Шаг попадает в результат: ИНН → `INN`, точное имя → `NAME`, алиас или нормализованное имя ЮЛ → `ALIAS`. ИНН, которого нет в справочнике, не блокирует поиск по имени (Review Focus 3). Ноль кандидатов на шаге — переход к следующему; больше одного — ручное сопоставление, дальше не идём. + + Алиас приоритетнее нормализованного имени (OQ-128): алиас уникален (`ALIAS_TAKEN`), его владелец однозначен + и берётся, даже если есть ЮЛ-тёзки по нормализованному имени. Несколько владельцев одного алиаса (нарушение + правила приложения, например данные импорта) — неоднозначность → `None`. Тёзки без алиаса → `None`. """ if inn and (found := self.by_inn.get(normalize_inn(inn))) is not None: return Resolution(found, CounterpartyMatchKind.INN) @@ -72,8 +84,11 @@ class CounterpartyIndex: return None if candidates := self.by_exact_name.get(stripped): return Resolution(next(iter(candidates)), CounterpartyMatchKind.NAME) if len(candidates) == 1 else None - if (normalized := normalize_alias(stripped)) and (candidates := self.by_normalized.get(normalized)): - return Resolution(next(iter(candidates)), CounterpartyMatchKind.ALIAS) if len(candidates) == 1 else None + if not (normalized := normalize_alias(stripped)): + return None + for step in (self.by_alias, self.by_normalized_name): + if candidates := step.get(normalized): + return Resolution(next(iter(candidates)), CounterpartyMatchKind.ALIAS) if len(candidates) == 1 else None return None def owner_names(self, entity: LegalEntity) -> frozenset[str]: diff --git a/api/tests/test_api_consolidated_osv.py b/api/tests/test_api_consolidated_osv.py index afd7fee..bf97a41 100644 --- a/api/tests/test_api_consolidated_osv.py +++ b/api/tests/test_api_consolidated_osv.py @@ -227,6 +227,43 @@ async def test_counterparty_types_and_read_time_resolution( assert _row(body, "ООО Б", "60", "ООО А")["counterparty_type"] == "GSK" +@pytest.mark.integration +async def test_read_time_resolution_alias_wins_over_namesakes( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """OQ-128 (§3 п. 4, п. 7): после ручного сопоставления алиас приоритетнее ЮЛ-тёзок по нормализованному имени — + дорезолв на чтение отдаёт владельца алиаса (`ALIAS`, `match_stored = false`), а не «несопоставлено».""" + world = await _world(seeded) + await make_legal_entity(seeded, name="ООО Мю Трейд", inn="6601000001") + await make_legal_entity(seeded, name="АО Мю Трейд", inn="6601000002") + owner = await make_legal_entity(seeded, name="ООО Совсем Другое Имя", inn="6601000003") + seeded.add( + LegalEntityAlias(legal_entity_id=owner.id, alias="Мю Трейд", normalized_alias=normalize_alias("Мю Трейд")) + ) + document = await make_confirmed_osv( + seeded, + entity=world.a, + account="70", + year=YEAR, + quarter=Q3, + is_cumulative=True, + rows=[{"name": "Мю Трейд", "closing_debit": D(1)}, {"name": "Ню Трейд", "closing_debit": D(1)}], + ) + await _bind(seeded, world.version, document) + await make_legal_entity(seeded, name="ООО Ню Трейд", inn="6601000004") + await make_legal_entity(seeded, name="АО Ню Трейд", inn="6601000005") + client = await auth_client(KA_RF) + + body = await _page(client, world) + + resolved = _row(body, "ООО А", "70", "Мю Трейд") + assert (resolved["counterparty_id"], resolved["match_kind"], resolved["match_stored"]) == ( + str(owner.id), "ALIAS", False) # fmt: skip + # тёзки без алиаса по-прежнему неоднозначны + ambiguous = _row(body, "ООО А", "70", "Ню Трейд") + assert (ambiguous["counterparty_id"], ambiguous["match_kind"]) == (None, None) + + @pytest.mark.integration async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) diff --git a/api/tests/test_api_counterparty_mapping.py b/api/tests/test_api_counterparty_mapping.py index 99cde4f..2502f29 100644 --- a/api/tests/test_api_counterparty_mapping.py +++ b/api/tests/test_api_counterparty_mapping.py @@ -387,8 +387,14 @@ async def test_mapping_from_slice_plan_propagates_and_creates_alias( alias = (await seeded.execute(select(LegalEntityAlias))).scalar_one() assert alias.created_by == (await _user(seeded, KA_RF)).id and alias.created_at is not None after_summary = await _summary(client, world) - assert after_summary["unmapped_total"] == before_summary["unmapped_total"] - (1 + body["propagated"]) - assert after_summary["unmapped_total"] == 1 # строка 76 с чужим ИНН + # строка 76 с чужим ИНН: хранимого сопоставления нет (проверено выше), но ИНН нет в справочнике и не блокирует + # поиск по имени, а алиас приоритетнее ЮЛ-тёзки (OQ-128) — на чтении она резолвится по алиасу + assert after_summary["unmapped_total"] == before_summary["unmapped_total"] - (1 + body["propagated"]) - 1 + assert after_summary["unmapped_total"] == 0 + page = (await client.get(f"/consolidations/versions/{world.version.id}/osv?limit=100")).json() + alien = next(r for r in page["items"] if r["account_code"] == "76" and r["counterparty_name"] == NAME) + assert (alien["counterparty_id"], alien["match_kind"], alien["match_stored"]) == ( + str(world.schastl.id), "ALIAS", False) # fmt: skip assert await _primary(seeded) == primary_before # counterparty_name/inn не тронуты await _assert_kind_iff_id(seeded) diff --git a/api/tests/test_counterparty_resolve.py b/api/tests/test_counterparty_resolve.py index fa6e773..4a052c3 100644 --- a/api/tests/test_counterparty_resolve.py +++ b/api/tests/test_counterparty_resolve.py @@ -95,14 +95,54 @@ async def test_exact_name_zero_candidates_goes_to_normalized(db_session: AsyncSe assert _entity(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, "Гамма сервис") +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 first.id != second.id - assert index.resolve(None, "Гамма Сервис") is None + 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: diff --git a/openspec/specs/consolidated-osv/spec.md b/openspec/specs/consolidated-osv/spec.md index 7e4c7db..6b611d1 100644 --- a/openspec/specs/consolidated-osv/spec.md +++ b/openspec/specs/consolidated-osv/spec.md @@ -65,6 +65,11 @@ MUST NOT агрегироваться между контрагентами. `co - **THEN** в ответе `counterparty_id` заполнен, `match_kind = ALIAS`, `match_stored = false`; в `osv_entry` значение не изменилось +#### Scenario: Алиас приоритетнее ЮЛ-тёзки +- **WHEN** у строки `counterparty_id` пуст, в справочнике есть ЮЛ-тёзки по нормализованному имени строки и владелец + алиаса с тем же `normalized_alias` (OQ-128, §3 п. 4) +- **THEN** дорезолв отдаёт владельца алиаса, `match_kind = ALIAS`; без алиаса несколько тёзок дают `counterparty_id = null` + ### Requirement: Страница свода и сводка `GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`, `row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`, diff --git a/openspec/specs/osv-result-mapping/spec.md b/openspec/specs/osv-result-mapping/spec.md index aa34a57..3591a8f 100644 --- a/openspec/specs/osv-result-mapping/spec.md +++ b/openspec/specs/osv-result-mapping/spec.md @@ -85,6 +85,11 @@ LOCKED`), проверять, что `result_s3_object` относится к б - **THEN** строка с ИНН `3528052718` → это ЮЛ, `counterparty_match_kind = INN`; строка «Русский бисквит» без ИНН → ЮЛ по алиасу, `ALIAS`; строка «ООО Альфа» → `counterparty_id = NULL`, `counterparty_match_kind = NULL` +#### Scenario: Алиас приоритетнее ЮЛ-тёзки +- **WHEN** в справочнике есть ЮЛ «ООО Гамма Сервис» и другое ЮЛ с алиасом `гамма сервис`, строка — «Гамма Сервис» без ИНН +- **THEN** `counterparty_id` — владелец алиаса, `counterparty_match_kind = ALIAS`; тот же результат при дорезолве свода + на чтение + ### Requirement: Проверки шапки и статус документа `counterparty_check` SHALL быть `MATCH`, если ИНН шапки равен ИНН владельца, или (без ИНН) нормализованное имя шапки совпадает с нормализованным именем или алиасом владельца; `MISMATCH` иначе; `UNKNOWN` при пустой шапке.