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