feat(consolidation): создание консолидации, список по ГК, карточка версии

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 03:57:52 +04:00
parent d3c37db197
commit 9411d9d2ae
21 changed files with 974 additions and 20 deletions

View File

@ -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,

View File

@ -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)]

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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-а

View File

@ -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)

View File

@ -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` — вся система (ГО)."""

View File

@ -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 = (

View File

@ -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

View File

@ -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
],
)

View File

@ -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)

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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 == "ЮЛ с таким ИНН уже существует"

View File

@ -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)

View File

@ -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": "Б"},

View File

@ -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): создание консолидации, список по ГК, карточка версии`.
---

View File

@ -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: