arm-core/api/src/domain/policy.py
Andrey Epifancev b2fa59061d feat(consolidation): политика редактируемости, блокировка с перехватом, ручные статусы Среза 4
- domain/policy: IN_WORK_STATUSES, ensure_version_editable (VERSION_NOT_EDITABLE / LOCK_REQUIRED / LOCKED_BY_OTHER)
- services/consolidation_locks: take/release, перехват с причиной, release_active_lock; FOR UPDATE версии первой (D-9),
  вставка под SAVEPOINT, нарушение uq_consolidation_lock_version_active -> 409 LOCKED_BY_OTHER с владельцем
- services/consolidation_status: MANUAL_TRANSITIONS, transition (единственное место смены статуса: история + аудит,
  CANCELLED снимает блокировку), ConsolidationStatusService.change
- маршруты POST/DELETE …/lock, POST …/status; реестр constraint_conflicts
- тесты: таблица ensure_version_editable, сценарии спеки, гонка двух POST …/lock, перехват во время правки и
  аннулирования (две сессии, Review Focus 4)
- минорные T3.1 (FINISHED_STATUSES из policy в фабриках), T3.2 (удалена CONSOLIDATION_NOT_FOUND)
- план: шаги Task 4; tasks.md 2.2, 2.3

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

160 lines
9.1 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.

"""Policy видимости справочников (§10.1 `05-domain-rules.md`, spec directory-policy).
Правило формулируется через данные, а не через роль (инвариант «права — данные», §10): пользователь головного
офиса (`regional_office.is_head_office`) видит всё; пользователь филиала — ГК с `base_regional_office_id` своего
филиала, их ЮЛ и ГСК/ГСЗ, а также монозаёмщиков своего филиала (`company_group_id IS NULL AND regional_office_id = …`).
Код роли (`role.code`) здесь не читается. Право (`require`) отвечает «может ли вообще», policy — «какие именно
записи»; объект вне видимости вызывающий превращает в 404 с тем же телом, что у несуществующего.
ASSUMPTION(OQ-86): ЗО (`app_user.business_segment`) в Срезе 1 не фильтрует — у ГК и ЮЛ нет поля сегмента (DBML v5.15).
Редактируемость версии консолидации (Срез 4, spec directory-policy «Видимость консолидаций и редактируемость версии»,
design D-8): `ensure_version_editable` — чистая проверка статуса и блокировки после права и видимости.
"""
import uuid
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from sqlalchemy import Select, and_, or_, select
from src.common.exceptions import ConflictError, DomainValidationError
from src.models.consolidation import ConsolidationLock, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity
from src.models.enums import ConsolidationStatus
if TYPE_CHECKING:
# Только для типов: домен не тянет репозитории в рантайме
from src.repositories.users import UserWithAccess
# Статус «в работе» (§9.1, spec consolidations-api «Статусы Среза 4»): только в нём версия редактируется
IN_WORK_STATUSES = frozenset(
{
ConsolidationStatus.INITIATED,
ConsolidationStatus.DOCUMENTS_PROCESSING,
ConsolidationStatus.DATA_CORRECTION,
}
)
# Статусы версии консолидации вне «активных» (фильтр `in_work_only` списка периодов, spec consolidations-api).
# Не дополнение `IN_WORK_STATUSES`: версия на маршруте согласования (Срез 7) не «в работе», но и не завершена
FINISHED_STATUSES = frozenset(
{
ConsolidationStatus.VERIFIED,
ConsolidationStatus.CANCELLED,
ConsolidationStatus.ARCHIVED,
ConsolidationStatus.REJECTED,
}
)
@dataclass(frozen=True)
class VisibilityScope:
"""Область видимости справочников: `office_id is None` — вся система (ГО)."""
office_id: uuid.UUID | None
@property
def is_global(self) -> bool:
"""Пользователь головного офиса — видит все записи (§10.1)."""
return self.office_id is None
def scope_for(*, is_head_office: bool, regional_office_id: uuid.UUID) -> VisibilityScope:
"""Область видимости по данным о филиале пользователя (§10.1): ГО — вся система, иначе — свой филиал.
ASSUMPTION(OQ-86): `business_segment` не учитывается.
"""
return VisibilityScope(office_id=None if is_head_office else regional_office_id)
def scope_of(principal: "UserWithAccess") -> VisibilityScope:
"""Область видимости текущего пользователя (§10.1); роли и права `principal` не участвуют."""
return scope_for(
is_head_office=principal.regional_office.is_head_office,
regional_office_id=principal.user.regional_office_id,
)
def company_groups_visible(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]:
"""Фильтр выборки ГК (§10.1): для филиала — `company_group.base_regional_office_id = <филиал>`.
`stmt` должен ссылаться на `CompanyGroup` (выборка ГК или запрос, уже присоединивший ГК). Для ГО `stmt` не меняется.
"""
if scope.office_id is None:
return stmt
return stmt.where(CompanyGroup.base_regional_office_id == scope.office_id)
def legal_entities_visible(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]:
"""Фильтр выборки ЮЛ (§10.1): для филиала — ЮЛ в ГК своего филиала ИЛИ монозаёмщик своего филиала.
Принадлежность ГК филиалу проверяется подзапросом `company_group_id IN (SELECT id … WHERE base_regional_office_id =
<филиал>)`, а не JOIN: число строк не меняется, а запрос вызывающего может сам присоединять `company_group` без
конфликта имён. Филиал самого ЮЛ в группе не важен — видимость определяет ГК. Для ГО `stmt` не меняется.
"""
if scope.office_id is None:
return stmt
groups_of_office = select(CompanyGroup.id).where(CompanyGroup.base_regional_office_id == scope.office_id)
return stmt.where(
or_(
LegalEntity.company_group_id.in_(groups_of_office),
and_(LegalEntity.company_group_id.is_(None), LegalEntity.regional_office_id == scope.office_id),
)
)
def company_group_is_visible(group: CompanyGroup, scope: VisibilityScope) -> bool:
"""Видна ли ГК пользователю (§10.1); то же правило, что :func:`company_groups_visible`."""
return scope.office_id is None or group.base_regional_office_id == scope.office_id
def legal_entity_is_visible(entity: LegalEntity, group_office_id: uuid.UUID | None, scope: VisibilityScope) -> bool:
"""Виден ли ЮЛ пользователю (§10.1); то же правило, что :func:`legal_entities_visible`.
`group_office_id` — `base_regional_office_id` ГК этого ЮЛ (для монозаёмщика игнорируется).
"""
if scope.office_id is None:
return True
if entity.company_group_id is not None:
return group_office_id == scope.office_id
return entity.regional_office_id == scope.office_id
def office_for_create(scope: VisibilityScope, requested: uuid.UUID | None) -> uuid.UUID | None:
"""Филиал закрепления новой ГК (spec directory-policy «Политика применяется до прав на изменение», §10.1).
ГО — `requested` как есть (в т.ч. `None`); филиал — `None` превращается в свой филиал, свой принимается, чужой →
422 `OFFICE_NOT_ALLOWED`.
"""
if scope.office_id is None:
return requested
if requested is None or requested == scope.office_id:
return scope.office_id
raise DomainValidationError("OFFICE_NOT_ALLOWED", "ГК можно закрепить только за своим филиалом")
def ensure_version_editable(
version: ConsolidationVersion, active_lock: ConsolidationLock | None, user_id: uuid.UUID
) -> None:
"""Можно ли пользователю `user_id` править версию (периметр, ручной статус, сопоставление контрагента; D-8).
Проверки по порядку: статус «в работе» (иначе 409 `VERSION_NOT_EDITABLE`) → активная блокировка есть (иначе 409
`LOCK_REQUIRED`) → она принадлежит пользователю (иначе 409 `LOCKED_BY_OTHER` с `locked_by`, `locked_at`).
Право и видимость проверяет вызывающий до этого; `version` и `active_lock` он читает под `FOR UPDATE` версии
(D-9), чтобы перехват или аннулирование не проскочили между проверкой и правкой. Код роли не читается.
ASSUMPTION(OQ-119): у блокировки нет срока действия.
"""
if version.status not in IN_WORK_STATUSES:
raise ConflictError("VERSION_NOT_EDITABLE", f"Версия в статусе {version.status.value} не редактируется")
if active_lock is None:
raise ConflictError("LOCK_REQUIRED", "Сначала возьмите версию в работу")
if active_lock.locked_by != user_id:
raise ConflictError(
"LOCKED_BY_OTHER",
"Версию держит в работе другой пользователь",
locked_by=active_lock.locked_by,
locked_at=active_lock.locked_at,
)