"""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_without_effective_rules_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: """DBML v5.23: версия правил обязательна — без правил, действующих на дату создания, консолидация не создаётся.""" group, gsk, _ = await _scenario(seeded) tomorrow = moscow_date(utc_now()) + timedelta(days=1) for rule in (await seeded.scalars(select(EliminationRule))).all(): rule.valid_from = tomorrow await seeded.flush() client = await auth_client(KA_RF) response = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2024)) assert response.status_code == HTTP_CONFLICT, response.text assert response.json()["detail"]["code"] == "ELIMINATION_RULES_MISSING" assert await _count(seeded, Consolidation) == 0 @pytest.mark.integration async def test_create_fixes_rules_for_creation_date_not_reporting_period( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: group, gsk, entities = 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 pairs_url = f"/consolidations/versions/{first_version.id}/elimination-pairs" params = { "rule_id": str(source.id), "company_a_id": str(entities[0].id), "company_b_id": str(entities[1].id), } for _ in range(2): pairs = await client.get(pairs_url, params=params) assert pairs.status_code == HTTP_OK, pairs.text assert pairs.json()["version_no"] == 1 assert pairs.json()["matched"] == [] assert (await client.get(pairs_url, params={**params, "rule_id": str(next_rule.id)})).status_code == HTTP_NOT_FOUND await seeded.refresh(first_version) 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}