Резолв контрагента: алиас приоритетнее ЮЛ-тёзки по нормализованному имени (#57)

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
This commit is contained in:
Andrey Epifancev 2026-10-08 00:21:15 +04:00
parent 53ac9f19ed
commit 7dcffbd10d
7 changed files with 121 additions and 13 deletions

View File

@ -722,7 +722,7 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
склеиваются по ключу (счёт, имя контрагента, ИНН): сальдо начала — из Q1, обороты — сумма, сальдо конца — из Qn, склеиваются по ключу (счёт, имя контрагента, ИНН): сальдо начала — из Q1, обороты — сумма, сальдо конца — из Qn,
отсутствующая в квартале строка — нули; нарастающий и квартальные вместе — нарастающий. Нет квартала — **пробел** отсутствующая в квартале строка — нули; нарастающий и квартальные вместе — нарастающий. Нет квартала — **пробел**
(`gaps[]` по паре, строк нет). Q1-эквивалентность ОСВ — см. «Привязка документов». (`gaps[]` по паре, строк нет). Q1-эквивалентность ОСВ — см. «Привязка документов».
- Контрагент строки: хранимый `osv_entry.counterparty_id` либо резолв на лету (ИНН → имя → алиас; в БД не пишется, - Контрагент строки: хранимый `osv_entry.counterparty_id` либо резолв на лету (ИНН → точное имя → алиас → нормализованное имя; алиас приоритетнее ЮЛ-тёзок, OQ-128; в БД не пишется,
`match_stored = false`); `match_kind` — `INN`/`NAME`/`ALIAS` (шаг резолва), `MANUAL`, `PROPAGATED`. `match_stored = false`); `match_kind` — `INN`/`NAME`/`ALIAS` (шаг резолва), `MANUAL`, `PROPAGATED`.
Тип контрагента: `GSK` (член периметра), `GK` (та же ГК), иначе `EXTERNAL`. Тип контрагента: `GSK` (член периметра), `GK` (та же ГК), иначе `EXTERNAL`.
- Фильтры: `legal_entity_id`, `account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q` - Фильтры: `legal_entity_id`, `account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q`

View File

@ -29,6 +29,8 @@ class CounterpartyIndex:
by_inn: dict[str, uuid.UUID] by_inn: dict[str, uuid.UUID]
by_exact_name: dict[str, set[uuid.UUID]] # ключ — `name.strip()` by_exact_name: dict[str, set[uuid.UUID]] # ключ — `name.strip()`
by_normalized: dict[str, set[uuid.UUID]] # normalize_alias(name) ∪ normalized_alias алиасов; без пустого ключа 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` aliases_by_entity: dict[uuid.UUID, set[str]] = field(default_factory=dict) # для `owner_names`
@classmethod @classmethod
@ -37,6 +39,8 @@ class CounterpartyIndex:
by_inn: dict[str, uuid.UUID] = {} by_inn: dict[str, uuid.UUID] = {}
by_exact_name: dict[str, set[uuid.UUID]] = defaultdict(set) by_exact_name: dict[str, set[uuid.UUID]] = defaultdict(set)
by_normalized: 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) 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)): 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) by_exact_name[name.strip()].add(entity_id)
if normalized := normalize_alias(name): if normalized := normalize_alias(name):
by_normalized[normalized].add(entity_id) 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)) alias_rows = await session.execute(select(LegalEntityAlias.legal_entity_id, LegalEntityAlias.normalized_alias))
for entity_id, normalized_alias in alias_rows: for entity_id, normalized_alias in alias_rows:
if normalized_alias: if normalized_alias:
by_normalized[normalized_alias].add(entity_id) by_normalized[normalized_alias].add(entity_id)
by_alias[normalized_alias].add(entity_id)
aliases_by_entity[entity_id].add(normalized_alias) aliases_by_entity[entity_id].add(normalized_alias)
return cls( return cls(
by_inn=by_inn, by_inn=by_inn,
by_exact_name=dict(by_exact_name), by_exact_name=dict(by_exact_name),
by_normalized=dict(by_normalized), by_normalized=dict(by_normalized),
by_normalized_name=dict(by_normalized_name),
by_alias=dict(by_alias),
aliases_by_entity=dict(aliases_by_entity), aliases_by_entity=dict(aliases_by_entity),
) )
def resolve(self, inn: str | None, name: str | None) -> Resolution | None: 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). Ноль кандидатов на шаге — ИНН, которого нет в справочнике, не блокирует поиск по имени (Review Focus 3). Ноль кандидатов на шаге —
переход к следующему; больше одного — ручное сопоставление, дальше не идём. переход к следующему; больше одного — ручное сопоставление, дальше не идём.
Алиас приоритетнее нормализованного имени (OQ-128): алиас уникален (`ALIAS_TAKEN`), его владелец однозначен
и берётся, даже если есть ЮЛ-тёзки по нормализованному имени. Несколько владельцев одного алиаса (нарушение
правила приложения, например данные импорта) — неоднозначность → `None`. Тёзки без алиаса → `None`.
""" """
if inn and (found := self.by_inn.get(normalize_inn(inn))) is not None: if inn and (found := self.by_inn.get(normalize_inn(inn))) is not None:
return Resolution(found, CounterpartyMatchKind.INN) return Resolution(found, CounterpartyMatchKind.INN)
@ -72,7 +84,10 @@ class CounterpartyIndex:
return None return None
if candidates := self.by_exact_name.get(stripped): if candidates := self.by_exact_name.get(stripped):
return Resolution(next(iter(candidates)), CounterpartyMatchKind.NAME) if len(candidates) == 1 else None 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)): 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 Resolution(next(iter(candidates)), CounterpartyMatchKind.ALIAS) if len(candidates) == 1 else None
return None return None

View File

@ -227,6 +227,43 @@ async def test_counterparty_types_and_read_time_resolution(
assert _row(body, "ООО Б", "60", "ООО А")["counterparty_type"] == "GSK" 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 @pytest.mark.integration
async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded) world = await _world(seeded)

View File

@ -387,8 +387,14 @@ async def test_mapping_from_slice_plan_propagates_and_creates_alias(
alias = (await seeded.execute(select(LegalEntityAlias))).scalar_one() 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 assert alias.created_by == (await _user(seeded, KA_RF)).id and alias.created_at is not None
after_summary = await _summary(client, world) after_summary = await _summary(client, world)
assert after_summary["unmapped_total"] == before_summary["unmapped_total"] - (1 + body["propagated"]) # строка 76 с чужим ИНН: хранимого сопоставления нет (проверено выше), но ИНН нет в справочнике и не блокирует
assert after_summary["unmapped_total"] == 1 # строка 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 не тронуты assert await _primary(seeded) == primary_before # counterparty_name/inn не тронуты
await _assert_kind_iff_id(seeded) await _assert_kind_iff_id(seeded)

View File

@ -95,14 +95,54 @@ async def test_exact_name_zero_candidates_goes_to_normalized(db_session: AsyncSe
assert _entity(index.resolve(None, "Нормализуемое имя, ООО")) == entity.id assert _entity(index.resolve(None, "Нормализуемое имя, ООО")) == entity.id
async def test_alias_ambiguity_between_name_and_alias_of_different_entities(db_session: AsyncSession) -> None: async def test_alias_wins_over_namesake_by_normalized_name(db_session: AsyncSession) -> None:
first = await make_legal_entity(db_session, name="ООО Гамма Сервис") """OQ-128 (§3 п. 4): ЮЛ-тёзка по нормализованному имени не мешает владельцу алиаса — алиас приоритетнее имени."""
second = await make_legal_entity(db_session, name="ООО Дельта") namesake = await make_legal_entity(db_session, name="ООО Гамма Сервис")
await _alias(db_session, second.id, "Гамма сервис") owner = await make_legal_entity(db_session, name="ООО Дельта")
await _alias(db_session, owner.id, "Гамма сервис")
index = await CounterpartyIndex.load(db_session) index = await CounterpartyIndex.load(db_session)
assert first.id != second.id assert namesake.id != owner.id
assert index.resolve(None, "Гамма Сервис") is None 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: async def test_same_entity_by_name_and_alias_is_not_ambiguous(db_session: AsyncSession) -> None:

View File

@ -65,6 +65,11 @@ MUST NOT агрегироваться между контрагентами. `co
- **THEN** в ответе `counterparty_id` заполнен, `match_kind = ALIAS`, `match_stored = false`; в `osv_entry` значение - **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: Страница свода и сводка ### Requirement: Страница свода и сводка
`GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`, `GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`,
`row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`, `row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`,

View File

@ -85,6 +85,11 @@ LOCKED`), проверять, что `result_s3_object` относится к б
- **THEN** строка с ИНН `3528052718` → это ЮЛ, `counterparty_match_kind = INN`; строка «Русский бисквит» без ИНН → ЮЛ по - **THEN** строка с ИНН `3528052718` → это ЮЛ, `counterparty_match_kind = INN`; строка «Русский бисквит» без ИНН → ЮЛ по
алиасу, `ALIAS`; строка «ООО Альфа» → `counterparty_id = NULL`, `counterparty_match_kind = NULL` алиасу, `ALIAS`; строка «ООО Альфа» → `counterparty_id = NULL`, `counterparty_match_kind = NULL`
#### Scenario: Алиас приоритетнее ЮЛ-тёзки
- **WHEN** в справочнике есть ЮЛ «ООО Гамма Сервис» и другое ЮЛ с алиасом `гамма сервис`, строка — «Гамма Сервис» без ИНН
- **THEN** `counterparty_id` — владелец алиаса, `counterparty_match_kind = ALIAS`; тот же результат при дорезолве свода
на чтение
### Requirement: Проверки шапки и статус документа ### Requirement: Проверки шапки и статус документа
`counterparty_check` SHALL быть `MATCH`, если ИНН шапки равен ИНН владельца, или (без ИНН) нормализованное имя шапки `counterparty_check` SHALL быть `MATCH`, если ИНН шапки равен ИНН владельца, или (без ИНН) нормализованное имя шапки
совпадает с нормализованным именем или алиасом владельца; `MISMATCH` иначе; `UNKNOWN` при пустой шапке. совпадает с нормализованным именем или алиасом владельца; `MISMATCH` иначе; `UNKNOWN` при пустой шапке.