arm-core/api/src/services/counterparty_mapping.py
Andrey Epifancev 5868b14c4d fix(consolidations): верхние границы входов и видимость до FOR UPDATE (M-6)
MembersIn.legal_entity_ids ≤ 500, StatusIn.comment ≤ 1000, OsvFilters.q ≤ 500 (422 со списком ошибок);
lock_visible_version проверяет видимость версии до FOR UPDATE. Спека consolidations-api: требование
«Границы входных данных».

Refs #21

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 13:06:11 +04:00

188 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Ручное сопоставление контрагента строки сводной ОСВ (spec consolidated-osv «Ручное сопоставление контрагента»;
audit-trail `OSV_COUNTERPARTY_MAP`, `LEGAL_ENTITY_CHANGE`; design D-10; §3 «Ручное сопоставление»).
Одна транзакция, порядок проверок и блокировок:
1. `lock_editable_version` — видимость (404) → версия `FOR UPDATE` → статус «в работе» и своя блокировка (409).
2. Строка — строка свода версии (`entry_in_version`, иначе 404 «строка не найдена»); ЮЛ существует (без policy:
контрагент может быть из другого филиала; иначе 404 `LEGAL_ENTITY_NOT_FOUND`).
3. `normalized = normalize_alias(имя строки)`; непустой — advisory-блокировка алиаса (`lock_alias_key`, общая со
справочником) и владельцы алиаса среди других ЮЛ: без `move_alias` — 409 `ALIAS_TAKEN` (`owners[]`), с ним — их
алиасы удаляются (`LEGAL_ENTITY_CHANGE` на каждый). `DIRECTORY_DELETE` не требуется (решение владельца 06.10.2026).
4. Строки с тем же `counterparty_name.strip()` (и сама строка) — `FOR NO KEY UPDATE ORDER BY id`
(`lock_entries_same_name`): строки ОСВ общие для всех версий документа (OQ-123), параллельное сопоставление в
соседней версии ждёт и видит наш результат.
5. Алиаса у выбранного ЮЛ нет и `normalized` непуст → алиас `counterparty_name.strip()` (`LEGAL_ENTITY_CHANGE`).
6. Строке — `counterparty_id`, `MANUAL`; автоприменение — строкам свода версии с тем же именем, пустым
`counterparty_id` и ИНН пустым или равным ИНН выбранного ЮЛ → `PROPAGATED`. `counterparty_name/inn`,
`modified_by/at`, `is_modified` не трогаются (первичка не мутируется; сопоставление — не правка значений).
7. Одно `OSV_COUNTERPARTY_MAP` с `consolidation_version_id`; ответ — строка свода, пересчитанная заново.
"""
import uuid
from typing import Any
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.constraint_conflicts import constraint_name
from src.common.exceptions import ConflictError, NotFoundError
from src.domain.aliases import normalize_alias
from src.domain.inn import normalize_inn
from src.domain.policy import VisibilityScope
from src.models.directories import LegalEntity, LegalEntityAlias
from src.models.enums import AuditAction, AuditSection, CounterpartyMatchKind
from src.models.osv import OsvEntry
from src.repositories import consolidations as repo
from src.repositories import directories as directories_repo
from src.schemas.consolidations import MapCounterpartyIn, MapCounterpartyOut
from src.services.aliases import OBJECT_TYPE as ALIAS_OBJECT_TYPE
from src.services.aliases import alias_payload
from src.services.consolidated_osv import ConsolidatedOsvService
from src.services.consolidation_guard import lock_editable_version
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
ENTRY_NOT_FOUND = "строка ОСВ не найдена в версии" # непривязанный документ, не строка свода, несуществующая
OBJECT_TYPE = "osv_entry"
ALIAS_UNIQUE_INDEX = "uq_legal_entity_alias_legal_entity_id_normalized_alias"
def inn_agrees(entry_inn: str | None, target_inn: str) -> bool:
"""ИНН строки не противоречит выбранному ЮЛ: пустой (после удаления пробелов) или равный его ИНН."""
inn = normalize_inn(entry_inn or "")
return not inn or inn == normalize_inn(target_inn)
class CounterpartyMappingService:
"""Ручное сопоставление контрагента строки свода от имени текущего пользователя."""
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
self._session = session
self._audit = audit
self._scope = scope
async def map(self, version_id: uuid.UUID, entry_id: uuid.UUID, data: MapCounterpartyIn) -> MapCounterpartyOut:
"""Связывает строку `entry_id` версии с ЮЛ `data.legal_entity_id` (порядок — в docstring модуля)."""
version, _ = await lock_editable_version(self._session, self._scope, version_id, self._audit.user_id)
entry = await repo.entry_in_version(self._session, entry_id, version.id)
if entry is None:
raise NotFoundError(ENTRY_NOT_FOUND)
target = await self._session.get(LegalEntity, data.legal_entity_id)
if target is None:
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
name = (entry.counterparty_name or "").strip()
normalized = normalize_alias(name)
owners: list[tuple[LegalEntityAlias, str]] = []
target_has_alias = False
if normalized:
await directories_repo.lock_alias_key(self._session, normalized)
for alias, owner_name in await directories_repo.find_alias_owners(self._session, normalized):
if alias.legal_entity_id == target.id:
target_has_alias = True
else:
owners.append((alias, owner_name))
if owners and not data.move_alias:
raise ConflictError(
"ALIAS_TAKEN",
"Такое наименование уже закреплено алиасом за другим ЮЛ",
owners=[{"legal_entity_id": alias.legal_entity_id, "name": owner} for alias, owner in owners],
)
locked = await repo.lock_entries_same_name(self._session, version.id, entry_id=entry.id, name=name)
before = {"counterparty_id": entry.counterparty_id, "match_kind": entry.counterparty_match_kind}
for alias, _ in owners:
await self._remove_alias(alias, version.id)
alias_created = bool(normalized) and not target_has_alias and await self._create_alias(target, name, version.id)
entry.counterparty_id = target.id
entry.counterparty_match_kind = CounterpartyMatchKind.MANUAL
propagated = [row for row in locked if self._propagates(row, entry, name, target)]
for row in propagated:
row.counterparty_id = target.id
row.counterparty_match_kind = CounterpartyMatchKind.PROPAGATED
await self._session.flush()
moved_from = owners[0][0].legal_entity_id if owners else None
await self._audit.record(
action=AuditAction.OSV_COUNTERPARTY_MAP,
section=AuditSection.CONSOLIDATION,
object_type=OBJECT_TYPE,
object_id=entry.id,
consolidation_version_id=version.id,
before_value=before,
after_value={
"counterparty_id": target.id,
"match_kind": CounterpartyMatchKind.MANUAL,
"propagated_entry_ids": [row.id for row in propagated],
"alias_created": alias_created,
"alias_moved_from": moved_from,
},
)
row_out = await ConsolidatedOsvService(self._session, self._scope).row_for_entry(version.id, entry.id)
return MapCounterpartyOut(
row=row_out, propagated=len(propagated), alias_created=alias_created, alias_moved_from=moved_from
)
@staticmethod
def _propagates(row: OsvEntry, entry: OsvEntry, name: str, target: LegalEntity) -> bool:
"""Автоприменение (D-10): другая строка, то же имя после `strip()`, не сопоставлена, ИНН не противоречит."""
return (
row.id != entry.id
and bool(name)
and (row.counterparty_name or "").strip() == name
and row.counterparty_id is None
and inn_agrees(row.counterparty_inn, target.inn)
)
async def _remove_alias(self, alias: LegalEntityAlias, version_id: uuid.UUID) -> None:
"""Удаляет чужой алиас (перенос, `move_alias`); `LEGAL_ENTITY_CHANGE` как у `aliases.remove`."""
before = alias_payload(alias)
alias_id = alias.id
await self._session.delete(alias)
await self._session.flush()
await self._record_alias_change(alias_id, version_id, before=before)
async def _create_alias(self, target: LegalEntity, name: str, version_id: uuid.UUID) -> bool:
"""Алиас `name` у выбранного ЮЛ под SAVEPOINT; `LEGAL_ENTITY_CHANGE` как у `aliases.add`.
Вставку конкурирующих писателей сериализует `lock_alias_key`; уникальный индекс (ЮЛ, `normalized_alias`) —
последний рубеж: его нарушение значит «алиас у ЮЛ уже есть» — `False`, сопоставление продолжается."""
alias = LegalEntityAlias(
legal_entity_id=target.id,
alias=name,
normalized_alias=normalize_alias(name),
created_by=self._audit.user_id,
created_at=utc_now(),
)
try:
async with self._session.begin_nested():
self._session.add(alias)
await self._session.flush()
except IntegrityError as exc:
if constraint_name(exc) != ALIAS_UNIQUE_INDEX:
raise
return False
await self._record_alias_change(alias.id, version_id, after=alias_payload(alias))
return True
async def _record_alias_change(
self,
alias_id: uuid.UUID,
version_id: uuid.UUID,
*,
before: dict[str, Any] | None = None,
after: dict[str, Any] | None = None,
) -> None:
await self._audit.record(
action=AuditAction.LEGAL_ENTITY_CHANGE,
section=AuditSection.DIRECTORIES,
object_type=ALIAS_OBJECT_TYPE,
object_id=alias_id,
consolidation_version_id=version_id,
before_value=before,
after_value=after,
)