716 lines
33 KiB
Python
716 lines
33 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 datetime import timedelta
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from src.common.clock import moscow_date, utc_now
|
||
from src.models.access import AppUser
|
||
from src.models.accounting import EliminationRule
|
||
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 version.elimination_rules_version == 1
|
||
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_fixes_rules_for_creation_date_not_reporting_period(
|
||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||
) -> None:
|
||
group, gsk, _ = await _scenario(seeded)
|
||
today = moscow_date(utc_now())
|
||
source = (await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == 1))).one()
|
||
next_rule = EliminationRule(
|
||
statement_type=source.statement_type,
|
||
rule_no=source.rule_no,
|
||
article_1_line=source.article_1_line,
|
||
article_2_line=source.article_2_line,
|
||
side_1=source.side_1,
|
||
measure=source.measure,
|
||
elimination_mode=source.elimination_mode,
|
||
excise_adjust=source.excise_adjust,
|
||
version_no=2,
|
||
valid_from=today + timedelta(days=1),
|
||
)
|
||
seeded.add(next_rule)
|
||
await seeded.flush()
|
||
client = await auth_client(KA_RF)
|
||
|
||
first = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2024))
|
||
assert first.status_code == HTTP_CREATED, first.text
|
||
first_version = await seeded.get(ConsolidationVersion, uuid.UUID(first.json()["id"]))
|
||
assert first_version is not None
|
||
assert first_version.elimination_rules_version == 1
|
||
|
||
next_rule.valid_from = today
|
||
second = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2023))
|
||
assert second.status_code == HTTP_CREATED, second.text
|
||
second_version = await seeded.get(ConsolidationVersion, uuid.UUID(second.json()["id"]))
|
||
assert second_version is not None
|
||
assert second_version.elimination_rules_version == next_rule.version_no
|
||
assert first_version.elimination_rules_version == 1
|
||
|
||
|
||
@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}
|