arm-core/api/src/services/consolidation_status.py
Andrey Epifancev 90b7bd5c16 feat(consolidation): редактирование периметра версии с подхватом и отвязкой документов
PUT /consolidations/versions/{id}/members: замена состава ЮЛ версии, проверка принадлежности ГК
(ENTITY_NOT_IN_GROUP), минимум два ЮЛ, diff с отвязкой документов ушедших (unbind_entity) и подхватом
добавленных (bind_confirmed, заглушка до Task 6), аудит MEMBERSHIP_CHANGE. Общий пролог
«lock → видимость → редактируемость» вынесен в services/consolidation_guard.py (T4.2). Решения #25–#29.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 04:34:52 +04:00

138 lines
7.0 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.

"""Статусы версии консолидации Среза 4 (§9.1; spec consolidations-api «Статусы Среза 4»; audit-trail
`CONSOLIDATION_STATUS_CHANGE`; design D-6, D-8).
`transition` — ЕДИНСТВЕННОЕ место, где меняется `consolidation_version.status`: ручной `POST …/status` и автоматические
переходы (загрузка, маппинг, подтверждение — Task 6–7) зовут её же, поэтому история и аудит пишутся всегда одинаково.
Ручные переходы — только `MANUAL_TRANSITIONS`; остальное — 409 `TRANSITION_NOT_ALLOWED`.
"""
import datetime
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, DomainValidationError
from src.domain.policy import VisibilityScope
from src.models.consolidation import ConsolidationStatusHistory, ConsolidationVersion
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus
from src.repositories.users import UserWithAccess
from src.schemas.consolidations import StatusIn, VersionCard
from src.services import consolidation_locks
from src.services.audit import AuditService
from src.services.consolidation_guard import lock_editable_version
from src.services.consolidations import OBJECT_TYPE, ConsolidationService
_CANCELLED = ConsolidationStatus.CANCELLED
_INITIATED = ConsolidationStatus.INITIATED
#: Ручные переходы Среза 4: из статуса «в работе» → куда можно (`CANCELLED` — из любого, возврат в `INITIATED` — из
#: `DOCUMENTS_PROCESSING` / `DATA_CORRECTION`). Переходы Среза 7 (`CONSOLIDATION_RF` и далее) сюда не входят.
MANUAL_TRANSITIONS: dict[ConsolidationStatus, frozenset[ConsolidationStatus]] = {
ConsolidationStatus.INITIATED: frozenset({_CANCELLED}),
ConsolidationStatus.DOCUMENTS_PROCESSING: frozenset({_CANCELLED, _INITIATED}),
ConsolidationStatus.DATA_CORRECTION: frozenset({_CANCELLED, _INITIATED}),
}
async def transition( # noqa: PLR0913 - контекст аудита (пользователь, IP, user-agent) и время — от вызывающего
session: AsyncSession,
audit: AuditService,
*,
version: ConsolidationVersion,
to: ConsolidationStatus,
changed_by: uuid.UUID,
comment: str | None,
ip_address: str | None = None,
user_agent: str | None = None,
now: datetime.datetime,
) -> ConsolidationStatusHistory:
"""Переводит версию в статус `to`: `status`, `updated_by/at`, запись `consolidation_status_history` и
`audit_event(CONSOLIDATION_STATUS_CHANGE, before = {status}, after = {status}, comment)` с `user_id = changed_by`.
При `CANCELLED` дополнительно: `cancelled_reason = comment`, `is_in_work = false`, активная блокировка снимается
(`consolidation_locks.release_active_lock`, design решение №3). Допустимость перехода НЕ проверяется — это дело
вызывающего (ручной — `MANUAL_TRANSITIONS`, автоматические — свои условия, D-6).
Версия ДОЛЖНА быть уже заблокирована вызывающим `FOR UPDATE` (`repo.lock_version` /
`repo.lock_versions_for_entity`, D-9): иначе параллельный переход или перехват прочтёт устаревший статус.
"""
from_status = version.status
version.status = to
version.updated_by = changed_by
version.updated_at = now
if to == ConsolidationStatus.CANCELLED:
version.cancelled_reason = comment
version.is_in_work = False
await consolidation_locks.release_active_lock(session, version.id, now)
record = ConsolidationStatusHistory(
version_id=version.id,
from_status=from_status,
to_status=to,
changed_by=changed_by,
changed_at=now,
comment=comment,
)
session.add(record)
await session.flush()
await audit.record(
action=AuditAction.CONSOLIDATION_STATUS_CHANGE,
section=AuditSection.CONSOLIDATION,
object_type=OBJECT_TYPE,
object_id=version.id,
user_id=changed_by,
consolidation_version_id=version.id,
before_value={"status": from_status},
after_value={"status": to},
comment=comment,
ip_address=ip_address,
user_agent=user_agent,
)
return record
class ConsolidationStatusService:
"""Ручная смена статуса версии от имени текущего пользователя (владельца блокировки)."""
def __init__(
self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, principal: UserWithAccess
) -> None:
self._session = session
self._audit = audit
self._scope = scope
self._principal = principal
async def change(self, version_id: uuid.UUID, data: StatusIn) -> VersionCard:
"""Ручной переход; ответ — карточка версии.
Порядок: `FOR UPDATE` версии (D-9) → видимость (404) → `ensure_version_editable` (409 `VERSION_NOT_EDITABLE`
/ `LOCK_REQUIRED` / `LOCKED_BY_OTHER`) → переход из `MANUAL_TRANSITIONS` (иначе 409 `TRANSITION_NOT_ALLOWED`
с `from_status`, `to_status`; повтор того же статуса — тоже) → `CANCELLED` без комментария — 422
`COMMENT_REQUIRED`. Комментарий из одних пробелов считается пустым.
"""
user = self._principal.user
version, _ = await lock_editable_version(self._session, self._scope, version_id, user.id)
if data.to_status not in MANUAL_TRANSITIONS.get(version.status, frozenset()):
raise ConflictError(
"TRANSITION_NOT_ALLOWED",
f"Переход {version.status.value} → {data.to_status.value} вручную недоступен",
from_status=version.status,
to_status=data.to_status,
)
comment = (data.comment or "").strip() or None
if data.to_status == ConsolidationStatus.CANCELLED and comment is None:
raise DomainValidationError("COMMENT_REQUIRED", "Укажите причину аннулирования")
await transition(
self._session,
self._audit.service,
version=version,
to=data.to_status,
changed_by=user.id,
comment=comment,
ip_address=self._audit.ip_address,
user_agent=self._audit.user_agent,
now=utc_now(),
)
return await ConsolidationService(self._session, self._audit, self._scope, self._principal).card(version.id)