"""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, )