feat(consolidation): редактирование периметра версии с подхватом и отвязкой документов
PUT /consolidations/versions/{id}/members: замена состава ЮЛ версии, проверка принадлежности ГК
(ENTITY_NOT_IN_GROUP), минимум два ЮЛ, diff с отвязкой документов ушедших (unbind_entity) и подхватом
добавленных (bind_confirmed, заглушка до Task 6), аудит MEMBERSHIP_CHANGE. Общий пролог
«lock → видимость → редактируемость» вынесен в services/consolidation_guard.py (T4.2). Решения #25–#29.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
e317fe59dd
commit
90b7bd5c16
@ -2,10 +2,11 @@
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from src.api.endpoints.consolidations import locks, periods, status, versions
|
||||
from src.api.endpoints.consolidations import locks, members, periods, status, versions
|
||||
|
||||
router = APIRouter(tags=["consolidations"])
|
||||
router.include_router(periods.router)
|
||||
router.include_router(versions.router)
|
||||
router.include_router(locks.router)
|
||||
router.include_router(status.router)
|
||||
router.include_router(members.router)
|
||||
|
||||
38
api/src/api/endpoints/consolidations/members.py
Normal file
38
api/src/api/endpoints/consolidations/members.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""`PUT /consolidations/versions/{id}/members` — периметр версии консолидации (spec consolidations-api «Периметр
|
||||
версии»; directory-policy «редактируемость версии»; audit-trail `MEMBERSHIP_CHANGE`; design D-8, D-9)."""
|
||||
|
||||
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 MembersIn, VersionCard
|
||||
|
||||
router = APIRouter(prefix="/consolidations/versions")
|
||||
|
||||
EDIT = [Depends(require("CONSOLIDATION_EDIT"))]
|
||||
RESPONSES: dict[int | str, dict[str, Any]] = {
|
||||
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"},
|
||||
status.HTTP_409_CONFLICT: {
|
||||
"model": ErrorDetail,
|
||||
"description": "VERSION_NOT_EDITABLE, LOCK_REQUIRED, LOCKED_BY_OTHER",
|
||||
},
|
||||
status.HTTP_422_UNPROCESSABLE_CONTENT: {
|
||||
"description": "ENTITY_NOT_IN_GROUP (legal_entity_ids), PERIMETER_TOO_SMALL или ошибка валидации тела"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{version_id}/members",
|
||||
response_model=VersionCard,
|
||||
dependencies=EDIT,
|
||||
responses=RESPONSES,
|
||||
summary="Заменить периметр версии",
|
||||
)
|
||||
async def set_members(version_id: uuid.UUID, data: MembersIn, service: ConsolidationServiceDep) -> VersionCard:
|
||||
"""Новый состав ЮЛ версии (все — из ГК консолидации, не меньше двух; нужна своя блокировка)."""
|
||||
return await service.set_members(version_id, data)
|
||||
@ -182,6 +182,16 @@ async def members(session: AsyncSession, version_id: uuid.UUID) -> list[tuple[Co
|
||||
return [(row[0], row[1]) for row in await session.execute(stmt)]
|
||||
|
||||
|
||||
async def entities_in_group(
|
||||
session: AsyncSession, group_id: uuid.UUID, entity_ids: Collection[uuid.UUID]
|
||||
) -> set[uuid.UUID]:
|
||||
"""Из `entity_ids` — те ЮЛ, что существуют и принадлежат ГК `group_id` (`legal_entity.company_group_id`)."""
|
||||
if not entity_ids:
|
||||
return set()
|
||||
stmt = select(LegalEntity.id).where(LegalEntity.id.in_(entity_ids), LegalEntity.company_group_id == group_id)
|
||||
return set((await session.execute(stmt)).scalars())
|
||||
|
||||
|
||||
async def bound_documents(session: AsyncSession, version_id: uuid.UUID) -> list[Document]:
|
||||
"""Документы, привязанные к версии (`consolidation_document`)."""
|
||||
stmt = (
|
||||
|
||||
@ -2,16 +2,21 @@
|
||||
|
||||
ЗАГЛУШКА Среза 4, задача 3: `bind_confirmed` ничего не привязывает и возвращает 0. Настоящую реализацию (отбор
|
||||
подтверждённых документов ЮЛ по правилу «подходящести» D-5, `consolidation_document`, порядок блокировок
|
||||
файл → документ → версии, R-1…R-3) вносит Task 6 — она заменяет тело функции, сигнатуру и вызов из
|
||||
`services.consolidations.ConsolidationService.create` сохраняет.
|
||||
файл → документ → версии, R-1…R-3) вносит Task 6 — она заменяет тело функции, сигнатуру и вызовы из
|
||||
`services.consolidations.ConsolidationService` (`create`, `set_members`) сохраняет.
|
||||
|
||||
`unbind_entity` (Task 5) — прямое удаление привязок ЮЛ к версии; Task 6 владеет функцией и вправе переписать тело
|
||||
(имя и сигнатура — прежние): вызывается из `set_members` для ЮЛ, ушедших из периметра.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.models.consolidation import ConsolidationVersion
|
||||
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
|
||||
from src.models.files import Document
|
||||
|
||||
|
||||
async def bind_confirmed(
|
||||
@ -21,3 +26,19 @@ async def bind_confirmed(
|
||||
) -> int:
|
||||
"""Привязывает к `version` подходящие подтверждённые документы ЮЛ `entity_ids`; возвращает число привязок."""
|
||||
return 0
|
||||
|
||||
|
||||
async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_id: uuid.UUID) -> int:
|
||||
"""Снимает все привязки документов ЮЛ `entity_id` к версии `version_id`; возвращает число снятых.
|
||||
|
||||
Task 5: простое прямое удаление `consolidation_document` по `document.legal_entity_id`. Принадлежит Task 6
|
||||
(отвязка по правилам D-5, порядок блокировок); версия должна быть под `FOR UPDATE` вызывающего.
|
||||
"""
|
||||
owned = select(Document.id).where(Document.legal_entity_id == entity_id)
|
||||
result = await session.execute(
|
||||
delete(ConsolidationDocument).where(
|
||||
ConsolidationDocument.consolidation_version_id == version_id,
|
||||
ConsolidationDocument.document_id.in_(owned),
|
||||
)
|
||||
)
|
||||
return int(result.rowcount) # type: ignore[attr-defined] # CursorResult у DML; у базового Result атрибута нет
|
||||
|
||||
40
api/src/services/consolidation_guard.py
Normal file
40
api/src/services/consolidation_guard.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""Общий пролог операций над версией консолидации: блокировка строки версии и видимость (design D-8, D-9).
|
||||
|
||||
Один порядок для всех операций, меняющих версию: `consolidation_version FOR UPDATE` → видимость (ГК консолидации в
|
||||
области пользователя; чужая и несуществующая версии — один и тот же 404 `VERSION_NOT_FOUND`) → для правок —
|
||||
активная блокировка и `ensure_version_editable` (409 `VERSION_NOT_EDITABLE` / `LOCK_REQUIRED` / `LOCKED_BY_OTHER`).
|
||||
Версию блокирует и правка, и перехват, и подтверждение документа (R-2), поэтому проверка статуса и владельца
|
||||
блокировки не может устареть до конца транзакции. Операции, которым редактируемость не нужна (взять в работу, снять
|
||||
блокировку), берут только `lock_visible_version`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.common.exceptions import NotFoundError
|
||||
from src.domain.policy import VisibilityScope, ensure_version_editable
|
||||
from src.models.consolidation import Consolidation, ConsolidationVersion
|
||||
from src.repositories import consolidations as repo
|
||||
|
||||
VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей
|
||||
|
||||
|
||||
async def lock_visible_version(
|
||||
session: AsyncSession, scope: VisibilityScope, version_id: uuid.UUID
|
||||
) -> tuple[ConsolidationVersion, Consolidation]:
|
||||
"""Версия под `FOR UPDATE` и её консолидация, если ГК видна пользователю; иначе 404 `VERSION_NOT_FOUND`."""
|
||||
version = await repo.lock_version(session, version_id)
|
||||
found = None if version is None else await repo.get_version(session, scope, version_id)
|
||||
if version is None or found is None:
|
||||
raise NotFoundError(VERSION_NOT_FOUND)
|
||||
return version, found[1]
|
||||
|
||||
|
||||
async def lock_editable_version(
|
||||
session: AsyncSession, scope: VisibilityScope, version_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> tuple[ConsolidationVersion, Consolidation]:
|
||||
"""`lock_visible_version` + `ensure_version_editable` по активной блокировке: версию вправе править `user_id`."""
|
||||
version, consolidation = await lock_visible_version(session, scope, version_id)
|
||||
ensure_version_editable(version, await repo.active_lock(session, version.id), user_id)
|
||||
return version, consolidation
|
||||
@ -17,15 +17,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.clock import utc_now
|
||||
from src.common.constraint_conflicts import constraint_name, resolve_unique_conflict
|
||||
from src.common.exceptions import ConflictError, NotFoundError
|
||||
from src.common.exceptions import ConflictError
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.models.access import AppUser
|
||||
from src.models.consolidation import ConsolidationLock, ConsolidationVersion
|
||||
from src.models.consolidation import ConsolidationLock
|
||||
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus
|
||||
from src.repositories import consolidations as repo
|
||||
from src.repositories.users import UserWithAccess
|
||||
from src.schemas.consolidations import LockIn, LockOut
|
||||
from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND, lock_out
|
||||
from src.services.consolidation_guard import lock_visible_version
|
||||
from src.services.consolidations import OBJECT_TYPE, lock_out
|
||||
|
||||
ACTIVE_LOCK_INDEX = "uq_consolidation_lock_version_active"
|
||||
|
||||
@ -70,7 +71,7 @@ class ConsolidationLockService:
|
||||
непустой причиной — прежняя снимается, новая с `taken_over_from`/`takeover_reason`, аудит перехвата.
|
||||
Причина при свободной версии не сохраняется (перехватывать не у кого).
|
||||
"""
|
||||
version = await self._locked_visible_version(version_id)
|
||||
version, _ = await lock_visible_version(self._session, self._scope, version_id)
|
||||
if version.status == ConsolidationStatus.CANCELLED:
|
||||
raise ConflictError("VERSION_NOT_EDITABLE", "Аннулированную версию нельзя взять в работу")
|
||||
user = self._principal.user
|
||||
@ -112,7 +113,7 @@ class ConsolidationLockService:
|
||||
async def release(self, version_id: uuid.UUID) -> None:
|
||||
"""Снять свою активную блокировку (аудит `CONSOLIDATION_LOCK`, `released = true`); чужая — 409
|
||||
`NOT_LOCK_OWNER`; активной нет — ничего не делает (идемпотентно)."""
|
||||
version = await self._locked_visible_version(version_id)
|
||||
version, _ = await lock_visible_version(self._session, self._scope, version_id)
|
||||
current = await repo.active_lock(self._session, version.id)
|
||||
if current is None:
|
||||
return
|
||||
@ -134,13 +135,6 @@ class ConsolidationLockService:
|
||||
after_value={"locked_by": user.id, "released": True},
|
||||
)
|
||||
|
||||
async def _locked_visible_version(self, version_id: uuid.UUID) -> ConsolidationVersion:
|
||||
"""Версия под `FOR UPDATE` (D-9), если её ГК видна; иначе 404 — одинаковый для чужой и несуществующей."""
|
||||
version = await repo.lock_version(self._session, version_id)
|
||||
if version is None or await repo.get_version(self._session, self._scope, version_id) is None:
|
||||
raise NotFoundError(VERSION_NOT_FOUND)
|
||||
return version
|
||||
|
||||
async def _insert(
|
||||
self,
|
||||
version_id: uuid.UUID,
|
||||
|
||||
@ -13,16 +13,16 @@ 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.policy import VisibilityScope, ensure_version_editable
|
||||
from src.common.exceptions import ConflictError, DomainValidationError
|
||||
from src.domain.policy import VisibilityScope
|
||||
from src.models.consolidation import ConsolidationStatusHistory, ConsolidationVersion
|
||||
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus
|
||||
from src.repositories import consolidations as repo
|
||||
from src.repositories.users import UserWithAccess
|
||||
from src.schemas.consolidations import StatusIn, VersionCard
|
||||
from src.services import consolidation_locks
|
||||
from src.services.audit import AuditService
|
||||
from src.services.consolidations import OBJECT_TYPE, VERSION_NOT_FOUND, ConsolidationService
|
||||
from src.services.consolidation_guard import lock_editable_version
|
||||
from src.services.consolidations import OBJECT_TYPE, ConsolidationService
|
||||
|
||||
_CANCELLED = ConsolidationStatus.CANCELLED
|
||||
_INITIATED = ConsolidationStatus.INITIATED
|
||||
@ -111,11 +111,8 @@ class ConsolidationStatusService:
|
||||
с `from_status`, `to_status`; повтор того же статуса — тоже) → `CANCELLED` без комментария — 422
|
||||
`COMMENT_REQUIRED`. Комментарий из одних пробелов считается пустым.
|
||||
"""
|
||||
version = await repo.lock_version(self._session, version_id)
|
||||
if version is None or await repo.get_version(self._session, self._scope, version_id) is None:
|
||||
raise NotFoundError(VERSION_NOT_FOUND)
|
||||
user = self._principal.user
|
||||
ensure_version_editable(version, await repo.active_lock(self._session, version.id), user.id)
|
||||
version, _ = await lock_editable_version(self._session, self._scope, version_id, user.id)
|
||||
if data.to_status not in MANUAL_TRANSITIONS.get(version.status, frozenset()):
|
||||
raise ConflictError(
|
||||
"TRANSITION_NOT_ALLOWED",
|
||||
|
||||
@ -44,15 +44,16 @@ from src.schemas.consolidations import (
|
||||
HistoryOut,
|
||||
LockOut,
|
||||
MemberOut,
|
||||
MembersIn,
|
||||
RefOut,
|
||||
VersionBrief,
|
||||
VersionCard,
|
||||
)
|
||||
from src.services import consolidation_documents
|
||||
from src.services.company_groups import COMPANY_GROUP_NOT_FOUND
|
||||
from src.services.consolidation_guard import VERSION_NOT_FOUND, lock_editable_version
|
||||
from src.services.related_groups import RELATED_GROUP_NOT_FOUND
|
||||
|
||||
VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей
|
||||
OBJECT_TYPE = "consolidation_version"
|
||||
MIN_PERIMETER = 2 # в периметре консолидации не меньше двух ЮЛ (иначе нечего сопоставлять)
|
||||
FIRST_VERSION_NO = 1
|
||||
@ -155,6 +156,61 @@ class ConsolidationService:
|
||||
raise NotFoundError(VERSION_NOT_FOUND)
|
||||
return await self._card(*found)
|
||||
|
||||
async def set_members(self, version_id: uuid.UUID, data: MembersIn) -> VersionCard:
|
||||
"""Заменяет периметр версии списком `data.legal_entity_ids`; ответ — карточка версии.
|
||||
|
||||
Порядок: `FOR UPDATE` версии → видимость (404) → редактируемость и своя блокировка (409, D-8, D-9) → дубли
|
||||
схлопываются → все ЮЛ существуют и принадлежат ГК консолидации (422 `ENTITY_NOT_IN_GROUP`,
|
||||
`legal_entity_ids` — в порядке запроса) → не меньше двух (422 `PERIMETER_TOO_SMALL`) → diff: ушедшие
|
||||
теряют `consolidation_member` и привязки документов (`unbind_entity`), пришедшие получают член
|
||||
(`is_head_entity = false`) и подхват подтверждённых документов (`bind_confirmed`); оставшиеся не трогаются →
|
||||
`MEMBERSHIP_CHANGE` с полными отсортированными списками ЮЛ до/после. Состав не изменился — 200 без записи и
|
||||
без события. `related_group_member` не меняется никогда.
|
||||
"""
|
||||
user = self._principal.user
|
||||
version, consolidation = await lock_editable_version(self._session, self._scope, version_id, user.id)
|
||||
requested = list(dict.fromkeys(data.legal_entity_ids))
|
||||
valid = await repo.entities_in_group(self._session, consolidation.company_group_id, requested)
|
||||
foreign = [entity_id for entity_id in requested if entity_id not in valid]
|
||||
if foreign:
|
||||
raise DomainValidationError(
|
||||
"ENTITY_NOT_IN_GROUP", "ЮЛ не принадлежат ГК консолидации", legal_entity_ids=foreign
|
||||
)
|
||||
if len(requested) < MIN_PERIMETER:
|
||||
raise DomainValidationError(
|
||||
"PERIMETER_TOO_SMALL", f"В периметре должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(requested)}"
|
||||
)
|
||||
|
||||
current = {member.legal_entity_id: member for member, _ in await repo.members(self._session, version.id)}
|
||||
added = [entity_id for entity_id in requested if entity_id not in current]
|
||||
removed = [entity_id for entity_id in current if entity_id not in set(requested)]
|
||||
if not added and not removed:
|
||||
return await self._card(version, consolidation)
|
||||
|
||||
for entity_id in removed:
|
||||
await self._session.delete(current[entity_id])
|
||||
self._session.add_all(
|
||||
ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity_id, is_head_entity=False)
|
||||
for entity_id in added
|
||||
)
|
||||
version.updated_by = user.id
|
||||
version.updated_at = utc_now()
|
||||
await self._session.flush()
|
||||
for entity_id in removed:
|
||||
await consolidation_documents.unbind_entity(self._session, version_id=version.id, entity_id=entity_id)
|
||||
if added:
|
||||
await consolidation_documents.bind_confirmed(self._session, version, added)
|
||||
await self._audit.record(
|
||||
action=AuditAction.MEMBERSHIP_CHANGE,
|
||||
section=AuditSection.CONSOLIDATION,
|
||||
object_type=OBJECT_TYPE,
|
||||
object_id=version.id,
|
||||
consolidation_version_id=version.id,
|
||||
before_value={"legal_entity_ids": sorted(current)},
|
||||
after_value={"legal_entity_ids": sorted(requested)},
|
||||
)
|
||||
return await self._card(version, consolidation)
|
||||
|
||||
async def _insert(
|
||||
self, group_id: uuid.UUID, data: ConsolidationCreate, entities: list[LegalEntity]
|
||||
) -> tuple[Consolidation, ConsolidationVersion]:
|
||||
|
||||
493
api/tests/test_api_consolidation_members.py
Normal file
493
api/tests/test_api_consolidation_members.py
Normal file
@ -0,0 +1,493 @@
|
||||
"""Периметр версии: `PUT /consolidations/versions/{id}/members` (spec consolidations-api «Периметр версии»;
|
||||
directory-policy «редактируемость версии»; audit-trail `MEMBERSHIP_CHANGE`; design D-5, D-8, D-9).
|
||||
|
||||
Подхват подтверждённых документов добавленных ЮЛ (`bind_confirmed`) до Task 6 — заглушка: здесь проверяется ВЫЗОВ
|
||||
(шпион на функцию модуля), а настоящие привязки появятся с реализацией и ячейкой `CONFIRMED` (Task 6, Task 8).
|
||||
Отвязка документов удалённых ЮЛ (`unbind_entity`) работает уже сейчас — прямое удаление `consolidation_document`.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
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 ConsolidationDocument, ConsolidationMember, ConsolidationVersion
|
||||
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroupMember
|
||||
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter
|
||||
from src.services import consolidation_documents
|
||||
from tests.conftest import AuthClientFactory
|
||||
from tests.factories import (
|
||||
make_company_group,
|
||||
make_confirmed_osv,
|
||||
make_consolidation,
|
||||
make_legal_entity,
|
||||
make_lock,
|
||||
make_membership,
|
||||
make_related_group,
|
||||
)
|
||||
|
||||
HTTP_OK = 200
|
||||
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"
|
||||
CURATOR = "curator_go@armka.local"
|
||||
EXPERT = "expert@armka.local"
|
||||
VERSIONS = "/consolidations/versions"
|
||||
ACCOUNT = "60"
|
||||
THREE = 3
|
||||
|
||||
|
||||
async def _user(session: AsyncSession, email: str) -> AppUser:
|
||||
return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one()
|
||||
|
||||
|
||||
async def _version( # noqa: PLR0913 - тестовая фабрика: состав периметра, статус, филиал и владелец блокировки
|
||||
session: AsyncSession,
|
||||
*,
|
||||
members: int = 2,
|
||||
spare: int = 1,
|
||||
status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION,
|
||||
office_name: str = ROSTOV,
|
||||
locked_by: str | None = KA_RF,
|
||||
) -> tuple[ConsolidationVersion, list[LegalEntity], list[LegalEntity], uuid.UUID]:
|
||||
"""Версия ГК филиала за 2024 Q3: `members` ЮЛ в периметре и `spare` ЮЛ той же ГК вне периметра.
|
||||
|
||||
Возвращает версию, члены, запасные ЮЛ и id ГК; `locked_by` — владелец активной блокировки.
|
||||
"""
|
||||
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one()
|
||||
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
|
||||
inside = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(members)]
|
||||
outside = [await make_legal_entity(session, name=f"ООО Запас {n}", group=group) for n in range(spare)]
|
||||
_, version = await make_consolidation(
|
||||
session,
|
||||
group=group,
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q3,
|
||||
members=inside,
|
||||
initiator=await _user(session, KA_RF),
|
||||
status=status,
|
||||
)
|
||||
if locked_by is not None:
|
||||
await make_lock(session, version=version, user=await _user(session, locked_by))
|
||||
return version, inside, outside, group.id
|
||||
|
||||
|
||||
def _url(version_id: uuid.UUID) -> str:
|
||||
return f"{VERSIONS}/{version_id}/members"
|
||||
|
||||
|
||||
def _body(entities: Sequence[LegalEntity | uuid.UUID]) -> dict[str, Any]:
|
||||
return {"legal_entity_ids": [str(e.id if isinstance(e, LegalEntity) else e) for e in entities]}
|
||||
|
||||
|
||||
async def _member_ids(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
|
||||
stmt = select(ConsolidationMember.legal_entity_id).where(ConsolidationMember.consolidation_version_id == version_id)
|
||||
return set((await session.execute(stmt)).scalars())
|
||||
|
||||
|
||||
async def _bound_documents(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
|
||||
stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id)
|
||||
return set((await session.execute(stmt)).scalars())
|
||||
|
||||
|
||||
async def _events(session: AsyncSession) -> list[AuditEvent]:
|
||||
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE)
|
||||
return list((await session.execute(stmt)).scalars())
|
||||
|
||||
|
||||
# --- добавление ----------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_add_member_updates_perimeter_and_picks_up_documents(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Добавленное ЮЛ — в периметре и в карточке; подхват подтверждённых документов вызван ровно для добавленных."""
|
||||
version, inside, outside, _ = await _version(seeded)
|
||||
await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=outside[0],
|
||||
account=ACCOUNT,
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q3,
|
||||
is_cumulative=True,
|
||||
rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}],
|
||||
)
|
||||
calls: list[tuple[uuid.UUID, list[uuid.UUID]]] = []
|
||||
|
||||
async def spy(session: AsyncSession, bound_version: ConsolidationVersion, entity_ids: Sequence[uuid.UUID]) -> int:
|
||||
calls.append((bound_version.id, list(entity_ids)))
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(consolidation_documents, "bind_confirmed", spy)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
|
||||
|
||||
assert response.status_code == HTTP_OK
|
||||
card = response.json()
|
||||
assert {m["id"] for m in card["members"]} == {str(e.id) for e in [*inside, outside[0]]}
|
||||
assert card["members_count"] == THREE
|
||||
assert calls == [(version.id, [outside[0].id])]
|
||||
assert await _member_ids(seeded, version.id) == {e.id for e in [*inside, outside[0]]}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_unchanged_members_are_not_touched_by_picking_up(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Подхват не вызывается для ЮЛ, которые уже в периметре, и не вызывается, если добавлять некого."""
|
||||
version, inside, _, _ = await _version(seeded, members=THREE, spare=0)
|
||||
calls: list[list[uuid.UUID]] = []
|
||||
|
||||
async def spy(session: AsyncSession, bound_version: ConsolidationVersion, entity_ids: Sequence[uuid.UUID]) -> int:
|
||||
calls.append(list(entity_ids))
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(consolidation_documents, "bind_confirmed", spy)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body(inside[:2]))
|
||||
|
||||
assert response.status_code == HTTP_OK
|
||||
assert calls == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_duplicates_are_collapsed(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
version, inside, outside, _ = await _version(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
ids = [outside[0], inside[0], outside[0], inside[1], inside[0]]
|
||||
|
||||
response = await client.put(_url(version.id), json=_body(ids))
|
||||
|
||||
assert response.status_code == HTTP_OK
|
||||
assert response.json()["members_count"] == THREE
|
||||
assert await _member_ids(seeded, version.id) == {inside[0].id, inside[1].id, outside[0].id}
|
||||
count = await seeded.execute(
|
||||
select(func.count())
|
||||
.select_from(ConsolidationMember)
|
||||
.where(ConsolidationMember.consolidation_version_id == version.id)
|
||||
)
|
||||
assert count.scalar_one() == THREE
|
||||
|
||||
|
||||
# --- удаление ------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_removing_member_drops_its_bindings_only(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Удалённое ЮЛ теряет привязки документов к этой версии; документы оставшихся и других версий не затронуты."""
|
||||
version, inside, _, _ = await _version(seeded, members=THREE, spare=0)
|
||||
documents = {}
|
||||
for entity in inside:
|
||||
documents[entity.id] = await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=entity,
|
||||
account=ACCOUNT,
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q3,
|
||||
is_cumulative=True,
|
||||
rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}],
|
||||
)
|
||||
seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=documents[entity.id].id))
|
||||
other, other_entities, _, _ = await _version(seeded, spare=0, locked_by=None)
|
||||
seeded.add(ConsolidationDocument(consolidation_version_id=other.id, document_id=documents[inside[2].id].id))
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body(inside[:2]))
|
||||
|
||||
assert response.status_code == HTTP_OK
|
||||
assert await _member_ids(seeded, version.id) == {inside[0].id, inside[1].id}
|
||||
assert await _bound_documents(seeded, version.id) == {documents[inside[0].id].id, documents[inside[1].id].id}
|
||||
assert await _bound_documents(seeded, other.id) == {documents[inside[2].id].id}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_unbind_entity_removes_only_that_entitys_documents_of_that_version(seeded: AsyncSession) -> None:
|
||||
"""`unbind_entity` (Task 6 заменит тело, имя и сигнатура — прежние) возвращает число снятых привязок."""
|
||||
version, inside, _, _ = await _version(seeded, locked_by=None)
|
||||
docs = []
|
||||
for entity in inside:
|
||||
document = await make_confirmed_osv(
|
||||
seeded,
|
||||
entity=entity,
|
||||
account=ACCOUNT,
|
||||
year=2024,
|
||||
quarter=PeriodQuarter.Q3,
|
||||
is_cumulative=True,
|
||||
rows=[{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}],
|
||||
)
|
||||
docs.append(document)
|
||||
seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id))
|
||||
await seeded.flush()
|
||||
|
||||
removed = await consolidation_documents.unbind_entity(seeded, version_id=version.id, entity_id=inside[0].id)
|
||||
|
||||
assert removed == 1
|
||||
assert await _bound_documents(seeded, version.id) == {docs[1].id}
|
||||
|
||||
|
||||
# --- проверки ------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_entity_of_other_group_is_rejected(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
version, inside, _, _ = await _version(seeded)
|
||||
office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
|
||||
stranger_group = await make_company_group(seeded, name="ГК Чужая", office=office)
|
||||
stranger = await make_legal_entity(seeded, name="ООО Чужое", group=stranger_group)
|
||||
ungrouped = await make_legal_entity(seeded, name="ООО Без ГК")
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([*inside, stranger, ungrouped]))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "ENTITY_NOT_IN_GROUP"
|
||||
assert set(detail["legal_entity_ids"]) == {str(stranger.id), str(ungrouped.id)}
|
||||
assert await _member_ids(seeded, version.id) == {e.id for e in inside}
|
||||
assert await _events(seeded) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_unknown_entity_is_rejected_as_not_in_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
version, inside, _, _ = await _version(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
ghost = uuid.uuid4()
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([*inside, ghost]))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE
|
||||
assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_GROUP"
|
||||
assert response.json()["detail"]["legal_entity_ids"] == [str(ghost)]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("count", [0, 1])
|
||||
async def test_perimeter_smaller_than_two_is_rejected(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, count: int
|
||||
) -> None:
|
||||
version, inside, _, _ = await _version(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body(inside[:count]))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE
|
||||
assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
|
||||
assert await _member_ids(seeded, version.id) == {e.id for e in inside}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_duplicates_do_not_make_a_perimeter_of_two(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Два одинаковых id — это один ЮЛ: меньше двух."""
|
||||
version, inside, _, _ = await _version(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([inside[0], inside[0]]))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE
|
||||
assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_foreign_entity_is_reported_before_size(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Порядок проверок: принадлежность ГК раньше размера (одно чужое ЮЛ → `ENTITY_NOT_IN_GROUP`)."""
|
||||
version, _, _, _ = await _version(seeded)
|
||||
stranger = await make_legal_entity(seeded, name="ООО Чужое")
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([stranger]))
|
||||
|
||||
assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_GROUP"
|
||||
|
||||
|
||||
# --- редактируемость, видимость, право -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_members_without_lock_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
version, inside, outside, _ = await _version(seeded, locked_by=None)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
|
||||
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert response.json()["detail"]["code"] == "LOCK_REQUIRED"
|
||||
assert await _member_ids(seeded, version.id) == {e.id for e in inside}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_members_with_foreign_lock_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
version, inside, outside, _ = await _version(seeded, locked_by=RKA)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
|
||||
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "LOCKED_BY_OTHER"
|
||||
assert detail["locked_by"] == str((await _user(seeded, RKA)).id)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_members_of_cancelled_version_is_not_editable(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
version, inside, outside, _ = await _version(seeded, status=ConsolidationStatus.CANCELLED)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
|
||||
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_editability_is_checked_before_body_validation(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""Без блокировки даже заведомо неверный состав даёт `LOCK_REQUIRED` (порядок спеки: редактируемость раньше)."""
|
||||
version, _, _, _ = await _version(seeded, locked_by=None)
|
||||
stranger = await make_legal_entity(seeded, name="ООО Чужое")
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([stranger]))
|
||||
|
||||
assert response.status_code == HTTP_CONFLICT
|
||||
assert response.json()["detail"]["code"] == "LOCK_REQUIRED"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_members_of_foreign_version_is_not_found(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
version, inside, outside, _ = await _version(seeded, office_name=KRASNODAR, locked_by=None)
|
||||
client = await auth_client(KA_RF)
|
||||
body = _body([*inside, outside[0]])
|
||||
|
||||
foreign = await client.put(_url(version.id), json=body)
|
||||
missing = await client.put(_url(uuid.uuid4()), json=body)
|
||||
|
||||
assert foreign.status_code == HTTP_NOT_FOUND
|
||||
assert foreign.json() == missing.json()
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("email", [CURATOR, EXPERT])
|
||||
async def test_members_requires_consolidation_edit(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession, email: str
|
||||
) -> None:
|
||||
version, inside, outside, _ = await _version(seeded)
|
||||
client = await auth_client(email)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([*inside, outside[0]]))
|
||||
|
||||
assert response.status_code == HTTP_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_members_rejects_malformed_body(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
version, _, _, _ = await _version(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json={"legal_entity_ids": ["не-uuid"]})
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE
|
||||
|
||||
|
||||
# --- аудит и независимость от ГСК ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_membership_change_is_audited_with_full_sorted_lists(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
version, inside, outside, _ = await _version(seeded, members=THREE, spare=1)
|
||||
client = await auth_client(KA_RF)
|
||||
new = [inside[0], inside[1], outside[0]] # inside[2] ушёл, outside[0] пришёл
|
||||
|
||||
response = await client.put(_url(version.id), json=_body(new))
|
||||
|
||||
assert response.status_code == HTTP_OK
|
||||
(event,) = await _events(seeded)
|
||||
assert event.section == AuditSection.CONSOLIDATION
|
||||
assert event.object_type == "consolidation_version"
|
||||
assert event.object_id == version.id
|
||||
assert event.consolidation_version_id == version.id
|
||||
assert event.user_id == (await _user(seeded, KA_RF)).id
|
||||
assert event.before_value == {"legal_entity_ids": sorted(str(e.id) for e in inside)}
|
||||
assert event.after_value == {"legal_entity_ids": sorted(str(e.id) for e in new)}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_same_perimeter_is_ok_without_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""Состав не изменился (в том числе порядок id другой): 200 с карточкой, события нет (design #25)."""
|
||||
version, inside, _, _ = await _version(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body(list(reversed(inside))))
|
||||
|
||||
assert response.status_code == HTTP_OK
|
||||
assert response.json()["members_count"] == len(inside)
|
||||
assert await _events(seeded) == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_kept_members_and_related_group_are_untouched(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""Оставшиеся члены не пересоздаются (`is_head_entity`, `id`), `related_group_member` не меняется."""
|
||||
version, inside, outside, group_id = await _version(seeded, members=THREE, spare=1)
|
||||
head = (
|
||||
await seeded.execute(
|
||||
select(ConsolidationMember).where(
|
||||
ConsolidationMember.consolidation_version_id == version.id,
|
||||
ConsolidationMember.legal_entity_id == inside[0].id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
head.is_head_entity = True
|
||||
group = await seeded.get(CompanyGroup, group_id)
|
||||
assert group is not None
|
||||
related = await make_related_group(seeded, group=group)
|
||||
for entity in inside:
|
||||
await make_membership(seeded, related_group=related, entity=entity)
|
||||
await seeded.flush()
|
||||
before = await _related_state(seeded, related.id)
|
||||
kept_id = head.id
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.put(_url(version.id), json=_body([inside[0], inside[1], outside[0]]))
|
||||
|
||||
assert response.status_code == HTTP_OK
|
||||
assert await _related_state(seeded, related.id) == before
|
||||
kept = (
|
||||
await seeded.execute(
|
||||
select(ConsolidationMember)
|
||||
.where(ConsolidationMember.id == kept_id)
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
).scalar_one()
|
||||
assert kept.is_head_entity is True
|
||||
head_flags = {m["id"]: m["is_head_entity"] for m in response.json()["members"]}
|
||||
assert head_flags[str(inside[0].id)] is True
|
||||
assert head_flags[str(outside[0].id)] is False
|
||||
|
||||
|
||||
async def _related_state(session: AsyncSession, related_group_id: uuid.UUID) -> list[tuple[uuid.UUID, bool, Any]]:
|
||||
stmt = (
|
||||
select(RelatedGroupMember.legal_entity_id, RelatedGroupMember.is_current, RelatedGroupMember.valid_to)
|
||||
.where(RelatedGroupMember.related_group_id == related_group_id)
|
||||
.order_by(RelatedGroupMember.legal_entity_id)
|
||||
)
|
||||
return [(row[0], row[1], row[2]) for row in await session.execute(stmt)]
|
||||
@ -57,6 +57,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
||||
"/api/v1/consolidations/versions/{version_id}": {"get"},
|
||||
"/api/v1/consolidations/versions/{version_id}/lock": {"post", "delete"},
|
||||
"/api/v1/consolidations/versions/{version_id}/status": {"post"},
|
||||
"/api/v1/consolidations/versions/{version_id}/members": {"put"},
|
||||
}
|
||||
|
||||
# Префиксы, под которыми маршрутов сверх EXPECTED_ROUTES быть не должно
|
||||
|
||||
@ -119,9 +119,9 @@
|
||||
- `ConsolidationService.set_members(version_id, data: MembersIn) -> VersionCard`: `lock_version` → видимость → `ensure_version_editable` → уникальные id → все ЮЛ с `company_group_id == consolidation.company_group_id` (иначе 422 `ENTITY_NOT_IN_GROUP`, `extra.legal_entity_ids`) → ≥ 2 → diff: удалить `ConsolidationMember` ушедших + `consolidation_documents.unbind_entity(version, entity_id)` (Task 6; до него — удаление `consolidation_document` по `document.legal_entity_id` прямо здесь), добавить новых + `bind_confirmed` → `audit(MEMBERSHIP_CHANGE, before = {legal_entity_ids: старые}, after = {legal_entity_ids: новые})`.
|
||||
- Маршрут: `PUT …/members` (200, карточка).
|
||||
|
||||
- [ ] **Step 1: Тесты.** Сценарии спеки «Периметр версии» (добавление ЮЛ с подтверждённой ОСВ → ячейка `CONFIRMED` появится в Task 8, здесь — `consolidation_document` создан; ЮЛ другой ГК; без блокировки; меньше двух; дубли схлопнуты; удаление ЮЛ снимает его привязки; `related_group_member` не изменился).
|
||||
- [ ] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_members.py -q` → PASS.
|
||||
- [ ] **Step 5: Commit** `feat(consolidation): редактирование периметра версии с подхватом и отвязкой документов`.
|
||||
- [x] **Step 1: Тесты.** Сценарии спеки «Периметр версии» (добавление ЮЛ с подтверждённой ОСВ → ячейка `CONFIRMED` появится в Task 8, здесь — `consolidation_document` создан; ЮЛ другой ГК; без блокировки; меньше двух; дубли схлопнуты; удаление ЮЛ снимает его привязки; `related_group_member` не изменился).
|
||||
- [x] **Step 2–4:** падает → реализация → `make check`, `pytest -m integration tests/test_api_consolidation_members.py -q` → PASS.
|
||||
- [x] **Step 5: Commit** `feat(consolidation): редактирование периметра версии с подхватом и отвязкой документов`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -209,3 +209,8 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/
|
||||
| 22 | 2026-10-06 | Перехват пишет одно событие `CONSOLIDATION_LOCK_TAKEOVER` без отдельного `CONSOLIDATION_LOCK` на снятие прежней блокировки | сценарий audit-trail «Перехват» — «одно событие» | в журнале нет отдельной строки «блокировка А снята» при перехвате (она следует из `before_value`) |
|
||||
| 23 | 2026-10-06 | `LOCKED_BY_OTHER` из `ensure_version_editable` (`PUT …/members`, `POST …/status`, `POST …/osv/entries/{id}/counterparty`) отдаёт `locked_by`, `locked_at` без `locked_by_name`; `POST …/lock` отдаёт и имя | доменная функция без БД имени не знает; directory-policy требует только `detail.locked_by`, имя спека требует лишь у `POST …/lock` | фронт для правок версии берёт имя владельца из карточки (`lock.locked_by_name`) или вызывающий сервис дополнит ответ |
|
||||
| 24 | 2026-10-06 | `CANCELLED` снимает активную блокировку без отдельного события `CONSOLIDATION_LOCK` — снятие покрывает `CONSOLIDATION_STATUS_CHANGE`; `uq_consolidation_lock_version_active` зарегистрирован в `UNIQUE_CONSTRAINT_CONFLICTS` как `LOCKED_BY_OTHER` (страховка для вставки в обход сервиса; сервис сам ловит нарушение под SAVEPOINT и добавляет владельца) | таблица audit-trail относит `CONSOLIDATION_LOCK` к действиям «взята в работу, снята» пользователем, а не к побочному эффекту аннулирования; реестр — конвенция гонок 2c | в журнале нет отдельной строки «блокировка снята» при аннулировании; реестр — нулевая |
|
||||
| 25 | 2026-10-06 | `PUT …/members` с составом, равным текущему, отвечает 200 с карточкой, ничего не пишет (ни членов, ни `updated_at`) и НЕ пишет `MEMBERSHIP_CHANGE` | событие «периметр изменён» без изменения вводит в заблуждение и засоряет журнал; идемпотентный повтор запроса (двойной клик, ретрай) безопасен | в журнале нет следа повторного сохранения того же состава (оно ничего не меняло) |
|
||||
| 26 | 2026-10-06 | `ENTITY_NOT_IN_GROUP` покрывает три случая: ЮЛ другой ГК, ЮЛ без ГК и несуществующий id; `legal_entity_ids` — в порядке запроса. Проверка идёт ДО проверки размера (одно чужое ЮЛ → `ENTITY_NOT_IN_GROUP`, не `PERIMETER_TOO_SMALL`); дубли схлопываются ДО подсчёта размера (`[a, a]` → `PERIMETER_TOO_SMALL`) | спека: «все ЮЛ SHALL принадлежать ГК» — несуществующее ЮЛ ей не принадлежит; существование отдельным кодом не различаем, чтобы не раскрывать чужие id (как 404 вместо 403) | клиент не отличает «нет такого ЮЛ» от «ЮЛ чужой ГК» |
|
||||
| 27 | 2026-10-06 | Члены, остающиеся в периметре, не пересоздаются (сохраняются `id` и `is_head_entity`); новые — `is_head_entity = false`. Непустая смена периметра выставляет `consolidation_version.updated_by/at` | признак головной организации — решение аналитика, смена состава его не сбрасывает; `updated_*` показывают последнюю правку версии (как у `transition`) | нулевая |
|
||||
| 28 | 2026-10-06 | Общий пролог операций над версией вынесен в `services/consolidation_guard.py`: `lock_visible_version` (`FOR UPDATE` → видимость → 404) и `lock_editable_version` (+ активная блокировка и `ensure_version_editable`); `VERSION_NOT_FOUND` живёт там и реэкспортируется из `services.consolidations`. `take`/`release` берут только `lock_visible_version` (редактируемость им не нужна). Поведение сервисов блокировки и статусов не менялось | закрывает T4.2 (три копии пролога; четвёртая — Task 11); порядок «версия → видимость → редактируемость» одинаков везде | правка пролога меняет все операции сразу (задумано) |
|
||||
| 29 | 2026-10-06 | До Task 6 отвязка ушедших ЮЛ — `consolidation_documents.unbind_entity(session, *, version_id, entity_id) -> int`: прямой `DELETE` строк `consolidation_document` документов этого ЮЛ в этой версии; `bind_confirmed` для добавленных — заглушка (вызывается, возвращает 0), тест подхвата проверяет вызов шпионом | план Task 5: до Task 6 отвязка прямым удалением; имена и сигнатуры зафиксированы за Task 6 | до Task 6 добавленное ЮЛ не получает привязок документов (ячейка `CONFIRMED` появится в Task 6/8) |
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
возврат в `INITIATED`, `TRANSITION_NOT_ALLOWED`, `VERSION_NOT_EDITABLE`, `LOCK_REQUIRED`.
|
||||
- [x] 2.3 **Блокировка.** `services/consolidation_locks.py:take/release`, `POST/DELETE …/lock`, `IntegrityError` → `LOCKED_BY_OTHER`.
|
||||
Тесты: сценарии «Взять в работу», гонка двух `POST` (`test_api_constraint_races`), `CANCELLED` снимает блокировку.
|
||||
- [ ] 2.4 **Периметр.** `PUT …/members` с подхватом/отвязкой (вызывает 3.1), `MEMBERSHIP_CHANGE`. Тесты: сценарии «Периметр версии».
|
||||
- [x] 2.4 **Периметр.** `PUT …/members` с подхватом/отвязкой (вызывает 3.1), `MEMBERSHIP_CHANGE`. Тесты: сценарии «Периметр версии».
|
||||
|
||||
## 3. Привязка документов и чек-лист
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user