From 9411d9d2ae4f469aaec977bde0aa41025c43b210 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 03:57:52 +0400 Subject: [PATCH] =?UTF-8?q?feat(consolidation):=20=D1=81=D0=BE=D0=B7=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8,=20=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BE=D0=BA=20=D0=BF=D0=BE=20=D0=93=D0=9A,=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=81=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /consolidations (версия №1 INITIATED, периметр из текущих членов ГСК, история, CONSOLIDATION_CREATE с consolidation_version_id), GET /company-groups/{id}/consolidations и GET /consolidations/versions/{id}. reporting_date по OQ-40; подхват документов — заглушка bind_confirmed до Task 6, чек-лист в карточке пуст до Task 8. UNIQUE периода -> 409 CONSOLIDATION_EXISTS; RequestAudit.record принимает consolidation_version_id. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/audit.py | 2 + api/src/api/dependencies/consolidations.py | 25 ++ .../api/endpoints/consolidations/__init__.py | 9 + .../api/endpoints/consolidations/periods.py | 59 +++ .../api/endpoints/consolidations/versions.py | 28 ++ api/src/api/router.py | 10 +- api/src/common/constraint_conflicts.py | 3 + api/src/domain/consolidation_periods.py | 27 ++ api/src/domain/policy.py | 12 + api/src/repositories/consolidations.py | 30 +- api/src/services/consolidation_documents.py | 23 + api/src/services/consolidations.py | 266 ++++++++++++ api/tests/factories.py | 11 +- api/tests/test_api_consolidations.py | 402 ++++++++++++++++++ api/tests/test_api_constraint_races.py | 23 + .../test_domain_consolidation_periods.py | 22 + api/tests/test_exception_handlers.py | 1 + api/tests/test_openapi_routes.py | 30 +- api/tests/test_uow_and_errors.py | 3 + .../plans/2026-10-06-slice-4-consolidation.md | 6 +- .../changes/slice-4-consolidation/tasks.md | 2 +- 21 files changed, 974 insertions(+), 20 deletions(-) create mode 100644 api/src/api/dependencies/consolidations.py create mode 100644 api/src/api/endpoints/consolidations/__init__.py create mode 100644 api/src/api/endpoints/consolidations/periods.py create mode 100644 api/src/api/endpoints/consolidations/versions.py create mode 100644 api/src/domain/consolidation_periods.py create mode 100644 api/src/services/consolidation_documents.py create mode 100644 api/src/services/consolidations.py create mode 100644 api/tests/test_api_consolidations.py create mode 100644 api/tests/test_domain_consolidation_periods.py diff --git a/api/src/api/dependencies/audit.py b/api/src/api/dependencies/audit.py index fb43279..4fbacd0 100644 --- a/api/src/api/dependencies/audit.py +++ b/api/src/api/dependencies/audit.py @@ -35,6 +35,7 @@ class RequestAudit: section: AuditSection, object_type: str, object_id: uuid.UUID | None = None, + consolidation_version_id: uuid.UUID | None = None, description: str | None = None, before_value: Mapping[str, Any] | None = None, after_value: Mapping[str, Any] | None = None, @@ -48,6 +49,7 @@ class RequestAudit: section=section, object_type=object_type, object_id=object_id, + consolidation_version_id=consolidation_version_id, description=description, before_value=before_value, after_value=after_value, diff --git a/api/src/api/dependencies/consolidations.py b/api/src/api/dependencies/consolidations.py new file mode 100644 index 0000000..450468d --- /dev/null +++ b/api/src/api/dependencies/consolidations.py @@ -0,0 +1,25 @@ +"""Зависимости API консолидаций (Срез 4; R-6: сессия — только `SessionDep`).""" + +from typing import Annotated + +from fastapi import Depends + +from src.api.dependencies.audit import RequestAudit, get_request_audit +from src.api.dependencies.auth import Principal, current_user +from src.api.dependencies.db import SessionDep +from src.api.dependencies.directories import get_scope +from src.domain.policy import VisibilityScope +from src.services.consolidations import ConsolidationService + + +def get_consolidation_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], + principal: Annotated[Principal, Depends(current_user)], +) -> ConsolidationService: + """Сервис консолидаций: создание, периоды ГК, карточка версии (видимость — через ГК консолидации).""" + return ConsolidationService(session, audit, scope, principal) + + +ConsolidationServiceDep = Annotated[ConsolidationService, Depends(get_consolidation_service)] diff --git a/api/src/api/endpoints/consolidations/__init__.py b/api/src/api/endpoints/consolidations/__init__.py new file mode 100644 index 0000000..78c2be8 --- /dev/null +++ b/api/src/api/endpoints/consolidations/__init__.py @@ -0,0 +1,9 @@ +"""Маршруты консолидаций (specs consolidations-api, consolidated-osv): периоды ГК, версии и их содержимое.""" + +from fastapi import APIRouter + +from src.api.endpoints.consolidations import periods, versions + +router = APIRouter(tags=["consolidations"]) +router.include_router(periods.router) +router.include_router(versions.router) diff --git a/api/src/api/endpoints/consolidations/periods.py b/api/src/api/endpoints/consolidations/periods.py new file mode 100644 index 0000000..6388042 --- /dev/null +++ b/api/src/api/endpoints/consolidations/periods.py @@ -0,0 +1,59 @@ +"""`POST /consolidations` и `GET /company-groups/{id}/consolidations` (spec consolidations-api «Создание +консолидации», «Список консолидаций ГК и карточка версии»; design D-15, D-16). + +Право (`require`) проверяется до видимости; ГК вне видимости и несуществующая — одинаковый 404. +""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import ConsolidationCreate, ConsolidationPeriodOut, VersionCard + +router = APIRouter() + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +EDIT = [Depends(require("CONSOLIDATION_EDIT"))] +CREATE_ERRORS: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "ГК или ГСК нет или вне видимости"}, + status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "CONSOLIDATION_EXISTS"}, + status.HTTP_422_UNPROCESSABLE_CONTENT: { + "description": "RELATED_GROUP_MISMATCH, PERIMETER_TOO_SMALL или ошибка валидации тела" + }, +} +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "ГК нет или вне видимости"} +} + + +@router.post( + "/consolidations", + response_model=VersionCard, + status_code=status.HTTP_201_CREATED, + dependencies=EDIT, + responses=CREATE_ERRORS, + summary="Создать консолидацию ГК за период", +) +async def create_consolidation(data: ConsolidationCreate, service: ConsolidationServiceDep) -> VersionCard: + """Консолидация с версией №1 (`INITIATED`), периметром из членов ГСК и подхватом подтверждённых документов.""" + return await service.create(data) + + +@router.get( + "/company-groups/{group_id}/consolidations", + response_model=list[ConsolidationPeriodOut], + dependencies=VIEW, + responses=NOT_FOUND, + summary="Периоды консолидации ГК и их версии", +) +async def list_consolidations( + group_id: uuid.UUID, + service: ConsolidationServiceDep, + in_work_only: bool = False, # noqa: FBT001, FBT002 +) -> list[ConsolidationPeriodOut]: + """Периоды ГК по убыванию года и квартала; версии — по убыванию номера; `in_work_only` скрывает завершённые.""" + return await service.list_periods(group_id, in_work_only=in_work_only) diff --git a/api/src/api/endpoints/consolidations/versions.py b/api/src/api/endpoints/consolidations/versions.py new file mode 100644 index 0000000..5cd99d8 --- /dev/null +++ b/api/src/api/endpoints/consolidations/versions.py @@ -0,0 +1,28 @@ +"""`/consolidations/versions/{id}` — карточка версии консолидации (spec consolidations-api «Список консолидаций ГК и +карточка версии»; design D-16). Право проверяется до видимости; версия ГК вне видимости — тот же 404, что у +несуществующей (spec directory-policy).""" + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.consolidations import ConsolidationServiceDep +from src.schemas.common import ErrorDetail +from src.schemas.consolidations import VersionCard + +router = APIRouter(prefix="/consolidations/versions") + +VIEW = [Depends(require("CONSOLIDATION_VIEW"))] +NOT_FOUND: dict[int | str, dict[str, Any]] = { + status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"} +} + + +@router.get( + "/{version_id}", response_model=VersionCard, dependencies=VIEW, responses=NOT_FOUND, summary="Карточка версии" +) +async def get_version(version_id: uuid.UUID, service: ConsolidationServiceDep) -> VersionCard: + """Версия и консолидация, ГК, инициатор, блокировка, периметр с чек-листом и история статусов.""" + return await service.card(version_id) diff --git a/api/src/api/router.py b/api/src/api/router.py index 79cbf87..8460dff 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -1,13 +1,14 @@ """Корневой роутер API `/api/v1` (R-6: эндпоинты — `src/api/endpoints/`). -`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*`, `/admin/*` и файлы -(`/legal-entities/{id}/files`) — всегда, dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный -`router` собран по настройкам процесса и подключается в `src/app.py`. +`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*`, `/admin/*`, файлы +(`/legal-entities/{id}/files`) и консолидации (`/consolidations`, `/company-groups/{id}/consolidations`) — всегда, +dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и +подключается в `src/app.py`. """ from fastapi import APIRouter -from src.api.endpoints import admin, directories, files, me +from src.api.endpoints import admin, consolidations, directories, files, me from src.api.endpoints.dev_auth import build_dev_router from src.core.config import Settings, get_settings @@ -21,6 +22,7 @@ def build_api_router(settings: Settings) -> APIRouter: api_router.include_router(directories.router) api_router.include_router(admin.router) api_router.include_router(files.router) + api_router.include_router(consolidations.router) if settings.AUTH_DEV_ISSUER and settings.DEBUG: api_router.include_router(build_dev_router(settings)) return api_router diff --git a/api/src/common/constraint_conflicts.py b/api/src/common/constraint_conflicts.py index e462ef1..d603203 100644 --- a/api/src/common/constraint_conflicts.py +++ b/api/src/common/constraint_conflicts.py @@ -36,6 +36,9 @@ UNIQUE_CONSTRAINT_CONFLICTS: dict[str, ConstraintConflict] = { "ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас" ), "uq_app_user_email": ConstraintConflict("EMAIL_EXISTS", "Пользователь с таким email уже существует"), + "uq_consolidation_group_year_quarter": ConstraintConflict( + "CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана" + ), "uq_related_group_member_group_entity_current": ConstraintConflict("MEMBER_EXISTS", "ЮЛ уже входит в эту группу"), # Нарушается и при повторном добавлении в ту же группу (PostgreSQL проверяет индексы в порядке OID, а этот создан # миграцией 0002 раньше второго), поэтому текст — общий для обоих случаев, а не «в другую группу» как у pre-check-а diff --git a/api/src/domain/consolidation_periods.py b/api/src/domain/consolidation_periods.py new file mode 100644 index 0000000..3783760 --- /dev/null +++ b/api/src/domain/consolidation_periods.py @@ -0,0 +1,27 @@ +"""Период консолидации: отчётная дата по году и кварталу (spec consolidations-api «Создание консолидации»). + +Чистая функция без БД. ASSUMPTION(OQ-40): отчётная дата — первый день месяца, следующего за кварталом. +""" + +import datetime + +from src.models.enums import PeriodQuarter + +__all__ = ["reporting_date"] + +# квартал → (месяц отчётной даты, сдвиг года): Q4 заканчивается 31 декабря, отчётная дата — 1 января следующего года +_REPORTING_MONTH: dict[PeriodQuarter, tuple[int, int]] = { + PeriodQuarter.Q1: (4, 0), + PeriodQuarter.Q2: (7, 0), + PeriodQuarter.Q3: (10, 0), + PeriodQuarter.Q4: (1, 1), +} + + +def reporting_date(year: int, quarter: PeriodQuarter) -> datetime.date: + """Отчётная дата консолидации: Q1 → 01.04.year, Q2 → 01.07.year, Q3 → 01.10.year, Q4 → 01.01.year+1. + + ASSUMPTION(OQ-40): правило банка не подтверждено аналитикой (в ТЗ отчётная дата не определена). + """ + month, year_shift = _REPORTING_MONTH[quarter] # ASSUMPTION(OQ-40) + return datetime.date(year + year_shift, month, 1) diff --git a/api/src/domain/policy.py b/api/src/domain/policy.py index 2ac7619..f190904 100644 --- a/api/src/domain/policy.py +++ b/api/src/domain/policy.py @@ -17,12 +17,24 @@ from sqlalchemy import Select, and_, or_, select from src.common.exceptions import DomainValidationError from src.models.directories import CompanyGroup, LegalEntity +from src.models.enums import ConsolidationStatus if TYPE_CHECKING: # Только для типов: домен не тянет репозитории в рантайме from src.repositories.users import UserWithAccess +# Статусы версии консолидации вне «активных» (фильтр `in_work_only` списка периодов, spec consolidations-api) +FINISHED_STATUSES = frozenset( + { + ConsolidationStatus.VERIFIED, + ConsolidationStatus.CANCELLED, + ConsolidationStatus.ARCHIVED, + ConsolidationStatus.REJECTED, + } +) + + @dataclass(frozen=True) class VisibilityScope: """Область видимости справочников: `office_id is None` — вся система (ГО).""" diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 5faba2b..7f19cf9 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -9,11 +9,12 @@ import uuid from collections import defaultdict from collections.abc import Collection, Sequence -from sqlalchemy import Select, exists, select +from sqlalchemy import Select, exists, func, select from sqlalchemy.ext.asyncio import AsyncSession from src.domain.checklist import EntityFilesState from src.domain.policy import VisibilityScope, company_groups_visible +from src.models.access import AppUser from src.models.consolidation import ( Consolidation, ConsolidationDocument, @@ -143,6 +144,33 @@ async def active_lock(session: AsyncSession, version_id: uuid.UUID) -> Consolida return (await session.execute(stmt)).scalar_one_or_none() +async def active_locks( + session: AsyncSession, version_ids: Collection[uuid.UUID] +) -> dict[uuid.UUID, tuple[ConsolidationLock, str]]: + """Активные блокировки версий из `version_ids` одним запросом: версия → (блокировка, ФИО владельца).""" + if not version_ids: + return {} + stmt = ( + select(ConsolidationLock, AppUser.full_name) + .join(AppUser, AppUser.id == ConsolidationLock.locked_by) + .where(ConsolidationLock.consolidation_version_id.in_(version_ids), ConsolidationLock.released_at.is_(None)) + ) + return {row[0].consolidation_version_id: (row[0], row[1]) for row in await session.execute(stmt)} + + +async def members_counts(session: AsyncSession, version_ids: Collection[uuid.UUID]) -> dict[uuid.UUID, int]: + """Размер периметра каждой версии из `version_ids` одним запросом (версия без членов — 0).""" + if not version_ids: + return {} + stmt = ( + select(ConsolidationMember.consolidation_version_id, func.count(ConsolidationMember.id)) + .where(ConsolidationMember.consolidation_version_id.in_(version_ids)) + .group_by(ConsolidationMember.consolidation_version_id) + ) + counts = {row[0]: row[1] for row in await session.execute(stmt)} + return {version_id: counts.get(version_id, 0) for version_id in version_ids} + + async def members(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[ConsolidationMember, LegalEntity]]: """Периметр версии: член и его ЮЛ, по имени ЮЛ (`id` — для устойчивости порядка).""" stmt = ( diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py new file mode 100644 index 0000000..68d63fe --- /dev/null +++ b/api/src/services/consolidation_documents.py @@ -0,0 +1,23 @@ +"""Привязка подтверждённых документов к версии консолидации (spec documents-api «Подхват документов»; design D-5). + +ЗАГЛУШКА Среза 4, задача 3: `bind_confirmed` ничего не привязывает и возвращает 0. Настоящую реализацию (отбор +подтверждённых документов ЮЛ по правилу «подходящести» D-5, `consolidation_document`, порядок блокировок +файл → документ → версии, R-1…R-3) вносит Task 6 — она заменяет тело функции, сигнатуру и вызов из +`services.consolidations.ConsolidationService.create` сохраняет. +""" + +import uuid +from collections.abc import Sequence + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.consolidation import ConsolidationVersion + + +async def bind_confirmed( + session: AsyncSession, # noqa: ARG001 - сигнатура окончательная, тело — заглушка до Task 6 + version: ConsolidationVersion, # noqa: ARG001 + entity_ids: Sequence[uuid.UUID], # noqa: ARG001 +) -> int: + """Привязывает к `version` подходящие подтверждённые документы ЮЛ `entity_ids`; возвращает число привязок.""" + return 0 diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py new file mode 100644 index 0000000..4ec8a7e --- /dev/null +++ b/api/src/services/consolidations.py @@ -0,0 +1,266 @@ +"""Сервис консолидаций ГК: создание, список периодов, карточка версии (spec consolidations-api «Создание +консолидации», «Список консолидаций ГК и карточка версии»; directory-policy «Видимость консолидаций»; +audit-trail `CONSOLIDATION_CREATE`; design D-15, D-16). + +Порядок операции: право (`require` на маршруте) → видимость (ГК/версия — через ГК, невидимое = несуществующее, 404 +с тем же телом) → проверки → запись. Перимeтр версии при создании — текущие члены ГСК, `is_head_entity = false`; +ГСК в `consolidation.related_group_id` не сохраняется (фиксируется при верификации, Срез 7). Блокировка на создателя +не ставится. Параллельное создание того же периода ловит UNIQUE `uq_consolidation_group_year_quarter` → +`IntegrityError` → 409 `CONSOLIDATION_EXISTS` (`common/constraint_conflicts.py`). +""" + +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, NotFoundError +from src.domain.consolidation_periods import reporting_date +from src.domain.policy import FINISHED_STATUSES, VisibilityScope +from src.models.access import AppUser +from src.models.consolidation import ( + Consolidation, + ConsolidationLock, + ConsolidationMember, + ConsolidationStatusHistory, + ConsolidationVersion, +) +from src.models.directories import LegalEntity +from src.models.enums import ( + AuditAction, + AuditSection, + ConsolidationApproach, + ConsolidationScenario, + ConsolidationStatus, + RelatedGroupKind, +) +from src.repositories import consolidations as repo +from src.repositories import directories as directories_repo +from src.repositories.users import UserWithAccess +from src.schemas.consolidations import ( + ConsolidationCreate, + ConsolidationPeriodOut, + HistoryOut, + LockOut, + MemberOut, + RefOut, + VersionBrief, + VersionCard, +) +from src.services import consolidation_documents +from src.services.company_groups import COMPANY_GROUP_NOT_FOUND +from src.services.related_groups import RELATED_GROUP_NOT_FOUND + +CONSOLIDATION_NOT_FOUND = "консолидация не найдена" # одинаково для чужой и несуществующей (spec directory-policy) +VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей +OBJECT_TYPE = "consolidation_version" +MIN_PERIMETER = 2 # в периметре консолидации не меньше двух ЮЛ (иначе нечего сопоставлять) +FIRST_VERSION_NO = 1 + + +def _lock_out(lock: ConsolidationLock, locked_by_name: str) -> LockOut: + return LockOut( + locked_by=lock.locked_by, + locked_by_name=locked_by_name, + locked_at=lock.locked_at, + taken_over_from=lock.taken_over_from, + takeover_reason=lock.takeover_reason, + ) + + +class ConsolidationService: + """Консолидации ГК от имени текущего пользователя в его области видимости.""" + + 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 create(self, data: ConsolidationCreate) -> VersionCard: + """Консолидация ГК за период с версией №1 (`INITIATED`), периметром из ГСК, историей и аудитом. + + Проверки по порядку: ГК видима (404) → ГСК видима (404), принадлежит ГК и `kind = gsk` (422 + `RELATED_GROUP_MISMATCH`) → в ГСК не меньше двух текущих членов (422 `PERIMETER_TOO_SMALL`) → периода ещё нет + (409 `CONSOLIDATION_EXISTS`; гонку закрывает UNIQUE). Всё — в транзакции запроса: любая ошибка откатывает всё. + """ + group = await directories_repo.get_company_group(self._session, self._scope, data.company_group_id) + if group is None: + raise NotFoundError(COMPANY_GROUP_NOT_FOUND) + related = await directories_repo.get_related_group(self._session, self._scope, data.related_group_id) + if related is None: + raise NotFoundError(RELATED_GROUP_NOT_FOUND) + if related.kind != RelatedGroupKind.gsk or related.company_group_id != group.id: + raise DomainValidationError("RELATED_GROUP_MISMATCH", "ГСК должна принадлежать выбранной ГК") + entities = [entity for _, entity in await directories_repo.current_members(self._session, related.id)] + if len(entities) < MIN_PERIMETER: + raise DomainValidationError( + "PERIMETER_TOO_SMALL", f"В ГСК должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(entities)}" + ) + if await repo.get_consolidation_for_group(self._session, self._scope, group.id, data.year, data.quarter): + raise ConflictError("CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана") + + consolidation, version = await self._insert(group.id, data, entities) + await self._audit.record( + action=AuditAction.CONSOLIDATION_CREATE, + section=AuditSection.CONSOLIDATION, + object_type=OBJECT_TYPE, + object_id=version.id, + consolidation_version_id=version.id, + after_value={ + "consolidation_id": consolidation.id, + "year": consolidation.year, + "quarter": consolidation.quarter, + "members": sorted(entity.id for entity in entities), + }, + ) + return await self._card(version, consolidation) + + async def list_periods(self, group_id: uuid.UUID, *, in_work_only: bool) -> list[ConsolidationPeriodOut]: + """Периоды ГК по убыванию года и квартала, в каждом — версии по убыванию `version_no`. + + `in_work_only` оставляет версии со статусом не из {`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED`}; период + без оставшихся версий в ответ не попадает. ГК вне видимости и несуществующая — один и тот же 404. + """ + if await directories_repo.get_company_group(self._session, self._scope, group_id) is None: + raise NotFoundError(COMPANY_GROUP_NOT_FOUND) + periods = await repo.list_periods(self._session, self._scope, group_id) + if in_work_only: + periods = [ + (consolidation, [v for v in versions if v.status not in FINISHED_STATUSES]) + for consolidation, versions in periods + ] + periods = [(consolidation, versions) for consolidation, versions in periods if versions] + version_ids = [version.id for _, versions in periods for version in versions] + locks = await repo.active_locks(self._session, version_ids) + counts = await repo.members_counts(self._session, version_ids) + return [ + ConsolidationPeriodOut( + consolidation_id=consolidation.id, + year=consolidation.year, + quarter=consolidation.quarter, + reporting_date=consolidation.reporting_date, + versions=[self._brief(version, locks.get(version.id), counts[version.id]) for version in versions], + ) + for consolidation, versions in periods + ] + + async def card(self, version_id: uuid.UUID) -> VersionCard: + """Карточка версии; версия ГК вне видимости и несуществующая — один и тот же 404.""" + found = await repo.get_version(self._session, self._scope, version_id) + if found is None: + raise NotFoundError(VERSION_NOT_FOUND) + return await self._card(*found) + + async def _insert( + self, group_id: uuid.UUID, data: ConsolidationCreate, entities: list[LegalEntity] + ) -> tuple[Consolidation, ConsolidationVersion]: + """Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы.""" + now = utc_now() + user = self._principal.user + consolidation = Consolidation( + company_group_id=group_id, + year=data.year, + quarter=data.quarter, + reporting_date=reporting_date(data.year, data.quarter), + created_at=now, + ) + self._session.add(consolidation) + await self._session.flush() # гонку за (ГК, год, квартал) ловит UNIQUE → IntegrityError → 409 + version = ConsolidationVersion( + consolidation_id=consolidation.id, + version_no=FIRST_VERSION_NO, + status=ConsolidationStatus.INITIATED, + scenario=ConsolidationScenario.CREATE, + approach=ConsolidationApproach.BASIC, + goodwill_enabled=False, + initiator_id=user.id, + initiator_office_id=user.regional_office_id, + is_in_work=True, + created_at=now, + created_by=user.id, + ) + self._session.add(version) + await self._session.flush() + self._session.add_all( + ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity.id, is_head_entity=False) + for entity in entities + ) + self._session.add( + ConsolidationStatusHistory( + version_id=version.id, + from_status=None, + to_status=ConsolidationStatus.INITIATED, + changed_by=user.id, + changed_at=now, + ) + ) + await self._session.flush() + await consolidation_documents.bind_confirmed(self._session, version, [entity.id for entity in entities]) + return consolidation, version + + @staticmethod + def _brief( + version: ConsolidationVersion, lock: tuple[ConsolidationLock, str] | None, members_count: int + ) -> VersionBrief: + return VersionBrief( + id=version.id, + version_no=version.version_no, + status=version.status, + scenario=version.scenario, + approach=version.approach, + lock=_lock_out(*lock) if lock is not None else None, + created_at=version.created_at, + members_count=members_count, + ) + + async def _card(self, version: ConsolidationVersion, consolidation: Consolidation) -> VersionCard: + """Карточка версии: версия, консолидация, ГК, инициатор, периметр, история, активная блокировка. + + Чек-лист у членов — пустой список до Task 8 (`domain.checklist.build`). + """ + group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id) + initiator = await self._session.get(AppUser, version.initiator_id) + office = await directories_repo.get_regional_office(self._session, version.initiator_office_id) + if group is None or initiator is None or office is None: # FK гарантируют наличие; группа видна по построению + raise NotFoundError(VERSION_NOT_FOUND) + members = await repo.members(self._session, version.id) + locks = await repo.active_locks(self._session, [version.id]) + history = await repo.history(self._session, version.id) + brief = self._brief(version, locks.get(version.id), len(members)) + return VersionCard( + **brief.model_dump(), + consolidation_id=consolidation.id, + year=consolidation.year, + quarter=consolidation.quarter, + reporting_date=consolidation.reporting_date, + company_group=RefOut(id=group.id, name=group.name), + initiator=RefOut(id=initiator.id, name=initiator.full_name), + initiator_office=RefOut(id=office.id, name=office.name), + goodwill_enabled=version.goodwill_enabled, + is_in_work=version.is_in_work, + previous_version_id=version.previous_version_id, + members=[ + MemberOut( + id=entity.id, + name=entity.name, + inn=entity.inn, + is_head_entity=member.is_head_entity, + checklist=[], # Task 8: ячейки по правилам подхода + ) + for member, entity in members + ], + history=[ + HistoryOut( + from_status=record.from_status, + to_status=record.to_status, + changed_by=record.changed_by, + changed_at=record.changed_at, + comment=record.comment, + ) + for record in history + ], + ) diff --git a/api/tests/factories.py b/api/tests/factories.py index 99be452..1a3504d 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -28,6 +28,7 @@ from sqlalchemy import delete, func, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.contract.result import OsvDocument, RecognitionResult +from src.domain.consolidation_periods import reporting_date from src.domain.osv_mapping import build_entries, document_fields from src.models.access import AppUser, Role, UserRole from src.models.accounting import ChartOfAccount @@ -72,13 +73,6 @@ from tests.conftest import FakeStorage DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF} TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB}) FACTORY_BUCKET = "arm-ka" -# первый день месяца, следующего за кварталом (reporting_date, ASSUMPTION(OQ-40)); Q4 — 1 января следующего года -REPORTING_DATES = { - PeriodQuarter.Q1: (4, 0), - PeriodQuarter.Q2: (7, 0), - PeriodQuarter.Q3: (10, 0), - PeriodQuarter.Q4: (1, 1), -} # статусы вне «в работе» (как фильтр `in_work_only` спеки): `is_in_work = false` ставится только им и аннулированию FINISHED_STATUSES = frozenset( { @@ -442,12 +436,11 @@ async def make_consolidation( # noqa: PLR0913 - сигнатура фабрик `reporting_date` — по правилу спеки: Q1 → 01.04, Q2 → 01.07, Q3 → 01.10, Q4 → 01.01 следующего года. """ - month, year_shift = REPORTING_DATES[quarter] consolidation = Consolidation( company_group_id=group.id, year=year, quarter=quarter, - reporting_date=datetime.date(year + year_shift, month, 1), + reporting_date=reporting_date(year, quarter), created_at=_now(), ) session.add(consolidation) diff --git a/api/tests/test_api_consolidations.py b/api/tests/test_api_consolidations.py new file mode 100644 index 0000000..db24a60 --- /dev/null +++ b/api/tests/test_api_consolidations.py @@ -0,0 +1,402 @@ +"""API консолидаций ГК: создание, список периодов, карточка версии (spec consolidations-api «Создание консолидации», +«Список консолидаций ГК и карточка версии»; directory-policy «Видимость консолидаций»; audit-trail +`CONSOLIDATION_CREATE`; design D-15, D-16).""" + +import uuid +from typing import Any + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.consolidation import ( + Consolidation, + ConsolidationLock, + ConsolidationMember, + ConsolidationStatusHistory, + ConsolidationVersion, +) +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup +from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter, RelatedGroupKind +from tests.conftest import AuthClientFactory +from tests.factories import ( + make_company_group, + make_consolidation, + make_legal_entity, + make_lock, + make_membership, + make_related_group, + make_version, +) + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" +KA_RF = "ka_rf@armka.local" +RKA = "rka@armka.local" +KA_GO = "ka_go@armka.local" +CURATOR = "curator_go@armka.local" +EXPERT = "expert@armka.local" +CONSOLIDATIONS = "/consolidations" +VERSIONS = "/consolidations/versions" + + +async def _office(session: AsyncSession, name: str) -> RegionalOffice: + return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one() + + +async def _user(session: AsyncSession, email: str) -> AppUser: + return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one() + + +async def _scenario( + session: AsyncSession, *, office_name: str = ROSTOV, members: int = 2 +) -> tuple[CompanyGroup, RelatedGroup, list[LegalEntity]]: + """ГК филиала с ГСК, в которой `members` текущих членов.""" + group = await make_company_group(session, name="ГК Русский бисквит", office=await _office(session, office_name)) + gsk = await make_related_group(session, group=group, name="ГСК Русский бисквит") + entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(members)] + for entity in entities: + await make_membership(session, related_group=gsk, entity=entity) + return group, gsk, entities + + +def _body(group: CompanyGroup, gsk: RelatedGroup, year: int = 2024, quarter: str = "Q3") -> dict[str, Any]: + return {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": year, "quarter": quarter} + + +async def _count(session: AsyncSession, model: type) -> int: + return (await session.execute(select(func.count()).select_from(model))).scalar_one() + + +@pytest.mark.integration +async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, gsk, entities = await _scenario(seeded) + client = await auth_client(KA_RF) + user = await _user(seeded, KA_RF) + + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + + assert response.status_code == HTTP_CREATED, response.text + card = response.json() + assert (card["version_no"], card["status"], card["scenario"], card["approach"]) == ( + 1, + "INITIATED", + "CREATE", + "BASIC", + ) + assert (card["year"], card["quarter"], card["reporting_date"]) == (2024, "Q3", "2024-10-01") + assert (card["goodwill_enabled"], card["is_in_work"], card["previous_version_id"]) == (False, True, None) + assert card["lock"] is None + assert card["members_count"] == len(entities) + assert card["company_group"] == {"id": str(group.id), "name": "ГК Русский бисквит"} + assert card["initiator"] == {"id": str(user.id), "name": user.full_name} + assert card["initiator_office"] == {"id": str(user.regional_office_id), "name": ROSTOV} + assert [member["name"] for member in card["members"]] == ["ООО Член 0", "ООО Член 1"] + assert {member["id"] for member in card["members"]} == {str(entity.id) for entity in entities} + assert all(member["is_head_entity"] is False and member["checklist"] == [] for member in card["members"]) + assert [(item["from_status"], item["to_status"]) for item in card["history"]] == [(None, "INITIATED")] + assert card["history"][0]["changed_by"] == str(user.id) + + consolidation = (await seeded.execute(select(Consolidation))).scalar_one() + assert consolidation.related_group_id is None # фиксируется при верификации (Срез 7) + assert str(consolidation.id) == card["consolidation_id"] + version = (await seeded.execute(select(ConsolidationVersion))).scalar_one() + assert (version.initiator_id, version.created_by, version.is_in_work) == (user.id, user.id, True) + assert await _count(seeded, ConsolidationLock) == 0 + assert await _count(seeded, ConsolidationMember) == len(entities) + assert await _count(seeded, ConsolidationStatusHistory) == 1 + + event = ( + await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_CREATE)) + ).scalar_one() + assert (event.section, event.object_type, event.object_id) == ( + AuditSection.CONSOLIDATION, + "consolidation_version", + version.id, + ) + assert event.consolidation_version_id == version.id + assert event.user_id == user.id + assert event.before_value is None + assert event.after_value == { + "consolidation_id": str(consolidation.id), + "year": 2024, + "quarter": "Q3", + "members": sorted(str(entity.id) for entity in entities), + } + + +@pytest.mark.integration +async def test_create_reporting_date_for_q4_is_next_year(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, gsk, _ = await _scenario(seeded) + client = await auth_client(KA_RF) + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk, 2024, "Q4")) + assert response.status_code == HTTP_CREATED, response.text + assert response.json()["reporting_date"] == "2025-01-01" + + +@pytest.mark.integration +async def test_create_with_single_member_is_perimeter_too_small( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + group, gsk, _ = await _scenario(seeded, members=1) + client = await auth_client(KA_RF) + + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL" + assert await _count(seeded, Consolidation) == 0 + assert await _count(seeded, ConsolidationVersion) == 0 + created = select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_CREATE) + assert (await seeded.execute(created)).scalar_one() == 0 + + +@pytest.mark.integration +async def test_create_ignores_closed_memberships(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """В периметр входят только текущие члены ГСК.""" + group, gsk, _ = await _scenario(seeded, members=2) + former = await make_legal_entity(seeded, name="ООО Бывший", group=group) + await make_membership(seeded, related_group=gsk, entity=former, is_current=False) + client = await auth_client(KA_RF) + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + assert response.status_code == HTTP_CREATED, response.text + assert "ООО Бывший" not in [member["name"] for member in response.json()["members"]] + + +@pytest.mark.integration +async def test_create_repeated_period_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, gsk, _ = await _scenario(seeded) + client = await auth_client(KA_RF) + assert (await client.post(CONSOLIDATIONS, json=_body(group, gsk))).status_code == HTTP_CREATED + + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "CONSOLIDATION_EXISTS" + assert await _count(seeded, Consolidation) == 1 + assert await _count(seeded, ConsolidationVersion) == 1 + + +@pytest.mark.integration +@pytest.mark.parametrize("year", [1999, 2101]) +async def test_create_year_out_of_range_is_unprocessable( + auth_client: AuthClientFactory, seeded: AsyncSession, year: int +) -> None: + group, gsk, _ = await _scenario(seeded) + client = await auth_client(KA_RF) + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year)) + assert response.status_code == HTTP_UNPROCESSABLE + assert await _count(seeded, Consolidation) == 0 + + +@pytest.mark.integration +async def test_create_gsk_must_belong_to_group_and_be_gsk(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, _, _ = await _scenario(seeded) + other_group = await make_company_group(seeded, name="ГК Соседняя", office=await _office(seeded, ROSTOV)) + foreign_gsk = await make_related_group(seeded, group=other_group) + gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ") + client = await auth_client(KA_RF) + + for related in (foreign_gsk, gsz): + response = await client.post(CONSOLIDATIONS, json=_body(group, related)) + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert response.json()["detail"]["code"] == "RELATED_GROUP_MISMATCH" + assert await _count(seeded, Consolidation) == 0 + + +@pytest.mark.integration +async def test_create_unknown_or_foreign_objects_are_not_found( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + group, gsk, _ = await _scenario(seeded) + foreign_group, foreign_gsk, _ = await _scenario(seeded, office_name=KRASNODAR) + client = await auth_client(KA_RF) + + unknown_group = await client.post(CONSOLIDATIONS, json={**_body(group, gsk), "company_group_id": str(uuid.uuid4())}) + foreign = await client.post(CONSOLIDATIONS, json=_body(foreign_group, foreign_gsk)) + unknown_gsk = await client.post(CONSOLIDATIONS, json={**_body(group, gsk), "related_group_id": str(uuid.uuid4())}) + + assert unknown_group.status_code == HTTP_NOT_FOUND + assert foreign.status_code == HTTP_NOT_FOUND + assert foreign.json() == unknown_group.json() + assert unknown_gsk.status_code == HTTP_NOT_FOUND + assert await _count(seeded, Consolidation) == 0 + + +@pytest.mark.integration +@pytest.mark.parametrize("email", [EXPERT, CURATOR]) +async def test_create_requires_consolidation_edit( + auth_client: AuthClientFactory, seeded: AsyncSession, email: str +) -> None: + group, gsk, _ = await _scenario(seeded) + client = await auth_client(email) + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + assert response.status_code == HTTP_FORBIDDEN + assert "CONSOLIDATION_EDIT" in response.json()["detail"] + assert await _count(seeded, Consolidation) == 0 + + +@pytest.mark.integration +async def test_go_user_creates_for_any_office(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, gsk, _ = await _scenario(seeded, office_name=KRASNODAR) + client = await auth_client(KA_GO) + response = await client.post(CONSOLIDATIONS, json=_body(group, gsk)) + assert response.status_code == HTTP_CREATED, response.text + go_office = (await _user(seeded, KA_GO)).regional_office_id + assert response.json()["initiator_office"]["id"] == str(go_office) + + +async def _tree(session: AsyncSession) -> tuple[CompanyGroup, dict[str, ConsolidationVersion]]: + """ГК Ростовского филиала: 2024 Q1 (версии 1 и 2), 2024 Q3 (версия 1), 2023 Q4 (версия 1).""" + group, _, entities = await _scenario(session) + initiator = await _user(session, KA_RF) + q1, q1v1 = await make_consolidation( + session, group=group, year=2024, quarter=PeriodQuarter.Q1, members=entities, initiator=initiator + ) + q1v2 = await make_version(session, consolidation=q1, version_no=2, members=entities[:1], initiator=initiator) + _, q3v1 = await make_consolidation( + session, group=group, year=2024, quarter=PeriodQuarter.Q3, members=entities, initiator=initiator + ) + _, old = await make_consolidation( + session, + group=group, + year=2023, + quarter=PeriodQuarter.Q4, + members=entities, + initiator=initiator, + status=ConsolidationStatus.CANCELLED, + ) + return group, {"q1v1": q1v1, "q1v2": q1v2, "q3v1": q3v1, "old": old} + + +@pytest.mark.integration +async def test_list_periods_tree_order_and_members_count(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, versions = await _tree(seeded) + owner = await _user(seeded, RKA) + await make_lock(seeded, version=versions["q1v2"], user=owner) + await make_lock(seeded, version=versions["q1v1"], user=owner, released=True) + client = await auth_client(KA_RF) + + response = await client.get(f"/company-groups/{group.id}/consolidations") + + assert response.status_code == HTTP_OK, response.text + periods = response.json() + assert [(item["year"], item["quarter"]) for item in periods] == [(2024, "Q3"), (2024, "Q1"), (2023, "Q4")] + assert [item["reporting_date"] for item in periods] == ["2024-10-01", "2024-04-01", "2024-01-01"] + q3, q1, _ = periods + assert [(v["id"], v["version_no"], v["members_count"]) for v in q3["versions"]] == [ + (str(versions["q3v1"].id), 1, 2) + ] + assert [(v["id"], v["version_no"], v["members_count"]) for v in q1["versions"]] == [ + (str(versions["q1v2"].id), 2, 1), + (str(versions["q1v1"].id), 1, 2), + ] + v2, v1 = q1["versions"] + assert v2["lock"]["locked_by"] == str(owner.id) + assert v2["lock"]["locked_by_name"] == owner.full_name + assert v2["lock"]["taken_over_from"] is None + assert v1["lock"] is None # снятая блокировка не активна + assert q3["versions"][0]["lock"] is None + assert {"status", "scenario", "approach", "created_at"} <= set(v1) + assert periods[0]["consolidation_id"] == q3["consolidation_id"] + + +@pytest.mark.integration +async def test_list_periods_in_work_only(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, versions = await _tree(seeded) + client = await auth_client(KA_RF) + # одну версию 2024 Q1 переводим в завершённое состояние, у периода остаётся другая + versions["q1v2"].status = ConsolidationStatus.VERIFIED + versions["q1v2"].is_in_work = False + await seeded.flush() + + response = await client.get(f"/company-groups/{group.id}/consolidations", params={"in_work_only": "true"}) + + assert response.status_code == HTTP_OK, response.text + periods = response.json() + # 2023 Q4 (единственная версия CANCELLED) пропадает, у 2024 Q1 остаётся только версия 1 + assert [(item["year"], item["quarter"]) for item in periods] == [(2024, "Q3"), (2024, "Q1")] + assert [v["version_no"] for v in periods[1]["versions"]] == [1] + everything = await client.get(f"/company-groups/{group.id}/consolidations", params={"in_work_only": "false"}) + assert len(everything.json()) == len(periods) + 1 + + +@pytest.mark.integration +async def test_list_periods_hidden_group_is_not_found_and_empty_group_is_empty( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + foreign, _, _ = await _scenario(seeded, office_name=KRASNODAR) + empty = await make_company_group(seeded, name="ГК без периодов", office=await _office(seeded, ROSTOV)) + client = await auth_client(KA_RF) + + hidden = await client.get(f"/company-groups/{foreign.id}/consolidations") + unknown = await client.get(f"/company-groups/{uuid.uuid4()}/consolidations") + nothing = await client.get(f"/company-groups/{empty.id}/consolidations") + + assert hidden.status_code == HTTP_NOT_FOUND + assert hidden.json() == unknown.json() + assert (nothing.status_code, nothing.json()) == (HTTP_OK, []) + + +@pytest.mark.integration +async def test_list_periods_foreign_office_is_not_found(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, _ = await _tree(seeded) + client = await auth_client("client_manager@armka.local") + # у менеджера клиента право CONSOLIDATION_VIEW есть, но ГК другого филиала (Краснодар) — вне видимости + assert (await client.get(f"/company-groups/{group.id}/consolidations")).status_code == HTTP_NOT_FOUND + + +@pytest.mark.integration +async def test_version_card_with_history_and_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group, versions = await _tree(seeded) + holder = await _user(seeded, RKA) + await make_lock(seeded, version=versions["q3v1"], user=holder) + client = await auth_client(KA_RF) + + response = await client.get(f"{VERSIONS}/{versions['old'].id}") + locked = await client.get(f"{VERSIONS}/{versions['q3v1'].id}") + + assert response.status_code == HTTP_OK, response.text + card = response.json() + assert card["id"] == str(versions["old"].id) + assert (card["status"], card["is_in_work"], card["version_no"]) == ("CANCELLED", False, 1) + assert (card["year"], card["quarter"]) == (2023, "Q4") + assert [(item["from_status"], item["to_status"]) for item in card["history"]] == [ + (None, "INITIATED"), + ("INITIATED", "CANCELLED"), + ] + assert card["company_group"]["id"] == str(group.id) + assert locked.json()["lock"]["locked_by_name"] == holder.full_name + + +@pytest.mark.integration +async def test_version_card_visible_to_go_curator_but_not_to_foreign_office( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + _, versions = await _tree(seeded) + version_id = versions["q3v1"].id + curator = await auth_client(CURATOR) + foreign = await auth_client("client_manager@armka.local") # Краснодарский филиал + random_id = uuid.uuid4() + + assert (await curator.get(f"{VERSIONS}/{version_id}")).status_code == HTTP_OK + hidden = await foreign.get(f"{VERSIONS}/{version_id}") + unknown = await foreign.get(f"{VERSIONS}/{random_id}") + assert hidden.status_code == unknown.status_code == HTTP_NOT_FOUND + assert hidden.json() == unknown.json() + + +@pytest.mark.integration +async def test_version_card_requires_consolidation_view(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + _, versions = await _tree(seeded) + client = await auth_client("admin@armka.local") # у роли ADMIN права CONSOLIDATION_VIEW нет + response = await client.get(f"{VERSIONS}/{versions['q3v1'].id}") + assert response.status_code == HTTP_FORBIDDEN diff --git a/api/tests/test_api_constraint_races.py b/api/tests/test_api_constraint_races.py index 653848f..30deff4 100644 --- a/api/tests/test_api_constraint_races.py +++ b/api/tests/test_api_constraint_races.py @@ -223,3 +223,26 @@ async def test_foreign_key_violation_stays_generic_conflict( assert response.status_code == HTTP_CONFLICT, response.text assert response.json() == {"detail": GENERIC_DETAIL} + + +@pytest.mark.integration +async def test_consolidation_period_race( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + """Два создания одного периода (ГК, год, квартал): pre-check пропустил, UNIQUE отклонил → `CONSOLIDATION_EXISTS`.""" + office = ( + await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == "Ростовский региональный филиал")) + ).scalar_one() + group = await make_company_group(seeded, office=office) + gsk = await make_related_group(seeded, group=group) + for number in range(2): + entity = await make_legal_entity(seeded, name=f"ООО Член {number}", group=group) + await make_membership(seeded, related_group=gsk, entity=entity) + client = await auth_client("ka_rf@armka.local") + body = {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": 2024, "quarter": "Q3"} + assert (await client.post("/consolidations", json=body)).status_code == HTTP_CREATED + monkeypatch.setattr("src.repositories.consolidations.get_consolidation_for_group", _free(None)) + + response = await client.post("/consolidations", json=body) + + _assert_coded(response, "CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана", str(group.id)) diff --git a/api/tests/test_domain_consolidation_periods.py b/api/tests/test_domain_consolidation_periods.py new file mode 100644 index 0000000..7a04477 --- /dev/null +++ b/api/tests/test_domain_consolidation_periods.py @@ -0,0 +1,22 @@ +"""Отчётная дата консолидации по году и кварталу (spec consolidations-api «Создание консолидации»; OQ-40).""" + +import datetime + +import pytest + +from src.domain.consolidation_periods import reporting_date +from src.models.enums import PeriodQuarter + + +@pytest.mark.parametrize( + ("year", "quarter", "expected"), + [ + (2024, PeriodQuarter.Q1, datetime.date(2024, 4, 1)), + (2024, PeriodQuarter.Q2, datetime.date(2024, 7, 1)), + (2024, PeriodQuarter.Q3, datetime.date(2024, 10, 1)), + (2024, PeriodQuarter.Q4, datetime.date(2025, 1, 1)), + (2100, PeriodQuarter.Q4, datetime.date(2101, 1, 1)), + ], +) +def test_reporting_date_is_first_day_after_quarter(year: int, quarter: PeriodQuarter, expected: datetime.date) -> None: + assert reporting_date(year, quarter) == expected diff --git a/api/tests/test_exception_handlers.py b/api/tests/test_exception_handlers.py index 7080d25..df96806 100644 --- a/api/tests/test_exception_handlers.py +++ b/api/tests/test_exception_handlers.py @@ -185,6 +185,7 @@ def test_registry_codes_and_messages_match_service_precheck_texts() -> None: "uq_app_user_email": "EMAIL_EXISTS", "uq_related_group_member_group_entity_current": "MEMBER_EXISTS", "uq_related_group_member_entity_kind_current": "MEMBER_OF_OTHER_GROUP", + "uq_consolidation_group_year_quarter": "CONSOLIDATION_EXISTS", } # у pre-check-ов постоянные тексты — тот же `message`, динамических значений в реестре нет assert UNIQUE_CONSTRAINT_CONFLICTS["uq_legal_entity_inn"].message == "ЮЛ с таким ИНН уже существует" diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 4fa95cb..47275be 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -1,4 +1,5 @@ -"""OpenAPI содержит все маршруты Среза 1 (specs directories-api, directory-import, users-admin) без дублей моделей. +"""OpenAPI содержит все маршруты Срезов 1–4 (specs directories-api, directory-import, users-admin, +consolidations-api) без дублей моделей. Схема строится из объявленных маршрутов без БД и сети. Состав маршрутов — по спекам change `slice-1-directories`; проверка нужна, чтобы маршрут не пропал при переносе роутеров и чтобы `RegionalOfficeOut` (R-13) был одной моделью. @@ -50,10 +51,22 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/documents/{document_id}": {"get"}, "/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/confirm": {"post"}, + # consolidations-api (Срез 4) + "/api/v1/consolidations": {"post"}, + "/api/v1/company-groups/{group_id}/consolidations": {"get"}, + "/api/v1/consolidations/versions/{version_id}": {"get"}, } # Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно -CHECKED_PREFIXES = (D, A, "/api/v1/legal-entities", "/api/v1/files", "/api/v1/documents") +CHECKED_PREFIXES = ( + D, + A, + "/api/v1/legal-entities", + "/api/v1/files", + "/api/v1/documents", + "/api/v1/consolidations", + "/api/v1/company-groups", +) # Модели, которые обязаны быть в схеме ровно один раз под коротким именем SINGLE_MODELS = [ @@ -66,6 +79,8 @@ SINGLE_MODELS = [ "FileOut", "DocumentOut", "EntryOut", + "VersionCard", + "ConsolidationPeriodOut", ] @@ -100,3 +115,14 @@ def test_components_have_no_duplicated_models(schema: dict[str, Any]) -> None: for model in SINGLE_MODELS: assert model in names, model assert not [name for name in names if "__" in name or "-" in name], names + + +def test_consolidation_routes_declare_documented_responses(schema: dict[str, Any]) -> None: + """Создание — 201 с карточкой версии и ошибки 404/409/422; чтение карточки и списка — 404 (ГК вне видимости).""" + create = schema["paths"]["/api/v1/consolidations"]["post"]["responses"] + assert {"201", "404", "409", "422"} <= set(create) + assert create["201"]["content"]["application/json"]["schema"]["$ref"].endswith("/VersionCard") + card = schema["paths"]["/api/v1/consolidations/versions/{version_id}"]["get"]["responses"] + assert {"200", "404"} <= set(card) + listing = schema["paths"]["/api/v1/company-groups/{group_id}/consolidations"]["get"]["responses"] + assert {"200", "404"} <= set(listing) diff --git a/api/tests/test_uow_and_errors.py b/api/tests/test_uow_and_errors.py index 9d95f08..37f6161 100644 --- a/api/tests/test_uow_and_errors.py +++ b/api/tests/test_uow_and_errors.py @@ -194,11 +194,13 @@ async def test_request_audit_delegates_with_request_context() -> None: service=cast(AuditService, service), user_id=user_id, ip_address="10.0.0.1", user_agent="pytest-agent" ) object_id = uuid.uuid4() + version_id = uuid.uuid4() result = await audit.record( action=AuditAction.GROUP_CHANGE, section=AuditSection.DIRECTORIES, object_type="company_group", object_id=object_id, + consolidation_version_id=version_id, before_value={"name": "А"}, after_value={"name": "Б"}, changed_field="name", @@ -210,6 +212,7 @@ async def test_request_audit_delegates_with_request_context() -> None: "section": AuditSection.DIRECTORIES, "object_type": "company_group", "object_id": object_id, + "consolidation_version_id": version_id, "description": None, "before_value": {"name": "А"}, "after_value": {"name": "Б"}, diff --git a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md index e02ed81..e7b1a5b 100644 --- a/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md +++ b/docs/superpowers/plans/2026-10-06-slice-4-consolidation.md @@ -84,9 +84,9 @@ - `create`: ГК видима (`company_group_is_visible`) → ГСК `get_related_group` + `kind == gsk` + `company_group_id == group` → `current_members` → ≥ 2 → `Consolidation(reporting_date)` + `ConsolidationVersion(version_no=1, INITIATED, CREATE, BASIC, initiator_id, initiator_office_id = principal.user.regional_office_id, created_by, created_at)` + `ConsolidationMember` × N + `ConsolidationStatusHistory(None → INITIATED, changed_by)` + подхват `consolidation_documents.bind_confirmed(version, entity_ids)` (Task 6; до него — заглушка `return 0`) + `audit(CONSOLIDATION_CREATE, object = consolidation_version, after = {consolidation_id, year, quarter, members: [...]})`. - Маршруты: `POST /consolidations` (201), `GET /company-groups/{id}/consolidations`, `GET /consolidations/versions/{id}`; права `require("CONSOLIDATION_EDIT")` / `require("CONSOLIDATION_VIEW")`. -- [ ] **Step 1: Тесты.** Домен: `reporting_date(2024, Q1) == date(2024, 4, 1)`, `Q4 → date(2025, 1, 1)`. API (integration, `auth_client`): сценарии спеки «Создание» (РБ Q3 2024, один член, повтор периода, `year = 1999` → 422), «Список и карточка» (дерево, чужой филиал 404 с телом как у случайного uuid, `in_work_only`), `curator_go` читает карточку, `expert` без `CONSOLIDATION_EDIT` → 403 на создании; `CONSOLIDATION_CREATE` с `consolidation_version_id`. -- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidations.py tests/test_openapi_routes.py -q` → PASS. -- [ ] **Step 5: Commit** `feat(consolidation): создание консолидации, список по ГК, карточка версии`. +- [x] **Step 1: Тесты.** Домен: `reporting_date(2024, Q1) == date(2024, 4, 1)`, `Q4 → date(2025, 1, 1)`. API (integration, `auth_client`): сценарии спеки «Создание» (РБ Q3 2024, один член, повтор периода, `year = 1999` → 422), «Список и карточка» (дерево, чужой филиал 404 с телом как у случайного uuid, `in_work_only`), `curator_go` читает карточку, `expert` без `CONSOLIDATION_EDIT` → 403 на создании; `CONSOLIDATION_CREATE` с `consolidation_version_id`. +- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidations.py tests/test_openapi_routes.py -q` → PASS. +- [x] **Step 5: Commit** `feat(consolidation): создание консолидации, список по ГК, карточка версии`. --- diff --git a/openspec/changes/slice-4-consolidation/tasks.md b/openspec/changes/slice-4-consolidation/tasks.md index 1f4b71d..a5ad074 100644 --- a/openspec/changes/slice-4-consolidation/tasks.md +++ b/openspec/changes/slice-4-consolidation/tasks.md @@ -18,7 +18,7 @@ ## 2. Версии, периметр, блокировка, статусы -- [ ] 2.1 **Создание, список, карточка.** `schemas/consolidations.py` (D-16), `services/consolidations.py:create/list_periods/card`, +- [x] 2.1 **Создание, список, карточка.** `schemas/consolidations.py` (D-16), `services/consolidations.py:create/list_periods/card`, `domain/consolidation_periods.py:reporting_date`, эндпоинты `POST /consolidations`, `GET /company-groups/{id}/consolidations`, `GET /consolidations/versions/{id}`, роутер. Тесты: сценарии `consolidations-api` «Создание», «Список и карточка», policy 404. - [ ] 2.2 **Статусы и политика.** `domain/policy.py:IN_WORK_STATUSES, ensure_version_editable`; `services/consolidation_status.py: