arm-core/api/tests/test_api_consolidations.py
Andrey Epifancev f46cf2fec5 feat(consolidation): чек-лист комплекта документов по правилам подхода
Домен `checklist` (Rule, CellStatus, Cell, order_rules, build) — чистые функции без ORM; ячейка (ЮЛ, правило):
CONFIRMED по привязанным документам (ОСВ: нарастающий за квартал версии либо квартальные Q1..Qn, пробел →
missing_quarters; ББ/ОФР: документ за квартал версии), NEEDS_CHECK/RECOGNIZED по неподтверждённому подходящему,
PROCESSING, ERROR, иначе MISSING; правило ОСВ закрывает только точный код счёта. Репозиторий: rules_for,
unconfirmed_documents. Сервис: checklist(), ячейки в карточке версии. Маршрут GET /consolidations/versions/{id}/checklist.
Схема использует CellStatus домена (T2.8). Решения №44-49 в design.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 05:50:48 +04:00

407 lines
19 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 typing import Any
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.consolidation import (
Consolidation,
ConsolidationLock,
ConsolidationMember,
ConsolidationStatusHistory,
ConsolidationVersion,
)
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter, RelatedGroupKind
from tests.conftest import AuthClientFactory
from tests.factories import (
make_company_group,
make_consolidation,
make_legal_entity,
make_lock,
make_membership,
make_related_group,
make_version,
)
HTTP_OK = 200
HTTP_CREATED = 201
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_CONFLICT = 409
HTTP_UNPROCESSABLE = 422
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}
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_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