From afc457f59314e7cec902675eb37bff18f2a1b41d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 18:27:06 +0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(config):=20=D0=BD=D0=B5=D1=81=D0=B5?= =?UTF-8?q?=D0=BA=D1=80=D0=B5=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8=D0=B7=20OpenBao?= =?UTF-8?q?=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20OPENBAO=5F=5FAPP=5F=5F=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Контур платформы передаёт в под только OPENBAO__*. Свой источник настроек (settings_customise_sources): приоритет обычный env -> OPENBAO__APP__* -> .env -> умолчание; неизвестное имя — ошибка при старте; ключ APP не ломает OpenBAOSettings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- README.md | 1 + api/.env.example | 9 ++ api/src/core/config.py | 34 ++++++- api/src/core/settings_sources.py | 50 ++++++++++ api/tests/test_config_openbao_app.py | 132 +++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 api/src/core/settings_sources.py create mode 100644 api/tests/test_config_openbao_app.py diff --git a/README.md b/README.md index 4410a4a..6e294b5 100644 --- a/README.md +++ b/README.md @@ -871,6 +871,7 @@ email без учёта регистра проверяет только сер | Переменная | По умолчанию | Назначение | |---|---|---| +| `OPENBAO__APP__<ИМЯ>` | — | **контур платформы:** туда передаются только секреты OpenBao (`OPENBAO__*`), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (`NATS_ENABLED`, `DEBUG`, `AUTH_DEV_ISSUER`, `FORWARDED_ALLOW_IPS`, `CORS_ORIGINS`, …) может прийти как `OPENBAO__APP__<ИМЯ>`, например `OPENBAO__APP__NATS_ENABLED=true`. Значения разбираются как из обычного env (bool, числа, списки). Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте (опечатка не игнорируется); `OPENBAO__APP__OPENBAO__…` не допускается. Читается только из окружения процесса (в `.env` не действует). Реализация — `api/src/core/settings_sources.py` | | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | | `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) | diff --git a/api/.env.example b/api/.env.example index edcb32c..1942f13 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,6 +1,15 @@ # Пример локального окружения. Скопируйте в api/.env: cp .env.example .env # Порты — стандартные для docs/dev/docker-compose.yml. +# Контур платформы: обычные переменные окружения там задать нельзя, передаются только секреты OpenBao (OPENBAO__*). +# Любая настройка верхнего уровня ниже (не вложенные OPENBAO__<ГРУППА>__*) может прийти как OPENBAO__APP__<ИМЯ>, например: +# OPENBAO__APP__NATS_ENABLED=true +# OPENBAO__APP__DEBUG=true +# OPENBAO__APP__AUTH_DEV_ISSUER=true +# OPENBAO__APP__FORWARDED_ALLOW_IPS=10.0.0.1,10.0.0.2 +# Приоритет: обычный env -> OPENBAO__APP__* -> .env -> значение по умолчанию. Неизвестное имя — ошибка при старте. +# В .env-файле OPENBAO__APP__* не читается (только процесс окружения). + DEBUG=true # Dev-запуск слушает только loopback: при DEBUG и AUTH_DEV_ISSUER эндпоинт dev-токенов выдаёт токен любому, кто знает diff --git a/api/src/core/config.py b/api/src/core/config.py index 5f15f84..0136606 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -2,8 +2,10 @@ import json import os from typing import Annotated, Any -from pydantic import NonNegativeInt, PositiveInt, field_validator -from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict +from pydantic import NonNegativeInt, PositiveInt, field_validator, model_validator +from pydantic_settings import BaseSettings, NoDecode, PydanticBaseSettingsSource, SettingsConfigDict + +from src.core.settings_sources import OpenBaoAppSettingsSource class DBSettings(BaseSettings): @@ -40,6 +42,16 @@ class OpenBAOSettings(BaseSettings): NATS: NATSSettings SMB: SMBSettings = SMBSettings() # все поля необязательны (см. SMBSettings) + @model_validator(mode="before") + @classmethod + def _drop_app(cls, data: Any) -> Any: + """`OPENBAO__APP__<ИМЯ>` — настройки верхнего уровня `Settings` (читает `OpenBaoAppSettingsSource`), не группа + секретов. Вложенный разбор `__` кладёт их сюда ключом `APP`, а `extra=forbid` отверг бы его; убирается только + этот ключ — опечатка в имени группы (`OPENBAO__DATABSE__...`) по-прежнему ошибка.""" + if isinstance(data, dict): + return {key: value for key, value in data.items() if str(key).upper() != "APP"} + return data + class ServerSettings(BaseSettings): HOST: str = "0.0.0.0" @@ -94,6 +106,24 @@ class Settings(BaseSettings): MAPPING_POLL_SECONDS: PositiveInt = 5 MAPPING_MAX_ATTEMPTS: PositiveInt = 3 + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию.""" + return ( + init_settings, + env_settings, + OpenBaoAppSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + @field_validator("CORS_ORIGINS", mode="before") @classmethod def _split_origins(cls, value: Any) -> Any: diff --git a/api/src/core/settings_sources.py b/api/src/core/settings_sources.py new file mode 100644 index 0000000..ce57bb1 --- /dev/null +++ b/api/src/core/settings_sources.py @@ -0,0 +1,50 @@ +"""Источник настроек `OPENBAO__APP__<ИМЯ>`: несекретные настройки верхнего уровня из OpenBao. + +Платформа передаёт в под только переменные `OPENBAO__*`, обычные переменные окружения задать нельзя. Поэтому любая +настройка верхнего уровня `Settings` может прийти как `OPENBAO__APP__<ИМЯ>` +(например `OPENBAO__APP__NATS_ENABLED=true`). +Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. + +Файл одинаковый в arm-core, arm-scan-gateway, arm-tabular-parser (менять во всех трёх). +""" + +from typing import Any + +from pydantic_settings import BaseSettings, EnvSettingsSource + +APP_PREFIX = "OPENBAO__APP__" +# Группа секретов не настраивается через APP: секреты — только `OPENBAO__<ГРУППА>__<ИМЯ>` +RESERVED_FIELD = "OPENBAO" + + +class OpenBaoAppSettingsSource(EnvSettingsSource): + """Читает `OPENBAO__APP__<ИМЯ>` как обычный env: те же разбор типов, `NoDecode`, вложенность `__`, регистр. + + Только процесс окружения (не `.env`): так платформа и передаёт значения. Неизвестное имя — `ValueError`. + """ + + def __init__(self, settings_cls: type[BaseSettings]) -> None: + super().__init__( + settings_cls, + env_prefix=APP_PREFIX, + env_nested_delimiter=settings_cls.model_config.get("env_nested_delimiter"), + ) + + def __call__(self) -> dict[str, Any]: + self._check_names() + return super().__call__() + + def _check_names(self) -> None: + known = {name.lower() for name in self.settings_cls.model_fields} - {RESERVED_FIELD.lower()} + delimiter = self.env_nested_delimiter or "__" + prefix = self.env_prefix.lower() + unknown = sorted( + env_name + for env_name in self.env_vars + if env_name.startswith(prefix) and env_name[len(prefix) :].split(delimiter, 1)[0] not in known + ) + if unknown: + raise ValueError( + f"Неизвестная настройка в {APP_PREFIX}*: {', '.join(name.upper() for name in unknown)}. " + "Допустимы имена полей верхнего уровня Settings (кроме OPENBAO)." + ) diff --git a/api/tests/test_config_openbao_app.py b/api/tests/test_config_openbao_app.py new file mode 100644 index 0000000..aa1832c --- /dev/null +++ b/api/tests/test_config_openbao_app.py @@ -0,0 +1,132 @@ +"""Несекретные настройки из OpenBao: `OPENBAO__APP__<ИМЯ>` (issue #44; без БД и сети). + +Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. +""" + +import os +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from src.core.config import Settings + +REQUIRED_ENV = { + "OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d", + "OPENBAO__MINIO__MINIO_URL": "localhost:9000", + "OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login", + "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password", + "OPENBAO__MINIO__MINIO_BUCKET": "bucket", + "OPENBAO__NATS__NATS_URL": "nats://localhost:4222", +} +# Плоские настройки, которые тесты ниже задают/проверяют +POLL = 7 +PORT = 9000 +FLAT_KEYS = {"DEBUG", "NATS_ENABLED", "AUTH_DEV_ISSUER", "FORWARDED_ALLOW_IPS", "CORS_ORIGINS", "OUTBOX_POLL_SECONDS"} + + +@pytest.fixture +def env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch: + """Чистое окружение с обязательными OPENBAO__*; остальное из процесса убрано.""" + for key in list(os.environ): + if key.startswith(("OPENBAO__", "SERVER__")) or key in FLAT_KEYS: + monkeypatch.delenv(key) + for key, value in REQUIRED_ENV.items(): + monkeypatch.setenv(key, value) + return monkeypatch + + +def _settings(env_file: Path | None = None) -> Settings: + return Settings(_env_file=env_file) # type: ignore[call-arg] + + +def test_app_value_applied(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__NATS_ENABLED", "true") + env.setenv("OPENBAO__APP__DEBUG", "true") + s = _settings() + assert s.NATS_ENABLED is True + assert s.DEBUG is True + # Секреты OpenBao по-прежнему читаются во вложенные группы + assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:4222" + + +def test_default_without_app(env: pytest.MonkeyPatch) -> None: + assert _settings().NATS_ENABLED is False + + +def test_plain_env_overrides_app(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__NATS_ENABLED", "true") + env.setenv("NATS_ENABLED", "false") + assert _settings().NATS_ENABLED is False + + +def test_app_overrides_dotenv(env: pytest.MonkeyPatch, tmp_path: Path) -> None: + dotenv = tmp_path / ".env" + dotenv.write_text("NATS_ENABLED=false\nFORWARDED_ALLOW_IPS=1.1.1.1\n", encoding="utf-8") + env.setenv("OPENBAO__APP__NATS_ENABLED", "true") + s = _settings(dotenv) + assert s.NATS_ENABLED is True # OPENBAO__APP__ сильнее .env + assert s.FORWARDED_ALLOW_IPS == "1.1.1.1" # то, чего нет в OPENBAO__APP__, остаётся из .env + + +def test_types_parsed_like_plain_env(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__OUTBOX_POLL_SECONDS", str(POLL)) + env.setenv("OPENBAO__APP__AUTH_DEV_ISSUER", "1") + env.setenv("OPENBAO__APP__FORWARDED_ALLOW_IPS", "10.0.0.1,10.0.0.2") + s = _settings() + assert s.OUTBOX_POLL_SECONDS == POLL + assert s.AUTH_DEV_ISSUER is True + assert s.FORWARDED_ALLOW_IPS == "10.0.0.1,10.0.0.2" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("https://a.example.ru,https://b.example.ru", ["https://a.example.ru", "https://b.example.ru"]), + ('["https://a.example.ru"]', ["https://a.example.ru"]), + ("", []), + ], + ids=["comma", "json", "empty"], +) +def test_cors_origins_list_parsed_like_plain_env(env: pytest.MonkeyPatch, raw: str, expected: list[str]) -> None: + """NoDecode-список разбирается валидатором так же, как из обычного env.""" + env.setenv("OPENBAO__APP__CORS_ORIGINS", raw) + assert _settings().CORS_ORIGINS == expected + + +def test_invalid_value_rejected(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__OUTBOX_POLL_SECONDS", "0") + with pytest.raises(ValidationError, match="OUTBOX_POLL_SECONDS"): + _settings() + + +def test_unknown_name_is_config_error(env: pytest.MonkeyPatch) -> None: + """Опечатка не игнорируется молча: ошибка при создании Settings (то есть при старте).""" + env.setenv("OPENBAO__APP__NATS_ENABLD", "true") + with pytest.raises(ValueError, match="NATS_ENABLD"): + _settings() + + +def test_openbao_group_not_settable_via_app(env: pytest.MonkeyPatch) -> None: + """Секреты — только `OPENBAO__<ГРУППА>__*`; `OPENBAO__APP__OPENBAO__...` — не имя настройки.""" + env.setenv("OPENBAO__APP__OPENBAO__NATS__NATS_URL", "nats://x:4222") + with pytest.raises(ValueError, match="OPENBAO"): + _settings() + + +def test_app_key_does_not_break_openbao_validation(env: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Ключ APP не попадает в OpenBAOSettings (там extra=forbid) — ни из env, ни из env-файла.""" + dotenv = tmp_path / ".env" + dotenv.write_text("OPENBAO__APP__NATS_ENABLED=true\n", encoding="utf-8") + env.setenv("OPENBAO__APP__DEBUG", "true") + s = _settings(dotenv) + assert s.DEBUG is True + assert not hasattr(s.OPENBAO, "APP") + + +def test_nested_group_still_works(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__MINIO__MINIO_SECURE", "false") + env.setenv("OPENBAO__APP__SERVER__PORT", str(PORT)) + s = _settings() + assert s.OPENBAO.MINIO.MINIO_SECURE is False + assert s.server.PORT == PORT From b1d0186c776d707f63eff0c7794d3b4800b7884f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 18:38:48 +0400 Subject: [PATCH 2/3] =?UTF-8?q?=D0=97=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B8=20=D0=BF=D0=BE=D0=B4=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=83=D1=80:=20PyJWT=202.8.0,=20cryptography=2043.?= =?UTF-8?q?0.3;=20JwksVerifier=20=D0=B1=D0=B5=D0=B7=20PyJWK=20=D0=B2=20dec?= =?UTF-8?q?ode=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requirements: PyJWT[crypto]==2.8.0, cryptography==43.0.3 (dev согласован) - JwksVerifier: в decode передаётся signing_key.key, алгоритм выводится из типа ключа (_algorithms_for_key: RSA -> RS256, EC P-256 -> ES256), как для AUTH_PUBLIC_KEY - DevHs256Verifier: TypeError/ValueError от PyJWT 2.8 (нечисловой exp) -> AuthError, не 500 - тест: RSA-ключ из JWKS + ES256-токен (и наоборот) -> AuthError Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- api/requirements-dev.txt | 2 +- api/requirements.txt | 4 ++-- api/src/core/auth.py | 30 ++++++++++++++++++++++-------- api/tests/test_auth_core.py | 12 ++++++++++++ 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt index d7aae10..b2c5b3a 100644 --- a/api/requirements-dev.txt +++ b/api/requirements-dev.txt @@ -7,7 +7,7 @@ pytest>=8.3 pytest-asyncio>=0.24 pytest-env>=1.1 pytest-cov>=6.0 -PyJWT[crypto]>=2.9 +PyJWT[crypto]==2.8.0 pydbml>=1.1 jsonschema>=4.23 types-PyYAML>=6.0 diff --git a/api/requirements.txt b/api/requirements.txt index 954ca54..990521c 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -13,7 +13,7 @@ attrs==26.1.0 certifi==2025.11.12 cffi==2.1.0 click==8.4.2 -cryptography==50.0.2 +cryptography==43.0.3 dnspython==2.8.0 email-validator==2.3.0 et_xmlfile==2.0.0 @@ -47,7 +47,7 @@ pydantic-extra-types==2.11.0 pydantic-settings==2.14.2 pydantic_core==2.46.4 Pygments==2.20.0 -PyJWT[crypto]==2.15.1 +PyJWT[crypto]==2.8.0 PyNaCl==1.5.0 python-dotenv==1.2.2 python-multipart==0.0.32 diff --git a/api/src/core/auth.py b/api/src/core/auth.py index c1c7682..729ef10 100644 --- a/api/src/core/auth.py +++ b/api/src/core/auth.py @@ -31,8 +31,6 @@ from loguru import logger if TYPE_CHECKING: from src.core.config import Settings -# Алгоритмы РАИСА-проверяющего: асимметричные, явно. ``none`` и HS* не допускаются никогда (атака подмены алгоритма). -JWKS_ALGORITHMS: tuple[str, ...] = ("RS256", "ES256") DEV_ALGORITHM = "HS256" # Допуск на расхождение часов с эмитентом токена JWKS_LEEWAY_SECONDS = 10 @@ -152,6 +150,9 @@ class DevHs256Verifier: ) except jwt.PyJWTError as exc: raise _to_auth_error(exc) from exc + except (TypeError, ValueError) as exc: + # PyJWT 2.8 не оборачивает нечисловой exp (например, список) в InvalidTokenError: страховка от 500 + raise AuthError("недействительный токен") from exc return _claims_from_payload(payload, self._email_claim) def issue_token(self, email: str, *, ttl: timedelta = DEV_TOKEN_TTL) -> str: @@ -168,11 +169,19 @@ def _load_static_key(pem: str) -> tuple[Any, tuple[str, ...]]: key = serialization.load_pem_public_key(pem.replace("\\n", "\n").strip().encode()) except (ValueError, TypeError) as exc: raise ValueError("AUTH_PUBLIC_KEY не является публичным ключом в формате PEM") from exc + algorithms = _algorithms_for_key(key) + if algorithms is None: + raise ValueError("AUTH_PUBLIC_KEY: поддерживаются ключи RSA и EC P-256 (алгоритмы RS256 и ES256)") + return key, algorithms + + +def _algorithms_for_key(key: Any) -> tuple[str, ...] | None: + """Допустимые алгоритмы подписи по типу открытого ключа: RSA → RS256, EC P-256 → ES256; иначе ``None``.""" if isinstance(key, rsa.RSAPublicKey): - return key, ("RS256",) + return ("RS256",) if isinstance(key, ec.EllipticCurvePublicKey) and key.curve.name == "secp256r1": - return key, ("ES256",) - raise ValueError("AUTH_PUBLIC_KEY: поддерживаются ключи RSA и EC P-256 (алгоритмы RS256 и ES256)") + return ("ES256",) + return None class JwksVerifier: @@ -221,9 +230,14 @@ class JwksVerifier: return self._static_key, list(self._static_algorithms) # PyJWKClient ходит в сеть блокирующе — выносим из цикла событий signing_key = await asyncio.to_thread(self._client.get_signing_key_from_jwt, token) - # Отдаём сам PyJWK, а не signing_key.key: PyJWT тогда сверяет алгоритм из заголовка токена с типом ключа - # (RSA-ключ с ES256 и наоборот — InvalidAlgorithmError, а не TypeError из недр библиотеки) - return signing_key, list(JWKS_ALGORITHMS) + # Передаём signing_key.key, а не сам PyJWK: PyJWT < 2.10 (в контуре 2.8.0) не принимает PyJWK в decode + # (TypeError «Expecting a PEM-formatted key»). Допустимый алгоритм выводим из типа ключа сами — + # RSA-ключ не принимает ES256-токен и наоборот (InvalidAlgorithmError → 401, а не 500) + key = signing_key.key + algorithms = _algorithms_for_key(key) + if algorithms is None: + raise jwt.InvalidAlgorithmError("тип ключа JWKS не поддерживается") + return key, list(algorithms) async def verify(self, token: str) -> Claims: try: diff --git a/api/tests/test_auth_core.py b/api/tests/test_auth_core.py index 1911f89..f6fe1d7 100644 --- a/api/tests/test_auth_core.py +++ b/api/tests/test_auth_core.py @@ -455,6 +455,18 @@ async def test_jwks_client_es256(ec_key: ec.EllipticCurvePrivateKey) -> None: assert (await verifier.verify(token)).email == EMAIL +async def test_jwks_client_rsa_key_rejects_es256_token( + rsa_key: rsa.RSAPrivateKey, ec_key: ec.EllipticCurvePrivateKey +) -> None: + """RSA-ключ из JWKS и ES256-токен — AuthError (401), а не TypeError/500; и наоборот.""" + verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key())) + with pytest.raises(AuthError, match="недопустимый алгоритм"): + await verifier.verify(jwt.encode(_payload(), _pem_private(ec_key), algorithm="ES256")) + verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=ec_key.public_key())) + with pytest.raises(AuthError, match="недопустимый алгоритм"): + await verifier.verify(jwt.encode(_payload(), _pem_private(rsa_key), algorithm="RS256")) + + async def test_jwks_client_key_confusion(rsa_key: rsa.RSAPrivateKey) -> None: verifier = JwksVerifier(jwk_client=_FakeJwkClient(key=rsa_key.public_key())) with pytest.raises(AuthError): From 9cceb84827b00b5d18350b4464492ac2bc106abb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 18:46:59 +0400 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=C2=AB=D0=9E=D1=81=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BD=D0=BE=D0=B9=20=D0=A0=D0=A4=C2=BB=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=8A=D0=B5=D0=BA=D1=82=D0=BE=D0=BC=20=D0=B2=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=82=D0=B0=D1=85=20=D0=AE=D0=9B=20=D0=B8=20=D0=93?= =?UTF-8?q?=D0=9A=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В LegalEntityOut/Detail/HistoryOut добавлено regional_office, в CompanyGroupOut/HistoryOut — base_regional_office (RegionalOfficeBrief {id, name, is_head_office} или null); *_id оставлены. Филиалы подгружаются одним батч-запросом на набор id (списки, карточки, history без FK); неактивный филиал отдаётся. Тесты: совпадение с *_id, null, число запросов списка не растёт, history — один запрос на филиалы. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- README.md | 3 + .../endpoints/directories/company_groups.py | 23 +- .../endpoints/directories/legal_entities.py | 20 +- api/src/repositories/directories.py | 12 + api/src/schemas/directories.py | 6 + api/src/services/company_groups.py | 46 +++- api/src/services/legal_entities.py | 41 +++- api/tests/test_api_company_groups.py | 1 + api/tests/test_api_legal_entities.py | 1 + api/tests/test_api_regional_office_object.py | 205 ++++++++++++++++++ 10 files changed, 319 insertions(+), 39 deletions(-) create mode 100644 api/tests/test_api_regional_office_object.py diff --git a/README.md b/README.md index 4410a4a..5471850 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,9 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб `src/domain/policy.py`: пользователь головного офиса видит всё, пользователь филиала — ГК своего филиала, их ЮЛ и ГСК/ГСЗ и монозаёмщиков своего филиала. Объект вне видимости — **404 с тем же телом, что у несуществующего** (не 403). +Филиал в ответах ЮЛ и ГК приходит объектом рядом с `*_id`: `regional_office` (ЮЛ, их история) и `base_regional_office` +(ГК, их история) — `{id, name, is_head_office}` или `null`; неактивный филиал тоже отдаётся (имя нужно для истории). + | Метод и путь | Право | Что делает | |---|---|---| | `GET /directories/regional-offices` | `DIRECTORY_VIEW` | филиалы (включая Головной офис) | diff --git a/api/src/api/endpoints/directories/company_groups.py b/api/src/api/endpoints/directories/company_groups.py index 4aba2f1..454ea03 100644 --- a/api/src/api/endpoints/directories/company_groups.py +++ b/api/src/api/endpoints/directories/company_groups.py @@ -10,7 +10,6 @@ from fastapi import APIRouter, Depends, Query, status from src.api.dependencies.auth import require from src.api.dependencies.directories import CompanyGroupServiceDep -from src.models.directories import CompanyGroup from src.schemas.common import Page, PageParams from src.schemas.directories import ( CompanyGroupCreate, @@ -30,12 +29,6 @@ class CompanyGroupListQuery(CompanyGroupFilter, PageParams): """Query списка ГК: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" -def _out(group: CompanyGroup, legal_entities_count: int) -> CompanyGroupOut: - out = CompanyGroupOut.model_validate(group) - out.legal_entities_count = legal_entities_count - return out - - @router.get("", response_model=Page[CompanyGroupOut], dependencies=VIEW, summary="Список ГК") async def list_company_groups( service: CompanyGroupServiceDep, @@ -44,9 +37,7 @@ async def list_company_groups( """Видимые ГК: поиск `q` по name/crm_id/code, фильтр `is_archived`, пагинация, сортировка по имени.""" page = PageParams(limit=query.limit, offset=query.offset) rows, total = await service.page(CompanyGroupFilter(q=query.q, is_archived=query.is_archived), page) - return Page[CompanyGroupOut]( - items=[_out(group, count) for group, count in rows], total=total, limit=page.limit, offset=page.offset - ) + return Page[CompanyGroupOut](items=await service.to_outs(rows), total=total, limit=page.limit, offset=page.offset) @router.post( @@ -54,13 +45,13 @@ async def list_company_groups( ) async def create_company_group(data: CompanyGroupCreate, service: CompanyGroupServiceDep) -> CompanyGroupOut: """Создаёт ГК с кодом `GK-NNNNNN`; без филиала пользователь филиала закрепляет ГК за своим.""" - return _out(await service.create(data), 0) + return await service.to_out(await service.create(data), 0) @router.get("/{group_id}", response_model=CompanyGroupOut, dependencies=VIEW, summary="Карточка ГК") async def get_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: group = await service.get(group_id) - return _out(group, await service.legal_entities_count(group.id)) + return await service.to_out(group, await service.legal_entities_count(group.id)) @router.patch("/{group_id}", response_model=CompanyGroupOut, dependencies=EDIT, summary="Изменить ГК") @@ -69,19 +60,19 @@ async def update_company_group( ) -> CompanyGroupOut: """Меняет `name`/`crm_id`/`base_regional_office_id`; прежние значения уходят в историю.""" group = await service.update(group_id, data) - return _out(group, await service.legal_entities_count(group.id)) + return await service.to_out(group, await service.legal_entities_count(group.id)) @router.post("/{group_id}/archive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Архивировать ГК") async def archive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: group = await service.set_archived(group_id, True) # noqa: FBT003 - return _out(group, await service.legal_entities_count(group.id)) + return await service.to_out(group, await service.legal_entities_count(group.id)) @router.post("/{group_id}/unarchive", response_model=CompanyGroupOut, dependencies=EDIT, summary="Вернуть ГК из архива") async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> CompanyGroupOut: group = await service.set_archived(group_id, False) # noqa: FBT003 - return _out(group, await service.legal_entities_count(group.id)) + return await service.to_out(group, await service.legal_entities_count(group.id)) @router.get( @@ -91,4 +82,4 @@ async def unarchive_company_group(group_id: uuid.UUID, service: CompanyGroupServ summary="История ГК (новые первыми)", ) async def company_group_history(group_id: uuid.UUID, service: CompanyGroupServiceDep) -> list[CompanyGroupHistoryOut]: - return [CompanyGroupHistoryOut.model_validate(row) for row in await service.history(group_id)] + return await service.history(group_id) diff --git a/api/src/api/endpoints/directories/legal_entities.py b/api/src/api/endpoints/directories/legal_entities.py index eda1317..4cf3952 100644 --- a/api/src/api/endpoints/directories/legal_entities.py +++ b/api/src/api/endpoints/directories/legal_entities.py @@ -11,7 +11,6 @@ from fastapi import APIRouter, Depends, Query, status from src.api.dependencies.auth import require from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep -from src.models.directories import LegalEntity from src.schemas.common import Page, PageParams from src.schemas.directories import ( AliasCreate, @@ -37,13 +36,6 @@ class LegalEntityListQuery(LegalEntityFilter, PageParams): """Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" -def _out(entity: LegalEntity, documents_count: int) -> LegalEntityOut: - """Конвертирует ORM ЮЛ в LegalEntityOut с подсчитанным количеством документов.""" - out = LegalEntityOut.model_validate(entity) - out.documents_count = documents_count - return out - - @router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП") async def list_legal_entities( service: LegalEntityServiceDep, @@ -56,7 +48,7 @@ async def list_legal_entities( entity_ids = [entity.id for entity in items] counts = await service.documents_count_by_entities(entity_ids) return Page[LegalEntityOut]( - items=[_out(entity, counts.get(entity.id, 0)) for entity in items], + items=await service.to_outs(items, counts), total=total, limit=page.limit, offset=page.offset, @@ -69,7 +61,7 @@ async def list_legal_entities( async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut: """Создаёт ЮЛ/ИП; без `company_group_id` — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12.""" entity = await service.create(data) - return _out(entity, 0) + return await service.to_out(entity, 0) @router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП") @@ -86,7 +78,7 @@ async def update_legal_entity( ) -> LegalEntityOut: """Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`).""" entity = await service.update(entity_id, data) - return _out(entity, await service.documents_count(entity_id)) + return await service.to_out(entity, await service.documents_count(entity_id)) @router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК") @@ -95,7 +87,7 @@ async def move_legal_entity( ) -> LegalEntityOut: """Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются.""" entity = await service.move(entity_id, data) - return _out(entity, await service.documents_count(entity_id)) + return await service.to_out(entity, await service.documents_count(entity_id)) @router.post( @@ -107,7 +99,7 @@ async def withdraw_legal_entity( """`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства закрываются.""" entity = await service.withdraw(entity_id, data) - return _out(entity, await service.documents_count(entity_id)) + return await service.to_out(entity, await service.documents_count(entity_id)) @router.get( @@ -117,7 +109,7 @@ async def withdraw_legal_entity( summary="История ЮЛ/ИП (новые первыми)", ) async def legal_entity_history(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> list[LegalEntityHistoryOut]: - return [LegalEntityHistoryOut.model_validate(row) for row in await service.history(entity_id)] + return await service.history(entity_id) @router.post( diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py index f60d681..98bbd26 100644 --- a/api/src/repositories/directories.py +++ b/api/src/repositories/directories.py @@ -7,6 +7,7 @@ SQL без бизнес-правил; видимость — `src.domain.policy` import datetime import uuid +from collections.abc import Iterable from typing import Any from sqlalchemy import Select, Sequence, and_, delete, func, or_, select @@ -415,6 +416,17 @@ async def get_regional_office(session: AsyncSession, office_id: uuid.UUID) -> Re return await session.get(RegionalOffice, office_id) +async def regional_offices_by_ids( + session: AsyncSession, office_ids: Iterable[uuid.UUID | None] +) -> dict[uuid.UUID, RegionalOffice]: + """Филиалы по набору id одним запросом (включая неактивные); `None` и дубли игнорируются.""" + ids = {office_id for office_id in office_ids if office_id is not None} + if not ids: + return {} + rows = (await session.execute(select(RegionalOffice).where(RegionalOffice.id.in_(ids)))).scalars().all() + return {office.id: office for office in rows} + + async def list_regional_offices(session: AsyncSession) -> list[RegionalOffice]: """Все филиалы по имени.""" return list((await session.execute(select(RegionalOffice).order_by(RegionalOffice.name))).scalars().all()) diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py index a731caa..49ac07e 100644 --- a/api/src/schemas/directories.py +++ b/api/src/schemas/directories.py @@ -99,6 +99,9 @@ class CompanyGroupOut(BaseModel): crm_id: str | None name: str base_regional_office_id: uuid.UUID | None + base_regional_office: RegionalOfficeBrief | None = ( + None # объект филиала; неактивный тоже отдаётся (имя для истории) + ) is_archived: bool valid_from: datetime.datetime legal_entities_count: int = 0 @@ -115,6 +118,7 @@ class CompanyGroupHistoryOut(BaseModel): crm_id: str | None name: str | None base_regional_office_id: uuid.UUID | None + base_regional_office: RegionalOfficeBrief | None = None # филиал снимка (в history нет FK — батч по id) valid_from: datetime.datetime | None valid_to: datetime.datetime | None changed_by: uuid.UUID | None @@ -222,6 +226,7 @@ class LegalEntityOut(BaseModel): name: str company_group_id: uuid.UUID | None regional_office_id: uuid.UUID | None + regional_office: RegionalOfficeBrief | None = None # объект филиала; неактивный тоже отдаётся close_date: datetime.date | None valid_from: datetime.datetime created_at: datetime.datetime | None @@ -285,6 +290,7 @@ class LegalEntityHistoryOut(BaseModel): name: str | None company_group_id: uuid.UUID | None regional_office_id: uuid.UUID | None + regional_office: RegionalOfficeBrief | None = None # филиал снимка (в history нет FK — батч по id) close_date: datetime.date | None event_type: LeHistoryEvent | None effective_date: datetime.date | None diff --git a/api/src/services/company_groups.py b/api/src/services/company_groups.py index 868671e..e523e30 100644 --- a/api/src/services/company_groups.py +++ b/api/src/services/company_groups.py @@ -6,7 +6,7 @@ import datetime import uuid -from collections.abc import Mapping +from collections.abc import Iterable, Mapping, Sequence from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -19,7 +19,14 @@ from src.models.directories import COMPANY_GROUP_CODE_SEQ, CompanyGroup, Company from src.models.enums import AuditAction, AuditSection from src.repositories import directories as repo from src.schemas.common import PageParams -from src.schemas.directories import CompanyGroupCreate, CompanyGroupFilter, CompanyGroupUpdate +from src.schemas.directories import ( + CompanyGroupCreate, + CompanyGroupFilter, + CompanyGroupHistoryOut, + CompanyGroupOut, + CompanyGroupUpdate, + RegionalOfficeBrief, +) COMPANY_GROUP_NOT_FOUND = "ГК не найдена" # одинаково для чужой и несуществующей (spec directory-policy) COMPANY_GROUP_CODE_PREFIX = "GK" @@ -149,14 +156,43 @@ class CompanyGroupService: raise NotFoundError(COMPANY_GROUP_NOT_FOUND) return group + async def offices_by_ids(self, office_ids: Iterable[uuid.UUID | None]) -> dict[uuid.UUID, RegionalOfficeBrief]: + """Филиалы (в т.ч. неактивные) по набору id — один запрос на набор, без N+1.""" + offices = await repo.regional_offices_by_ids(self._session, office_ids) + return {office_id: RegionalOfficeBrief.model_validate(office) for office_id, office in offices.items()} + + async def to_out(self, group: CompanyGroup, legal_entities_count: int) -> CompanyGroupOut: + """Ответ по одной ГК с объектом филиала.""" + return (await self.to_outs([(group, legal_entities_count)]))[0] + + async def to_outs(self, rows: Sequence[tuple[CompanyGroup, int]]) -> list[CompanyGroupOut]: + """Ответы по набору ГК; филиалы — одним батч-запросом.""" + offices = await self.offices_by_ids(group.base_regional_office_id for group, _ in rows) + result: list[CompanyGroupOut] = [] + for group, count in rows: + out = CompanyGroupOut.model_validate(group) + out.legal_entities_count = count + out.base_regional_office = ( + offices.get(group.base_regional_office_id) if group.base_regional_office_id else None + ) + result.append(out) + return result + async def legal_entities_count(self, group_id: uuid.UUID) -> int: """Число ЮЛ ГК (для карточки).""" return await repo.count_legal_entities(self._session, group_id) - async def history(self, group_id: uuid.UUID) -> list[CompanyGroupHistory]: - """Снимки ГК по убыванию `valid_from`; видимость проверяется как для карточки.""" + async def history(self, group_id: uuid.UUID) -> list[CompanyGroupHistoryOut]: + """Снимки ГК по убыванию `valid_from`; видимость — как для карточки; филиалы снимков — одним батчем.""" group = await self.get(group_id) - return await repo.list_company_group_history(self._session, group.id) + rows = await repo.list_company_group_history(self._session, group.id) + offices = await self.offices_by_ids(row.base_regional_office_id for row in rows) + result: list[CompanyGroupHistoryOut] = [] + for row in rows: + out = CompanyGroupHistoryOut.model_validate(row) + out.base_regional_office = offices.get(row.base_regional_office_id) if row.base_regional_office_id else None + result.append(out) + return result async def page(self, params: CompanyGroupFilter, page: PageParams) -> tuple[list[tuple[CompanyGroup, int]], int]: """Страница видимых ГК с числом ЮЛ и общее число по фильтру.""" diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index ffaa497..e5bab0b 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -8,7 +8,7 @@ import datetime import uuid -from collections.abc import Mapping +from collections.abc import Iterable, Mapping, Sequence from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -27,10 +27,13 @@ from src.schemas.directories import ( LegalEntityCreate, LegalEntityDetail, LegalEntityFilter, + LegalEntityHistoryOut, LegalEntityMove, + LegalEntityOut, LegalEntityUpdate, LegalEntityWithdraw, MembershipOut, + RegionalOfficeBrief, ) from src.services.company_groups import COMPANY_GROUP_NOT_FOUND @@ -243,6 +246,7 @@ class LegalEntityService: """Карточка: ЮЛ, текущие членства ГСК/ГСЗ и алиасы; невидимое и несуществующее — один и тот же 404.""" entity = await self._get_entity(entity_id) detail = LegalEntityDetail.model_validate(entity) + detail.regional_office = (await self.offices_by_ids([entity.regional_office_id])).get(entity.regional_office_id) detail.memberships = [ MembershipOut( related_group_id=group.id, @@ -256,10 +260,39 @@ class LegalEntityService: detail.aliases = [AliasOut.model_validate(alias) for alias in await repo.list_aliases(self._session, entity.id)] return detail - async def history(self, entity_id: uuid.UUID) -> list[LegalEntityHistory]: - """Снимки ЮЛ по убыванию `valid_from` (R-3); видимость — как для карточки.""" + async def history(self, entity_id: uuid.UUID) -> list[LegalEntityHistoryOut]: + """Снимки ЮЛ по убыванию `valid_from` (R-3); видимость — как для карточки; филиалы снимков — одним батчем.""" entity = await self._get_entity(entity_id) - return await repo.list_legal_entity_history(self._session, entity.id) + rows = await repo.list_legal_entity_history(self._session, entity.id) + offices = await self.offices_by_ids(row.regional_office_id for row in rows) + result: list[LegalEntityHistoryOut] = [] + for row in rows: + out = LegalEntityHistoryOut.model_validate(row) + out.regional_office = offices.get(row.regional_office_id) + result.append(out) + return result + + async def offices_by_ids( + self, office_ids: Iterable[uuid.UUID | None] + ) -> dict[uuid.UUID | None, RegionalOfficeBrief]: + """Филиалы (в т.ч. неактивные) по набору id — один запрос на набор, без N+1.""" + offices = await repo.regional_offices_by_ids(self._session, office_ids) + return {office_id: RegionalOfficeBrief.model_validate(office) for office_id, office in offices.items()} + + async def to_outs(self, entities: Sequence[LegalEntity], counts: Mapping[uuid.UUID, int]) -> list[LegalEntityOut]: + """Ответы по набору ЮЛ с числом документов; филиалы — одним батч-запросом.""" + offices = await self.offices_by_ids(entity.regional_office_id for entity in entities) + result: list[LegalEntityOut] = [] + for entity in entities: + out = LegalEntityOut.model_validate(entity) + out.documents_count = counts.get(entity.id, 0) + out.regional_office = offices.get(entity.regional_office_id) + result.append(out) + return result + + async def to_out(self, entity: LegalEntity, documents_count: int) -> LegalEntityOut: + """Ответ по одному ЮЛ с объектом филиала.""" + return (await self.to_outs([entity], {entity.id: documents_count}))[0] async def page(self, flt: LegalEntityFilter, page: PageParams) -> tuple[list[LegalEntity], int]: """Страница видимых ЮЛ и общее число по фильтру.""" diff --git a/api/tests/test_api_company_groups.py b/api/tests/test_api_company_groups.py index 2bf27dc..166990e 100644 --- a/api/tests/test_api_company_groups.py +++ b/api/tests/test_api_company_groups.py @@ -265,6 +265,7 @@ async def test_history_is_ordered_by_valid_from_desc(auth_client: AuthClientFact "crm_id", "name", "base_regional_office_id", + "base_regional_office", "valid_from", "valid_to", "changed_by", diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py index f7edaff..d8f2899 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -388,6 +388,7 @@ async def test_patch_writes_update_snapshot_and_audit(auth_client: AuthClientFac "company_group_id", "regional_office_id", "close_date", + "regional_office", "event_type", "effective_date", "valid_from", diff --git a/api/tests/test_api_regional_office_object.py b/api/tests/test_api_regional_office_object.py new file mode 100644 index 0000000..2709450 --- /dev/null +++ b/api/tests/test_api_regional_office_object.py @@ -0,0 +1,205 @@ +"""Основной РФ объектом в ответах ЮЛ и ГК (arm-core#47): `regional_office` / `base_regional_office`, без N+1.""" + +import datetime +from collections.abc import Iterator +from typing import Any + +import pytest +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from src.common.clock import moscow_date +from src.models.directories import CompanyGroupHistory, LegalEntityHistory +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity, make_regional_office + +HTTP_OK = 200 +HTTP_CREATED = 201 +SMALL = 2 +ENTITIES = "/directories/legal-entities" +GROUPS = "/directories/company-groups" +KA_GO = "ka_go@armka.local" + + +def _brief(office: Any) -> dict[str, Any]: + return {"id": str(office.id), "name": office.name, "is_head_office": office.is_head_office} + + +@pytest.fixture +def sql_log(db_engine: AsyncEngine) -> Iterator[list[str]]: + statements: list[str] = [] + + def record(_conn: Any, _cursor: Any, statement: str, *_rest: Any) -> None: + statements.append(statement) + + event.listen(db_engine.sync_engine, "before_cursor_execute", record) + try: + yield statements + finally: + event.remove(db_engine.sync_engine, "before_cursor_execute", record) + + +def _office_selects(statements: list[str]) -> int: + return sum(1 for s in statements if s.lstrip().upper().startswith("SELECT") and "FROM dkb.regional_office" in s) + + +@pytest.mark.integration +async def test_legal_entity_responses_carry_office_object(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + office = await make_regional_office(seeded, name="Филиал Объект") + other = await make_regional_office(seeded, name="Филиал Другой") + group = await make_company_group(seeded, name="ГК Объект", office=office) + entity = await make_legal_entity(seeded, name="ООО Объект", group=group, office=office) + new_group = await make_company_group(seeded, name="ГК Новая цель", office=other) + mono = await make_legal_entity(seeded, name="ООО Без филиала") + client = await auth_client(KA_GO) + + card = (await client.get(f"{ENTITIES}/{entity.id}")).json() + assert card["regional_office"] == _brief(office) + assert card["regional_office_id"] == str(office.id) + no_office = (await client.get(f"{ENTITIES}/{mono.id}")).json() + assert no_office["regional_office"] is None and no_office["regional_office_id"] is None + + listing = (await client.get(ENTITIES, params={"q": "ООО"})).json()["items"] + by_name = {item["name"]: item for item in listing} + assert by_name["ООО Объект"]["regional_office"] == _brief(office) + assert by_name["ООО Без филиала"]["regional_office"] is None + + patched = await client.patch(f"{ENTITIES}/{entity.id}", json={"regional_office_id": str(other.id)}) + assert patched.status_code == HTTP_OK, patched.text + assert patched.json()["regional_office"] == _brief(other) + + history = (await client.get(f"{ENTITIES}/{entity.id}/history")).json() + assert history[0]["regional_office_id"] == str(office.id) + assert history[0]["regional_office"] == _brief(office) # филиал снимка, не текущий + + move = await client.post( + f"{ENTITIES}/{entity.id}/move", + json={ + "company_group_id": str(new_group.id), + "effective_date": moscow_date(datetime.datetime.now(datetime.UTC)).isoformat(), + }, + ) + assert move.status_code == HTTP_OK, move.text + assert move.json()["regional_office"] == _brief(other) + withdraw = await client.post( + f"{ENTITIES}/{entity.id}/withdraw", + json={ + "event": "REMOVE_FROM_GROUP", + "effective_date": moscow_date(datetime.datetime.now(datetime.UTC)).isoformat(), + }, + ) + assert withdraw.status_code == HTTP_OK, withdraw.text + assert withdraw.json()["regional_office"] == _brief(other) + + created = await client.post( + ENTITIES, + json={"inn": "7701234567", "name": "ООО Новое", "entity_type": "ЮЛ", "regional_office_id": str(office.id)}, + ) + assert created.status_code == HTTP_CREATED, created.text + assert created.json()["regional_office"] == _brief(office) + + +@pytest.mark.integration +async def test_inactive_office_is_still_returned(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + office = await make_regional_office(seeded, name="Филиал Закрытый") + office.is_active = False + await seeded.flush() + group = await make_company_group(seeded, name="ГК Закрытая", office=office) + entity = await make_legal_entity(seeded, name="ООО Закрытое", group=group) + client = await auth_client(KA_GO) + + assert (await client.get(f"{ENTITIES}/{entity.id}")).json()["regional_office"] == _brief(office) + assert (await client.get(f"{GROUPS}/{group.id}")).json()["base_regional_office"] == _brief(office) + + +@pytest.mark.integration +async def test_company_group_responses_carry_office_object( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + office = await make_regional_office(seeded, name="Филиал ГК") + other = await make_regional_office(seeded, name="Филиал ГК 2") + group = await make_company_group(seeded, name="ГК Один", office=office) + bare = await make_company_group(seeded, name="ГК Два") + client = await auth_client(KA_GO) + + card = (await client.get(f"{GROUPS}/{group.id}")).json() + assert card["base_regional_office"] == _brief(office) and card["base_regional_office_id"] == str(office.id) + assert (await client.get(f"{GROUPS}/{bare.id}")).json()["base_regional_office"] is None + items = {i["name"]: i for i in (await client.get(GROUPS, params={"q": "ГК "})).json()["items"]} + assert items["ГК Один"]["base_regional_office"] == _brief(office) + assert items["ГК Два"]["base_regional_office"] is None + + patched = await client.patch(f"{GROUPS}/{group.id}", json={"base_regional_office_id": str(other.id)}) + assert patched.status_code == HTTP_OK, patched.text + assert patched.json()["base_regional_office"] == _brief(other) + for action in ("archive", "unarchive"): + resp = await client.post(f"{GROUPS}/{group.id}/{action}") + assert resp.json()["base_regional_office"] == _brief(other) + created = await client.post(GROUPS, json={"name": "ГК Новая", "base_regional_office_id": str(office.id)}) + assert created.status_code == HTTP_CREATED, created.text + assert created.json()["base_regional_office"] == _brief(office) + + history = (await client.get(f"{GROUPS}/{group.id}/history")).json() + assert history[0]["base_regional_office_id"] == str(office.id) + assert history[0]["base_regional_office"] == _brief(office) + + +@pytest.mark.integration +async def test_history_loads_offices_in_one_query( + auth_client: AuthClientFactory, seeded: AsyncSession, sql_log: list[str] +) -> None: + offices = [await make_regional_office(seeded, name=f"Филиал история {n}") for n in range(4)] + group = await make_company_group(seeded, name="ГК История", office=offices[0]) + entity = await make_legal_entity(seeded, name="ООО История", group=group, office=offices[0]) + now = datetime.datetime.now(datetime.UTC) + for n, office in enumerate(offices): + stamp = now - datetime.timedelta(days=n + 1) + seeded.add( + CompanyGroupHistory( + company_group_id=group.id, name="x", base_regional_office_id=office.id, valid_from=stamp, valid_to=now + ) + ) + seeded.add( + LegalEntityHistory( + legal_entity_id=entity.id, name="x", regional_office_id=office.id, valid_from=stamp, valid_to=now + ) + ) + await seeded.flush() + client = await auth_client(KA_GO) + + sql_log.clear() + rows = (await client.get(f"{ENTITIES}/{entity.id}/history")).json() + assert {r["regional_office"]["name"] for r in rows} == {o.name for o in offices} + assert _office_selects(sql_log) == 1 + sql_log.clear() + rows = (await client.get(f"{GROUPS}/{group.id}/history")).json() + assert {r["base_regional_office"]["name"] for r in rows} == {o.name for o in offices} + assert _office_selects(sql_log) == 1 + + +@pytest.mark.integration +async def test_lists_do_not_grow_queries_with_rows( + auth_client: AuthClientFactory, seeded: AsyncSession, sql_log: list[str] +) -> None: + client = await auth_client(KA_GO) + await client.get(ENTITIES) # прогрев (первый запрос сессии тянет лишнее) + await client.get(GROUPS) + total_before: dict[str, int] = {} + for size in (2, 12): + for n in range(size): + office = await make_regional_office(seeded, name=f"Филиал список {size}-{n}") + group = await make_company_group(seeded, name=f"ГК список {size}-{n}", office=office) + await make_legal_entity(seeded, name=f"ООО список {size}-{n}", group=group, office=office) + for url, key in ((ENTITIES, "regional_office"), (GROUPS, "base_regional_office")): + sql_log.clear() + response = await client.get(url, params={"q": "список", "limit": 200}) + assert response.status_code == HTTP_OK, response.text + items = response.json()["items"] + assert all(item[key] is not None for item in items) + total = len(sql_log) + if size == SMALL: + total_before[url] = total + else: + assert len(items) == 14 # noqa: PLR2004 - 2 + 12 + assert total == total_before[url] + assert _office_selects(sql_log) == 1