Merge pull request 'Алиас приоритетнее имени при резолве контрагента (#57)' (#59) from feat/57-alias-priority into main
Reviewed-on: #59
This commit is contained in:
commit
07ccf98f76
@ -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`
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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` при пустой шапке.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user