"""API ЮЛ/ИП (spec directories-api, directory-policy, audit-trail): CRUD, перевод, вывод из состава, история, откат.""" import datetime import uuid from collections.abc import Sequence from typing import Any import httpx import pytest from fastapi import FastAPI from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from src.common.clock import moscow_date from src.models.audit_ex import AuditEvent from src.models.directories import ( LegalEntity, LegalEntityAlias, LegalEntityHistory, RegionalOffice, RelatedGroupMember, ) from src.models.enums import ( AuditAction, AuditSection, EntityType, FileStatus, RecognitionStatus, RelatedGroupKind, ) from tests.conftest import AuthClientFactory from tests.factories import ( make_company_group, make_document, make_legal_entity, make_membership, make_related_group, make_stored_file, ) HTTP_OK = 200 HTTP_CREATED = 201 HTTP_FORBIDDEN = 403 HTTP_NOT_FOUND = 404 HTTP_CONFLICT = 409 HTTP_UNPROCESSABLE = 422 HTTP_SERVER_ERROR = 500 ROSTOV = "Ростовский региональный филиал" KRASNODAR = "Краснодарский региональный филиал" ENTITIES = "/directories/legal-entities" ENTITY_NOT_FOUND = "ЮЛ не найдено" GROUP_NOT_FOUND = "ГК не найдена" def _today() -> datetime.date: """«Сегодня» сервиса — дата по Москве (тест не зависит от часового пояса машины).""" return moscow_date(datetime.datetime.now(datetime.UTC)) async def _office(session: AsyncSession, name: str) -> RegionalOffice: return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one() async def _events(session: AsyncSession, *actions: AuditAction) -> Sequence[AuditEvent]: stmt = select(AuditEvent).where(AuditEvent.action.in_(actions)).order_by(AuditEvent.occurred_at, AuditEvent.id) return (await session.execute(stmt)).scalars().all() async def _history_count(session: AsyncSession) -> int: return (await session.execute(select(func.count()).select_from(LegalEntityHistory))).scalar_one() async def _create(client: httpx.AsyncClient, **body: Any) -> dict[str, Any]: response = await client.post(ENTITIES, json=body) assert response.status_code == HTTP_CREATED, response.text created: dict[str, Any] = response.json() return created @pytest.mark.integration async def test_sole_proprietor_with_ten_digits_is_invalid(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: client = await auth_client("ka_go@armka.local") response = await client.post(ENTITIES, json={"inn": "3528052718", "entity_type": "ИП", "name": "ИП Иванов"}) assert response.status_code == HTTP_UNPROCESSABLE assert response.json()["detail"]["code"] == "INVALID_INN" assert (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one() == 0 assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) @pytest.mark.integration async def test_duplicate_inn_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: client = await auth_client("ka_go@armka.local") await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Первое") response = await client.post(ENTITIES, json={"inn": "35 2805 2718", "entity_type": "ЮЛ", "name": "ООО Второе"}) assert response.status_code == HTTP_CONFLICT assert response.json()["detail"]["code"] == "INN_EXISTS" assert len(await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)) == 1 @pytest.mark.integration async def test_create_audit_and_default_office_from_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: rostov = await _office(seeded, ROSTOV) group = await make_company_group(seeded, name="ГК Ростов", office=rostov) client = await auth_client("ka_go@armka.local") created = await _create( client, inn="0123456789", entity_type="ЮЛ", name=" ООО Ноль ", company_group_id=str(group.id), crm_id="1-AB" ) assert created["inn"] == "0123456789" # Review Focus 2: строка, ведущий ноль сохранён assert created["name"] == "ООО Ноль" assert created["regional_office_id"] == str(rostov.id) assert (created["outside_group"], created["closed"]) == (False, False) assert created["valid_from"] (event,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) assert (event.section, event.object_type, str(event.object_id)) == ( AuditSection.DIRECTORIES, "legal_entity", created["id"], ) assert event.after_value == { "inn": "0123456789", "kpp": None, "entity_type": "ЮЛ", "name": "ООО Ноль", "crm_id": "1-AB", "company_group_id": str(group.id), "regional_office_id": str(rostov.id), } assert event.before_value is None assert await _history_count(seeded) == 0 # при создании история не пишется (D-6) duplicate_crm = await client.post( ENTITIES, json={"inn": "3528052718", "entity_type": "ЮЛ", "name": "Х", "crm_id": "1-AB"} ) assert duplicate_crm.status_code == HTTP_CONFLICT assert duplicate_crm.json()["detail"]["code"] == "CRM_ID_EXISTS" @pytest.mark.integration async def test_mono_borrower_visible_to_own_branch_and_head_office( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: rostov = await _office(seeded, ROSTOV) branch = await auth_client("ka_rf@armka.local") # Ростовский филиал created = await _create(branch, inn="6164000001", entity_type="ЮЛ", name="ООО Монозаёмщик") assert created["company_group_id"] is None assert created["outside_group"] is True assert created["regional_office_id"] == str(rostov.id) for email in ("ka_rf@armka.local", "ka_go@armka.local"): client = await auth_client(email) page = (await client.get(ENTITIES, params={"outside_group": "true"})).json() assert created["id"] in {item["id"] for item in page["items"]}, email assert (await client.get(f"{ENTITIES}/{created['id']}")).status_code == HTTP_OK foreign = await auth_client("client_manager@armka.local") # Краснодарский филиал page = (await foreign.get(ENTITIES)).json() assert created["id"] not in {item["id"] for item in page["items"]} card = await foreign.get(f"{ENTITIES}/{created['id']}") missing = await foreign.get(f"{ENTITIES}/{uuid.uuid4()}") assert card.status_code == missing.status_code == HTTP_NOT_FOUND assert card.json() == missing.json() assert card.json()["detail"]["message"] == ENTITY_NOT_FOUND @pytest.mark.integration async def test_move_closes_memberships_and_writes_history(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: rostov = await _office(seeded, ROSTOV) old_group = await make_company_group(seeded, name="ГК Старая", office=rostov) new_group = await make_company_group(seeded, name="ГК Новая", office=rostov) entity = await make_legal_entity(seeded, name="ООО Переводимое", group=old_group) gsk = await make_related_group(seeded, group=old_group, kind=RelatedGroupKind.gsk) gsz = await make_related_group(seeded, group=old_group, kind=RelatedGroupKind.gsz, name="ГСЗ Тест") gsk_member = await make_membership(seeded, related_group=gsk, entity=entity) gsz_member = await make_membership(seeded, related_group=gsz, entity=entity) old_valid_from = entity.valid_from client = await auth_client("ka_rf@armka.local") effective = _today() response = await client.post( f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(new_group.id), "effective_date": effective.isoformat()}, ) assert response.status_code == HTTP_OK, response.text assert response.json()["company_group_id"] == str(new_group.id) for member in (gsk_member, gsz_member): await seeded.refresh(member) assert member.is_current is False assert member.valid_to is not None history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json() assert len(history) == 1 snapshot = history[0] assert snapshot["event_type"] == "MOVE_TO_GROUP" assert snapshot["effective_date"] == effective.isoformat() assert snapshot["company_group_id"] == str(old_group.id) # снимок — прежнее состояние assert datetime.datetime.fromisoformat(snapshot["valid_from"]) == old_valid_from assert snapshot["valid_to"] is not None and snapshot["changed_by"] is not None (change,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) assert change.before_value == {"company_group_id": str(old_group.id)} assert change.after_value == {"company_group_id": str(new_group.id)} assert change.changed_field == "company_group_id" memberships = await _events(seeded, AuditAction.MEMBERSHIP_CHANGE) assert {(e.object_type, e.object_id) for e in memberships} == { ("related_group_member", gsk_member.id), ("related_group_member", gsz_member.id), } assert all(e.before_value["is_current"] is True and e.after_value["is_current"] is False for e in memberships) card = (await client.get(f"{ENTITIES}/{entity.id}")).json() assert card["memberships"] == [] @pytest.mark.integration async def test_branch_user_cannot_move_to_foreign_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: """Review Focus 3: целевая ГК чужого филиала — 404, как несуществующая (не 422, без утечки).""" rostov, krasnodar = await _office(seeded, ROSTOV), await _office(seeded, KRASNODAR) own = await make_company_group(seeded, name="ГК Своя", office=rostov) foreign = await make_company_group(seeded, name="ГК Чужая", office=krasnodar) entity = await make_legal_entity(seeded, group=own) client = await auth_client("ka_rf@armka.local") body = {"effective_date": _today().isoformat()} to_foreign = await client.post(f"{ENTITIES}/{entity.id}/move", json={**body, "company_group_id": str(foreign.id)}) to_missing = await client.post(f"{ENTITIES}/{entity.id}/move", json={**body, "company_group_id": str(uuid.uuid4())}) assert to_foreign.status_code == to_missing.status_code == HTTP_NOT_FOUND assert to_foreign.json() == to_missing.json() assert to_foreign.json()["detail"]["message"] == GROUP_NOT_FOUND await seeded.refresh(entity) assert entity.company_group_id == own.id assert await _history_count(seeded) == 0 assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) create_in_foreign = await client.post( ENTITIES, json={"inn": "6164000002", "entity_type": "ЮЛ", "name": "ООО Х", "company_group_id": str(foreign.id)} ) assert create_in_foreign.status_code == HTTP_NOT_FOUND assert create_in_foreign.json() == to_missing.json() @pytest.mark.integration async def test_effective_date_in_past_is_invalid(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = await make_company_group(seeded, name="ГК А") target = await make_company_group(seeded, name="ГК Б") entity = await make_legal_entity(seeded, group=group) client = await auth_client("ka_go@armka.local") yesterday = (_today() - datetime.timedelta(days=1)).isoformat() move = await client.post( f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(target.id), "effective_date": yesterday} ) assert move.status_code == HTTP_UNPROCESSABLE assert move.json()["detail"]["code"] == "EFFECTIVE_DATE_IN_PAST" withdraw = await client.post( f"{ENTITIES}/{entity.id}/withdraw", json={"event": "CLOSE", "effective_date": yesterday} ) assert withdraw.status_code == HTTP_UNPROCESSABLE assert withdraw.json()["detail"]["code"] == "EFFECTIVE_DATE_IN_PAST" await seeded.refresh(entity) assert (entity.company_group_id, entity.close_date) == (group.id, None) assert await _history_count(seeded) == 0 @pytest.mark.integration async def test_withdraw_close(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = await make_company_group(seeded, name="ГК Закрытие") entity = await make_legal_entity(seeded, name="ООО Закрываемое", group=group) other = await make_legal_entity(seeded, name="ООО Живое", group=group) gsk = await make_related_group(seeded, group=group) member = await make_membership(seeded, related_group=gsk, entity=entity) client = await auth_client("ka_go@armka.local") close_date = _today() + datetime.timedelta(days=3) response = await client.post( f"{ENTITIES}/{entity.id}/withdraw", json={"event": "CLOSE", "effective_date": close_date.isoformat(), "comment": "Ликвидация"}, ) assert response.status_code == HTTP_OK, response.text body = response.json() assert body["close_date"] == close_date.isoformat() assert body["company_group_id"] is None assert body["closed"] is True closed = (await client.get(ENTITIES, params={"closed": "true"})).json() assert [item["id"] for item in closed["items"]] == [str(entity.id)] assert closed["items"][0]["closed"] is True active_ids = {item["id"] for item in (await client.get(ENTITIES, params={"closed": "false"})).json()["items"]} assert str(other.id) in active_ids and str(entity.id) not in active_ids await seeded.refresh(member) assert member.is_current is False history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json() assert [(row["event_type"], row["effective_date"], row["company_group_id"]) for row in history] == [ ("CLOSE", close_date.isoformat(), str(group.id)) ] (change,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) assert change.comment == "Ликвидация" assert change.before_value == {"company_group_id": str(group.id), "close_date": None} assert change.after_value == {"company_group_id": None, "close_date": close_date.isoformat()} assert len(await _events(seeded, AuditAction.MEMBERSHIP_CHANGE)) == 1 again = await client.post( f"{ENTITIES}/{entity.id}/withdraw", json={"event": "CLOSE", "effective_date": close_date.isoformat()} ) assert again.status_code == HTTP_CONFLICT assert again.json()["detail"]["code"] == "ALREADY_CLOSED" target = await make_company_group(seeded, name="ГК Куда") move_closed = await client.post( f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(target.id), "effective_date": _today().isoformat()}, ) assert move_closed.status_code == HTTP_CONFLICT assert move_closed.json()["detail"]["code"] == "LEGAL_ENTITY_CLOSED" @pytest.mark.integration async def test_withdraw_remove_from_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = await make_company_group(seeded, name="ГК Вывод") entity = await make_legal_entity(seeded, group=group) client = await auth_client("ka_go@armka.local") today = _today().isoformat() response = await client.post( f"{ENTITIES}/{entity.id}/withdraw", json={"event": "REMOVE_FROM_GROUP", "effective_date": today} ) assert response.status_code == HTTP_OK, response.text assert (response.json()["company_group_id"], response.json()["close_date"]) == (None, None) assert response.json()["outside_group"] is True history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json() assert [row["event_type"] for row in history] == ["REMOVE_FROM_GROUP"] again = await client.post( f"{ENTITIES}/{entity.id}/withdraw", json={"event": "REMOVE_FROM_GROUP", "effective_date": today} ) assert again.status_code == HTTP_CONFLICT assert again.json()["detail"]["code"] == "NOT_IN_COMPANY_GROUP" same = await client.post(f"{ENTITIES}/{entity.id}/withdraw", json={"event": "UNKNOWN", "effective_date": today}) assert same.status_code == HTTP_UNPROCESSABLE @pytest.mark.integration async def test_move_to_same_group_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = await make_company_group(seeded, name="ГК Та же") entity = await make_legal_entity(seeded, group=group) client = await auth_client("ka_go@armka.local") response = await client.post( f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(group.id), "effective_date": _today().isoformat()} ) assert response.status_code == HTTP_CONFLICT assert response.json()["detail"]["code"] == "SAME_COMPANY_GROUP" @pytest.mark.integration async def test_noop_patch_writes_no_history_and_no_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: """Review Focus 1: тело с теми же значениями не плодит историю и аудит.""" client = await auth_client("ka_go@armka.local") created = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Без изменений", crm_id="1-NOOP") audit_before = len(await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)) response = await client.patch( f"{ENTITIES}/{created['id']}", json={"name": " ООО Без изменений ", "entity_type": "ЮЛ", "crm_id": "1-NOOP"} ) assert response.status_code == HTTP_OK assert response.json()["valid_from"] == created["valid_from"] assert await _history_count(seeded) == 0 assert len(await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE)) == audit_before @pytest.mark.integration async def test_patch_writes_update_snapshot_and_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: client = await auth_client("ka_go@armka.local") created = await _create(client, inn="3528052718", entity_type="ЮЛ", name="Имя 0") for index in (1, 2): response = await client.patch(f"{ENTITIES}/{created['id']}", json={"name": f"Имя {index}"}) assert response.status_code == HTTP_OK, response.text assert response.json()["valid_from"] != created["valid_from"] history = (await client.get(f"{ENTITIES}/{created['id']}/history")).json() assert [(row["name"], row["event_type"]) for row in history] == [("Имя 1", "UPDATE"), ("Имя 0", "UPDATE")] # R-3 assert history[0]["effective_date"] is None assert set(history[0]) == { "id", "inn", "kpp", "crm_id", "entity_type", "name", "company_group_id", "regional_office_id", "close_date", "regional_office", "event_type", "effective_date", "valid_from", "valid_to", "changed_by", } last = (await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE))[-1] assert (last.changed_field, last.before_value, last.after_value) == ("name", {"name": "Имя 1"}, {"name": "Имя 2"}) wrong_type = await client.patch(f"{ENTITIES}/{created['id']}", json={"entity_type": "ИП"}) assert wrong_type.status_code == HTTP_UNPROCESSABLE assert wrong_type.json()["detail"]["code"] == "INVALID_INN" # 10 цифр не ИНН ИП extra = await client.patch(f"{ENTITIES}/{created['id']}", json={"inn": "0000000000"}) assert extra.status_code == HTTP_UNPROCESSABLE # extra="forbid": ИНН не меняется PATCH @pytest.mark.integration async def test_inn_filter_strips_spaces_and_q_searches_name_and_inn( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: """Review Focus 2 и R-4: `inn` — точное сравнение после strip; `q` — подстрока name/inn, LIKE экранирован.""" target = await make_legal_entity(seeded, name="ООО Вологодский", inn="3528052718") await make_legal_entity(seeded, name="ООО 100% Другое", inn="3528052719") client = await auth_client("ka_go@armka.local") by_inn = (await client.get(ENTITIES, params={"inn": " 3528052718 "})).json() assert [item["id"] for item in by_inn["items"]] == [str(target.id)] assert by_inn["total"] == 1 assert (await client.get(ENTITIES, params={"inn": "352805271"})).json()["total"] == 0 # не подстрока assert {item["name"] for item in (await client.get(ENTITIES, params={"q": "вологод"})).json()["items"]} == { "ООО Вологодский" } by_inn_part = (await client.get(ENTITIES, params={"q": "52805271"})).json() assert by_inn_part["total"] == 2 # noqa: PLR2004 assert [item["name"] for item in by_inn_part["items"]] == ["ООО 100% Другое", "ООО Вологодский"] percent = (await client.get(ENTITIES, params={"q": "%"})).json() assert [item["name"] for item in percent["items"]] == ["ООО 100% Другое"] @pytest.mark.integration async def test_list_filters_by_group_and_outside_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = await make_company_group(seeded, name="ГК Фильтр") in_group = await make_legal_entity(seeded, name="ООО В группе", group=group) mono = await make_legal_entity(seeded, name="ООО Сам по себе") client = await auth_client("ka_go@armka.local") by_group = (await client.get(ENTITIES, params={"company_group_id": str(group.id)})).json() assert [item["id"] for item in by_group["items"]] == [str(in_group.id)] outside = {item["id"] for item in (await client.get(ENTITIES, params={"outside_group": "true"})).json()["items"]} assert str(mono.id) in outside and str(in_group.id) not in outside inside = {item["id"] for item in (await client.get(ENTITIES, params={"outside_group": "false"})).json()["items"]} assert str(in_group.id) in inside and str(mono.id) not in inside @pytest.mark.integration async def test_card_has_current_memberships_and_aliases(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: group = await make_company_group(seeded, name="ГК Карточка") entity = await make_legal_entity(seeded, name="ООО Карточка", group=group) gsk = await make_related_group(seeded, group=group, name="ГСК Карточка") old_gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ Старая") await make_membership(seeded, related_group=gsk, entity=entity) await make_membership(seeded, related_group=old_gsz, entity=entity, is_current=False) seeded.add( LegalEntityAlias( legal_entity_id=entity.id, alias="ООО «Карточка»", normalized_alias="карточка", created_at=datetime.datetime.now(datetime.UTC), ) ) await seeded.flush() client = await auth_client("ka_go@armka.local") card = (await client.get(f"{ENTITIES}/{entity.id}")).json() assert [(m["related_group_id"], m["code"], m["kind"], m["name"]) for m in card["memberships"]] == [ (str(gsk.id), gsk.code, "gsk", "ГСК Карточка") ] assert card["memberships"][0]["valid_from"] assert [(a["alias"], a["normalized_alias"]) for a in card["aliases"]] == [("ООО «Карточка»", "карточка")] assert card["entity_type"] == EntityType.ЮЛ.value @pytest.mark.integration async def test_foreign_entity_operations_are_404(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: krasnodar = await _office(seeded, KRASNODAR) foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar) entity = await make_legal_entity(seeded, group=foreign_group) client = await auth_client("ka_rf@armka.local") missing = await client.get(f"{ENTITIES}/{uuid.uuid4()}") today = _today().isoformat() for call in ( client.get(f"{ENTITIES}/{entity.id}"), client.patch(f"{ENTITIES}/{entity.id}", json={"name": "Взлом"}), client.post( f"{ENTITIES}/{entity.id}/move", json={"company_group_id": str(foreign_group.id), "effective_date": today} ), client.post(f"{ENTITIES}/{entity.id}/withdraw", json={"event": "CLOSE", "effective_date": today}), client.get(f"{ENTITIES}/{entity.id}/history"), ): response = await call assert response.status_code == HTTP_NOT_FOUND assert response.json() == missing.json() @pytest.mark.integration async def test_view_only_user_cannot_create(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: client = await auth_client("expert@armka.local") response = await client.post(ENTITIES, json={"inn": "3528052718", "entity_type": "ЮЛ", "name": "ООО Эксперта"}) assert response.status_code == HTTP_FORBIDDEN assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) assert (await client.get(ENTITIES)).status_code == HTTP_OK @pytest.mark.integration async def test_failed_move_rolls_back_everything( app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch ) -> None: """spec «Ошибка внутри мутации»: сбой закрытия членств → 500, ЮЛ в прежней ГК, истории и аудита нет.""" old_group = await make_company_group(seeded, name="ГК Откат") new_group = await make_company_group(seeded, name="ГК Цель") entity = await make_legal_entity(seeded, group=old_group) gsk = await make_related_group(seeded, group=old_group) member = await make_membership(seeded, related_group=gsk, entity=entity) entity_id, member_id, old_group_id = entity.id, member.id, old_group.id # откат SAVEPOINT протухит ORM-объекты authorized = await auth_client("ka_go@armka.local") async def broken_close_memberships(*args: Any, **kwargs: Any) -> list[RelatedGroupMember]: raise RuntimeError("сбой БД при закрытии членств") monkeypatch.setattr("src.repositories.directories.close_memberships", broken_close_memberships) transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) async with httpx.AsyncClient( transport=transport, base_url=authorized.base_url, headers=authorized.headers ) as client: response = await client.post( f"{ENTITIES}/{entity_id}/move", json={"company_group_id": str(new_group.id), "effective_date": _today().isoformat()}, ) assert response.status_code == HTTP_SERVER_ERROR # SAVEPOINT запроса откатился — проверяем в той же сессии (populate_existing: читать из БД, не из identity map) reloaded = ( await seeded.execute( select(LegalEntity).where(LegalEntity.id == entity_id).execution_options(populate_existing=True) ) ).scalar_one() assert reloaded.company_group_id == old_group_id reloaded_member = ( await seeded.execute( select(RelatedGroupMember) .where(RelatedGroupMember.id == member_id) .execution_options(populate_existing=True) ) ).scalar_one() assert reloaded_member.is_current is True assert reloaded_member.valid_to is None assert await _history_count(seeded) == 0 assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE, AuditAction.MEMBERSHIP_CHANGE) @pytest.mark.integration async def test_branch_patch_with_unchanged_foreign_office_is_not_rejected( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: """I-1 (Review Focus 1): ЮЛ в ГК своего филиала с чужим `regional_office_id`; форма шлёт тот же филиал — не 422.""" rostov, krasnodar = await _office(seeded, ROSTOV), await _office(seeded, KRASNODAR) group = await make_company_group(seeded, name="ГК Ростов", office=rostov) entity = await make_legal_entity(seeded, name="ООО Старое", group=group, office=krasnodar) # как создал бы ГО client = await auth_client("ka_rf@armka.local") same_office_only = await client.patch(f"{ENTITIES}/{entity.id}", json={"regional_office_id": str(krasnodar.id)}) assert same_office_only.status_code == HTTP_OK, same_office_only.text assert same_office_only.json()["regional_office_id"] == str(krasnodar.id) assert await _history_count(seeded) == 0 assert not await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) renamed = await client.patch( f"{ENTITIES}/{entity.id}", json={"name": "ООО Новое", "regional_office_id": str(krasnodar.id)} ) assert renamed.status_code == HTTP_OK, renamed.text assert (renamed.json()["name"], renamed.json()["regional_office_id"]) == ("ООО Новое", str(krasnodar.id)) assert await _history_count(seeded) == 1 (event,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) assert (event.before_value, event.after_value) == ({"name": "ООО Старое"}, {"name": "ООО Новое"}) to_foreign = await client.patch(f"{ENTITIES}/{entity.id}", json={"regional_office_id": None}) assert to_foreign.status_code == HTTP_OK # явный null у филиала — свой филиал (это изменение) assert to_foreign.json()["regional_office_id"] == str(rostov.id) @pytest.mark.integration async def test_create_rejects_unknown_fields(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: """M-8: `extra="forbid"` — неизвестное поле (`close_date`) при создании даёт 422 (массив), ЮЛ не создаётся.""" client = await auth_client("ka_go@armka.local") before = (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one() body = {"inn": "3528052718", "entity_type": "ЮЛ", "name": "ООО Лишнее", "close_date": "2026-01-01"} response = await client.post(ENTITIES, json=body) assert response.status_code == HTTP_UNPROCESSABLE, response.text assert isinstance(response.json()["detail"], list) count = (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one() assert count == before @pytest.mark.integration async def test_documents_count(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """D-13: documents_count в списке и карточке ЮЛ — число документов файлов со статусом не SUPERSEDED.""" entity = await make_legal_entity(seeded, name="ООО Счётчик") # монозаёмщик live, job = await make_stored_file( seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) await make_document(seeded, file=live, job=job, account="60") await make_document(seeded, file=live, job=job, account="62") old, old_job = await make_stored_file( seeded, entity=entity, status=FileStatus.SUPERSEDED, job_status=RecognitionStatus.done ) await make_document(seeded, file=old, job=old_job, account="66") client = await auth_client("ka_go@armka.local") listed = (await client.get("/directories/legal-entities", params={"q": "Счётчик"})).json() expected_count = 2 # два документа в не заменённом файле, один в заменённом (не считается) assert listed["items"][0]["documents_count"] == expected_count card = (await client.get(f"/directories/legal-entities/{entity.id}")).json() assert card["documents_count"] == expected_count patched = await client.patch(f"/directories/legal-entities/{entity.id}", json={"name": "ООО Счётчик плюс"}) assert patched.status_code == HTTP_OK, patched.text assert patched.json()["documents_count"] == expected_count # T11.2: тот же подсчёт в PATCH async def _with_documents(session: AsyncSession, entity: Any, accounts: Sequence[str]) -> None: """Один обработанный файл ЮЛ с документами по перечисленным счетам.""" file, job = await make_stored_file( session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) for account in accounts: await make_document(session, file=file, job=job, account=account) @pytest.mark.integration async def test_documents_count_zero_and_grouped_per_entity( seeded: AsyncSession, auth_client: AuthClientFactory ) -> None: """D-13: у ЮЛ без документов счётчик 0 (а не пропуск), в списке из нескольких ЮЛ счёт идёт по каждому ЮЛ.""" group = await make_company_group(seeded, name="ГК Счётчики") empty = await make_legal_entity(seeded, name="ООО Пустое", group=group) single = await make_legal_entity(seeded, name="ООО Одиночное", group=group) triple = await make_legal_entity(seeded, name="ООО Тройное", group=group) await _with_documents(seeded, single, ["60"]) await _with_documents(seeded, triple, ["60", "62", "66"]) client = await auth_client("ka_go@armka.local") listed = (await client.get(ENTITIES, params={"company_group_id": str(group.id)})).json() counts = {item["id"]: item["documents_count"] for item in listed["items"]} assert counts == {str(empty.id): 0, str(single.id): 1, str(triple.id): 3} card = (await client.get(f"{ENTITIES}/{empty.id}")).json() assert card["documents_count"] == 0 @pytest.mark.integration async def test_documents_count_in_move_and_withdraw_responses( seeded: AsyncSession, auth_client: AuthClientFactory ) -> None: """Ответы move/withdraw (как PATCH) несут реальный `documents_count` (PATCH покрыт `test_documents_count`).""" rostov = await _office(seeded, ROSTOV) old_group = await make_company_group(seeded, name="ГК Откуда", office=rostov) new_group = await make_company_group(seeded, name="ГК Куда", office=rostov) moved = await make_legal_entity(seeded, name="ООО Переезд", group=old_group) closed = await make_legal_entity(seeded, name="ООО Закрытие", group=old_group) await _with_documents(seeded, moved, ["60", "62"]) await _with_documents(seeded, closed, ["60", "62", "66"]) client = await auth_client("ka_rf@armka.local") today = _today().isoformat() move = await client.post( f"{ENTITIES}/{moved.id}/move", json={"company_group_id": str(new_group.id), "effective_date": today} ) assert move.status_code == HTTP_OK, move.text assert move.json()["documents_count"] == 2 # noqa: PLR2004 - два документа ЮЛ withdraw = await client.post( f"{ENTITIES}/{closed.id}/withdraw", json={"event": "CLOSE", "effective_date": today, "comment": "Ликвидация"} ) assert withdraw.status_code == HTTP_OK, withdraw.text assert withdraw.json()["documents_count"] == 3 # noqa: PLR2004 - три документа ЮЛ # --- КПП (DBML v5.17, OQ-31, 05-domain-rules §13.5) -------------------------------------------------------------- @pytest.mark.integration async def test_create_with_kpp_returns_it_in_out_detail_and_audit( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: client = await auth_client("ka_go@armka.local") created = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО С КПП", kpp=" 3528 ab 001 ") assert created["kpp"] == "3528AB001" # нормализован: без пробелов, верхний регистр assert (await client.get(f"{ENTITIES}/{created['id']}")).json()["kpp"] == "3528AB001" listed = (await client.get(ENTITIES)).json()["items"] assert [item["kpp"] for item in listed] == ["3528AB001"] (event,) = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) assert event.after_value is not None assert event.after_value["kpp"] == "3528AB001" @pytest.mark.integration async def test_kpp_is_optional_for_legal_entity(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: client = await auth_client("ka_go@armka.local") without = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Без КПП") assert without["kpp"] is None blank = await _create(client, inn="3528052719", entity_type="ЮЛ", name="ООО Пустой КПП", kpp=" ") assert blank["kpp"] is None @pytest.mark.integration @pytest.mark.parametrize("kpp", ["35280100", "3528010011", "A52801001", "3528010A1", "3528я1001"]) async def test_invalid_kpp_format_is_unprocessable( auth_client: AuthClientFactory, seeded: AsyncSession, kpp: str ) -> None: client = await auth_client("ka_go@armka.local") response = await client.post(ENTITIES, json={"inn": "3528052718", "entity_type": "ЮЛ", "name": "ООО", "kpp": kpp}) assert response.status_code == HTTP_UNPROCESSABLE assert response.json()["detail"]["code"] == "INVALID_KPP" assert (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one() == 0 @pytest.mark.integration async def test_kpp_is_forbidden_for_sole_proprietor(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: client = await auth_client("ka_go@armka.local") response = await client.post( ENTITIES, json={"inn": "352805271812", "entity_type": "ИП", "name": "ИП Иванов", "kpp": "352801001"} ) assert response.status_code == HTTP_UNPROCESSABLE assert response.json()["detail"]["code"] == "INVALID_KPP" ok = await _create(client, inn="352805271812", entity_type="ИП", name="ИП Иванов") assert ok["kpp"] is None patch = await client.patch(f"{ENTITIES}/{ok['id']}", json={"kpp": "352801001"}) assert patch.status_code == HTTP_UNPROCESSABLE assert patch.json()["detail"]["code"] == "INVALID_KPP" @pytest.mark.integration async def test_inn_is_unique_kpp_is_not(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: """Уникален только `inn`: два ЮЛ с одним КПП допустимы.""" client = await auth_client("ka_go@armka.local") await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Первое", kpp="352801001") await _create(client, inn="3528052719", entity_type="ЮЛ", name="ООО Второе", kpp="352801001") @pytest.mark.integration async def test_patch_kpp_writes_snapshot_and_audit_before_after( auth_client: AuthClientFactory, seeded: AsyncSession ) -> None: client = await auth_client("ka_go@armka.local") created = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО", kpp="352801001") response = await client.patch(f"{ENTITIES}/{created['id']}", json={"kpp": "352845002"}) assert response.status_code == HTTP_OK, response.text assert response.json()["kpp"] == "352845002" history = (await client.get(f"{ENTITIES}/{created['id']}/history")).json() assert [(row["event_type"], row["kpp"]) for row in history] == [("UPDATE", "352801001")] # снимок ПРЕЖНЕГО КПП events = await _events(seeded, AuditAction.LEGAL_ENTITY_CHANGE) assert (events[-1].before_value, events[-1].after_value) == ({"kpp": "352801001"}, {"kpp": "352845002"}) same = await client.patch(f"{ENTITIES}/{created['id']}", json={"kpp": "3528 45 002"}) assert same.status_code == HTTP_OK assert len((await client.get(f"{ENTITIES}/{created['id']}/history")).json()) == 1 # то же значение — не изменение cleared = await client.patch(f"{ENTITIES}/{created['id']}", json={"kpp": None}) assert cleared.status_code == HTTP_OK assert cleared.json()["kpp"] is None omitted = await client.patch(f"{ENTITIES}/{created['id']}", json={"name": "ООО Новое"}) assert omitted.json()["kpp"] is None # поле не передано — не затрагивается @pytest.mark.integration async def test_search_q_finds_by_kpp(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: client = await auth_client("ka_go@armka.local") target = await _create(client, inn="3528052718", entity_type="ЮЛ", name="ООО Первое", kpp="352845001") await _create(client, inn="3528052719", entity_type="ЮЛ", name="ООО Второе", kpp="770101001") found = (await client.get(ENTITIES, params={"q": "352845001"})).json() assert [item["id"] for item in found["items"]] == [target["id"]]