- I-1 (T4.1): lock_out — публичный в services/consolidations.py, копия в consolidation_locks удалена - M-b (T4.3): удалён осиротевший комментарий в tests/factories.py - M-c (T4.4): точная проверка ip_address/user_agent в аудите смены статуса - M-d (T4.5): docstring теста Review Focus 4 ссылается на различающий тест порядка D-9 - design.md: решения без обсуждения #17 (R-8), #18 (R-9), #19–#24 (решения Task 4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
451 lines
18 KiB
Python
451 lines
18 KiB
Python
"""Ручные статусы версии консолидации Среза 4 и общий переход `transition` (spec consolidations-api «Статусы Среза 4»;
|
||
directory-policy «Видимость консолидаций и редактируемость версии»; audit-trail `CONSOLIDATION_STATUS_CHANGE`;
|
||
design D-6, D-8)."""
|
||
|
||
import datetime
|
||
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 (
|
||
ConsolidationDocument,
|
||
ConsolidationLock,
|
||
ConsolidationStatusHistory,
|
||
ConsolidationVersion,
|
||
)
|
||
from src.models.directories import LegalEntity, RegionalOffice
|
||
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter
|
||
from src.services import consolidation_locks, consolidation_status
|
||
from src.services.audit import AuditService
|
||
from tests.conftest import AuthClientFactory
|
||
from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity, make_lock
|
||
|
||
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"
|
||
IN_WORK = (
|
||
ConsolidationStatus.INITIATED,
|
||
ConsolidationStatus.DOCUMENTS_PROCESSING,
|
||
ConsolidationStatus.DATA_CORRECTION,
|
||
)
|
||
|
||
|
||
async def _user(session: AsyncSession, email: str) -> AppUser:
|
||
return (await session.execute(select(AppUser).where(AppUser.email == email))).scalar_one()
|
||
|
||
|
||
async def _version(
|
||
session: AsyncSession,
|
||
*,
|
||
status: ConsolidationStatus = ConsolidationStatus.DATA_CORRECTION,
|
||
office_name: str = ROSTOV,
|
||
locked_by: str | None = KA_RF,
|
||
) -> tuple[ConsolidationVersion, list[LegalEntity]]:
|
||
"""Версия ГК филиала за 2024 Q3 из двух ЮЛ в статусе `status`; `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)
|
||
entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(2)]
|
||
_, version = await make_consolidation(
|
||
session,
|
||
group=group,
|
||
year=2024,
|
||
quarter=PeriodQuarter.Q3,
|
||
members=entities,
|
||
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, entities
|
||
|
||
|
||
def _status_url(version_id: uuid.UUID) -> str:
|
||
return f"{VERSIONS}/{version_id}/status"
|
||
|
||
|
||
async def _history(session: AsyncSession, version_id: uuid.UUID) -> list[ConsolidationStatusHistory]:
|
||
stmt = (
|
||
select(ConsolidationStatusHistory)
|
||
.where(ConsolidationStatusHistory.version_id == version_id)
|
||
.order_by(ConsolidationStatusHistory.changed_at, ConsolidationStatusHistory.id)
|
||
)
|
||
return list((await session.execute(stmt)).scalars())
|
||
|
||
|
||
async def _status_events(session: AsyncSession) -> list[AuditEvent]:
|
||
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_STATUS_CHANGE)
|
||
return list((await session.execute(stmt)).scalars())
|
||
|
||
|
||
async def _reload(session: AsyncSession, version_id: uuid.UUID) -> ConsolidationVersion:
|
||
stmt = (
|
||
select(ConsolidationVersion)
|
||
.where(ConsolidationVersion.id == version_id)
|
||
.execution_options(populate_existing=True)
|
||
)
|
||
return (await session.execute(stmt)).scalar_one()
|
||
|
||
|
||
async def _active_locks(session: AsyncSession, version_id: uuid.UUID) -> int:
|
||
stmt = (
|
||
select(func.count())
|
||
.select_from(ConsolidationLock)
|
||
.where(ConsolidationLock.consolidation_version_id == version_id, ConsolidationLock.released_at.is_(None))
|
||
)
|
||
return (await session.execute(stmt)).scalar_one()
|
||
|
||
|
||
# --- аннулирование -------------------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_cancel_by_lock_owner(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version, _ = await _version(seeded, status=ConsolidationStatus.DATA_CORRECTION)
|
||
user = await _user(seeded, KA_RF)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(
|
||
_status_url(version.id),
|
||
json={"to_status": "CANCELLED", "comment": "Дубль"},
|
||
headers={"User-Agent": "pytest-status"},
|
||
)
|
||
|
||
assert response.status_code == HTTP_OK, response.text
|
||
card = response.json()
|
||
assert (card["status"], card["is_in_work"], card["lock"]) == ("CANCELLED", False, None)
|
||
assert card["history"][-1]["from_status"] == "DATA_CORRECTION"
|
||
assert (card["history"][-1]["to_status"], card["history"][-1]["comment"]) == ("CANCELLED", "Дубль")
|
||
stored = await _reload(seeded, version.id)
|
||
assert (stored.status, stored.cancelled_reason, stored.is_in_work) == (
|
||
ConsolidationStatus.CANCELLED,
|
||
"Дубль",
|
||
False,
|
||
)
|
||
assert stored.updated_by == user.id
|
||
assert stored.updated_at is not None
|
||
assert await _active_locks(seeded, version.id) == 0
|
||
last = (await _history(seeded, version.id))[-1]
|
||
assert (last.from_status, last.to_status, last.changed_by, last.comment) == (
|
||
ConsolidationStatus.DATA_CORRECTION,
|
||
ConsolidationStatus.CANCELLED,
|
||
user.id,
|
||
"Дубль",
|
||
)
|
||
assert last.changed_at == stored.updated_at
|
||
[event] = await _status_events(seeded)
|
||
assert (event.section, event.object_type, event.object_id, event.consolidation_version_id) == (
|
||
AuditSection.CONSOLIDATION,
|
||
"consolidation_version",
|
||
version.id,
|
||
version.id,
|
||
)
|
||
assert event.user_id == user.id
|
||
assert (event.before_value, event.after_value, event.comment) == (
|
||
{"status": "DATA_CORRECTION"},
|
||
{"status": "CANCELLED"},
|
||
"Дубль",
|
||
)
|
||
# контекст запроса передан в аудит (ASGITransport отдаёт клиента 127.0.0.1)
|
||
assert (event.ip_address, event.user_agent) == ("127.0.0.1", "pytest-status")
|
||
|
||
|
||
@pytest.mark.integration
|
||
@pytest.mark.parametrize("status", IN_WORK)
|
||
async def test_cancel_allowed_from_every_in_work_status(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, status: ConsolidationStatus
|
||
) -> None:
|
||
version, _ = await _version(seeded, status=status)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "Ошибка"})
|
||
|
||
assert response.status_code == HTTP_OK, response.text
|
||
assert response.json()["status"] == "CANCELLED"
|
||
|
||
|
||
@pytest.mark.integration
|
||
@pytest.mark.parametrize("payload", [{"to_status": "CANCELLED"}, {"to_status": "CANCELLED", "comment": " "}])
|
||
async def test_cancel_without_comment(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, payload: dict[str, Any]
|
||
) -> None:
|
||
version, _ = await _version(seeded)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_status_url(version.id), json=payload)
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE
|
||
assert response.json()["detail"]["code"] == "COMMENT_REQUIRED"
|
||
assert (await _reload(seeded, version.id)).status == ConsolidationStatus.DATA_CORRECTION
|
||
assert await _active_locks(seeded, version.id) == 1
|
||
|
||
|
||
# --- возврат в INITIATED -------------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
@pytest.mark.parametrize("status", [ConsolidationStatus.DOCUMENTS_PROCESSING, ConsolidationStatus.DATA_CORRECTION])
|
||
async def test_return_to_initiated_keeps_bound_documents(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, status: ConsolidationStatus
|
||
) -> None:
|
||
version, entities = await _version(seeded, status=status)
|
||
document = await make_confirmed_osv(
|
||
seeded, entity=entities[0], account="60", year=2024, quarter=PeriodQuarter.Q3, is_cumulative=True, rows=[]
|
||
)
|
||
seeded.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id))
|
||
await seeded.flush()
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_status_url(version.id), json={"to_status": "INITIATED"})
|
||
|
||
assert response.status_code == HTTP_OK, response.text
|
||
card = response.json()
|
||
assert (card["status"], card["is_in_work"]) == ("INITIATED", True)
|
||
assert card["lock"]["locked_by"] == str((await _user(seeded, KA_RF)).id) # блокировка остаётся
|
||
last = (await _history(seeded, version.id))[-1]
|
||
assert (last.from_status, last.to_status, last.comment) == (status, ConsolidationStatus.INITIATED, None)
|
||
bound = (
|
||
await seeded.execute(
|
||
select(ConsolidationDocument.document_id).where(
|
||
ConsolidationDocument.consolidation_version_id == version.id
|
||
)
|
||
)
|
||
).scalars()
|
||
assert list(bound) == [document.id]
|
||
[event] = await _status_events(seeded)
|
||
assert (event.before_value, event.after_value, event.comment) == (
|
||
{"status": status.value},
|
||
{"status": "INITIATED"},
|
||
None,
|
||
)
|
||
|
||
|
||
# --- запрещённые переходы ------------------------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
@pytest.mark.parametrize(
|
||
("status", "to_status"),
|
||
[
|
||
(ConsolidationStatus.DATA_CORRECTION, "CONSOLIDATION_RF"), # переход Среза 7
|
||
(ConsolidationStatus.INITIATED, "INITIATED"), # повтор статуса
|
||
(ConsolidationStatus.DATA_CORRECTION, "DATA_CORRECTION"),
|
||
(ConsolidationStatus.INITIATED, "DOCUMENTS_PROCESSING"), # автоматический, не ручной
|
||
(ConsolidationStatus.DOCUMENTS_PROCESSING, "DATA_CORRECTION"),
|
||
(ConsolidationStatus.DATA_CORRECTION, "VERIFIED"),
|
||
],
|
||
)
|
||
async def test_transition_not_allowed(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, status: ConsolidationStatus, to_status: str
|
||
) -> None:
|
||
version, _ = await _version(seeded, status=status)
|
||
history_before = len(await _history(seeded, version.id))
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_status_url(version.id), json={"to_status": to_status, "comment": "попытка"})
|
||
|
||
assert response.status_code == HTTP_CONFLICT, response.text
|
||
detail = response.json()["detail"]
|
||
assert (detail["code"], detail["from_status"], detail["to_status"]) == (
|
||
"TRANSITION_NOT_ALLOWED",
|
||
status.value,
|
||
to_status,
|
||
)
|
||
assert (await _reload(seeded, version.id)).status == status
|
||
assert len(await _history(seeded, version.id)) == history_before
|
||
assert await _status_events(seeded) == []
|
||
|
||
|
||
# --- политика редактируемости на ручной смене статуса --------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_status_requires_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version, _ = await _version(seeded, locked_by=None)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "x"})
|
||
|
||
assert response.status_code == HTTP_CONFLICT
|
||
assert response.json()["detail"]["code"] == "LOCK_REQUIRED"
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_status_with_foreign_lock(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version, _ = await _version(seeded, locked_by=RKA)
|
||
owner = await _user(seeded, RKA)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "x"})
|
||
|
||
assert response.status_code == HTTP_CONFLICT
|
||
detail = response.json()["detail"]
|
||
assert (detail["code"], detail["locked_by"]) == ("LOCKED_BY_OTHER", str(owner.id))
|
||
assert "locked_at" in detail
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_status_of_cancelled_version_is_not_editable(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
"""Аннулированная версия: даже владелец прежней блокировки получает 409 `VERSION_NOT_EDITABLE`."""
|
||
version, _ = await _version(seeded, status=ConsolidationStatus.CANCELLED)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_status_url(version.id), json={"to_status": "INITIATED"})
|
||
|
||
assert response.status_code == HTTP_CONFLICT
|
||
assert response.json()["detail"]["code"] == "VERSION_NOT_EDITABLE"
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_status_of_foreign_version_is_not_found(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version, _ = await _version(seeded, office_name=KRASNODAR, locked_by=None)
|
||
client = await auth_client(KA_RF)
|
||
body = {"to_status": "CANCELLED", "comment": "x"}
|
||
|
||
foreign = await client.post(_status_url(version.id), json=body)
|
||
missing = await client.post(_status_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_status_requires_consolidation_edit(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession, email: str
|
||
) -> None:
|
||
version, _ = await _version(seeded)
|
||
client = await auth_client(email)
|
||
|
||
response = await client.post(_status_url(version.id), json={"to_status": "CANCELLED", "comment": "x"})
|
||
|
||
assert response.status_code == HTTP_FORBIDDEN
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_status_rejects_unknown_status(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
version, _ = await _version(seeded)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(_status_url(version.id), json={"to_status": "DONE"})
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE
|
||
|
||
|
||
# --- transition: единственное место смены статуса (автопереходы Task 6–7 зовут её же) ------------------------------
|
||
|
||
|
||
def test_manual_transitions_table() -> None:
|
||
cancelled, initiated = ConsolidationStatus.CANCELLED, ConsolidationStatus.INITIATED
|
||
assert consolidation_status.MANUAL_TRANSITIONS == {
|
||
ConsolidationStatus.INITIATED: frozenset({cancelled}),
|
||
ConsolidationStatus.DOCUMENTS_PROCESSING: frozenset({cancelled, initiated}),
|
||
ConsolidationStatus.DATA_CORRECTION: frozenset({cancelled, initiated}),
|
||
}
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_transition_to_cancelled_releases_active_lock(
|
||
seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
version, _ = await _version(seeded, status=ConsolidationStatus.INITIATED)
|
||
user = await _user(seeded, RKA)
|
||
now = datetime.datetime(2026, 10, 6, 12, 0, tzinfo=datetime.UTC)
|
||
calls: list[tuple[uuid.UUID, datetime.datetime]] = []
|
||
original = consolidation_locks.release_active_lock
|
||
|
||
async def spy(session: AsyncSession, version_id: uuid.UUID, at: datetime.datetime) -> ConsolidationLock | None:
|
||
calls.append((version_id, at))
|
||
return await original(session, version_id, at)
|
||
|
||
monkeypatch.setattr(consolidation_locks, "release_active_lock", spy)
|
||
|
||
record = await consolidation_status.transition(
|
||
seeded,
|
||
AuditService(seeded),
|
||
version=version,
|
||
to=ConsolidationStatus.CANCELLED,
|
||
changed_by=user.id,
|
||
comment="Аннулирована",
|
||
ip_address="10.0.0.1",
|
||
user_agent="pytest",
|
||
now=now,
|
||
)
|
||
|
||
assert calls == [(version.id, now)]
|
||
assert await _active_locks(seeded, version.id) == 0
|
||
released = (
|
||
await seeded.execute(select(ConsolidationLock).where(ConsolidationLock.consolidation_version_id == version.id))
|
||
).scalar_one()
|
||
assert released.released_at == now
|
||
assert (record.from_status, record.to_status, record.changed_by, record.changed_at, record.comment) == (
|
||
ConsolidationStatus.INITIATED,
|
||
ConsolidationStatus.CANCELLED,
|
||
user.id,
|
||
now,
|
||
"Аннулирована",
|
||
)
|
||
assert (version.status, version.is_in_work, version.cancelled_reason) == (
|
||
ConsolidationStatus.CANCELLED,
|
||
False,
|
||
"Аннулирована",
|
||
)
|
||
assert (version.updated_by, version.updated_at) == (user.id, now)
|
||
[event] = await _status_events(seeded)
|
||
assert (event.user_id, event.ip_address, event.user_agent) == (user.id, "10.0.0.1", "pytest")
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_transition_other_than_cancelled_keeps_lock(
|
||
seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
"""Автоматический переход (Task 6–7) и возврат в `INITIATED` блокировку не снимают; `user_id` — `changed_by`."""
|
||
version, _ = await _version(seeded, status=ConsolidationStatus.INITIATED)
|
||
confirmer = await _user(seeded, RKA)
|
||
calls: list[uuid.UUID] = []
|
||
|
||
async def spy(_session: AsyncSession, version_id: uuid.UUID, _at: datetime.datetime) -> None:
|
||
calls.append(version_id)
|
||
|
||
monkeypatch.setattr(consolidation_locks, "release_active_lock", spy)
|
||
|
||
await consolidation_status.transition(
|
||
seeded,
|
||
AuditService(seeded),
|
||
version=version,
|
||
to=ConsolidationStatus.DATA_CORRECTION,
|
||
changed_by=confirmer.id,
|
||
comment=None,
|
||
now=datetime.datetime.now(datetime.UTC),
|
||
)
|
||
|
||
assert calls == []
|
||
assert await _active_locks(seeded, version.id) == 1
|
||
assert (version.status, version.is_in_work, version.cancelled_reason) == (
|
||
ConsolidationStatus.DATA_CORRECTION,
|
||
True,
|
||
None,
|
||
)
|
||
[event] = await _status_events(seeded)
|
||
assert (event.user_id, event.ip_address, event.before_value, event.after_value) == (
|
||
confirmer.id,
|
||
None,
|
||
{"status": "INITIATED"},
|
||
{"status": "DATA_CORRECTION"},
|
||
)
|