arm-core/api/tests/test_api_consolidations.py
2026-10-07 18:17:13 +04:00

674 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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,
DocumentStatus,
EntityType,
FileStatus,
PeriodQuarter,
RecognitionStatus,
RelatedGroupKind,
)
from tests.conftest import AuthClientFactory
from tests.factories import (
make_company_group,
make_consolidation,
make_document,
make_legal_entity,
make_lock,
make_membership,
make_related_group,
make_stored_file,
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
async def _osv( # noqa: PLR0913 - параметры документа сценария
session: AsyncSession,
entity: LegalEntity,
account: str,
year: int,
quarter: PeriodQuarter,
*,
status: DocumentStatus = DocumentStatus.RECOGNIZED,
file_status: FileStatus = FileStatus.PROCESSED,
) -> None:
"""ОСВ-документ ЮЛ в отдельном файле (на файл — одна ОСВ по счёту)."""
file, job = await make_stored_file(session, entity=entity, status=file_status, job_status=RecognitionStatus.done)
await make_document(session, file=file, job=job, account=account, year=year, quarter=quarter, status=status)
async def _card_world(
session: AsyncSession, quarter: PeriodQuarter = PeriodQuarter.Q3
) -> tuple[ConsolidationVersion, LegalEntity, LegalEntity]:
"""Версия 2024 `quarter` ГК Ростовского филиала: ЮЛ «ООО А» и ИП «ИП Б»."""
group = await make_company_group(session, name="ГК Карточка", office=await _office(session, ROSTOV))
company = await make_legal_entity(session, name="ООО А", group=group)
person = await make_legal_entity(session, name="ИП Б", group=group, entity_type=EntityType.ИП, inn="770100000012")
_, version = await make_consolidation(
session,
group=group,
year=2024,
quarter=quarter,
members=[company, person],
initiator=await _user(session, KA_RF),
)
return version, company, person
@pytest.mark.integration
async def test_card_member_entity_type(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, company, person = await _card_world(seeded)
client = await auth_client(KA_RF)
card = (await client.get(f"{VERSIONS}/{version.id}")).json()
types = {member["id"]: member["entity_type"] for member in card["members"]}
assert types == {str(company.id): "ЮЛ", str(person.id): "ИП"}
@pytest.mark.integration
async def test_card_member_osv_accounts_loaded_to_version_period(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""D-3 (вариант B): счета действующих ОСВ за год версии с кварталом ≤ версии, в любом статусе, кроме ERROR."""
version, company, person = await _card_world(seeded)
q1, q2, q3, q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4
await _osv(seeded, company, "60", 2024, q1) # RECOGNIZED
await _osv(seeded, company, "60", 2024, q2) # тот же счёт ещё раз — один раз в списке
await _osv(seeded, company, "51", 2024, q3, status=DocumentStatus.CONFIRMED)
await _osv(seeded, company, "58.03", 2024, q3, status=DocumentStatus.NEEDS_CHECK)
await _osv(seeded, company, "62", 2024, q4) # позже квартала версии
await _osv(seeded, company, "01", 2023, q3) # прошлый год
await _osv(seeded, company, "76", 2024, q3, file_status=FileStatus.SUPERSEDED) # заменённый файл
await _osv(seeded, company, "90", 2024, q3, status=DocumentStatus.ERROR) # документ в ошибке
await _osv(seeded, company, "10", 2024, q3, file_status=FileStatus.ERROR) # файл в ошибке
client = await auth_client(KA_RF)
card = (await client.get(f"{VERSIONS}/{version.id}")).json()
accounts = {member["id"]: member["osv_accounts"] for member in card["members"]}
assert accounts == {str(company.id): ["51", "58.03", "60"], str(person.id): []}
@pytest.mark.integration
async def test_card_member_osv_accounts_q1_version_ignores_later_quarters(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Review Focus: версия за Q1 — документы Q2 того же года не входят."""
version, company, _ = await _card_world(seeded, PeriodQuarter.Q1)
await _osv(seeded, company, "60", 2024, PeriodQuarter.Q1)
await _osv(seeded, company, "62", 2024, PeriodQuarter.Q2)
client = await auth_client(KA_RF)
card = (await client.get(f"{VERSIONS}/{version.id}")).json()
member = next(member for member in card["members"] if member["id"] == str(company.id))
assert member["osv_accounts"] == ["60"]
@pytest.mark.integration
async def test_card_member_without_entity_type_is_served(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""I-1: `legal_entity.entity_type` nullable в DBML — член периметра без типа не роняет карточку (тип `null`)."""
version, company, person = await _card_world(seeded)
person.entity_type = None
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.get(f"{VERSIONS}/{version.id}")
assert response.status_code == HTTP_OK, response.text
types = {member["id"]: member["entity_type"] for member in response.json()["members"]}
assert types == {str(company.id): "ЮЛ", str(person.id): None}