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