Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
563 lines
26 KiB
Python
563 lines
26 KiB
Python
"""API консолидаций ГК: создание, список периодов, карточка версии (spec consolidations-api «Создание консолидации»,
|
||
«Список консолидаций ГК и карточка версии»; directory-policy «Видимость консолидаций»; audit-trail
|
||
`CONSOLIDATION_CREATE`; design D-15, D-16)."""
|
||
|
||
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 (
|
||
Consolidation,
|
||
ConsolidationLock,
|
||
ConsolidationMember,
|
||
ConsolidationStatusHistory,
|
||
ConsolidationVersion,
|
||
)
|
||
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup, RelatedGroupMember
|
||
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
|
||
CHECKLIST_COLUMNS = 10 # ББ, ОФР и ОСВ 01, 58, 60, 62, 66, 67, 76, 90
|
||
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}
|
||
|
||
|
||
def _with_ids(body: dict[str, Any], ids: Sequence[uuid.UUID | str]) -> dict[str, Any]:
|
||
"""Тело создания с явным периметром (модалка [42], front-api-gaps D-1)."""
|
||
return {**body, "legal_entity_ids": [str(entity_id) for entity_id in ids]}
|
||
|
||
|
||
async def _gsk_members(session: AsyncSession, gsk: RelatedGroup) -> set[uuid.UUID]:
|
||
stmt = select(RelatedGroupMember.legal_entity_id).where(
|
||
RelatedGroupMember.related_group_id == gsk.id, RelatedGroupMember.is_current.is_(True)
|
||
)
|
||
return set((await session.execute(stmt)).scalars())
|
||
|
||
|
||
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 for member in card["members"])
|
||
# чек-лист по правилам подхода BASIC (ББ, ОФР, ОСВ по 8 счетам): документов нет — все ячейки MISSING
|
||
expected = ["MISSING"] * CHECKLIST_COLUMNS
|
||
assert all([cell["status"] for cell in member["checklist"]] == expected 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_with_selected_perimeter_wider_than_gsk(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
"""Выбранный периметр шире ГСК: ровно эти ЮЛ, одно событие создания, состав ГСК не меняется."""
|
||
group, gsk, entities = await _scenario(seeded)
|
||
outsider = await make_legal_entity(seeded, name="ООО Вне ГСК", group=group)
|
||
selected = [*entities, outsider]
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [e.id for e in selected]))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
assert {member["id"] for member in response.json()["members"]} == {str(e.id) for e in selected}
|
||
event = (
|
||
await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_CREATE))
|
||
).scalar_one()
|
||
assert event.after_value is not None
|
||
assert event.after_value["members"] == sorted(str(e.id) for e in selected)
|
||
membership = select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE)
|
||
assert (await seeded.execute(membership)).scalar_one() == 0
|
||
assert await _gsk_members(seeded, gsk) == {e.id for e in entities}
|
||
assert await _count(seeded, ConsolidationLock) == 0
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_create_gsk_of_one_with_second_entity_selected(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
"""ГСК из одного ЮЛ, второе отмечено вручную — периметр из двух (проверка размера — по выбранным)."""
|
||
group, gsk, (single,) = await _scenario(seeded, members=1)
|
||
second = await make_legal_entity(seeded, name="ООО Второе", group=group)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [single.id, second.id]))
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
assert {member["id"] for member in response.json()["members"]} == {str(single.id), str(second.id)}
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_create_with_entity_of_other_group_is_rejected(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
group, gsk, entities = await _scenario(seeded)
|
||
other_group = await make_company_group(seeded, name="ГК Соседняя", office=await _office(seeded, ROSTOV))
|
||
stranger = await make_legal_entity(seeded, name="ООО Чужое", group=other_group)
|
||
client = await auth_client(KA_RF)
|
||
|
||
body = _with_ids(_body(group, gsk), [*[e.id for e in entities], stranger.id])
|
||
response = await client.post(CONSOLIDATIONS, json=body)
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||
detail = response.json()["detail"]
|
||
assert detail["code"] == "ENTITY_NOT_IN_GROUP"
|
||
assert detail["legal_entity_ids"] == [str(stranger.id)]
|
||
assert await _count(seeded, Consolidation) == 0
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_create_with_unknown_entity_is_not_in_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||
group, gsk, entities = await _scenario(seeded)
|
||
ghost = uuid.uuid4()
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [*[e.id for e in entities], ghost]))
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||
assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_GROUP"
|
||
assert response.json()["detail"]["legal_entity_ids"] == [str(ghost)]
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_create_with_duplicates_collapsing_to_one_is_too_small(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
group, gsk, entities = await _scenario(seeded)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [entities[0].id, entities[0].id]))
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||
assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
|
||
assert await _count(seeded, Consolidation) == 0
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_create_with_empty_selected_perimeter_is_too_small(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
"""Review Focus: `[]` — явный пустой периметр, а не «поле не передано» (ГСК из двух не подставляется)."""
|
||
group, gsk, _ = await _scenario(seeded)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), []))
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||
assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_create_with_null_selected_perimeter_takes_gsk(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
"""Решение №3: `legal_entity_ids: null` равнозначно отсутствию поля."""
|
||
group, gsk, entities = await _scenario(seeded)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(CONSOLIDATIONS, json={**_body(group, gsk), "legal_entity_ids": None})
|
||
|
||
assert response.status_code == HTTP_CREATED, response.text
|
||
assert {member["id"] for member in response.json()["members"]} == {str(e.id) for e in entities}
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_create_gsk_mismatch_reported_before_perimeter(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
"""Порядок проверок (решение №5): ГСК раньше периметра."""
|
||
group, _, _ = await _scenario(seeded)
|
||
gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ")
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsz), [uuid.uuid4()]))
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||
assert response.json()["detail"]["code"] == "RELATED_GROUP_MISMATCH"
|
||
|
||
|
||
@pytest.mark.integration
|
||
async def test_create_selected_perimeter_longer_than_limit(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
group, gsk, _ = await _scenario(seeded)
|
||
client = await auth_client(KA_RF)
|
||
|
||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [uuid.uuid4() for _ in range(501)]))
|
||
|
||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||
assert response.json()["detail"][0]["loc"] == ["body", "legal_entity_ids"]
|
||
assert await _count(seeded, Consolidation) == 0
|
||
|
||
|
||
@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
|