- 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
160 lines
9.1 KiB
Python
160 lines
9.1 KiB
Python
"""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,
|
||
)
|