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,
|
section: AuditSection,
|
||||||
object_type: str,
|
object_type: str,
|
||||||
object_id: uuid.UUID | None = None,
|
object_id: uuid.UUID | None = None,
|
||||||
|
consolidation_version_id: uuid.UUID | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
before_value: Mapping[str, Any] | None = None,
|
before_value: Mapping[str, Any] | None = None,
|
||||||
after_value: Mapping[str, Any] | None = None,
|
after_value: Mapping[str, Any] | None = None,
|
||||||
@ -48,6 +49,7 @@ class RequestAudit:
|
|||||||
section=section,
|
section=section,
|
||||||
object_type=object_type,
|
object_type=object_type,
|
||||||
object_id=object_id,
|
object_id=object_id,
|
||||||
|
consolidation_version_id=consolidation_version_id,
|
||||||
description=description,
|
description=description,
|
||||||
before_value=before_value,
|
before_value=before_value,
|
||||||
after_value=after_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/`).
|
"""Корневой роутер API `/api/v1` (R-6: эндпоинты — `src/api/endpoints/`).
|
||||||
|
|
||||||
`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*`, `/admin/*` и файлы
|
`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*`, `/admin/*`, файлы
|
||||||
(`/legal-entities/{id}/files`) — всегда, dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный
|
(`/legal-entities/{id}/files`) и консолидации (`/consolidations`, `/company-groups/{id}/consolidations`) — всегда,
|
||||||
`router` собран по настройкам процесса и подключается в `src/app.py`.
|
dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и
|
||||||
|
подключается в `src/app.py`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
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.api.endpoints.dev_auth import build_dev_router
|
||||||
from src.core.config import Settings, get_settings
|
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(directories.router)
|
||||||
api_router.include_router(admin.router)
|
api_router.include_router(admin.router)
|
||||||
api_router.include_router(files.router)
|
api_router.include_router(files.router)
|
||||||
|
api_router.include_router(consolidations.router)
|
||||||
if settings.AUTH_DEV_ISSUER and settings.DEBUG:
|
if settings.AUTH_DEV_ISSUER and settings.DEBUG:
|
||||||
api_router.include_router(build_dev_router(settings))
|
api_router.include_router(build_dev_router(settings))
|
||||||
return api_router
|
return api_router
|
||||||
|
|||||||
@ -36,6 +36,9 @@ UNIQUE_CONSTRAINT_CONFLICTS: dict[str, ConstraintConflict] = {
|
|||||||
"ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас"
|
"ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас"
|
||||||
),
|
),
|
||||||
"uq_app_user_email": ConstraintConflict("EMAIL_EXISTS", "Пользователь с таким email уже существует"),
|
"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", "ЮЛ уже входит в эту группу"),
|
"uq_related_group_member_group_entity_current": ConstraintConflict("MEMBER_EXISTS", "ЮЛ уже входит в эту группу"),
|
||||||
# Нарушается и при повторном добавлении в ту же группу (PostgreSQL проверяет индексы в порядке OID, а этот создан
|
# Нарушается и при повторном добавлении в ту же группу (PostgreSQL проверяет индексы в порядке OID, а этот создан
|
||||||
# миграцией 0002 раньше второго), поэтому текст — общий для обоих случаев, а не «в другую группу» как у pre-check-а
|
# миграцией 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.common.exceptions import DomainValidationError
|
||||||
from src.models.directories import CompanyGroup, LegalEntity
|
from src.models.directories import CompanyGroup, LegalEntity
|
||||||
|
from src.models.enums import ConsolidationStatus
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# Только для типов: домен не тянет репозитории в рантайме
|
# Только для типов: домен не тянет репозитории в рантайме
|
||||||
from src.repositories.users import UserWithAccess
|
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)
|
@dataclass(frozen=True)
|
||||||
class VisibilityScope:
|
class VisibilityScope:
|
||||||
"""Область видимости справочников: `office_id is None` — вся система (ГО)."""
|
"""Область видимости справочников: `office_id is None` — вся система (ГО)."""
|
||||||
|
|||||||
@ -9,11 +9,12 @@ import uuid
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Collection, Sequence
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.domain.checklist import EntityFilesState
|
from src.domain.checklist import EntityFilesState
|
||||||
from src.domain.policy import VisibilityScope, company_groups_visible
|
from src.domain.policy import VisibilityScope, company_groups_visible
|
||||||
|
from src.models.access import AppUser
|
||||||
from src.models.consolidation import (
|
from src.models.consolidation import (
|
||||||
Consolidation,
|
Consolidation,
|
||||||
ConsolidationDocument,
|
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()
|
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]]:
|
async def members(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[ConsolidationMember, LegalEntity]]:
|
||||||
"""Периметр версии: член и его ЮЛ, по имени ЮЛ (`id` — для устойчивости порядка)."""
|
"""Периметр версии: член и его ЮЛ, по имени ЮЛ (`id` — для устойчивости порядка)."""
|
||||||
stmt = (
|
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 sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from src.contract.result import OsvDocument, RecognitionResult
|
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.domain.osv_mapping import build_entries, document_fields
|
||||||
from src.models.access import AppUser, Role, UserRole
|
from src.models.access import AppUser, Role, UserRole
|
||||||
from src.models.accounting import ChartOfAccount
|
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}
|
DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF}
|
||||||
TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB})
|
TABULAR_FILE_TYPES = frozenset({FileType.XLS, FileType.XLSX, FileType.XLSB})
|
||||||
FACTORY_BUCKET = "arm-ka"
|
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` ставится только им и аннулированию
|
# статусы вне «в работе» (как фильтр `in_work_only` спеки): `is_in_work = false` ставится только им и аннулированию
|
||||||
FINISHED_STATUSES = frozenset(
|
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 следующего года.
|
`reporting_date` — по правилу спеки: Q1 → 01.04, Q2 → 01.07, Q3 → 01.10, Q4 → 01.01 следующего года.
|
||||||
"""
|
"""
|
||||||
month, year_shift = REPORTING_DATES[quarter]
|
|
||||||
consolidation = Consolidation(
|
consolidation = Consolidation(
|
||||||
company_group_id=group.id,
|
company_group_id=group.id,
|
||||||
year=year,
|
year=year,
|
||||||
quarter=quarter,
|
quarter=quarter,
|
||||||
reporting_date=datetime.date(year + year_shift, month, 1),
|
reporting_date=reporting_date(year, quarter),
|
||||||
created_at=_now(),
|
created_at=_now(),
|
||||||
)
|
)
|
||||||
session.add(consolidation)
|
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.status_code == HTTP_CONFLICT, response.text
|
||||||
assert response.json() == {"detail": GENERIC_DETAIL}
|
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_app_user_email": "EMAIL_EXISTS",
|
||||||
"uq_related_group_member_group_entity_current": "MEMBER_EXISTS",
|
"uq_related_group_member_group_entity_current": "MEMBER_EXISTS",
|
||||||
"uq_related_group_member_entity_kind_current": "MEMBER_OF_OTHER_GROUP",
|
"uq_related_group_member_entity_kind_current": "MEMBER_OF_OTHER_GROUP",
|
||||||
|
"uq_consolidation_group_year_quarter": "CONSOLIDATION_EXISTS",
|
||||||
}
|
}
|
||||||
# у pre-check-ов постоянные тексты — тот же `message`, динамических значений в реестре нет
|
# у pre-check-ов постоянные тексты — тот же `message`, динамических значений в реестре нет
|
||||||
assert UNIQUE_CONSTRAINT_CONFLICTS["uq_legal_entity_inn"].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`;
|
Схема строится из объявленных маршрутов без БД и сети. Состав маршрутов — по спекам change `slice-1-directories`;
|
||||||
проверка нужна, чтобы маршрут не пропал при переносе роутеров и чтобы `RegionalOfficeOut` (R-13) был одной моделью.
|
проверка нужна, чтобы маршрут не пропал при переносе роутеров и чтобы `RegionalOfficeOut` (R-13) был одной моделью.
|
||||||
@ -50,10 +51,22 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
|||||||
"/api/v1/documents/{document_id}": {"get"},
|
"/api/v1/documents/{document_id}": {"get"},
|
||||||
"/api/v1/documents/{document_id}/entries": {"get"},
|
"/api/v1/documents/{document_id}/entries": {"get"},
|
||||||
"/api/v1/documents/{document_id}/confirm": {"post"},
|
"/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 быть не должно
|
# Префиксы, под которыми маршрутов сверх 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 = [
|
SINGLE_MODELS = [
|
||||||
@ -66,6 +79,8 @@ SINGLE_MODELS = [
|
|||||||
"FileOut",
|
"FileOut",
|
||||||
"DocumentOut",
|
"DocumentOut",
|
||||||
"EntryOut",
|
"EntryOut",
|
||||||
|
"VersionCard",
|
||||||
|
"ConsolidationPeriodOut",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -100,3 +115,14 @@ def test_components_have_no_duplicated_models(schema: dict[str, Any]) -> None:
|
|||||||
for model in SINGLE_MODELS:
|
for model in SINGLE_MODELS:
|
||||||
assert model in names, model
|
assert model in names, model
|
||||||
assert not [name for name in names if "__" in name or "-" in name], names
|
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"
|
service=cast(AuditService, service), user_id=user_id, ip_address="10.0.0.1", user_agent="pytest-agent"
|
||||||
)
|
)
|
||||||
object_id = uuid.uuid4()
|
object_id = uuid.uuid4()
|
||||||
|
version_id = uuid.uuid4()
|
||||||
result = await audit.record(
|
result = await audit.record(
|
||||||
action=AuditAction.GROUP_CHANGE,
|
action=AuditAction.GROUP_CHANGE,
|
||||||
section=AuditSection.DIRECTORIES,
|
section=AuditSection.DIRECTORIES,
|
||||||
object_type="company_group",
|
object_type="company_group",
|
||||||
object_id=object_id,
|
object_id=object_id,
|
||||||
|
consolidation_version_id=version_id,
|
||||||
before_value={"name": "А"},
|
before_value={"name": "А"},
|
||||||
after_value={"name": "Б"},
|
after_value={"name": "Б"},
|
||||||
changed_field="name",
|
changed_field="name",
|
||||||
@ -210,6 +212,7 @@ async def test_request_audit_delegates_with_request_context() -> None:
|
|||||||
"section": AuditSection.DIRECTORIES,
|
"section": AuditSection.DIRECTORIES,
|
||||||
"object_type": "company_group",
|
"object_type": "company_group",
|
||||||
"object_id": object_id,
|
"object_id": object_id,
|
||||||
|
"consolidation_version_id": version_id,
|
||||||
"description": None,
|
"description": None,
|
||||||
"before_value": {"name": "А"},
|
"before_value": {"name": "А"},
|
||||||
"after_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: [...]})`.
|
- `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")`.
|
- Маршруты: `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`.
|
- [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`.
|
||||||
- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidations.py tests/test_openapi_routes.py -q` → PASS.
|
- [x] **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 5: Commit** `feat(consolidation): создание консолидации, список по ГК, карточка версии`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
## 2. Версии, периметр, блокировка, статусы
|
## 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`,
|
`domain/consolidation_periods.py:reporting_date`, эндпоинты `POST /consolidations`, `GET /company-groups/{id}/consolidations`,
|
||||||
`GET /consolidations/versions/{id}`, роутер. Тесты: сценарии `consolidations-api` «Создание», «Список и карточка», policy 404.
|
`GET /consolidations/versions/{id}`, роутер. Тесты: сценарии `consolidations-api` «Создание», «Список и карточка», policy 404.
|
||||||
- [ ] 2.2 **Статусы и политика.** `domain/policy.py:IN_WORK_STATUSES, ensure_version_editable`; `services/consolidation_status.py:
|
- [ ] 2.2 **Статусы и политика.** `domain/policy.py:IN_WORK_STATUSES, ensure_version_editable`; `services/consolidation_status.py:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user