"""Ручное сопоставление контрагента строки сводной ОСВ (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, )