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:
parent
d3c37db197
commit
9411d9d2ae
@ -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,
|
||||
|
||||
25
api/src/api/dependencies/consolidations.py
Normal file
25
api/src/api/dependencies/consolidations.py
Normal 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)]
|
||||
9
api/src/api/endpoints/consolidations/__init__.py
Normal file
9
api/src/api/endpoints/consolidations/__init__.py
Normal 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)
|
||||
59
api/src/api/endpoints/consolidations/periods.py
Normal file
59
api/src/api/endpoints/consolidations/periods.py
Normal 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)
|
||||
28
api/src/api/endpoints/consolidations/versions.py
Normal file
28
api/src/api/endpoints/consolidations/versions.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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-а
|
||||
|
||||
27
api/src/domain/consolidation_periods.py
Normal file
27
api/src/domain/consolidation_periods.py
Normal 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)
|
||||
@ -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` — вся система (ГО)."""
|
||||
|
||||
@ -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 = (
|
||||
|
||||
23
api/src/services/consolidation_documents.py
Normal file
23
api/src/services/consolidation_documents.py
Normal 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
|
||||
266
api/src/services/consolidations.py
Normal file
266
api/src/services/consolidations.py
Normal 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
|
||||
],
|
||||
)
|
||||
@ -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)
|
||||
|
||||
402
api/tests/test_api_consolidations.py
Normal file
402
api/tests/test_api_consolidations.py
Normal 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
|
||||
@ -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))
|
||||
|
||||
22
api/tests/test_domain_consolidation_periods.py
Normal file
22
api/tests/test_domain_consolidation_periods.py
Normal 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
|
||||
@ -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 == "ЮЛ с таким ИНН уже существует"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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": "Б"},
|
||||
|
||||
@ -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): создание консолидации, список по ГК, карточка версии`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user