Резолв контрагента: алиас приоритетнее ЮЛ-тёзки по нормализованному имени (#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:
parent
53ac9f19ed
commit
7dcffbd10d
@ -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`
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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`,
|
||||||
|
|||||||
@ -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` при пустой шапке.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user