"""Статусы версии консолидации Среза 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)