diff --git a/CLAUDE.md b/CLAUDE.md index f30f84b..7a03618 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,9 +19,15 @@ Бутстрап в банке — `setup/setup.sh` (pip/npm через Nexus). - **Раскладка `api/src/`**: `src/api/endpoints` → `services` → `repositories` → `models`; DTO — `schemas/`; инфраструктура — `core/` (config, clients, nats, logger, exception_handlers, auth); `db/` — engine и сессия. Наши добавления: - `domain/` (чистые функции по §§ правил; появится в Срезе 3+), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, + `domain/` (чистые функции по §§ правил, без БД и сессий: `policy.py` — видимость справочников §10.1, + `directory_import.py` — чтение xlsx/csv и план импорта §13, `aliases.py` — нормализация алиаса §3, `inn.py` — формат + ИНН; для вычислений консолидации — Срез 3+), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, `seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`. -- **DI** — ручные фабрики `Depends` в `src/api/dependencies/`; сессия из `get_async_session`; `storage`/`nats` — из `request.app.state`. +- **DI** — ручные фабрики `Depends` в `src/api/dependencies/`; `storage`/`nats` — из `request.app.state`. **Сессия** — + только `session: SessionDep` (`src/api/dependencies/db.py` = `Depends(get_async_session, scope="function")`): одна + транзакция на запрос (`get_async_session` открывает `session.begin()`), commit **до** отправки ответа, rollback при + любом исключении; объявление через голый `Depends(get_async_session)` даёт вторую сессию и ловится тестом + `test_api_routes_declare_session_with_function_scope`. Сервисы не коммитят сами. - **Настройки** — pydantic-settings, `api/.env`, вложенность `__`, инфраструктура под `OPENBAO__*`. Флаги разработки (`AUTH_DEV_ISSUER`, `DEBUG`) и обмена (`NATS_ENABLED`, по умолчанию выключен) — отдельные поля `Settings`. NATS управляется только `NATS_ENABLED` (от `DEBUG` не зависит); при `DEBUG=false` шаблонный импорт @@ -29,14 +35,25 @@ не подключается. - **БД** — схема **`dkb`** (MetaData, `search_path`, Alembic); PK `uuid` через `UUIDPrimaryMixin`; ревизии в `api/alembic/versions/`; автогенерация — черновик, финал сверяется с DBML скриптом. -- **Ошибки** — `common/exceptions.py` → `core/exception_handlers.py`, ответ `{"detail": …}` по-русски. **Логи** — loguru. +- **Ошибки** — `common/exceptions.py` → `core/exception_handlers.py`. Доменные ошибки (`DomainError`: `NotFoundError` 404 + `NOT_FOUND`, `ConflictError` 409, `DomainValidationError` 422; код и доп. поля задаёт вызывающий) отдаются как + `{"detail": {"code", "message", **extra}}` (message по-русски, `code`/`message` в `extra` запрещены); `HTTPException` + (401/403/413) и ошибки БД — `{"detail": "<строка>"}`, 422 валидации тела — массив. Объект вне видимости пользователя — + 404 с тем же телом, что у несуществующего (не 403). Каталог кодов — README, «Ошибки API». **Логи** — loguru. - **Тесты** — pytest-asyncio (auto, session loop), `APP_ENV=test`, `ENV_FILE=.env.test`; маркеры `integration` (нужны PostgreSQL/NATS) и `fixtures` (реальные данные из `../fixtures`); БД сессии — свежая `armcore_pytest_` с миграциями, удаляется в конце; `domain/` — без БД. - **Линтеры** — backend `ruff` + `mypy` (`api/pyproject.toml`); frontend `biome` (ширина 140, одинарные кавычки) + `tsc`. -- **Frontend** — React 18 + TS strict, rsbuild, antd 6 (основной UI-кит), Redux Toolkit (`store/slices/`), react-router 7; - `layouts/` — страницы, `widgets/` — блоки, `components/` — примитивы; env только `REACT_APP_*` через `conf/config.ts`; - запросы — `${Config.API_URL}/api/v1/...`; `REACT_APP_ROOT_PATH` = `ROOT_PATH` бэка. +- **Frontend** — React 18 + TS strict, rsbuild, antd 6 (основной UI-кит), react-router 7; `layouts/` — страницы + (`Main`, `Admin`), `widgets/` — блоки (`Sidebar`, `Admin/*`), `components/` — примитивы; env только `REACT_APP_*` через + `conf/config.ts`; `REACT_APP_ROOT_PATH` = `ROOT_PATH` бэка. Redux Toolkit из шаблона подключён (`store/store.ts`, + `Provider` в `App.tsx`), но стор пуст и `store/slices/` без слайсов: состояние экранов `/admin` — локальные хуки + (`widgets/Admin/hooks.ts`), профиль — контекст. `api/client.ts` — единый клиент (`apiFetch`, `api.get|post|put|patch`, + префикс `${Config.API_URL}/api/v1`, Bearer-токен, `ApiError {status, code, message}` по трём формам `detail`, событие + `armka:unauthorized` на 401). `auth/` — `AuthGate` (профиль `/me` → `MeContext`/`useCurrentUser`), `DevLogin` (только не + в production-сборке: `POST /dev/token`), `token.ts` (sessionStorage с запасом в памяти), `permissions.ts` (коды прав + для скрытия пунктов меню; доступ решает сервер). `/admin` (`layouts/Admin.tsx`, право `USERS_ADMIN`) — пользователи, + роли, матрица прав. - **Сборка** — платформа (Python 3.11) запускает `api/run_server.py` (`SERVER__HOST/PORT`), ресурсы — `raisa_config.yml`; SPA: `web/dist` → `api/web/`. `Dockerfile` (`python:3.11-slim`) в корне — для dev-compose и `make check-full` (`docker build`; убран из `.gitignore` шаблона). CI в репозитории нет: перед PR — `make check-full`. Без Nexus: @@ -47,7 +64,14 @@ публикатор и вахтёр — фоновые задачи lifespan. Запрос без ack повторно публикуется (`ACK_TIMEOUT_SECONDS`=30 × `OUTBOX_MAX_REPUBLISH`=3, счётчик — `recognition_job.metrics.republish_count`) → `dlq`; дедуп — `request_msg_id` / `result_msg_id`. Статусы задания в Срезе 0 `audit_event` не пишут. -- **Аудит** — `services/audit.py`, единственная точка записи `audit_event` (append-only: только `record`, в текущей транзакции). +- **Аудит** — `services/audit.py`, единственная точка записи `audit_event` (append-only: только `record`, в текущей транзакции; + тест `test_audit_service.py` сканирует `src/` на UPDATE/DELETE по `AuditEvent`). В маршрутах — `RequestAudit` + (`get_request_audit`: пользователь, IP, user-agent). +- **Справочники и права (Срез 1)** — видимость только через `domain/policy.py` (`VisibilityScope`: ГО — всё, филиал — свои + ГК/ЮЛ/ГСК/ГСЗ и монозаёмщики), код роли в логике не читается; `domain/inn.py` — ИНН строкой (ЮЛ 10, ИП 12 цифр, + без контрольных разрядов, OQ-88); `domain/aliases.py` — `normalized_alias` по §3; `domain/directory_import.py` — импорт + ЕЦП (§13, зависимость `openpyxl` для xlsx; допущения `ASSUMPTION(OQ-87…91)`). Администрирование — `services/users_admin.py` + (`LAST_ADMIN` с блокировкой строки права). Порядок: маршрут (`require(code)`) → policy → сервис → аудит. - **Аутентификация** — `core/auth.py` (проверка JWT), `src/api/dependencies/auth.py` (`current_user`, `require(code)`); dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`. - **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен; `--dev` — только для разработки. @@ -61,7 +85,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.13.dbml`; `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. +- ORM 1:1 с `../docs/model/data-model-v5.14.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. diff --git a/Makefile b/Makefile index 6acc5e2..01d2ea0 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ VENV := api/.venv BIN := $(CURDIR)/$(VENV)/bin COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ -DBML ?= ../../docs/model/data-model-v5.13.dbml +DBML ?= ../../docs/model/data-model-v5.14.dbml .PHONY: venv lint test test-integration dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev diff --git a/README.md b/README.md index 2765b75..baac7f3 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,13 @@ PostgreSQL 16, core NATS без JetStream), `web/` — фронтенд (React + - [`../docs/`](../docs/) — контекст (`context/`), модель данных (`model/`), контракты (`contracts/`), окружение (`dev/`); - [`CLAUDE.md`](CLAUDE.md) — соглашения этого репозитория. -Срез 0 («скелет ядра»): схема БД по DBML v5.13, справочники (seed), аутентификация и права, `GET /api/v1/me`, +Срез 0 («скелет ядра»): схема БД по DBML, справочники (seed), аутентификация и права, `GET /api/v1/me`, `/healthcheck`. Спецификация — `openspec/changes/slice-0-skeleton/`. +Срез 1 («справочники»): схема по DBML v5.14, API ГК / ЮЛ / ГСК/ГСЗ с политикой видимости, импорт из ЕЦП CRM, +администрирование пользователей и ролей, экран `/admin`, аудит. Спецификация — `openspec/changes/slice-1-directories/`; +разделы ниже: «API справочников», «Импорт из ЕЦП CRM», «Администрирование и экран /admin», «Ошибки API». + ## Предварительные требования - **Python 3.11** (версия платформы) и **[`uv`](https://docs.astral.sh/uv/)** — `make venv` создаёт окружение и ставит @@ -188,6 +192,240 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб Имя проекта compose фиксировано (`arm-ka`), тома общие для всех worktree. +## API справочников + +Срез 1: справочники и их политика видимости. Все маршруты — под `/api/v1`, нужен токен (`Authorization: Bearer …`), +схема — `/openapi.json` (интерактивно `/docs`); состав маршрутов проверяет `api/tests/test_openapi_routes.py`. +Права — данные (`permission` × `role_permission`), код роли в логике не проверяется. Видимость записей решает +`src/domain/policy.py`: пользователь головного офиса видит всё, пользователь филиала — ГК своего филиала, их ЮЛ и +ГСК/ГСЗ и монозаёмщиков своего филиала. Объект вне видимости — **404 с тем же телом, что у несуществующего** (не 403). + +| Метод и путь | Право | Что делает | +|---|---|---| +| `GET /directories/regional-offices` | `DIRECTORY_VIEW` | филиалы (включая Головной офис) | +| `GET /directories/company-groups` | `DIRECTORY_VIEW` | видимые ГК: `q` (имя/`crm_id`/код), `is_archived`, `limit`/`offset` | +| `POST /directories/company-groups` | `DIRECTORY_EDIT` | создать ГК (пользователь филиала закрепляет только за своим филиалом, иначе `OFFICE_NOT_ALLOWED`) | +| `GET /directories/company-groups/{group_id}` | `DIRECTORY_VIEW` | карточка ГК | +| `PATCH /directories/company-groups/{group_id}` | `DIRECTORY_EDIT` | изменить ГК (в т.ч. перезакрепление за филиалом) | +| `POST /directories/company-groups/{group_id}/archive`, `/unarchive` | `DIRECTORY_EDIT` | архивировать / вернуть из архива | +| `GET /directories/company-groups/{group_id}/history` | `DIRECTORY_VIEW` | история ГК, новые первыми | +| `GET`, `POST /directories/company-groups/{company_group_id}/related-groups` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | ГСК/ГСЗ группы компаний (простой массив) / создать | +| `GET /directories/related-groups/{group_id}` | `DIRECTORY_VIEW` | карточка ГСК/ГСЗ с текущими членами | +| `PATCH /directories/related-groups/{group_id}` | `DIRECTORY_EDIT` | изменить ГСК/ГСЗ | +| `DELETE /directories/related-groups/{group_id}` | `DIRECTORY_DELETE` | удалить **пустую** ГСК/ГСЗ (`GROUP_NOT_EMPTY`) | +| `POST /directories/related-groups/{group_id}/members` | `DIRECTORY_EDIT` | добавить ЮЛ (только из той же ГК) | +| `DELETE /directories/related-groups/{group_id}/members/{legal_entity_id}` | `DIRECTORY_EDIT` | исключить ЮЛ (членство закрывается, не удаляется) | +| `GET /directories/legal-entities` | `DIRECTORY_VIEW` | видимые ЮЛ/ИП: `company_group_id`, `outside_group`, `inn`, `q`, `closed`, `limit`/`offset` | +| `POST /directories/legal-entities` | `DIRECTORY_EDIT` | создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12) | +| `GET`, `PATCH /directories/legal-entities/{entity_id}` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | карточка (с членствами и алиасами) / изменить | +| `POST /directories/legal-entities/{entity_id}/move` | `DIRECTORY_EDIT` | перевести в другую ГК с датой `effective_date` ≥ сегодня; членства ГСК/ГСЗ закрываются | +| `POST /directories/legal-entities/{entity_id}/withdraw` | `DIRECTORY_EDIT` | вывести из ГК: `CLOSE` (с `close_date`) или `REMOVE_FROM_GROUP` | +| `GET /directories/legal-entities/{entity_id}/history` | `DIRECTORY_VIEW` | история ЮЛ/ИП, новые первыми | +| `POST /directories/legal-entities/{entity_id}/aliases` | `DIRECTORY_EDIT` | добавить алиас (`normalized_alias` по §3) | +| `DELETE /directories/legal-entities/{entity_id}/aliases/{alias_id}` | `DIRECTORY_DELETE` | удалить алиас | +| `POST /directories/import` | `DIRECTORY_ADMIN` | импорт выгрузки ЕЦП CRM (см. ниже) | +| `GET /admin/users`, `POST /admin/users`, `PATCH /admin/users/{user_id}`, `PUT /admin/users/{user_id}/roles` | `USERS_ADMIN` | пользователи и их роли (см. «Администрирование») | +| `GET /admin/roles`, `GET /admin/permissions`, `PUT /admin/roles/{code}/permissions` | `USERS_ADMIN` | матрица «роль × право» | + +Списки верхнего уровня (ГК, ЮЛ, пользователи) отдают страницу `{items, total, limit, offset}` (`limit` 1–200, по +умолчанию 50); список ГСК/ГСЗ одной ГК — простой массив. Каждая мутация пишет `audit_event` в той же транзакции, что и +сама правка; повтор того же состояния ничего не пишет. Примеры: + +```bash +TOKEN=$(curl -s -X POST localhost:8000/api/v1/dev/token -H 'Content-Type: application/json' \ + -d '{"email":"ka_go@armka.local"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])') +curl -s 'localhost:8000/api/v1/directories/company-groups?q=бисквит' -H "Authorization: Bearer $TOKEN" +``` + +## Импорт из ЕЦП CRM + +`POST /api/v1/directories/import` (multipart) загружает справочник из выгрузки ЕЦП CRM: ГК, ЮЛ/ИП, филиалы и членство +в ГСК/ГСЗ. Нужно право `DIRECTORY_ADMIN` (seed: `ADMIN`). Алгоритм — `../docs/context/05-domain-rules.md` §13 +(§13.1 формат файла, §13.2 «строка → решение», §13.3 транзакция и отчёт), чистое ядро — `src/domain/directory_import.py`. + +**Порядок работы.** + +1. Сначала **пробный прогон**: `dry_run=true` (это значение по умолчанию). Прогон идёт тем же кодом, но транзакция + откатывается; в БД, аудит и S3 ничего не попадает, отчёт тот же, что будет при записи. +2. Прочитайте отчёт: счётчики и построчный список `lines` (`row`, `inn`, `code`, `message`). Пропущенные строки + (`errors`) исправляются в файле и прогоняются заново. +3. Когда отчёт устраивает — тот же файл с `dry_run=false`. Один импорт — одна транзакция; результат — отчёт в ответе, + `audit_event(DIRECTORY_IMPORT)` с отчётом в `after_value` и файл в S3 (`uploads/imports/{audit_event_id}/{имя}`). +4. Повторный импорт того же файла при `on_existing=skip` идемпотентен: все ранее принятые строки получают коды `*_EXISTS`; + ранее пропущенные (ошибочные) строки остаются ошибками. + +```bash +# шаг 1: пробный прогон (dry_run по умолчанию true, on_existing по умолчанию skip) +curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bearer $ADMIN_TOKEN" \ + -F file=@ecp-export.xlsx -F dry_run=true -F on_existing=skip +# шаг 3: запись +curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bearer $ADMIN_TOKEN" \ + -F file=@ecp-export.xlsx -F dry_run=false -F on_existing=update +``` + +**Формат файла (§13.1).** xlsx (первый лист) или csv (UTF-8, разделитель `;` или `,`); формат выбирается по расширению +имени. Заголовки — первая непустая строка, сопоставляются без регистра и пробелов, `ё` = `е`; порядок колонок не важен, +лишние игнорируются. + +| Колонка | Обязательна | Смысл | +|---|---|---| +| `ИНН` | да | пробелы убираются; ЮЛ — 10 цифр, ИП — 12 | +| `iD ГК` | да | `company_group.crm_id` | +| `Группа компаний` | да | имя ГК | +| `Организация` | да | имя ЮЛ/ИП | +| `Тип` | да | `ЮЛ` или `ИП` (без регистра) | +| `Базовый РФ ГК в CRM` | нет | филиал по точному имени | +| `ГСК`, `ГСЗ` | нет | флаги «входит в ГСК» / «входит в ГСЗ» | + +Нет любой обязательной колонки — файл отвергается целиком (422 `MISSING_COLUMNS` с перечнем в поле `missing`). + +**Параметр `on_existing`.** `skip` (по умолчанию) — существующие ГК/ЮЛ не меняются; `update` — обновляются имя и +филиал ГК и имя/тип ЮЛ (со снимками в историю и аудитом). + +**Коды отчёта (§13.2).** У каждой строки ровно один итоговый код, плюс сопутствующие события той же строки. +Пропуск строки (учитывается в `errors`): `UNSUPPORTED_TYPE`, `INVALID_INN`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`, +`GK_NAME_CONFLICT` (разные имена одной ГК в файле или пустой `iD ГК`). Итоговые: `LE_CREATED`, `LE_UPDATED`, +`LE_EXISTS`. Сопутствующие: `RF_CREATED`, `GK_CREATED`, `GK_UPDATED`, `GK_EXISTS`, `GSK_CREATED`, `GSZ_CREATED`, +`MEMBER_ADDED`, `UNKNOWN_FLAG`. + +**Допущения (открытые вопросы, в коде помечены `ASSUMPTION(OQ-…)`, список — `../docs/context/07-open-questions.md`).** + +- OQ-87: типы вне {ЮЛ, ИП} (`нерез.`, `ФЛ`, `нет инн`, пусто) пропускаются — `UNSUPPORTED_TYPE`. +- OQ-88: ИНН проверяется только по длине и составу (ASCII-цифры), контрольные разряды не считаются; нечисловые + значения (`LV…`, `РНН…`) — `INVALID_INN`. +- OQ-89: филиала из файла нет в справочнике — он создаётся как обычный (`is_head_office=false`), в том числе «ЦКБ» и + «Головная организация» (`RF_CREATED`). +- OQ-90: ЮЛ, которое уже состоит в другой ГК (или вне ГК), импорт не переносит: `LE_IN_OTHER_GROUP`, перевод — только + `POST …/move`. Сообщение называет имя этого ЮЛ из другой группы — импорт доступен только `DIRECTORY_ADMIN`, который + видит весь справочник. +- OQ-91: флаги ГСК/ГСЗ создают не более одной ГСК и одной ГСЗ на ГК с именем ГК; отсутствие флага членство не закрывает. + +**Особенности, о которых стоит знать.** + +- **ИНН — только текстом.** Число из ячейки xlsx теряет ведущий ноль (`0123456789` → `123456789`) и получает + `INVALID_INN`; в Excel задавайте текстовый формат колонки (или используйте csv). +- Пустые «Организация» или «Группа компаний» при непустом `iD ГК` не отвергаются: создаётся запись с пустым именем + (допущение, правила на пустое имя в §13 нет). +- У **существующей** ГК при `skip` новый филиал из файла всё равно создаётся (`RF_CREATED`), но ГК не меняется. +- Архивные ГК входят в снимок справочника; `update` не возвращает ГК из архива. +- Пустые строки пропускаются, но входят в счётчик строк `MAX_IMPORT_ROWS`. + +**Пределы.** + +| Предел | Значение | Результат при превышении | +|---|---|---| +| `IMPORT_MAX_BYTES` (настройка) | 20 МиБ | 413, файл не читается в память целиком | +| `MAX_IMPORT_ROWS` | 100 000 строк листа/записей csv, **включая пустые** | 422 `UNSUPPORTED_FILE` | +| `MAX_UNCOMPRESSED_BYTES` | 200 МиБ суммарно по частям xlsx (zip), проверяется до разбора | 422 `UNSUPPORTED_FILE` | + +**Требование к ingress / reverse proxy (M-1).** FastAPI/Starlette разбирают multipart-тело **до** аутентификации: файл +сбрасывается во временный файл на диске ещё до ответов 401/403 и до проверки `IMPORT_MAX_BYTES`. Поэтому лимит тела +запроса должен ставить ingress: `client_max_body_size` (или аналог) не меньше 20 МиБ — только для пути +`/api/v1/directories/import`, а для остальных путей — небольшой (например, 1 МиБ). + +Остальные причины `UNSUPPORTED_FILE`: расширение не `xlsx`/`csv`, повреждённый xlsx, csv не в UTF-8. + +## Администрирование и экран /admin + +Бэкенд — `/api/v1/admin/*`, все маршруты требуют `USERS_ADMIN` (без права — 403 до любых проверок тела и существования +объектов). Фронтенд — страница `/admin` («Пользователи и роли» в боковом меню; видна только с `USERS_ADMIN`): таблица +пользователей (создание, смена ролей и активности) и вкладка «Матрица прав» (роль × право). Без права экран показывает +«Нет доступа» и запросов к `/admin/*` не делает (настоящую защиту обеспечивает сервер). + +Форма создания пользователя берёт список филиалов из `GET /directories/regional-offices` (право `DIRECTORY_VIEW`). +Матрица прав на этом же экране позволяет снять `DIRECTORY_VIEW` с роли администратора — тогда форма получит пустой +список / «недостаточно прав»; держите `DIRECTORY_VIEW` у роли администратора. + +**Как запустить.** + +```bash +make migrate && make seed-dev # схема, справочники и dev-пользователи +make up # если инфраструктура ещё не поднята +cd api && .venv/bin/python run_server.py # бэкенд, :8000 (в api/.env — DEBUG=true и AUTH_DEV_ISSUER=true) +cd web && npm install && npm run start # фронтенд (rsbuild dev), откроется браузер +``` + +Вход — dev-форма «Вход для разработки» (`web/src/auth/DevLogin.tsx`): введите `admin@armka.local`; форма вызывает +`POST /api/v1/dev/token`, токен хранится в `sessionStorage` вкладки. В production-сборке формы нет — вход через SSO +РАИСА (параметры JWT — открытый вопрос). + +**Что нужно знать.** + +- **Роли и права — данные.** Права роли правятся матрицей (`PUT /admin/roles/{code}/permissions`), набор ролей + пользователя — `PUT /admin/users/{user_id}/roles`; код роли нигде в логике не проверяется. Новых прав и ролей через + API создать нельзя: неизвестный код — 422 `UNKNOWN_PERMISSION` / `UNKNOWN_ROLE` (неактивная роль не назначается). +- **`LAST_ADMIN` (409).** После операции хотя бы у одного активного пользователя должно остаться право `USERS_ADMIN` + через активную роль. Защищены: деактивация пользователя (в том числе себя), замена его ролей и снятие права с роли; + параллельные запросы двух администраторов идут по очереди (блокировка строки права). +- **Email.** В теле запроса — `EmailStr`, поэтому адрес с зарезервированным доменом `*.local` (`x@corp.local`) через + API и UI **отклоняется** (422); dev-пользователи `*@armka.local` создаёт `make seed-dev` напрямую в БД. Для ручной + проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён. +- **Повторный seed возвращает снятые права.** `make seed` / `make seed-dev` идемпотентны и только **добавляют** + недостающие связи «роль × право» из `api/seed/core/role_permissions.yaml`: право, которое администратор снял с роли + в матрице, следующий запуск seed выдаст снова (`granted_by` — пусто); права, добавленные администратором сверх seed, + seed не отзывает. На стенде с правками матрицы seed повторно не запускайте (follow-up к M-8 Среза 0). +- **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите + страницу или войдите заново. +- **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after` + (решение №35): выборка журнала по `GROUP_CHANGE` такое переименование не найдёт. +- **Один выбор роли — один запрос.** Мульти-выбор ролей в строке таблицы отправляет по одному `PUT …/roles` на каждое + нажатие, поэтому каждое нажатие — отдельное событие `audit_event(USER_ROLE_CHANGE)`. +- **Показываются первые 200 пользователей** (`limit=200`, максимум страницы); постраничного просмотра в UI пока нет — + остальных ищет `GET /admin/users?q=…` (по email/ФИО). +- **Аудит.** `USER_CHANGE` (создание, правка полей, деактивация) и `USER_ROLE_CHANGE` — раздел `USERS`; + `ROLE_PERMISSION_CHANGE` — раздел `ACCESS`; `before_value`/`after_value` — прежнее и новое состояние. + +## Ошибки API + +Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом: + +```json +{"detail": {"code": "ALIAS_TAKEN", "message": "Такой алиас уже закреплён за другим ЮЛ", "legal_entity_id": "…"}} +``` + +`code` — машинный код (по нему работает клиент), `message` — текст по-русски, дополнительные поля зависят от ошибки +(например, `legal_entity_id`, `related_group_id`, `permissions`, `roles`, `missing`). Остальные ответы — `{"detail": …}` +другой формы: 401/403/413 — строка, 422 валидации тела — массив ошибок pydantic, 409 нарушения целостности БД — строка. +Объект вне видимости — 404 `NOT_FOUND` с тем же телом, что у несуществующего. + +**Гонки уникальных ограничений.** Если параллельный запрос успел вставить запись между проверкой сервиса и INSERT, +БД отклоняет вставку (`unique_violation`), и ответ тот же 409 с тем же кодом, что у проверки (реестр — +`src/common/constraint_conflicts.py`): `CRM_ID_EXISTS` (ГК и ЮЛ), `INN_EXISTS`, `ALIAS_EXISTS`, `EMAIL_EXISTS`, +`MEMBER_EXISTS`/`MEMBER_OF_OTHER_GROUP`. В теле гонки — только код и постоянный текст, без дополнительных полей +(`legal_entity_id`, `related_group_id`) и без значений ключей. Особенности: повторное добавление в ту же группу +отдаёт `MEMBER_OF_OTHER_GROUP` с нейтральным текстом «ЮЛ уже входит в группу этого вида» (его раньше срабатывает индекс +«ЮЛ + вид»); гонка за один алиас у разных ЮЛ (`ALIAS_TAKEN`) в БД не защищена (уникален только `(ЮЛ, алиас)`); +email без учёта регистра проверяет только сервис, поэтому гонка с другим регистром остаётся открытым ограничением. +Любое иное нарушение целостности (FK, check, неизвестное unique) — прежняя строка `нарушение целостности данных в БД`. + +| Код | HTTP | Где | +|---|---|---| +| `NOT_FOUND` | 404 | объекта нет или он вне видимости | +| `OFFICE_NOT_FOUND` | 422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) | +| `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом | +| `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр | +| `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней | +| `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) | +| `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ | +| `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) | +| `MISSING_COLUMNS` / `UNSUPPORTED_FILE` | 422 | файл импорта целиком (`missing` — перечень колонок) | +| `CRM_ID_EXISTS` | 409 | ГК или ЮЛ с таким `crm_id` уже есть | +| `INN_EXISTS` | 409 | ЮЛ с таким ИНН уже есть (без дополнительных полей) | +| `LEGAL_ENTITY_CLOSED` | 409 | перевод закрытого ЮЛ | +| `SAME_COMPANY_GROUP` | 409 | перевод в ту же ГК | +| `ALREADY_CLOSED` | 409 | ЮЛ уже закрыто | +| `NOT_IN_COMPANY_GROUP` | 409 | ЮЛ не входит в ГК (вывод невозможен) | +| `GROUP_NOT_EMPTY` | 409 | удаление ГСК/ГСЗ с текущими членами | +| `MEMBER_EXISTS` | 409 | ЮЛ уже входит в эту группу | +| `MEMBER_OF_OTHER_GROUP` | 409 | ЮЛ уже входит в другую группу этого вида (`related_group_id`) | +| `ALIAS_EXISTS` | 409 | у ЮЛ уже есть такой алиас | +| `ALIAS_TAKEN` | 409 | алиас закреплён за другим ЮЛ (`legal_entity_id`) | +| `EMAIL_EXISTS` | 409 | пользователь с таким email уже есть | +| `LAST_ADMIN` | 409 | операция оставила бы систему без активного `USERS_ADMIN` | + +Файл импорта больше `IMPORT_MAX_BYTES` — 413 со строкой в `detail`. Недостаточно прав — 403 строкой +(`недостаточно прав: требуется <ПРАВО>`), нет токена или он неверен — 401. + ## Конфигурация Настройки — pydantic-settings; читаются из переменных окружения и файла `api/.env`. Вложенные настройки @@ -219,6 +457,7 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `OUTBOX_POLL_SECONDS` | `5` | период опроса outbox (`queued` → публикация → `published`) | | `ACK_TIMEOUT_SECONDS` | `30` | ожидание ack сервиса распознавания до повторной публикации | | `OUTBOX_MAX_REPUBLISH` | `3` | число повторных публикаций до перевода в `dlq` | +| `IMPORT_MAX_BYTES` | `20971520` (20 МиБ) | предельный размер файла импорта справочников; больше — 413 | | `AUTH_DEV_ISSUER` | `false` | dev-эмитент токенов: работает **только** при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true` | | `AUTH_DEV_SECRET` | пусто | секрет HS256 dev-эмитента (не короче 32 байт); только для разработки | | `AUTH_JWKS_URL` или `AUTH_PUBLIC_KEY` | пусто | проверка токенов РАИСА: JWKS по URL либо публичный ключ (PEM) | @@ -247,7 +486,7 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `make lint` | `ruff check`, `ruff format --check`, `mypy src scripts` | | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m integration`, нужны PostgreSQL (и NATS) | -| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.13.dbml` | +| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.14.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | @@ -270,7 +509,7 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб ### Проверка ORM и DBML -Модель данных — `../docs/model/data-model-v5.13.dbml`. ORM обязана совпадать с ней один в один: +Модель данных — `../docs/model/data-model-v5.14.dbml`. ORM обязана совпадать с ней один в один: ```bash make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно @@ -293,13 +532,13 @@ make migration-cycle # проверить цикл upgr ``` api/ run_server.py точка входа (uvicorn; хост и порт — из настроек) - alembic/ миграции (versions/0001_dbml_v5_13.py — вся модель v5.13) + alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/dev/ dev-данные: филиалы и пользователи по ролям scripts/ check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников) src/ app.py FastAPI-приложение: lifespan (S3, NATS, SMB), /healthcheck, раздача SPA - api/endpoints/ эндпоинты (me.py, dev_auth.py); router.py собирает /api/v1 + api/endpoints/ эндпоинты (me.py, dev_auth.py, directories/, admin/); router.py собирает /api/v1 api/dependencies/ зависимости FastAPI (current_user, проверка прав) core/ config, auth (JWT), clients (S3), nats (подключение, publish, подписка), logger, exception_handlers @@ -308,10 +547,10 @@ api/ repositories/ доступ к данным schemas/ pydantic-схемы запросов и ответов services/ бизнес-логика - common/ исключения - domain/ (появится) чистые функции по §§ правил предметной области, без ORM + common/ доменные ошибки (`{detail: {code, message}}`), часы + domain/ чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn tests/ pytest (юнит и integration/fixtures по маркерам) -web/ фронтенд (React 18 + TypeScript, rsbuild, antd); сборка web/dist -> api/web/ +web/ фронтенд (React 18 + TypeScript, rsbuild, antd): api/ (клиент), auth/ (вход, профиль), layouts/Admin (/admin); сборка web/dist -> api/web/ setup/ бутстрап шаблона через Nexus банка (pip.conf, setup.sh) openspec/ спецификации и изменения (OpenSpec) docs/superpowers/ планы реализации @@ -351,7 +590,7 @@ Dockerfile только для локального compose - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и - [`../docs/model/data-model-v5.13.dbml`](../docs/model/data-model-v5.13.dbml) — модель данных; + [`../docs/model/data-model-v5.14.dbml`](../docs/model/data-model-v5.14.dbml) — модель данных; - [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области; - [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы; - [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов; diff --git a/api/.env.example b/api/.env.example index 97311dc..5255f9c 100644 --- a/api/.env.example +++ b/api/.env.example @@ -46,3 +46,6 @@ NATS_ENABLED=true OUTBOX_POLL_SECONDS=5 ACK_TIMEOUT_SECONDS=30 OUTBOX_MAX_REPUBLISH=3 + +# Импорт справочников из выгрузки ЕЦП CRM: предельный размер файла в байтах (20 МБ), больше — ответ 413 +IMPORT_MAX_BYTES=20971520 diff --git a/api/.env.test b/api/.env.test index 8801f1d..59fcd77 100644 --- a/api/.env.test +++ b/api/.env.test @@ -40,3 +40,6 @@ NATS_ENABLED=false OUTBOX_POLL_SECONDS=5 ACK_TIMEOUT_SECONDS=30 OUTBOX_MAX_REPUBLISH=3 + +# Импорт справочников: предельный размер файла (20 МБ), как в .env.example +IMPORT_MAX_BYTES=20971520 diff --git a/api/alembic/versions/0002_dbml_v5_14.py b/api/alembic/versions/0002_dbml_v5_14.py new file mode 100644 index 0000000..1abd967 --- /dev/null +++ b/api/alembic/versions/0002_dbml_v5_14.py @@ -0,0 +1,124 @@ +"""DBML v5.14: related_group_member.kind + partial unique (legal_entity_id, kind), audit_action += LEGAL_ENTITY_CHANGE, +USER_CHANGE, последовательности кодов ГК/ГСК (design D-1, D-5). + +`related_group_member` пересоздаётся (копия данных → DROP → CREATE → INSERT), а не `ADD COLUMN`: DBML ставит `kind` +сразу после `legal_entity_id`, PostgreSQL добавляет колонку только в конец, а каталог после миграции обязан совпадать +с `Base.metadata.create_all` (порядок колонок — тоже). На таблицу никто не ссылается по FK; `kind` берётся из группы. + +`ALTER TYPE … ADD VALUE` необратим — downgrade пересоздаёт тип audit_action со значениями v5.13 (строк с новыми +значениями в audit_event быть не должно; иначе downgrade падает на USING — это осознанно). + +Revision ID: 0002_dbml_v5_14 +Revises: 0001_dbml_v5_13 +Create Date: 2026-10-04 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.schema import CreateSequence, DropSequence + +from alembic import op + +revision: str = "0002_dbml_v5_14" +down_revision: str | Sequence[str] | None = "0001_dbml_v5_13" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "dkb" +TABLE = "related_group_member" +OLD_INDEX = "uq_related_group_member_entity_current" +NEW_INDEX = "uq_related_group_member_entity_kind_current" +GROUP_ENTITY_INDEX = "uq_related_group_member_group_entity_current" +#: Временная копия строк на время пересоздания таблицы (в схеме dkb: прав на TEMP у роли миграций может не быть). +COPY_TABLE = "related_group_member_v5_13" +SEQUENCES = ("company_group_code_seq", "related_group_code_seq") +AUDIT_ACTION_V5_13 = ( + "LOGIN", "LOGIN_FAILED", "LOGOUT", "CONSOLIDATION_CREATE", "CONSOLIDATION_STATUS_CHANGE", "CONSOLIDATION_APPROVE", + "CONSOLIDATION_VERIFY", "GROUP_CHANGE", "MEMBERSHIP_CHANGE", "ASSIGNMENT_CHANGE", "FILE_UPLOAD", "FILE_DELETE", + "FILE_REPLACE", "DOCUMENT_CONFIRM", "CONSOLIDATION_LOCK", "CONSOLIDATION_LOCK_TAKEOVER", "DISCLOSURE_EDIT", + "RECOGNIZED_DATA_EDIT", "OSV_COUNTERPARTY_MAP", "OSV_TYPE_OVERRIDE", "CORRECTION_CREATE", "CORRECTION_EDIT", + "CORRECTION_REVOKE", "GOODWILL_EDIT", "EXPORT", "DIRECTORY_IMPORT", "USER_ROLE_CHANGE", "ROLE_PERMISSION_CHANGE", +) # fmt: skip + + +def _create_member_table() -> None: + """`related_group_member` v5.14: колонки в порядке DBML, имена ограничений и индексов — как в ORM.""" + op.create_table( + TABLE, + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("related_group_id", sa.UUID(), nullable=False), + sa.Column("legal_entity_id", sa.UUID(), nullable=False), + sa.Column("kind", postgresql.ENUM(name="related_group_kind", schema=SCHEMA, create_type=False), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False), + sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True), + sa.Column("is_current", sa.Boolean(), nullable=False), + sa.Column("created_by", sa.UUID(), nullable=True), + sa.ForeignKeyConstraint( + ["created_by"], ["dkb.app_user.id"], name=op.f("fk_related_group_member_created_by_app_user") + ), + sa.ForeignKeyConstraint( + ["legal_entity_id"], + ["dkb.legal_entity.id"], + name=op.f("fk_related_group_member_legal_entity_id_legal_entity"), + ), + sa.ForeignKeyConstraint( + ["related_group_id"], + ["dkb.related_group.id"], + name=op.f("fk_related_group_member_related_group_id_related_group"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_related_group_member")), + schema=SCHEMA, + ) + op.create_index( + NEW_INDEX, + TABLE, + ["legal_entity_id", "kind"], + unique=True, + schema=SCHEMA, + postgresql_where=sa.text("is_current"), + ) + op.create_index( + GROUP_ENTITY_INDEX, + TABLE, + ["related_group_id", "legal_entity_id"], + unique=True, + schema=SCHEMA, + postgresql_where=sa.text("is_current"), + ) + + +def upgrade() -> None: + # копия текущих строк с kind группы (FK related_group_id NOT NULL — JOIN не теряет строк) + op.execute( + f"CREATE TABLE {SCHEMA}.{COPY_TABLE} AS SELECT m.id, m.related_group_id, m.legal_entity_id, g.kind, " + f"m.valid_from, m.valid_to, m.is_current, m.created_by FROM {SCHEMA}.{TABLE} m " + f"JOIN {SCHEMA}.related_group g ON g.id = m.related_group_id" + ) + op.drop_table(TABLE, schema=SCHEMA) # вместе с индексами v5.13 + _create_member_table() + op.execute(f"INSERT INTO {SCHEMA}.{TABLE} SELECT * FROM {SCHEMA}.{COPY_TABLE}") + op.drop_table(COPY_TABLE, schema=SCHEMA) + # порядок значений — как в DBML v5.14 (сверка enum'ов сравнивает упорядоченные списки) + op.execute(f"ALTER TYPE {SCHEMA}.audit_action ADD VALUE IF NOT EXISTS 'LEGAL_ENTITY_CHANGE' AFTER 'GROUP_CHANGE'") + op.execute(f"ALTER TYPE {SCHEMA}.audit_action ADD VALUE IF NOT EXISTS 'USER_CHANGE' AFTER 'USER_ROLE_CHANGE'") + for name in SEQUENCES: + op.execute(CreateSequence(sa.Sequence(name, schema=SCHEMA))) + + +def downgrade() -> None: + for name in reversed(SEQUENCES): + op.execute(DropSequence(sa.Sequence(name, schema=SCHEMA))) + op.drop_index(NEW_INDEX, table_name=TABLE, schema=SCHEMA) + op.create_index( + OLD_INDEX, TABLE, ["legal_entity_id"], unique=True, schema=SCHEMA, postgresql_where=sa.text("is_current") + ) + op.drop_column(TABLE, "kind", schema=SCHEMA) + op.execute(f"ALTER TYPE {SCHEMA}.audit_action RENAME TO audit_action_old") + postgresql.ENUM(*AUDIT_ACTION_V5_13, name="audit_action", schema=SCHEMA).create(op.get_bind(), checkfirst=False) + op.execute( + f"ALTER TABLE {SCHEMA}.audit_event ALTER COLUMN action TYPE {SCHEMA}.audit_action " + f"USING action::text::{SCHEMA}.audit_action" + ) + op.execute(f"DROP TYPE {SCHEMA}.audit_action_old") diff --git a/api/requirements-dev.txt b/api/requirements-dev.txt index f067282..d7aae10 100644 --- a/api/requirements-dev.txt +++ b/api/requirements-dev.txt @@ -11,3 +11,4 @@ PyJWT[crypto]>=2.9 pydbml>=1.1 jsonschema>=4.23 types-PyYAML>=6.0 +types-openpyxl>=3.1 diff --git a/api/requirements.txt b/api/requirements.txt index babec49..954ca54 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -16,6 +16,7 @@ click==8.4.2 cryptography==50.0.2 dnspython==2.8.0 email-validator==2.3.0 +et_xmlfile==2.0.0 fastapi==0.135.1 fastapi-cli==0.0.24 fastapi-cloud-cli==0.14.1 @@ -37,6 +38,7 @@ miniopy-async==1.23.4 multidict==6.7.1 nats-py==2.14.0 nkeys==0.2.1 +openpyxl==3.1.5 propcache==0.5.2 pycparser==3.0 pycryptodome==3.23.0 diff --git a/api/scripts/check_dbml_vs_orm.py b/api/scripts/check_dbml_vs_orm.py index c8d3837..2f7cbb6 100644 --- a/api/scripts/check_dbml_vs_orm.py +++ b/api/scripts/check_dbml_vs_orm.py @@ -2,7 +2,7 @@ Запуск из `api/`: - python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.13.dbml + python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.14.dbml Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`. diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index ac3feb5..eeb2926 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -56,7 +56,7 @@ from sqlalchemy.sql.elements import TextClause from sqlalchemy.sql.functions import FunctionElement SCHEMA = "dkb" -DBML_FILE_NAME = "data-model-v5.13.dbml" +DBML_FILE_NAME = "data-model-v5.14.dbml" ORM = "ORM" DBML = "DBML" ABSENT = "нет" diff --git a/api/src/api/dependencies/admin.py b/api/src/api/dependencies/admin.py new file mode 100644 index 0000000..756d53f --- /dev/null +++ b/api/src/api/dependencies/admin.py @@ -0,0 +1,23 @@ +"""Зависимости API администрирования (spec users-admin): право `USERS_ADMIN` и сервис (R-6: сессия — `SessionDep`).""" + +from typing import Annotated + +from fastapi import Depends + +from src.api.dependencies.audit import RequestAudit, get_request_audit +from src.api.dependencies.auth import require +from src.api.dependencies.db import SessionDep +from src.services.users_admin import USERS_ADMIN, UsersAdminService + +# Право на все маршруты `/admin/*`: без него — 403 до любых проверок тела и существования объектов +ADMIN_ONLY = [Depends(require(USERS_ADMIN))] + + +def get_users_admin_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], +) -> UsersAdminService: + return UsersAdminService(session, audit, audit.user_id) + + +UsersAdminServiceDep = Annotated[UsersAdminService, Depends(get_users_admin_service)] diff --git a/api/src/api/dependencies/audit.py b/api/src/api/dependencies/audit.py new file mode 100644 index 0000000..2d41694 --- /dev/null +++ b/api/src/api/dependencies/audit.py @@ -0,0 +1,73 @@ +"""`RequestAudit` — запись `audit_event` с контекстом запроса (spec audit-trail: `user_id`, `ip_address`, `user_agent`). + +Пишет через `AuditService.record` в сессию запроса (`SessionDep`), то есть в той же транзакции, что и мутация: +откат запроса откатывает и аудит. Журнал append-only — здесь только делегирование `record`. +""" + +import uuid +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Annotated, Any + +from fastapi import Depends, Request + +from src.api.dependencies.auth import Principal, current_user +from src.api.dependencies.db import SessionDep +from src.models.audit_ex import AuditEvent +from src.models.enums import AuditAction, AuditSection +from src.services.audit import AuditService + + +@dataclass(frozen=True) +class RequestAudit: + """Сервис аудита, связанный с инициатором и клиентом текущего запроса.""" + + service: AuditService + user_id: uuid.UUID + ip_address: str | None + user_agent: str | None + + async def record( # noqa: PLR0913 - поля audit_event из спеки audit-trail (без контекста запроса) + self, + *, + action: AuditAction, + section: AuditSection, + object_type: str, + object_id: uuid.UUID | None = None, + description: str | None = None, + before_value: Mapping[str, Any] | None = None, + after_value: Mapping[str, Any] | None = None, + comment: str | None = None, + result: str | None = None, + changed_field: str | None = None, + ) -> AuditEvent: + """`AuditService.record` с `user_id`/`ip_address`/`user_agent` запроса.""" + return await self.service.record( + action=action, + section=section, + object_type=object_type, + object_id=object_id, + description=description, + before_value=before_value, + after_value=after_value, + comment=comment, + result=result, + changed_field=changed_field, + user_id=self.user_id, + ip_address=self.ip_address, + user_agent=self.user_agent, + ) + + +async def get_request_audit( + request: Request, + session: SessionDep, + principal: Annotated[Principal, Depends(current_user)], +) -> RequestAudit: + """Зависимость: аудит в сессии запроса; IP — адрес клиента (за прокси — из `ProxyHeadersMiddleware`).""" + return RequestAudit( + service=AuditService(session), + user_id=principal.user.id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) diff --git a/api/src/api/dependencies/auth.py b/api/src/api/dependencies/auth.py index 69ad7f1..5fd7b8e 100644 --- a/api/src/api/dependencies/auth.py +++ b/api/src/api/dependencies/auth.py @@ -13,11 +13,10 @@ from collections.abc import Awaitable, Callable from typing import Annotated from fastapi import Depends, Header, HTTPException, Request, status -from sqlalchemy.ext.asyncio import AsyncSession +from src.api.dependencies.db import SessionDep from src.core.auth import AuthError, Claims, TokenVerifier, build_verifier from src.core.config import get_settings -from src.db.db import get_async_session from src.repositories.users import AmbiguousUserError, UserWithAccess, load_principal_data # Текущий пользователь вместе с филиалом, ролями и эффективными правами (то, что нужно `GET /me` и `require`) @@ -74,7 +73,7 @@ async def verified_claims( async def current_user( claims: Annotated[Claims, Depends(verified_claims)], - session: Annotated[AsyncSession, Depends(get_async_session)], + session: SessionDep, ) -> Principal: """Пользователь по email из токена с филиалом, ролями и эффективными правами (один запрос к БД). diff --git a/api/src/api/dependencies/db.py b/api/src/api/dependencies/db.py new file mode 100644 index 0000000..332091f --- /dev/null +++ b/api/src/api/dependencies/db.py @@ -0,0 +1,22 @@ +"""Сессия БД запроса для маршрутов и зависимостей (R-1, design #12). + +`SessionDep` — ЕДИНСТВЕННЫЙ способ объявить сессию в `Depends`: `get_async_session` с `scope="function"`. + +- Commit до ответа. Зависимость с `yield` по умолчанию (`scope="request"`) завершается ПОСЛЕ отправки ответа: клиент + получил бы 201, а commit мог бы затем упасть (данные потеряны) или ещё не произойти к следующему запросу клиента. + С `scope="function"` выход из зависимости (commit/rollback) идёт сразу после эндпоинта и сериализации ответа, ошибка + commit проходит через обработчики исключений (например, `IntegrityError` → 409). +- Одна сессия на запрос. Кэш зависимостей FastAPI различает `scope`: `Depends(get_async_session)` и + `Depends(get_async_session, scope="function")` в одном запросе дали бы ДВЕ сессии и две транзакции. Поэтому + везде — только `SessionDep` (проверяет тест `test_api_routes_declare_session_with_function_scope` в + `tests/test_uow_and_errors.py`). +""" + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from src.db.db import get_async_session + +SessionDep = Annotated[AsyncSession, Depends(get_async_session, scope="function")] diff --git a/api/src/api/dependencies/directories.py b/api/src/api/dependencies/directories.py new file mode 100644 index 0000000..e4cd836 --- /dev/null +++ b/api/src/api/dependencies/directories.py @@ -0,0 +1,86 @@ +"""Зависимости API справочников: область видимости и сервисы (R-6: сессия — только `SessionDep`).""" + +from typing import Annotated + +from fastapi import Depends, Request + +from src.api.dependencies.audit import RequestAudit, get_request_audit +from src.api.dependencies.auth import Principal, current_user +from src.api.dependencies.db import SessionDep +from src.core.config import Settings, settings +from src.domain.policy import VisibilityScope, scope_of +from src.services.aliases import AliasService +from src.services.company_groups import CompanyGroupService +from src.services.directory_import import DirectoryImportService +from src.services.legal_entities import LegalEntityService +from src.services.related_groups import RelatedGroupService + + +def get_scope(principal: Annotated[Principal, Depends(current_user)]) -> VisibilityScope: + """Область видимости текущего пользователя (§10.1).""" + return scope_of(principal) + + +def get_company_group_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> CompanyGroupService: + return CompanyGroupService(session, audit, scope) + + +CompanyGroupServiceDep = Annotated[CompanyGroupService, Depends(get_company_group_service)] + + +def get_legal_entity_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> LegalEntityService: + return LegalEntityService(session, audit, scope) + + +LegalEntityServiceDep = Annotated[LegalEntityService, Depends(get_legal_entity_service)] + + +def get_alias_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> AliasService: + return AliasService(session, audit, scope) + + +AliasServiceDep = Annotated[AliasService, Depends(get_alias_service)] + + +def get_related_group_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> RelatedGroupService: + return RelatedGroupService(session, audit, scope) + + +RelatedGroupServiceDep = Annotated[RelatedGroupService, Depends(get_related_group_service)] + + +def get_app_settings() -> Settings: + """Настройки процесса (`src.core.config.settings`); зависимость — чтобы тест мог подменить.""" + return settings + + +SettingsDep = Annotated[Settings, Depends(get_app_settings)] + + +def get_directory_import_service( + request: Request, + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + app_settings: SettingsDep, +) -> DirectoryImportService: + """Сервис импорта; S3 — `app.state.storage` (`None`, если S3 недоступно или не подключено).""" + return DirectoryImportService(session, audit, getattr(request.app.state, "storage", None), app_settings) + + +DirectoryImportServiceDep = Annotated[DirectoryImportService, Depends(get_directory_import_service)] diff --git a/api/src/api/endpoints/admin/__init__.py b/api/src/api/endpoints/admin/__init__.py new file mode 100644 index 0000000..4564d3c --- /dev/null +++ b/api/src/api/endpoints/admin/__init__.py @@ -0,0 +1,10 @@ +"""Маршруты администрирования `/api/v1/admin/*` (spec users-admin): все требуют право `USERS_ADMIN`.""" + +from fastapi import APIRouter + +from src.api.dependencies.admin import ADMIN_ONLY +from src.api.endpoints.admin import roles, users + +router = APIRouter(prefix="/admin", tags=["admin"], dependencies=ADMIN_ONLY) +router.include_router(users.router) +router.include_router(roles.router) diff --git a/api/src/api/endpoints/admin/roles.py b/api/src/api/endpoints/admin/roles.py new file mode 100644 index 0000000..ac9a0c9 --- /dev/null +++ b/api/src/api/endpoints/admin/roles.py @@ -0,0 +1,42 @@ +"""Матрица прав `/admin/roles`, `/admin/permissions` (spec users-admin): чтение и замена набора прав роли. + +Права — справочник только для чтения (новые коды не создаются); роли не создаются, не переименовываются и не +деактивируются через API (системные роли — `is_system`, D-8). +""" + +from fastapi import APIRouter + +from src.api.dependencies.admin import UsersAdminServiceDep +from src.models.access import Role +from src.schemas.admin import PermissionOut, PermissionsSet, RoleOut + +router = APIRouter() + + +def _out(role: Role, permissions: list[str]) -> RoleOut: + return RoleOut( + code=role.code, + name=role.name, + description=role.description, + is_system=role.is_system, + is_active=role.is_active, + permissions=permissions, + ) + + +@router.get("/roles", response_model=list[RoleOut], summary="Роли с правами") +async def list_roles(service: UsersAdminServiceDep) -> list[RoleOut]: + return [_out(role, permissions) for role, permissions in await service.list_roles()] + + +@router.get("/permissions", response_model=list[PermissionOut], summary="Справочник прав") +async def list_permissions(service: UsersAdminServiceDep) -> list[PermissionOut]: + return [PermissionOut.model_validate(permission) for permission in await service.list_permissions()] + + +@router.put("/roles/{code}/permissions", response_model=RoleOut, summary="Заменить права роли") +async def set_role_permissions(code: str, data: PermissionsSet, service: UsersAdminServiceDep) -> RoleOut: + """Неизвестная роль — 404; неизвестный код права — 422 `UNKNOWN_PERMISSION`; снятие последнего `USERS_ADMIN` — + 409 `LAST_ADMIN`.""" + await service.set_role_permissions(code, data.permissions) + return _out(*await service.role_view(code)) diff --git a/api/src/api/endpoints/admin/users.py b/api/src/api/endpoints/admin/users.py new file mode 100644 index 0000000..47803d7 --- /dev/null +++ b/api/src/api/endpoints/admin/users.py @@ -0,0 +1,66 @@ +"""Пользователи `/admin/users` (spec users-admin): список, создание, изменение/деактивация, замена ролей. + +Удаления нет (D-8): пользователь только деактивируется (`is_active=false`). Неизвестный id — 404. +""" + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Query, status + +from src.api.dependencies.admin import UsersAdminServiceDep +from src.schemas.admin import RolesSet, UserCreate, UserListQuery, UserOut, UserUpdate +from src.schemas.common import Page, PageParams +from src.schemas.directories import RegionalOfficeOut +from src.services.users_admin import UserWithRoles + +router = APIRouter(prefix="/users") + + +class UserListParams(UserListQuery, PageParams): + """Query списка пользователей: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель).""" + + +def _out(view: UserWithRoles) -> UserOut: + user, office, roles = view + return UserOut( + id=user.id, + email=user.email, + full_name=user.full_name, + employee_no=user.employee_no, + regional_office=RegionalOfficeOut.model_validate(office), + business_segment=user.business_segment, + is_active=user.is_active, + roles=roles, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +@router.get("", response_model=Page[UserOut], summary="Список пользователей") +async def list_users(service: UsersAdminServiceDep, query: Annotated[UserListParams, Query()]) -> Page[UserOut]: + """Пользователи с филиалом и ролями: поиск `q` по email/ФИО, фильтр `is_active`, сортировка по ФИО.""" + page = PageParams(limit=query.limit, offset=query.offset) + rows, total = await service.list_users(query.q, query.is_active, page) + return Page[UserOut](items=[_out(row) for row in rows], total=total, limit=page.limit, offset=page.offset) + + +@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED, summary="Создать пользователя") +async def create_user(data: UserCreate, service: UsersAdminServiceDep) -> UserOut: + """Email уникален без учёта регистра (409 `EMAIL_EXISTS`); роли — коды активных ролей (иначе 422 `UNKNOWN_ROLE`).""" + user = await service.create_user(data) + return _out(await service.user_view(user.id)) + + +@router.patch("/{user_id}", response_model=UserOut, summary="Изменить или деактивировать пользователя") +async def update_user(user_id: uuid.UUID, data: UserUpdate, service: UsersAdminServiceDep) -> UserOut: + """Деактивация последнего активного обладателя `USERS_ADMIN` (и себя) — 409 `LAST_ADMIN`.""" + user = await service.update_user(user_id, data) + return _out(await service.user_view(user.id)) + + +@router.put("/{user_id}/roles", response_model=UserOut, summary="Заменить роли пользователя") +async def set_user_roles(user_id: uuid.UUID, data: RolesSet, service: UsersAdminServiceDep) -> UserOut: + """Набор ролей заменяется целиком; снятие последнего источника `USERS_ADMIN` — 409 `LAST_ADMIN`.""" + await service.set_roles(user_id, data.roles) + return _out(await service.user_view(user_id)) diff --git a/api/src/api/endpoints/dev_auth.py b/api/src/api/endpoints/dev_auth.py index e1708d6..681d9a9 100644 --- a/api/src/api/endpoints/dev_auth.py +++ b/api/src/api/endpoints/dev_auth.py @@ -7,13 +7,12 @@ from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel, Field, field_validator -from sqlalchemy.ext.asyncio import AsyncSession +from src.api.dependencies.db import SessionDep from src.core.auth import DevHs256Verifier from src.core.config import Settings -from src.db.db import get_async_session from src.repositories.users import AmbiguousUserError, load_principal_data USER_NOT_FOUND = "пользователь не найден или отключён" @@ -55,9 +54,7 @@ def build_dev_router(settings: Settings) -> APIRouter: router = APIRouter(tags=["dev"]) @router.post("/dev/token", response_model=DevTokenResponse, summary="Dev-токен по email (только DEBUG)") - async def issue_dev_token( - body: DevTokenRequest, session: Annotated[AsyncSession, Depends(get_async_session)] - ) -> DevTokenResponse: + async def issue_dev_token(body: DevTokenRequest, session: SessionDep) -> DevTokenResponse: """HS256 JWT на 8 часов для существующего активного пользователя.""" try: principal = await load_principal_data(session, body.email) diff --git a/api/src/api/endpoints/directories/__init__.py b/api/src/api/endpoints/directories/__init__.py new file mode 100644 index 0000000..9454158 --- /dev/null +++ b/api/src/api/endpoints/directories/__init__.py @@ -0,0 +1,12 @@ +"""Маршруты справочников `/api/v1/directories/*` (spec directories-api).""" + +from fastapi import APIRouter + +from src.api.endpoints.directories import company_groups, import_, legal_entities, regional_offices, related_groups + +router = APIRouter(prefix="/directories", tags=["directories"]) +router.include_router(regional_offices.router) +router.include_router(company_groups.router) +router.include_router(legal_entities.router) +router.include_router(related_groups.router) +router.include_router(import_.router) diff --git a/api/src/api/endpoints/directories/company_groups.py b/api/src/api/endpoints/directories/company_groups.py new file mode 100644 index 0000000..4aba2f1 --- /dev/null +++ b/api/src/api/endpoints/directories/company_groups.py @@ -0,0 +1,94 @@ +"""Маршруты ГК `/directories/company-groups` (spec directories-api): список, карточка, создание, изменение, архив. + +Право (`require`) проверяется до видимости объекта; чужая и несуществующая ГК — одинаковый 404 (spec directory-policy). +""" + +import uuid +from typing import Annotated + +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, + CompanyGroupFilter, + CompanyGroupHistoryOut, + CompanyGroupOut, + CompanyGroupUpdate, +) + +router = APIRouter(prefix="/company-groups") + +VIEW = [Depends(require("DIRECTORY_VIEW"))] +EDIT = [Depends(require("DIRECTORY_EDIT"))] + + +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, + query: Annotated[CompanyGroupListQuery, Query()], +) -> Page[CompanyGroupOut]: + """Видимые ГК: поиск `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 + ) + + +@router.post( + "", response_model=CompanyGroupOut, status_code=status.HTTP_201_CREATED, dependencies=EDIT, summary="Создать ГК" +) +async def create_company_group(data: CompanyGroupCreate, service: CompanyGroupServiceDep) -> CompanyGroupOut: + """Создаёт ГК с кодом `GK-NNNNNN`; без филиала пользователь филиала закрепляет ГК за своим.""" + return _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)) + + +@router.patch("/{group_id}", response_model=CompanyGroupOut, dependencies=EDIT, summary="Изменить ГК") +async def update_company_group( + group_id: uuid.UUID, data: CompanyGroupUpdate, service: CompanyGroupServiceDep +) -> 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)) + + +@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)) + + +@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)) + + +@router.get( + "/{group_id}/history", + response_model=list[CompanyGroupHistoryOut], + dependencies=VIEW, + 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)] diff --git a/api/src/api/endpoints/directories/import_.py b/api/src/api/endpoints/directories/import_.py new file mode 100644 index 0000000..3a45b94 --- /dev/null +++ b/api/src/api/endpoints/directories/import_.py @@ -0,0 +1,45 @@ +"""`POST /directories/import` — импорт выгрузки ЕЦП CRM (spec directory-import; `05-domain-rules.md` §13). + +multipart: `file` (xlsx/csv), `dry_run` (по умолчанию `true`), `on_existing` ∈ {`skip`, `update`} (по умолчанию +`skip`); право `DIRECTORY_ADMIN`. Размер больше `IMPORT_MAX_BYTES` — 413 без чтения файла в память целиком. +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, File, Form, UploadFile + +from src.api.dependencies.auth import require +from src.api.dependencies.directories import DirectoryImportServiceDep, SettingsDep +from src.domain.directory_import import OnExisting +from src.schemas.directory_import import ImportReportOut +from src.services.directory_import import check_size + +router = APIRouter() + + +async def _read_limited(file: UploadFile, limit: int) -> bytes: + """Содержимое загрузки не больше `limit` байт: размер из multipart, если известен, иначе чтение `limit + 1`.""" + if file.size is not None: + check_size(file.size, limit) + content = await file.read(limit + 1) + check_size(len(content), limit) + return content + + +@router.post( + "/import", + response_model=ImportReportOut, + dependencies=[Depends(require("DIRECTORY_ADMIN"))], + summary="Импорт выгрузки ЕЦП CRM", +) +async def import_directory( + file: Annotated[UploadFile, File(description="Выгрузка ЕЦП CRM: xlsx (первый лист) или csv (UTF-8, `;`/`,`)")], + service: DirectoryImportServiceDep, + settings: SettingsDep, + dry_run: Annotated[bool, Form(description="Пробный прогон: отчёт без записи в БД, аудит и S3")] = True, + on_existing: Annotated[OnExisting, Form(description="Существующие ГК/ЮЛ: оставить или обновить")] = OnExisting.skip, +) -> ImportReportOut: + """Отчёт: счётчики и построчные коды §13.2; при `dry_run = false` — запись в справочник, аудит и файл в S3.""" + content = await _read_limited(file, settings.IMPORT_MAX_BYTES) + report = await service.run(filename=file.filename or "", content=content, dry_run=dry_run, on_existing=on_existing) + return ImportReportOut.model_validate(report.to_dict()) diff --git a/api/src/api/endpoints/directories/legal_entities.py b/api/src/api/endpoints/directories/legal_entities.py new file mode 100644 index 0000000..62f1c86 --- /dev/null +++ b/api/src/api/endpoints/directories/legal_entities.py @@ -0,0 +1,126 @@ +"""Маршруты ЮЛ/ИП `/directories/legal-entities` (spec directories-api): список, карточка, создание, изменение, +перевод в другую ГК, вывод из состава, история, алиасы. + +Право (`require`) проверяется до видимости объекта; чужое и несуществующее ЮЛ — одинаковый 404 (spec directory-policy). +""" + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Query, status + +from src.api.dependencies.auth import require +from src.api.dependencies.directories import AliasServiceDep, LegalEntityServiceDep +from src.schemas.common import Page, PageParams +from src.schemas.directories import ( + AliasCreate, + AliasOut, + LegalEntityCreate, + LegalEntityDetail, + LegalEntityFilter, + LegalEntityHistoryOut, + LegalEntityMove, + LegalEntityOut, + LegalEntityUpdate, + LegalEntityWithdraw, +) + +router = APIRouter(prefix="/legal-entities") + +VIEW = [Depends(require("DIRECTORY_VIEW"))] +EDIT = [Depends(require("DIRECTORY_EDIT"))] +DELETE = [Depends(require("DIRECTORY_DELETE"))] + + +class LegalEntityListQuery(LegalEntityFilter, PageParams): + """Query списка ЮЛ: фильтры и пагинация одной моделью (FastAPI поддерживает одну Query-модель на маршрут).""" + + +@router.get("", response_model=Page[LegalEntityOut], dependencies=VIEW, summary="Список ЮЛ/ИП") +async def list_legal_entities( + service: LegalEntityServiceDep, + query: Annotated[LegalEntityListQuery, Query()], +) -> Page[LegalEntityOut]: + """Видимые ЮЛ: фильтры `company_group_id`, `outside_group`, `inn` (точно), `q` (name/ИНН), `closed`; по имени.""" + page = PageParams(limit=query.limit, offset=query.offset) + flt = LegalEntityFilter.model_validate(query.model_dump(include=set(LegalEntityFilter.model_fields))) + items, total = await service.page(flt, page) + return Page[LegalEntityOut]( + items=[LegalEntityOut.model_validate(entity) for entity in items], + total=total, + limit=page.limit, + offset=page.offset, + ) + + +@router.post( + "", response_model=LegalEntityOut, status_code=status.HTTP_201_CREATED, dependencies=EDIT, summary="Создать ЮЛ/ИП" +) +async def create_legal_entity(data: LegalEntityCreate, service: LegalEntityServiceDep) -> LegalEntityOut: + """Создаёт ЮЛ/ИП; без `company_group_id` — монозаёмщик; ИНН: ЮЛ — 10 цифр, ИП — 12.""" + return LegalEntityOut.model_validate(await service.create(data)) + + +@router.get("/{entity_id}", response_model=LegalEntityDetail, dependencies=VIEW, summary="Карточка ЮЛ/ИП") +async def get_legal_entity(entity_id: uuid.UUID, service: LegalEntityServiceDep) -> LegalEntityDetail: + """ЮЛ с текущими членствами ГСК/ГСЗ и алиасами.""" + return await service.get(entity_id) + + +@router.patch("/{entity_id}", response_model=LegalEntityOut, dependencies=EDIT, summary="Изменить ЮЛ/ИП") +async def update_legal_entity( + entity_id: uuid.UUID, data: LegalEntityUpdate, service: LegalEntityServiceDep +) -> LegalEntityOut: + """Меняет `name`/`entity_type`/`crm_id`/`regional_office_id`; прежние значения уходят в историю (`UPDATE`).""" + return LegalEntityOut.model_validate(await service.update(entity_id, data)) + + +@router.post("/{entity_id}/move", response_model=LegalEntityOut, dependencies=EDIT, summary="Перевести ЮЛ в другую ГК") +async def move_legal_entity( + entity_id: uuid.UUID, data: LegalEntityMove, service: LegalEntityServiceDep +) -> LegalEntityOut: + """Переводит ЮЛ в другую ГК с `effective_date` (≥ сегодня); текущие членства ГСК/ГСЗ закрываются.""" + return LegalEntityOut.model_validate(await service.move(entity_id, data)) + + +@router.post( + "/{entity_id}/withdraw", response_model=LegalEntityOut, dependencies=EDIT, summary="Вывести ЮЛ из состава ГК" +) +async def withdraw_legal_entity( + entity_id: uuid.UUID, data: LegalEntityWithdraw, service: LegalEntityServiceDep +) -> LegalEntityOut: + """`CLOSE` — закрытие (`close_date = effective_date`), `REMOVE_FROM_GROUP` — вывод; ГК снимается, членства + закрываются.""" + return LegalEntityOut.model_validate(await service.withdraw(entity_id, data)) + + +@router.get( + "/{entity_id}/history", + response_model=list[LegalEntityHistoryOut], + dependencies=VIEW, + 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)] + + +@router.post( + "/{entity_id}/aliases", + response_model=AliasOut, + status_code=status.HTTP_201_CREATED, + dependencies=EDIT, + summary="Добавить алиас ЮЛ/ИП", +) +async def add_alias(entity_id: uuid.UUID, data: AliasCreate, service: AliasServiceDep) -> AliasOut: + """Сохраняет `alias` как есть и `normalized_alias` по §3; повтор — 409 `ALIAS_EXISTS`, чужой — `ALIAS_TAKEN`.""" + return AliasOut.model_validate(await service.add(entity_id, data)) + + +@router.delete( + "/{entity_id}/aliases/{alias_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=DELETE, + summary="Удалить алиас ЮЛ/ИП", +) +async def delete_alias(entity_id: uuid.UUID, alias_id: uuid.UUID, service: AliasServiceDep) -> None: + await service.remove(entity_id, alias_id) diff --git a/api/src/api/endpoints/directories/regional_offices.py b/api/src/api/endpoints/directories/regional_offices.py new file mode 100644 index 0000000..f19f519 --- /dev/null +++ b/api/src/api/endpoints/directories/regional_offices.py @@ -0,0 +1,21 @@ +"""`GET /directories/regional-offices` — список филиалов (только чтение, spec directories-api).""" + +from fastapi import APIRouter, Depends + +from src.api.dependencies.auth import require +from src.api.dependencies.db import SessionDep +from src.repositories import directories as repo +from src.schemas.directories import RegionalOfficeOut + +router = APIRouter() + + +@router.get( + "/regional-offices", + response_model=list[RegionalOfficeOut], + dependencies=[Depends(require("DIRECTORY_VIEW"))], + summary="Филиалы", +) +async def list_regional_offices(session: SessionDep) -> list[RegionalOfficeOut]: + """Все филиалы (включая Головной офис) по имени.""" + return [RegionalOfficeOut.model_validate(office) for office in await repo.list_regional_offices(session)] diff --git a/api/src/api/endpoints/directories/related_groups.py b/api/src/api/endpoints/directories/related_groups.py new file mode 100644 index 0000000..2704e83 --- /dev/null +++ b/api/src/api/endpoints/directories/related_groups.py @@ -0,0 +1,108 @@ +"""Маршруты ГСК/ГСЗ (spec directories-api «ГСК и ГСЗ — группы и состав»): список и создание в ГК +(`/company-groups/{gk_id}/related-groups`), карточка, изменение, удаление, состав (`/related-groups/{id}…`). + +Право (`require`) проверяется до видимости; группа видна через её ГК, чужая и несуществующая — одинаковый 404 +(spec directory-policy). `DIRECTORY_DELETE` — только для удаления группы; исключение члена — `DIRECTORY_EDIT`. +""" + +import uuid + +from fastapi import APIRouter, Depends, status + +from src.api.dependencies.auth import require +from src.api.dependencies.directories import RelatedGroupServiceDep +from src.schemas.directories import ( + MemberAdd, + RelatedGroupCreate, + RelatedGroupDetail, + RelatedGroupMemberOut, + RelatedGroupOut, + RelatedGroupUpdate, +) + +router = APIRouter() + +VIEW = [Depends(require("DIRECTORY_VIEW"))] +EDIT = [Depends(require("DIRECTORY_EDIT"))] +DELETE = [Depends(require("DIRECTORY_DELETE"))] + + +@router.get( + "/company-groups/{company_group_id}/related-groups", + response_model=list[RelatedGroupOut], + dependencies=VIEW, + summary="ГСК/ГСЗ группы компаний", +) +async def list_related_groups(company_group_id: uuid.UUID, service: RelatedGroupServiceDep) -> list[RelatedGroupOut]: + """ГСК и ГСЗ ГК по имени с числом текущих членов.""" + return await service.list_for(company_group_id) + + +@router.post( + "/company-groups/{company_group_id}/related-groups", + response_model=RelatedGroupOut, + status_code=status.HTTP_201_CREATED, + dependencies=EDIT, + summary="Создать ГСК/ГСЗ", +) +async def create_related_group( + company_group_id: uuid.UUID, data: RelatedGroupCreate, service: RelatedGroupServiceDep +) -> RelatedGroupOut: + """Создаёт ГСК (`kind = gsk`, код `GSK-NNNNNN`) или ГСЗ (`gsz`, `GSZ-NNNNNN`) в ГК.""" + return await service.summary(await service.create(company_group_id, data)) + + +@router.get( + "/related-groups/{group_id}", response_model=RelatedGroupDetail, dependencies=VIEW, summary="Карточка ГСК/ГСЗ" +) +async def get_related_group(group_id: uuid.UUID, service: RelatedGroupServiceDep) -> RelatedGroupDetail: + """Группа со списком текущих членов (по имени ЮЛ).""" + return await service.get(group_id) + + +@router.patch( + "/related-groups/{group_id}", response_model=RelatedGroupOut, dependencies=EDIT, summary="Изменить ГСК/ГСЗ" +) +async def update_related_group( + group_id: uuid.UUID, data: RelatedGroupUpdate, service: RelatedGroupServiceDep +) -> RelatedGroupOut: + """Меняет `name`/`created_date`; вид и ГК группы не меняются.""" + return await service.summary(await service.update(group_id, data)) + + +@router.delete( + "/related-groups/{group_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=DELETE, + summary="Удалить пустую ГСК/ГСЗ", +) +async def delete_related_group(group_id: uuid.UUID, service: RelatedGroupServiceDep) -> None: + """Удаляет группу без текущих членов (иначе 409 `GROUP_NOT_EMPTY`) вместе с историей состава.""" + await service.delete(group_id) + + +@router.post( + "/related-groups/{group_id}/members", + response_model=RelatedGroupMemberOut, + status_code=status.HTTP_201_CREATED, + dependencies=EDIT, + summary="Добавить ЮЛ в ГСК/ГСЗ", +) +async def add_member(group_id: uuid.UUID, data: MemberAdd, service: RelatedGroupServiceDep) -> RelatedGroupMemberOut: + """ЮЛ той же ГК (иначе 422 `DIFFERENT_COMPANY_GROUP`); уже в группе этого вида — 409 `MEMBER_OF_OTHER_GROUP` + (с `related_group_id`), в этой же — 409 `MEMBER_EXISTS`.""" + member, entity = await service.add_member(group_id, data) + return RelatedGroupMemberOut( + legal_entity_id=entity.id, inn=entity.inn, name=entity.name, valid_from=member.valid_from + ) + + +@router.delete( + "/related-groups/{group_id}/members/{legal_entity_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=EDIT, + summary="Исключить ЮЛ из ГСК/ГСЗ", +) +async def remove_member(group_id: uuid.UUID, legal_entity_id: uuid.UUID, service: RelatedGroupServiceDep) -> None: + """Закрывает текущее членство (`valid_to = now`, `is_current = false`); строка остаётся в истории.""" + await service.remove_member(group_id, legal_entity_id) diff --git a/api/src/api/endpoints/me.py b/api/src/api/endpoints/me.py index 30be0b4..8ca157a 100644 --- a/api/src/api/endpoints/me.py +++ b/api/src/api/endpoints/me.py @@ -9,7 +9,8 @@ from typing import Annotated from fastapi import APIRouter, Depends from src.api.dependencies.auth import Principal, current_user -from src.schemas.me import MeResponse, RegionalOfficeOut +from src.schemas.directories import RegionalOfficeBrief +from src.schemas.me import MeResponse router = APIRouter(tags=["me"]) @@ -24,7 +25,7 @@ async def get_me(principal: Annotated[Principal, Depends(current_user)]) -> MeRe # пользователь найден по email, поэтому он заполнен; пустая строка — лишь для типов (колонка nullable) email=user.email or "", full_name=user.full_name, - regional_office=RegionalOfficeOut(id=office.id, name=office.name, is_head_office=office.is_head_office), + regional_office=RegionalOfficeBrief.model_validate(office), business_segment=user.business_segment, roles=sorted(principal.roles), permissions=sorted(principal.permissions), diff --git a/api/src/api/router.py b/api/src/api/router.py index ce6ef43..5feefb4 100644 --- a/api/src/api/router.py +++ b/api/src/api/router.py @@ -1,12 +1,13 @@ """Корневой роутер API `/api/v1` (R-6: эндпоинты — `src/api/endpoints/`). -`build_api_router(settings)` собирает роутер под настройки: `/me` — всегда, dev-эмитент токенов — только при -`AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и подключается в `src/app.py`. +`build_api_router(settings)` собирает роутер под настройки: `/me`, `/directories/*` и `/admin/*` — всегда, +dev-эмитент токенов — только при `AUTH_DEV_ISSUER and DEBUG`. Модульный `router` собран по настройкам процесса и +подключается в `src/app.py`. """ from fastapi import APIRouter -from src.api.endpoints import me +from src.api.endpoints import admin, directories, me from src.api.endpoints.dev_auth import build_dev_router from src.core.config import Settings, get_settings @@ -17,6 +18,8 @@ def build_api_router(settings: Settings) -> APIRouter: """Роутер `/api/v1` под настройки; dev-маршруты при любом другом сочетании флагов отсутствуют.""" api_router = APIRouter(prefix=API_PREFIX) api_router.include_router(me.router) + api_router.include_router(directories.router) + api_router.include_router(admin.router) if settings.AUTH_DEV_ISSUER and settings.DEBUG: api_router.include_router(build_dev_router(settings)) return api_router diff --git a/api/src/common/clock.py b/api/src/common/clock.py new file mode 100644 index 0000000..c7bf2e5 --- /dev/null +++ b/api/src/common/clock.py @@ -0,0 +1,8 @@ +"""Текущее время сервисов: одно место для «сейчас» (UTC, с часовым поясом) — `valid_from`/`valid_to`, `updated_at`.""" + +import datetime + + +def utc_now() -> datetime.datetime: + """Текущий момент в UTC (aware).""" + return datetime.datetime.now(datetime.UTC) diff --git a/api/src/common/constraint_conflicts.py b/api/src/common/constraint_conflicts.py new file mode 100644 index 0000000..e462ef1 --- /dev/null +++ b/api/src/common/constraint_conflicts.py @@ -0,0 +1,73 @@ +"""Гонки уникальных ограничений: имя нарушенного ограничения PostgreSQL → тот же код и текст 409, что у pre-check. + +Сервисы проверяют уникальность до вставки (`INN_EXISTS`, `ALIAS_EXISTS`, …), но между проверкой и вставкой параллельный +запрос может успеть раньше — тогда БД отвечает `unique_violation` (SQLSTATE 23505), и `IntegrityError` доходит до +обработчика исключений. Реестр `UNIQUE_CONSTRAINT_CONFLICTS` сопоставляет имя ограничения доменному конфликту, чтобы +клиент видел тот же `{"detail": {"code", "message"}}`, что и без гонки. Текст сообщения — только постоянная часть +pre-check-а: динамические поля (`legal_entity_id`, `related_group_id`) в ответ гонки не попадают. + +Имя ограничения берётся из структурированного поля драйвера (asyncpg: `__cause__.constraint_name`, psycopg: +`diag.constraint_name`), а не из текста ошибки: текст содержит DETAIL со значениями ключей (email, ИНН) — это ПДн. +""" + +from dataclasses import dataclass +from typing import Any + +from sqlalchemy.exc import DBAPIError + +UNIQUE_VIOLATION_SQLSTATE = "23505" + + +@dataclass(frozen=True) +class ConstraintConflict: + """Код и постоянный текст доменного конфликта 409.""" + + code: str + message: str + + +#: Имя уникального ограничения/индекса → конфликт. Ключи обязаны существовать в `Base.metadata` +#: (`tests/test_exception_handlers.py` падает при переименовании). Тексты совпадают с pre-check-ами сервисов. +UNIQUE_CONSTRAINT_CONFLICTS: dict[str, ConstraintConflict] = { + "uq_company_group_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ГК с таким crm_id уже существует"), + "uq_legal_entity_crm_id": ConstraintConflict("CRM_ID_EXISTS", "ЮЛ с таким crm_id уже существует"), + "uq_legal_entity_inn": ConstraintConflict("INN_EXISTS", "ЮЛ с таким ИНН уже существует"), + "uq_legal_entity_alias_legal_entity_id_normalized_alias": ConstraintConflict( + "ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас" + ), + "uq_app_user_email": ConstraintConflict("EMAIL_EXISTS", "Пользователь с таким email уже существует"), + "uq_related_group_member_group_entity_current": ConstraintConflict("MEMBER_EXISTS", "ЮЛ уже входит в эту группу"), + # Нарушается и при повторном добавлении в ту же группу (PostgreSQL проверяет индексы в порядке OID, а этот создан + # миграцией 0002 раньше второго), поэтому текст — общий для обоих случаев, а не «в другую группу» как у pre-check-а + "uq_related_group_member_entity_kind_current": ConstraintConflict( + "MEMBER_OF_OTHER_GROUP", "ЮЛ уже входит в группу этого вида" + ), +} + + +def _sqlstate(orig: Any) -> str | None: + value = getattr(orig, "sqlstate", None) or getattr(orig, "pgcode", None) + return value if isinstance(value, str) else None + + +def constraint_name(exc: DBAPIError) -> str | None: + """Имя нарушенного ограничения из полей драйвера (asyncpg — `__cause__`, psycopg — `diag`); текст не разбирается.""" + orig = exc.orig + if orig is None: + return None + for source in (getattr(orig, "__cause__", None), orig): + name = getattr(source, "constraint_name", None) + if isinstance(name, str) and name: + return name + diag_name = getattr(getattr(source, "diag", None), "constraint_name", None) + if isinstance(diag_name, str) and diag_name: + return diag_name + return None + + +def resolve_unique_conflict(exc: DBAPIError) -> ConstraintConflict | None: + """Конфликт из реестра, если это `unique_violation` по известному ограничению; иначе `None` (общий 409).""" + if _sqlstate(exc.orig) != UNIQUE_VIOLATION_SQLSTATE: + return None + name = constraint_name(exc) + return UNIQUE_CONSTRAINT_CONFLICTS.get(name) if name is not None else None diff --git a/api/src/common/exceptions.py b/api/src/common/exceptions.py index 3eaa18e..8e7360f 100644 --- a/api/src/common/exceptions.py +++ b/api/src/common/exceptions.py @@ -1,6 +1,47 @@ -class NotFoundError(Exception): - pass +"""Доменные ошибки (spec directories-api): обработчик отдаёт `{"detail": {"code", "message", **extra}}`. + +`code` — машинный код ошибки (`NOT_FOUND`, `INN_EXISTS`, …), `message` — текст по-русски, `extra` — дополнительные поля +ответа (например, `legal_entity_id` существующей записи). Ответы `HTTPException` (401/403 строкой) — отдельно. +""" + +from typing import Any + +from fastapi import status as http_status + +_RESERVED_KEYS = frozenset({"code", "message"}) -class CouldntCreateError(Exception): - pass +class DomainError(Exception): + """Базовая доменная ошибка с HTTP-статусом ответа.""" + + def __init__(self, status: int, code: str, message: str, extra: dict[str, Any] | None = None) -> None: + extra = dict(extra or {}) + shadowed = sorted(_RESERVED_KEYS & extra.keys()) + if shadowed: + raise ValueError(f"дополнительные поля ошибки не могут называться {', '.join(shadowed)}") + super().__init__(message) + self.status = status + self.code = code + self.message = message + self.extra = extra + + +class NotFoundError(DomainError): + """404: объекта нет или он вне видимости пользователя (тело одинаковое — без утечки существования).""" + + def __init__(self, message: str = "объект не найден") -> None: + super().__init__(http_status.HTTP_404_NOT_FOUND, "NOT_FOUND", message) + + +class ConflictError(DomainError): + """409: конфликт с существующими данными (`INN_EXISTS`, `MEMBER_EXISTS`, …).""" + + def __init__(self, code: str, message: str, **extra: Any) -> None: + super().__init__(http_status.HTTP_409_CONFLICT, code, message, extra) + + +class DomainValidationError(DomainError): + """422: нарушение доменного правила валидации (`INVALID_INN`, `DIFFERENT_COMPANY_GROUP`, …).""" + + def __init__(self, code: str, message: str, **extra: Any) -> None: + super().__init__(http_status.HTTP_422_UNPROCESSABLE_CONTENT, code, message, extra) diff --git a/api/src/core/config.py b/api/src/core/config.py index 47f093d..91fdeb6 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -76,6 +76,9 @@ class Settings(BaseSettings): 3 # число повторных публикаций до перевода в dlq (0 — сразу dlq без повторов) ) + # Импорт справочников из выгрузки ЕЦП (§13.3): предельный размер файла, больше — 413 + IMPORT_MAX_BYTES: PositiveInt = 20 * 1024 * 1024 + model_config = SettingsConfigDict( extra="ignore", env_file=os.environ.get("ENV_FILE", ".env"), diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 6ede971..3c480a7 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -1,9 +1,11 @@ """Обработчики исключений приложения: единый формат ответа `{"detail": ...}`. -Ошибки БД (`IntegrityError` → 409, прочие `SQLAlchemyError` → 500) пишутся в лог одной записью уровня ERROR без -текста исключения: `str()` ошибки SQLAlchemy/asyncpg содержит параметры запроса и DETAIL PostgreSQL со значениями -ключей (email, ИНН) — это персональные данные. В лог идут путь и метод запроса, классы ошибок, SQLSTATE, текст SQL -с плейсхолдерами (без значений, усечён) и стек вызовов без сообщения исключения. +`IntegrityError` → 409: для известных уникальных ограничений — доменный 409 с кодом из +`common/constraint_conflicts.py` (гонка pre-check/INSERT), иначе общая строка; прочие `SQLAlchemyError` → 500. +Ошибки БД пишутся в лог одной записью уровня ERROR без текста исключения: `str()` ошибки SQLAlchemy/asyncpg содержит +параметры запроса и DETAIL PostgreSQL со значениями ключей (email, ИНН) — это персональные данные. В лог идут путь и +метод запроса, классы ошибок, SQLSTATE, код разрешённого конфликта, текст SQL с плейсхолдерами (без значений, усечён) и +стек вызовов без сообщения исключения. """ import traceback @@ -15,27 +17,42 @@ from fastapi.responses import JSONResponse from loguru import logger from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError +from src.common.constraint_conflicts import resolve_unique_conflict +from src.common.exceptions import DomainError + LOG_STATEMENT_LIMIT = 200 # символов SQL-текста (с плейсхолдерами) в записи лога -def log_db_error(request: Request, exc: SQLAlchemyError, message: str) -> None: - """Одна запись ERROR об ошибке БД при обработке запроса — без параметров запроса и текста ошибки драйвера.""" +def log_db_error(request: Request, exc: SQLAlchemyError, message: str, *, resolved_code: str | None = None) -> None: + """Одна запись ERROR об ошибке БД при обработке запроса — без параметров запроса и текста ошибки драйвера. + + `resolved_code` — код доменного конфликта, в который отображена гонка уникальных ограничений (для диагностики). + """ orig = exc.orig if isinstance(exc, DBAPIError) else None statement = exc.statement if isinstance(exc, DBAPIError) else None logger.error( message + " path={path}, method={method}, error_type={error_type}, db_error_type={db_error_type}, " - "sqlstate={sqlstate}, statement={statement}\n{stack}", + "sqlstate={sqlstate}, resolved_code={resolved_code}, statement={statement}\n{stack}", path=request.url.path, method=request.method, error_type=type(exc).__name__, db_error_type=type(orig).__name__ if orig is not None else None, sqlstate=getattr(orig, "sqlstate", None), + resolved_code=resolved_code, statement=repr(statement[:LOG_STATEMENT_LIMIT]) if statement else None, stack="".join(traceback.format_tb(exc.__traceback__)).rstrip(), ) def register_exception_handlers(app: FastAPI) -> None: + # Доменные ошибки — до обработчиков `SQLAlchemyError`/`Exception` (spec directories-api: формат detail-объекта) + @app.exception_handler(DomainError) + async def domain_error_handler(_: Request, exc: DomainError): + return JSONResponse( + status_code=exc.status, + content={"detail": {"code": exc.code, "message": exc.message, **jsonable_encoder(exc.extra)}}, + ) + @app.exception_handler(RequestValidationError) async def request_validation_exception_handler(_: Request, exc: RequestValidationError): return JSONResponse( @@ -46,7 +63,19 @@ def register_exception_handlers(app: FastAPI) -> None: @app.exception_handler(IntegrityError) async def integrity_error_handler(request: Request, exc: IntegrityError): - log_db_error(request, exc, "Нарушение целостности данных в БД при обработке запроса.") + # Гонка pre-check/INSERT по известному уникальному ограничению → тот же 409 с кодом, что отдал бы pre-check + conflict = resolve_unique_conflict(exc) + log_db_error( + request, + exc, + "Нарушение целостности данных в БД при обработке запроса.", + resolved_code=conflict.code if conflict is not None else None, + ) + if conflict is not None: + return JSONResponse( + status_code=status.HTTP_409_CONFLICT, + content={"detail": {"code": conflict.code, "message": conflict.message}}, + ) return JSONResponse( status_code=status.HTTP_409_CONFLICT, content={"detail": "нарушение целостности данных в БД"}, diff --git a/api/src/db/db.py b/api/src/db/db.py index 44bf885..b32ef1a 100644 --- a/api/src/db/db.py +++ b/api/src/db/db.py @@ -1,3 +1,5 @@ +from collections.abc import AsyncIterator + from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine # from fastapi_users.db import SQLAlchemyUserDatabase, SQLAlchemyBaseUserTableUUID @@ -9,7 +11,11 @@ engine = create_async_engine( async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) -# @asynccontextmanager -async def get_async_session(): - async with async_session_maker() as session: +async def get_async_session() -> AsyncIterator[AsyncSession]: + """Сессия запроса = одна транзакция: commit при успешном ответе, rollback при любом исключении (R-1, design #12). + + В маршрутах объявляется только через `src.api.dependencies.db.SessionDep` (`scope="function"`): тогда commit + выполняется до отправки ответа, а все зависимости запроса получают одну и ту же сессию. + """ + async with async_session_maker() as session, session.begin(): yield session diff --git a/api/src/domain/__init__.py b/api/src/domain/__init__.py new file mode 100644 index 0000000..993f891 --- /dev/null +++ b/api/src/domain/__init__.py @@ -0,0 +1 @@ +"""Доменный слой: чистые функции по §§ правил `05-domain-rules.md`, без I/O (ни сессий БД, ни сети, ни файлов).""" diff --git a/api/src/domain/aliases.py b/api/src/domain/aliases.py new file mode 100644 index 0000000..9c1191c --- /dev/null +++ b/api/src/domain/aliases.py @@ -0,0 +1,41 @@ +"""Нормализация алиаса ЮЛ (§3 п. 4, ANL-028): ключ сопоставления контрагента из ОСВ с ЮЛ по тексту названия. + +Чистая функция без обращения к БД; одно и то же название в разных написаниях (`ООО «Пекарня»`, `Пекарня, ООО`) +даёт один и тот же `normalized_alias`. +""" + +import re + +# Одиночные токены ОПФ (§3 п. 4: «ООО, АО, ПАО, ИП…»); убираются только целиком — «ооо» внутри слова не трогается. +LEGAL_FORMS: frozenset[str] = frozenset( + { + "ооо", + "ао", + "пао", + "зао", + "оао", + "нао", + "ип", + "гуп", + "муп", + "фгуп", + "ано", + "тсж", + "кфх", + "пк", + "спк", + "сх", + "сха", + } +) + +_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE) # кавычки «»"'„“ и прочая пунктуация → пробел + + +def normalize_alias(raw: str) -> str: + """§3: lower → ё→е → пунктуация и кавычки → пробел → убрать токены ОПФ → схлопнуть пробелы → strip. + + Строка из одних ОПФ и пунктуации нормализуется в `""` — вызывающий отвергает такой алиас. + """ + text = _PUNCT_RE.sub(" ", raw.lower().replace("ё", "е")) + return " ".join(token for token in text.split() if token not in LEGAL_FORMS) diff --git a/api/src/domain/directory_import.py b/api/src/domain/directory_import.py new file mode 100644 index 0000000..58ae3c9 --- /dev/null +++ b/api/src/domain/directory_import.py @@ -0,0 +1,592 @@ +"""Импорт справочников из выгрузки ЕЦП CRM: чтение файла и план операций (`05-domain-rules.md` §13.1–13.2). + +Чистое ядро без БД (spec directory-import): `read_rows` превращает xlsx/csv в строки `ImportRow`, `plan_import` по +снимку справочника (`DirectorySnapshot`, строит сервис) решает судьбу каждой строки и возвращает операции (только +данные — применяет сервис) и отчёт с кодами §13.2. Идентификаторы новых объектов генерируются здесь (`uuid4`), чтобы +операции плана ссылались друг на друга до записи в БД. + +Технические пределы чтения (защита от повреждённых и «раздутых» файлов, не правила предметной области): +`MAX_IMPORT_ROWS` — число строк листа/записей csv, включая пустые (реальная выгрузка ЕЦП — 8400 строк); +`MAX_UNCOMPRESSED_BYTES` — суммарный распакованный размер частей xlsx (zip), проверяется до разбора. Превышение — +`UNSUPPORTED_FILE`. +""" + +import csv +import enum +import io +import re +import uuid +import zipfile +import zlib +from collections.abc import Iterable, Iterator, Sequence +from dataclasses import asdict, dataclass, field +from typing import Any + +from openpyxl import load_workbook +from openpyxl.utils.exceptions import InvalidFileException + +from src.common.exceptions import DomainValidationError +from src.domain.inn import normalize_inn, validate_inn +from src.models.enums import EntityType, RelatedGroupKind + + +class ImportCode(enum.StrEnum): + """Коды строк отчёта (§13.2). Итоговые: пять кодов пропуска и `LE_*`; остальные — сопутствующие события.""" + + UNSUPPORTED_TYPE = "UNSUPPORTED_TYPE" + INVALID_INN = "INVALID_INN" + DUPLICATE_IN_FILE = "DUPLICATE_IN_FILE" + LE_IN_OTHER_GROUP = "LE_IN_OTHER_GROUP" + GK_NAME_CONFLICT = "GK_NAME_CONFLICT" + LE_CREATED = "LE_CREATED" + LE_UPDATED = "LE_UPDATED" + LE_EXISTS = "LE_EXISTS" + RF_CREATED = "RF_CREATED" + GK_CREATED = "GK_CREATED" + GK_UPDATED = "GK_UPDATED" + GK_EXISTS = "GK_EXISTS" + GSK_CREATED = "GSK_CREATED" + GSZ_CREATED = "GSZ_CREATED" + MEMBER_ADDED = "MEMBER_ADDED" + UNKNOWN_FLAG = "UNKNOWN_FLAG" + + +class OnExisting(enum.StrEnum): + """Стратегия для существующих ГК/ЮЛ (§13.3): оставить или обновить имя/РФ/тип.""" + + skip = "skip" + update = "update" + + +# --------------------------------------------------------------------------- формат файла (§13.1) + +REQUIRED_COLUMNS = ("инн", "idгк", "группакомпаний", "организация", "тип") +OPTIONAL_COLUMNS = ("базовыйрфгквcrm", "гск", "гсз") +# Исходные имена колонок — для перечня в `MISSING_COLUMNS` +COLUMN_TITLES: dict[str, str] = { + "инн": "ИНН", + "idгк": "iD ГК", + "базовыйрфгквcrm": "Базовый РФ ГК в CRM", + "группакомпаний": "Группа компаний", + "организация": "Организация", + "гск": "ГСК", + "гсз": "ГСЗ", + "тип": "Тип", +} +FLAG_GSK = "входитвгск" +FLAG_GSZ = "входитвгсз" + +MISSING_COLUMNS = "MISSING_COLUMNS" +UNSUPPORTED_FILE = "UNSUPPORTED_FILE" + +MAX_IMPORT_ROWS = 100_000 +MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024 +# Сбои разбора повреждённого xlsx (zip, XML, ссылки ячеек, книга без листов); `SyntaxError` покрывает +# `xml.etree.ElementTree.ParseError`. openpyxl в read-only читает лист лениво — ошибки возникают и при итерации строк +_XLSX_ERRORS: tuple[type[BaseException], ...] = ( + zipfile.BadZipFile, + InvalidFileException, + KeyError, + ValueError, + IndexError, + OSError, + EOFError, + SyntaxError, + zlib.error, +) + +_WHITESPACE_RE = re.compile(r"\s+") + + +def normalize_header(raw: object) -> str: + """Заголовок/флаг для сопоставления: нижний регистр, без пробельных символов, `ё` = `е` (§13.1).""" + return _WHITESPACE_RE.sub("", normalize_cell(raw).lower()).replace("ё", "е") + + +def normalize_cell(raw: object) -> str: + """Значение ячейки строкой: `None` → `""`; целое число (в т.ч. `3528052718.0` из xlsx) — без дробной части; trim.""" + if raw is None: + return "" + if isinstance(raw, bool): + return str(raw) + if isinstance(raw, int): + return str(raw) + if isinstance(raw, float) and raw.is_integer(): + return str(int(raw)) + return str(raw).strip() + + +class FileFormatError(Exception): + """Ошибка файла целиком (§13.3: прерывает импорт): `MISSING_COLUMNS` (с перечнем) или `UNSUPPORTED_FILE`.""" + + def __init__(self, code: str, message: str, missing: list[str] | None = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.missing = missing if missing is not None else [] + + +@dataclass(frozen=True) +class ImportRow: + """Строка выгрузки после нормализации ячеек; `row` — номер строки в файле (1-based, как в Excel).""" + + row: int + inn: str + crm_id: str + office_name: str + group_name: str + entity_name: str + gsk_flag: str + gsz_flag: str + type_raw: str + + +def read_rows(filename: str, content: bytes) -> list[ImportRow]: + """Строки данных из xlsx (первый лист) или csv (UTF-8, `;`/`,`); формат выбирается по расширению имени файла. + + Заголовки — первая непустая строка, сопоставляются по `normalize_header`; пустые строки пропускаются. + """ + extension = filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if extension == "xlsx": + raw_rows = _xlsx_rows(content) + elif extension == "csv": + raw_rows = _csv_rows(content) + else: + raise FileFormatError(UNSUPPORTED_FILE, "поддерживаются только файлы xlsx и csv") + return _rows_from_table(raw_rows) + + +def _xlsx_rows(content: bytes) -> Iterator[tuple[int, list[str]]]: + """Строки первого листа лениво (пустые строки openpyxl отдаёт пустыми кортежами, номер = позиция в листе).""" + _check_uncompressed_size(content) + try: + workbook = load_workbook(io.BytesIO(content), read_only=True, data_only=True) + except _XLSX_ERRORS as error: + raise FileFormatError(UNSUPPORTED_FILE, "файл xlsx не читается") from error + try: + sheet = workbook.worksheets[0] + # Размер листа из файла бывает неверным (экспорт сторонних систем) — читаем все строки фактически + sheet.reset_dimensions() + for number, values in enumerate(sheet.iter_rows(values_only=True), start=1): + yield number, [normalize_cell(value) for value in values] + except _XLSX_ERRORS as error: + raise FileFormatError(UNSUPPORTED_FILE, "файл xlsx не читается") from error + finally: + workbook.close() + + +def _check_uncompressed_size(content: bytes) -> None: + try: + with zipfile.ZipFile(io.BytesIO(content)) as archive: + total = sum(info.file_size for info in archive.infolist()) + except _XLSX_ERRORS as error: + raise FileFormatError(UNSUPPORTED_FILE, "файл xlsx не читается") from error + if total > MAX_UNCOMPRESSED_BYTES: + raise FileFormatError(UNSUPPORTED_FILE, "файл xlsx слишком велик после распаковки") + + +def _csv_rows(content: bytes) -> Iterator[tuple[int, list[str]]]: + """Записи csv лениво; номер — физическая строка файла, с которой начинается запись (поля в кавычках бывают + многострочными).""" + try: + text = content.decode("utf-8-sig") + except UnicodeDecodeError as error: + raise FileFormatError(UNSUPPORTED_FILE, "кодировка csv не распознана: ожидается UTF-8") from error + header_line = next((line for line in text.splitlines() if line.strip()), "") + delimiter = ";" if header_line.count(";") > header_line.count(",") else "," + reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter) + start = 1 + try: + for values in reader: + yield start, [normalize_cell(value) for value in values] + start = reader.line_num + 1 + except csv.Error as error: + raise FileFormatError(UNSUPPORTED_FILE, "файл csv не читается") from error + + +def _rows_from_table(raw_rows: Iterable[tuple[int, list[str]]]) -> list[ImportRow]: + columns: dict[str, int] | None = None + result: list[ImportRow] = [] + for count, (number, cells) in enumerate(raw_rows, start=1): + # Считаются и пустые строки: лист с одной ячейкой в строке 2 000 000 отвергается, не дочитываясь + if count > MAX_IMPORT_ROWS: + raise FileFormatError(UNSUPPORTED_FILE, f"слишком много строк: больше {MAX_IMPORT_ROWS}") + if not any(cells): + continue + if columns is None: + columns = _header_columns(cells) + else: + result.append(_import_row(number, cells, columns)) + if columns is None: + _header_columns([]) + return result + + +def _import_row(number: int, cells: list[str], columns: dict[str, int]) -> ImportRow: + def cell(name: str) -> str: + index = columns.get(name) + return cells[index] if index is not None and index < len(cells) else "" + + return ImportRow( + row=number, + inn=cell("инн"), + crm_id=cell("idгк"), + office_name=cell("базовыйрфгквcrm"), + group_name=cell("группакомпаний"), + entity_name=cell("организация"), + gsk_flag=cell("гск"), + gsz_flag=cell("гсз"), + type_raw=cell("тип"), + ) + + +def _header_columns(cells: list[str]) -> dict[str, int]: + """Индексы известных колонок (первое вхождение); нет обязательной → `MISSING_COLUMNS` с исходными именами.""" + columns: dict[str, int] = {} + for index, value in enumerate(cells): + name = normalize_header(value) + if name in COLUMN_TITLES and name not in columns: + columns[name] = index + missing = [COLUMN_TITLES[name] for name in REQUIRED_COLUMNS if name not in columns] + if missing: + raise FileFormatError(MISSING_COLUMNS, "нет обязательных колонок: " + ", ".join(missing), missing) + return columns + + +# --------------------------------------------------------------------------- снимок справочника + + +@dataclass +class OfficeSnap: + id: uuid.UUID + name: str + + +@dataclass +class GroupSnap: + id: uuid.UUID + crm_id: str + name: str + office_id: uuid.UUID | None + gsk_id: uuid.UUID | None + gsz_id: uuid.UUID | None + + +@dataclass +class EntitySnap: + """ЮЛ/ИП; `gsk_group_id`/`gsz_group_id` — текущие членства (`related_group_member.is_current`).""" + + id: uuid.UUID + inn: str + name: str + entity_type: EntityType + group_id: uuid.UUID | None + gsk_group_id: uuid.UUID | None + gsz_group_id: uuid.UUID | None + + +@dataclass +class DirectorySnapshot: + offices_by_name: dict[str, OfficeSnap] + groups_by_crm: dict[str, GroupSnap] + entities_by_inn: dict[str, EntitySnap] + + +# --------------------------------------------------------------------------- операции плана + + +@dataclass(frozen=True) +class CreateOffice: + id: uuid.UUID + name: str + + +@dataclass(frozen=True) +class CreateGroup: + id: uuid.UUID + crm_id: str + name: str + office_id: uuid.UUID | None + + +@dataclass(frozen=True) +class UpdateGroup: + """`None` в поле — без изменений; офис меняется только непустым значением из файла.""" + + id: uuid.UUID + name: str | None + office_id: uuid.UUID | None + + +@dataclass(frozen=True) +class CreateEntity: + id: uuid.UUID + inn: str + name: str + entity_type: EntityType + group_id: uuid.UUID + office_id: uuid.UUID | None + + +@dataclass(frozen=True) +class UpdateEntity: + """`None` в поле — без изменений.""" + + id: uuid.UUID + name: str | None + entity_type: EntityType | None + + +@dataclass(frozen=True) +class CreateRelatedGroup: + id: uuid.UUID + group_id: uuid.UUID + kind: RelatedGroupKind + name: str + + +@dataclass(frozen=True) +class AddMember: + related_group_id: uuid.UUID + entity_id: uuid.UUID + kind: RelatedGroupKind + + +Operation = CreateOffice | CreateGroup | UpdateGroup | CreateEntity | UpdateEntity | CreateRelatedGroup | AddMember + + +# --------------------------------------------------------------------------- отчёт + + +@dataclass +class RowResult: + row: int + inn: str + code: ImportCode + message: str + + +@dataclass +class ImportReport: + """Счётчики и построчный список отчёта (§13.3); `errors` — число пропущенных строк.""" + + rows: int = 0 + groups_created: int = 0 + groups_updated: int = 0 + groups_skipped: int = 0 + entities_created: int = 0 + entities_updated: int = 0 + entities_skipped: int = 0 + memberships_created: int = 0 + offices_created: int = 0 + errors: int = 0 + lines: list[RowResult] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + """JSON-совместимый словарь (коды — строки) для ответа и `audit_event.after_value`.""" + data = asdict(self) + data["lines"] = [{**line, "code": str(line["code"])} for line in data["lines"]] + return data + + +@dataclass +class ImportPlan: + operations: list[Operation] + report: ImportReport + + +# --------------------------------------------------------------------------- план (§13.2) + +_ENTITY_TYPES: dict[str, EntityType] = {"юл": EntityType.ЮЛ, "ип": EntityType.ИП} +_FLAGS: tuple[tuple[RelatedGroupKind, str, str], ...] = ( + (RelatedGroupKind.gsk, FLAG_GSK, "ГСК"), + (RelatedGroupKind.gsz, FLAG_GSZ, "ГСЗ"), +) +_RELATED_CREATED = {RelatedGroupKind.gsk: ImportCode.GSK_CREATED, RelatedGroupKind.gsz: ImportCode.GSZ_CREATED} + + +@dataclass +class _GroupState: + """ГК, уже встреченная в файле: имя из первой строки, действующий РФ и ГСК/ГСЗ (из снимка или созданные).""" + + id: uuid.UUID + file_name: str + first_row: int + office_id: uuid.UUID | None + related: dict[RelatedGroupKind, uuid.UUID | None] + + +class _RowSkipped(Exception): + """Строка пропущена с итоговым кодом (код уже записан в отчёт).""" + + +class _Planner: + def __init__(self, snapshot: DirectorySnapshot, on_existing: OnExisting) -> None: + self.snapshot = snapshot + self.on_existing = on_existing + self.operations: list[Operation] = [] + self.report = ImportReport() + self.offices: dict[str, uuid.UUID] = {name: office.id for name, office in snapshot.offices_by_name.items()} + self.groups: dict[str, _GroupState] = {} + self.seen_inns: dict[str, int] = {} + + def line(self, row: ImportRow, inn: str, code: ImportCode, message: str) -> None: + self.report.lines.append(RowResult(row=row.row, inn=inn, code=code, message=message)) + + def skip(self, row: ImportRow, inn: str, code: ImportCode, message: str) -> _RowSkipped: + self.line(row, inn, code, message) + self.report.errors += 1 + return _RowSkipped() + + def plan_row(self, row: ImportRow) -> None: + inn = normalize_inn(row.inn) + # ASSUMPTION(OQ-87): типы вне {ЮЛ, ИП} (`нерез.`, `ФЛ`, `нет инн`, пусто) пропускаются + entity_type = _ENTITY_TYPES.get(row.type_raw.lower()) + if entity_type is None: + raise self.skip(row, inn, ImportCode.UNSUPPORTED_TYPE, f"тип «{row.type_raw}» не поддерживается: ЮЛ или ИП") + try: + # ASSUMPTION(OQ-88): только длина (ЮЛ — 10, ИП — 12) и цифры, контрольные разряды не проверяются + inn = validate_inn(inn, entity_type) + except DomainValidationError as error: + raise self.skip(row, inn, ImportCode.INVALID_INN, error.message) from error + if inn in self.seen_inns: + raise self.skip( + row, inn, ImportCode.DUPLICATE_IN_FILE, f"ИНН уже встречался в строке {self.seen_inns[inn]}" + ) + self.seen_inns[inn] = row.row + group = self.plan_group(row, inn) + entity_id, current = self.plan_entity(row, inn, entity_type, group) + self.plan_flags(row, inn, group, entity_id, current) + + def plan_office(self, row: ImportRow, inn: str) -> uuid.UUID | None: + name = row.office_name + if not name: + return None + office_id = self.offices.get(name) + if office_id is None: + # ASSUMPTION(OQ-89): РФ, которого нет в справочнике (в т.ч. «ЦКБ», «Головная организация»), создаётся + office_id = uuid.uuid4() + self.offices[name] = office_id + self.operations.append(CreateOffice(id=office_id, name=name)) + self.report.offices_created += 1 + self.line(row, inn, ImportCode.RF_CREATED, f"создан филиал «{name}»") + return office_id + + def plan_group(self, row: ImportRow, inn: str) -> _GroupState: + crm_id = row.crm_id + if not crm_id: + raise self.skip(row, inn, ImportCode.GK_NAME_CONFLICT, "пустой iD ГК") + state = self.groups.get(crm_id) + if state is not None: + if row.group_name != state.file_name: + message = ( + f"ГК {crm_id}: имя «{row.group_name}» расходится с «{state.file_name}» (строка {state.first_row})" + ) + raise self.skip(row, inn, ImportCode.GK_NAME_CONFLICT, message) + return state + office_id = self.plan_office(row, inn) + snap = self.snapshot.groups_by_crm.get(crm_id) + if snap is None: + group_id = uuid.uuid4() + self.operations.append(CreateGroup(id=group_id, crm_id=crm_id, name=row.group_name, office_id=office_id)) + self.report.groups_created += 1 + self.line(row, inn, ImportCode.GK_CREATED, f"создана ГК {crm_id} «{row.group_name}»") + state = _GroupState(group_id, row.group_name, row.row, office_id, {kind: None for kind in RelatedGroupKind}) + else: + state = self.existing_group(row, inn, snap, office_id) + self.groups[crm_id] = state + return state + + def existing_group(self, row: ImportRow, inn: str, snap: GroupSnap, office_id: uuid.UUID | None) -> _GroupState: + new_name = row.group_name if row.group_name != snap.name else None + new_office = office_id if office_id is not None and office_id != snap.office_id else None + effective_office = snap.office_id + if self.on_existing is OnExisting.update and (new_name is not None or new_office is not None): + self.operations.append(UpdateGroup(id=snap.id, name=new_name, office_id=new_office)) + self.report.groups_updated += 1 + self.line(row, inn, ImportCode.GK_UPDATED, f"обновлена ГК {snap.crm_id} «{row.group_name}»") + effective_office = new_office if new_office is not None else snap.office_id + else: + self.report.groups_skipped += 1 + self.line(row, inn, ImportCode.GK_EXISTS, f"ГК {snap.crm_id} «{snap.name}» уже есть") + related = {RelatedGroupKind.gsk: snap.gsk_id, RelatedGroupKind.gsz: snap.gsz_id} + return _GroupState(snap.id, row.group_name, row.row, effective_office, related) + + def plan_entity( + self, row: ImportRow, inn: str, entity_type: EntityType, group: _GroupState + ) -> tuple[uuid.UUID, EntitySnap | None]: + snap = self.snapshot.entities_by_inn.get(inn) + if snap is None: + entity_id = uuid.uuid4() + self.operations.append( + CreateEntity( + id=entity_id, + inn=inn, + name=row.entity_name, + entity_type=entity_type, + group_id=group.id, + office_id=group.office_id, + ) + ) + self.report.entities_created += 1 + self.line(row, inn, ImportCode.LE_CREATED, f"создано {entity_type.value} «{row.entity_name}»") + return entity_id, None + if snap.group_id != group.id: + # ASSUMPTION(OQ-90): ЮЛ из другой ГК (или без ГК) импорт не переводит — только явная операция §13.4 + raise self.skip(row, inn, ImportCode.LE_IN_OTHER_GROUP, f"«{snap.name}» уже состоит в другой ГК") + new_name = row.entity_name if row.entity_name != snap.name else None + new_type = entity_type if entity_type != snap.entity_type else None + if self.on_existing is OnExisting.update and (new_name is not None or new_type is not None): + self.operations.append(UpdateEntity(id=snap.id, name=new_name, entity_type=new_type)) + self.report.entities_updated += 1 + self.line(row, inn, ImportCode.LE_UPDATED, f"обновлено {entity_type.value} «{row.entity_name}»") + else: + self.report.entities_skipped += 1 + self.line(row, inn, ImportCode.LE_EXISTS, f"«{snap.name}» уже есть") + return snap.id, snap + + def plan_flags( + self, row: ImportRow, inn: str, group: _GroupState, entity_id: uuid.UUID, current: EntitySnap | None + ) -> None: + for kind, expected, title in _FLAGS: + raw = row.gsk_flag if kind is RelatedGroupKind.gsk else row.gsz_flag + if not raw: + continue + if normalize_header(raw) != expected: + self.line( + row, inn, ImportCode.UNKNOWN_FLAG, f"значение «{raw}» в колонке {title} не распознано: не входит" + ) + continue + related_id = group.related[kind] + if related_id is None: + # ASSUMPTION(OQ-91): одна группа вида на ГК, имя — имя ГК + related_id = uuid.uuid4() + group.related[kind] = related_id + self.operations.append( + CreateRelatedGroup(id=related_id, group_id=group.id, kind=kind, name=group.file_name) + ) + self.line(row, inn, _RELATED_CREATED[kind], f"создана {title} «{group.file_name}»") + membership = None + if current is not None: + membership = current.gsk_group_id if kind is RelatedGroupKind.gsk else current.gsz_group_id + # Текущее членство (в этой или другой группе того же вида) импорт не меняет и не закрывает + if membership is None: + self.operations.append(AddMember(related_group_id=related_id, entity_id=entity_id, kind=kind)) + self.report.memberships_created += 1 + self.line(row, inn, ImportCode.MEMBER_ADDED, f"добавлено в {title} «{group.file_name}»") + + +def plan_import(rows: Sequence[ImportRow], snapshot: DirectorySnapshot, on_existing: OnExisting) -> ImportPlan: + """План импорта по §13.2: строки в порядке файла, у каждой ровно один итоговый код и события того же `row`. + + Накопители «создано в этом файле» (РФ по имени, ГК по `iD ГК`, ГСК/ГСЗ по ГК, ИНН) гарантируют, что одна ГК из + многих строк создаётся один раз. Снимок не изменяется. + """ + planner = _Planner(snapshot, on_existing) + for row in rows: + planner.report.rows += 1 + try: + planner.plan_row(row) + except _RowSkipped: + continue + return ImportPlan(operations=planner.operations, report=planner.report) diff --git a/api/src/domain/inn.py b/api/src/domain/inn.py new file mode 100644 index 0000000..0fbecf8 --- /dev/null +++ b/api/src/domain/inn.py @@ -0,0 +1,31 @@ +"""ИНН ЮЛ/ИП: нормализация и проверка формата (spec directories-api «Юридическое лицо / ИП», §13.2). + +ИНН — строка (ведущие нули значимы: `0123456789` валиден), только ASCII-цифры 0–9; длина задаётся типом лица: +ЮЛ — 10, ИП — 12. Нечисловые значения (`LV…`, `РНН…`, `нерез.`) — `INVALID_INN`. Контрольные разряды не +проверяются (OQ-88). +""" + +import re + +from src.common.exceptions import DomainValidationError +from src.models.enums import EntityType + +INN_LENGTH: dict[EntityType, int] = {EntityType.ЮЛ: 10, EntityType.ИП: 12} + +_WHITESPACE_RE = re.compile(r"\s+") +_ASCII_DIGITS_RE = re.compile(r"[0-9]+") + + +def normalize_inn(raw: str) -> str: + """Убирает все пробельные символы (по краям и внутри): `" 3528 052 718 "` → `"3528052718"`.""" + return _WHITESPACE_RE.sub("", raw) + + +def validate_inn(inn: str, entity_type: EntityType) -> str: + """Нормализованный ИНН, если он из ровно `INN_LENGTH[entity_type]` цифр 0–9; иначе 422 `INVALID_INN`.""" + # ASSUMPTION(OQ-88): контрольные разряды не проверяются — только длина и состав символов + normalized = normalize_inn(inn) + length = INN_LENGTH[entity_type] + if len(normalized) != length or _ASCII_DIGITS_RE.fullmatch(normalized) is None: + raise DomainValidationError("INVALID_INN", f"ИНН {entity_type.value} — ровно {length} цифр") + return normalized diff --git a/api/src/domain/policy.py b/api/src/domain/policy.py new file mode 100644 index 0000000..f6b96f9 --- /dev/null +++ b/api/src/domain/policy.py @@ -0,0 +1,109 @@ +"""Policy видимости справочников (§10.1 `05-domain-rules.md`, spec directory-policy). + +Правило формулируется через данные, а не через роль (инвариант «права — данные», §10): пользователь головного +офиса (`regional_office.is_head_office`) видит всё; пользователь филиала — ГК с `base_regional_office_id` своего +филиала, их ЮЛ и ГСК/ГСЗ, а также монозаёмщиков своего филиала (`company_group_id IS NULL AND regional_office_id = …`). +Код роли (`role.code`) здесь не читается. Право (`require`) отвечает «может ли вообще», policy — «какие именно +записи»; объект вне видимости вызывающий превращает в 404 с тем же телом, что у несуществующего. + +ASSUMPTION(OQ-86): ЗО (`app_user.business_segment`) в Срезе 1 не фильтрует — у ГК и ЮЛ нет поля сегмента (DBML v5.14). +""" + +import uuid +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from sqlalchemy import Select, and_, or_, select + +from src.common.exceptions import DomainValidationError +from src.models.directories import CompanyGroup, LegalEntity + +if TYPE_CHECKING: + # Только для типов: домен не тянет репозитории в рантайме + from src.repositories.users import UserWithAccess + + +@dataclass(frozen=True) +class VisibilityScope: + """Область видимости справочников: `office_id is None` — вся система (ГО).""" + + office_id: uuid.UUID | None + + @property + def is_global(self) -> bool: + """Пользователь головного офиса — видит все записи (§10.1).""" + return self.office_id is None + + +def scope_for(*, is_head_office: bool, regional_office_id: uuid.UUID) -> VisibilityScope: + """Область видимости по данным о филиале пользователя (§10.1): ГО — вся система, иначе — свой филиал. + + ASSUMPTION(OQ-86): `business_segment` не учитывается. + """ + return VisibilityScope(office_id=None if is_head_office else regional_office_id) + + +def scope_of(principal: "UserWithAccess") -> VisibilityScope: + """Область видимости текущего пользователя (§10.1); роли и права `principal` не участвуют.""" + return scope_for( + is_head_office=principal.regional_office.is_head_office, + regional_office_id=principal.user.regional_office_id, + ) + + +def company_groups_visible(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]: + """Фильтр выборки ГК (§10.1): для филиала — `company_group.base_regional_office_id = <филиал>`. + + `stmt` должен ссылаться на `CompanyGroup` (выборка ГК или запрос, уже присоединивший ГК). Для ГО `stmt` не меняется. + """ + if scope.office_id is None: + return stmt + return stmt.where(CompanyGroup.base_regional_office_id == scope.office_id) + + +def legal_entities_visible(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]: + """Фильтр выборки ЮЛ (§10.1): для филиала — ЮЛ в ГК своего филиала ИЛИ монозаёмщик своего филиала. + + Принадлежность ГК филиалу проверяется подзапросом `company_group_id IN (SELECT id … WHERE base_regional_office_id = + <филиал>)`, а не JOIN: число строк не меняется, а запрос вызывающего может сам присоединять `company_group` без + конфликта имён. Филиал самого ЮЛ в группе не важен — видимость определяет ГК. Для ГО `stmt` не меняется. + """ + if scope.office_id is None: + return stmt + groups_of_office = select(CompanyGroup.id).where(CompanyGroup.base_regional_office_id == scope.office_id) + return stmt.where( + or_( + LegalEntity.company_group_id.in_(groups_of_office), + and_(LegalEntity.company_group_id.is_(None), LegalEntity.regional_office_id == scope.office_id), + ) + ) + + +def company_group_is_visible(group: CompanyGroup, scope: VisibilityScope) -> bool: + """Видна ли ГК пользователю (§10.1); то же правило, что :func:`company_groups_visible`.""" + return scope.office_id is None or group.base_regional_office_id == scope.office_id + + +def legal_entity_is_visible(entity: LegalEntity, group_office_id: uuid.UUID | None, scope: VisibilityScope) -> bool: + """Виден ли ЮЛ пользователю (§10.1); то же правило, что :func:`legal_entities_visible`. + + `group_office_id` — `base_regional_office_id` ГК этого ЮЛ (для монозаёмщика игнорируется). + """ + if scope.office_id is None: + return True + if entity.company_group_id is not None: + return group_office_id == scope.office_id + return entity.regional_office_id == scope.office_id + + +def office_for_create(scope: VisibilityScope, requested: uuid.UUID | None) -> uuid.UUID | None: + """Филиал закрепления новой ГК (spec directory-policy «Политика применяется до прав на изменение», §10.1). + + ГО — `requested` как есть (в т.ч. `None`); филиал — `None` превращается в свой филиал, свой принимается, чужой → + 422 `OFFICE_NOT_ALLOWED`. + """ + if scope.office_id is None: + return requested + if requested is None or requested == scope.office_id: + return scope.office_id + raise DomainValidationError("OFFICE_NOT_ALLOWED", "ГК можно закрепить только за своим филиалом") diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index fcbbb72..520bdda 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -1,4 +1,4 @@ -"""ORM-модели. Источник истины — docs/model/data-model-v5.13.dbml; заполняется в Срезе 0. +"""ORM-модели. Источник истины — docs/model/data-model-v5.14.dbml; заполняется в Срезе 0. Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic). """ @@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k from .base import Base, UUIDPrimaryMixin #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). -DBML_VERSION = "5.13" +DBML_VERSION = "5.14" __all__ = [ "DBML_VERSION", diff --git a/api/src/models/access.py b/api/src/models/access.py index cf8f275..9c6f0d4 100644 --- a/api/src/models/access.py +++ b/api/src/models/access.py @@ -1,4 +1,4 @@ -"""Часть 02 DBML v5.13 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" +"""Часть 02 DBML v5.14 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" import datetime import uuid diff --git a/api/src/models/accounting.py b/api/src/models/accounting.py index def7fd6..10a69b3 100644 --- a/api/src/models/accounting.py +++ b/api/src/models/accounting.py @@ -1,4 +1,4 @@ -"""Часть 03 DBML v5.13 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" +"""Часть 03 DBML v5.14 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" import datetime import uuid diff --git a/api/src/models/audit_ex.py b/api/src/models/audit_ex.py index 27c1915..4b8ae61 100644 --- a/api/src/models/audit_ex.py +++ b/api/src/models/audit_ex.py @@ -1,4 +1,4 @@ -"""Часть 09 DBML v5.13 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. +"""Часть 09 DBML v5.14 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов. `audit_event` — append-only, высокообъёмная (before/after — jsonb). Срезы ЕХ самодостаточны (v5.12): FK на консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `ex_snapshot.source_version_id` diff --git a/api/src/models/base.py b/api/src/models/base.py index 875f3d9..6436aed 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -6,7 +6,7 @@ from sqlalchemy import MetaData from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column -#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.13). +#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.14). SCHEMA = "dkb" #: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же diff --git a/api/src/models/consolidation.py b/api/src/models/consolidation.py index 53135a6..1af26eb 100644 --- a/api/src/models/consolidation.py +++ b/api/src/models/consolidation.py @@ -1,4 +1,4 @@ -"""Часть 07 DBML v5.13 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. +"""Часть 07 DBML v5.14 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы. Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из `osv_entry` × `consolidation_member` × `elimination_rule`. Версия после VERIFIED — read-only (ТЗ 6.10). diff --git a/api/src/models/directories.py b/api/src/models/directories.py index 7b0f0a8..849ac92 100644 --- a/api/src/models/directories.py +++ b/api/src/models/directories.py @@ -1,15 +1,20 @@ -"""Часть 01 DBML v5.13 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" +"""Часть 01 DBML v5.14 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2).""" import datetime import uuid -from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, String, text +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Sequence, String, text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column -from .base import Base, UUIDPrimaryMixin +from .base import SCHEMA, Base, UUIDPrimaryMixin from .enums import EntityType, LeHistoryEvent, RelatedGroupKind, pg_enum +#: Последовательности кодов ГК и ГСК/ГСЗ (design D-5): `GK-000001`, `GSK-000001`/`GSZ-000001`. В DBML не описываются +#: (не таблицы); создаются миграцией 0002 и `Base.metadata.create_all` (привязаны к metadata). +COMPANY_GROUP_CODE_SEQ = Sequence("company_group_code_seq", schema=SCHEMA, metadata=Base.metadata) +RELATED_GROUP_CODE_SEQ = Sequence("related_group_code_seq", schema=SCHEMA, metadata=Base.metadata) + class RegionalOffice(UUIDPrimaryMixin, Base): """Филиал: ГО и региональные филиалы (DECISION-3: ГО — обычная запись с `is_head_office`).""" @@ -148,10 +153,11 @@ class RelatedGroupMember(UUIDPrimaryMixin, Base): unique=True, postgresql_where=text("is_current"), ), - # legal_entity_id: partial UNIQUE WHERE is_current — ЮЛ только в одной ГСК или ГСЗ (ТЗ 6.1, ANL-030) + # (legal_entity_id, kind): partial UNIQUE WHERE is_current — v5.14: ЮЛ в одной ГСК И одной ГСЗ одновременно Index( - "uq_related_group_member_entity_current", + "uq_related_group_member_entity_kind_current", "legal_entity_id", + "kind", unique=True, postgresql_where=text("is_current"), ), @@ -163,6 +169,7 @@ class RelatedGroupMember(UUIDPrimaryMixin, Base): legal_entity_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False ) + kind: Mapped[RelatedGroupKind] = mapped_column(pg_enum("related_group_kind"), nullable=False) valid_from: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) valid_to: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) is_current: Mapped[bool] = mapped_column(Boolean, nullable=False) diff --git a/api/src/models/enums.py b/api/src/models/enums.py index 2b621c8..0c69eec 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -1,6 +1,6 @@ -"""Enum'ы модели данных DBML v5.13 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок. +"""Enum'ы модели данных DBML v5.14 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок. -Источник истины — docs/model/data-model-v5.13.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке +Источник истины — docs/model/data-model-v5.14.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима). Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`. @@ -269,6 +269,7 @@ class AuditAction(enum.StrEnum): CONSOLIDATION_APPROVE = "CONSOLIDATION_APPROVE" CONSOLIDATION_VERIFY = "CONSOLIDATION_VERIFY" GROUP_CHANGE = "GROUP_CHANGE" + LEGAL_ENTITY_CHANGE = "LEGAL_ENTITY_CHANGE" MEMBERSHIP_CHANGE = "MEMBERSHIP_CHANGE" ASSIGNMENT_CHANGE = "ASSIGNMENT_CHANGE" FILE_UPLOAD = "FILE_UPLOAD" @@ -288,6 +289,7 @@ class AuditAction(enum.StrEnum): EXPORT = "EXPORT" DIRECTORY_IMPORT = "DIRECTORY_IMPORT" USER_ROLE_CHANGE = "USER_ROLE_CHANGE" + USER_CHANGE = "USER_CHANGE" ROLE_PERMISSION_CHANGE = "ROLE_PERMISSION_CHANGE" diff --git a/api/src/models/files.py b/api/src/models/files.py index 84ff62f..6b2c51a 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -1,4 +1,4 @@ -"""Часть 04 DBML v5.13 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. +"""Часть 04 DBML v5.14 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания. Цикл FK `stored_file.current_recognition_job_id` -> `recognition_job` и `recognition_job.file_id` -> `stored_file` разорван через `use_alter=True` на nullable-стороне (`stored_file.current_recognition_job_id`): `create_all` создаёт diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py index dc0df0f..3ad377b 100644 --- a/api/src/models/kfo.py +++ b/api/src/models/kfo.py @@ -1,4 +1,4 @@ -"""Часть 08 DBML v5.13 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. +"""Часть 08 DBML v5.14 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`. diff --git a/api/src/models/osv.py b/api/src/models/osv.py index 3986b4f..a2011bf 100644 --- a/api/src/models/osv.py +++ b/api/src/models/osv.py @@ -1,4 +1,4 @@ -"""Часть 05 DBML v5.13 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). +"""Часть 05 DBML v5.14 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5). Первичка не мутируется: сырые значения хранятся как пришли, правка аналитика — рядом (`is_modified`, `original_values`, `modified_by/at` + audit_event(RECOGNIZED_DATA_EDIT)). diff --git a/api/src/models/statements.py b/api/src/models/statements.py index 877396c..3f6dff5 100644 --- a/api/src/models/statements.py +++ b/api/src/models/statements.py @@ -1,4 +1,4 @@ -"""Часть 06 DBML v5.13 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). +"""Часть 06 DBML v5.14 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением. Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету. diff --git a/api/src/repositories/directories.py b/api/src/repositories/directories.py new file mode 100644 index 0000000..0eea753 --- /dev/null +++ b/api/src/repositories/directories.py @@ -0,0 +1,443 @@ +"""Репозиторий справочников: ГК, ЮЛ, их история, членства ГСК/ГСЗ, алиасы, филиалы. + +SQL без бизнес-правил; видимость — `src.domain.policy`. + +Функции не делают `commit`: транзакцией запроса управляет `SessionDep`. +""" + +import datetime +import uuid +from typing import Any + +from sqlalchemy import Select, Sequence, and_, delete, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.directory_import import DirectorySnapshot, EntitySnap, GroupSnap, OfficeSnap +from src.domain.policy import VisibilityScope, company_groups_visible, legal_entities_visible +from src.models.directories import ( + CompanyGroup, + CompanyGroupHistory, + LegalEntity, + LegalEntityAlias, + LegalEntityHistory, + RegionalOffice, + RelatedGroup, + RelatedGroupMember, +) +from src.models.enums import RelatedGroupKind + +LIKE_ESCAPE = "\\" + + +def escape_like(text: str) -> str: + """Экранирует `\\`, `%` и `_`, чтобы пользовательская строка искалась как подстрока, а не как шаблон LIKE.""" + return text.replace(LIKE_ESCAPE, LIKE_ESCAPE * 2).replace("%", f"{LIKE_ESCAPE}%").replace("_", f"{LIKE_ESCAPE}_") + + +async def next_code(session: AsyncSession, seq: Sequence, prefix: str) -> str: + """Следующий код из последовательности PostgreSQL: `-NNNNNN` (6 цифр с нулями, D-5).""" + number = (await session.execute(select(seq.next_value()))).scalar_one() + return f"{prefix}-{number:06d}" + + +def _filtered_groups(stmt: Select[Any], scope: VisibilityScope, q: str | None, is_archived: bool | None) -> Select[Any]: + stmt = company_groups_visible(stmt, scope) + if q is not None and q.strip(): + pattern = f"%{escape_like(q.strip())}%" + stmt = stmt.where( + or_( + CompanyGroup.name.ilike(pattern, escape=LIKE_ESCAPE), + CompanyGroup.crm_id.ilike(pattern, escape=LIKE_ESCAPE), + CompanyGroup.code.ilike(pattern, escape=LIKE_ESCAPE), + ) + ) + if is_archived is not None: + stmt = stmt.where(CompanyGroup.is_archived.is_(is_archived)) + return stmt + + +async def list_company_groups( # noqa: PLR0913 - фильтры и пагинация списка передаются явными именованными аргументами + session: AsyncSession, + scope: VisibilityScope, + *, + q: str | None, + is_archived: bool | None, + limit: int, + offset: int, +) -> tuple[list[tuple[CompanyGroup, int]], int]: + """Страница видимых ГК (по `name`) со счётчиком ЮЛ и общее число записей по фильтру (отдельный `count()`).""" + legal_entities_count = ( + select(func.count(LegalEntity.id)) + .where(LegalEntity.company_group_id == CompanyGroup.id) + .correlate(CompanyGroup) + .scalar_subquery() + ) + page_stmt = ( + _filtered_groups(select(CompanyGroup, legal_entities_count), scope, q, is_archived) + .order_by(CompanyGroup.name, CompanyGroup.id) + .limit(limit) + .offset(offset) + ) + total_stmt = _filtered_groups(select(func.count(CompanyGroup.id)), scope, q, is_archived) + rows = (await session.execute(page_stmt)).all() + total = (await session.execute(total_stmt)).scalar_one() + return [(group, count) for group, count in rows], total + + +async def get_company_group(session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID) -> CompanyGroup | None: + """ГК по id; `None` и для несуществующей, и для невидимой (неразличимы для вызывающего).""" + stmt = company_groups_visible(select(CompanyGroup).where(CompanyGroup.id == group_id), scope) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def crm_id_taken(session: AsyncSession, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> bool: + """Есть ли у какой-либо ГК (кроме `except_group_id`) такой `crm_id` — без фильтра видимости: уникален глобально.""" + stmt = select(CompanyGroup.id).where(CompanyGroup.crm_id == crm_id) + if except_group_id is not None: + stmt = stmt.where(CompanyGroup.id != except_group_id) + return (await session.execute(stmt.limit(1))).first() is not None + + +async def count_legal_entities(session: AsyncSession, group_id: uuid.UUID) -> int: + """Число ЮЛ/ИП с `company_group_id = group_id`.""" + stmt = select(func.count(LegalEntity.id)).where(LegalEntity.company_group_id == group_id) + return (await session.execute(stmt)).scalar_one() + + +async def list_company_group_history(session: AsyncSession, group_id: uuid.UUID) -> list[CompanyGroupHistory]: + """Снимки ГК по убыванию `valid_from` (новые первыми).""" + stmt = ( + select(CompanyGroupHistory) + .where(CompanyGroupHistory.company_group_id == group_id) + .order_by(CompanyGroupHistory.valid_from.desc(), CompanyGroupHistory.id) + ) + return list((await session.execute(stmt)).scalars().all()) + + +def _filtered_entities( # noqa: PLR0913 - фильтры списка ЮЛ передаются явными именованными аргументами + stmt: Select[Any], + scope: VisibilityScope, + *, + company_group_id: uuid.UUID | None, + outside_group: bool | None, + inn: str | None, + q: str | None, + closed: bool | None, +) -> Select[Any]: + stmt = legal_entities_visible(stmt, scope) + if company_group_id is not None: + stmt = stmt.where(LegalEntity.company_group_id == company_group_id) + if outside_group is not None: + group_column = LegalEntity.company_group_id + stmt = stmt.where(group_column.is_(None) if outside_group else group_column.is_not(None)) + if inn is not None: + stmt = stmt.where(LegalEntity.inn == inn) + if q is not None and q.strip(): + pattern = f"%{escape_like(q.strip())}%" + stmt = stmt.where( + or_(LegalEntity.name.ilike(pattern, escape=LIKE_ESCAPE), LegalEntity.inn.ilike(pattern, escape=LIKE_ESCAPE)) + ) + if closed is not None: + stmt = stmt.where(LegalEntity.close_date.is_not(None) if closed else LegalEntity.close_date.is_(None)) + return stmt + + +async def list_legal_entities( # noqa: PLR0913 - фильтры и пагинация списка передаются явными именованными аргументами + session: AsyncSession, + scope: VisibilityScope, + *, + company_group_id: uuid.UUID | None, + outside_group: bool | None, + inn: str | None, + q: str | None, + closed: bool | None, + limit: int, + offset: int, +) -> tuple[list[LegalEntity], int]: + """Страница видимых ЮЛ (по `name`, `id`) и общее число записей по фильтру. + + `inn` — точное равенство (вызывающий уже сделал `strip`); `q` — подстрока `name`/`inn` без учёта регистра (R-4). + """ + filters: dict[str, Any] = { + "company_group_id": company_group_id, + "outside_group": outside_group, + "inn": inn, + "q": q, + "closed": closed, + } + page_stmt = ( + _filtered_entities(select(LegalEntity), scope, **filters) + .order_by(LegalEntity.name, LegalEntity.id) + .limit(limit) + .offset(offset) + ) + total_stmt = _filtered_entities(select(func.count(LegalEntity.id)), scope, **filters) + items = list((await session.execute(page_stmt)).scalars().all()) + total = (await session.execute(total_stmt)).scalar_one() + return items, total + + +async def get_legal_entity(session: AsyncSession, scope: VisibilityScope, entity_id: uuid.UUID) -> LegalEntity | None: + """ЮЛ по id; `None` и для несуществующего, и для невидимого (неразличимы для вызывающего).""" + stmt = legal_entities_visible(select(LegalEntity).where(LegalEntity.id == entity_id), scope) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def get_legal_entity_by_inn(session: AsyncSession, inn: str) -> LegalEntity | None: + """ЮЛ по ИНН без фильтра видимости — для проверки глобальной уникальности `inn`.""" + return (await session.execute(select(LegalEntity).where(LegalEntity.inn == inn))).scalar_one_or_none() + + +async def legal_entity_crm_id_taken( + session: AsyncSession, crm_id: str, *, except_entity_id: uuid.UUID | None = None +) -> bool: + """Есть ли у какого-либо ЮЛ (кроме `except_entity_id`) такой `crm_id` — без фильтра видимости.""" + stmt = select(LegalEntity.id).where(LegalEntity.crm_id == crm_id) + if except_entity_id is not None: + stmt = stmt.where(LegalEntity.id != except_entity_id) + return (await session.execute(stmt.limit(1))).first() is not None + + +async def list_legal_entity_history(session: AsyncSession, entity_id: uuid.UUID) -> list[LegalEntityHistory]: + """Снимки ЮЛ по убыванию `valid_from` (новые первыми, R-3).""" + stmt = ( + select(LegalEntityHistory) + .where(LegalEntityHistory.legal_entity_id == entity_id) + .order_by(LegalEntityHistory.valid_from.desc(), LegalEntityHistory.id) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def current_memberships( + session: AsyncSession, entity_id: uuid.UUID +) -> list[tuple[RelatedGroupMember, RelatedGroup]]: + """Текущие членства ЮЛ в ГСК/ГСЗ вместе с группой (для карточки), по виду и коду группы.""" + stmt = ( + select(RelatedGroupMember, RelatedGroup) + .join(RelatedGroup, RelatedGroup.id == RelatedGroupMember.related_group_id) + .where(RelatedGroupMember.legal_entity_id == entity_id, RelatedGroupMember.is_current.is_(True)) + .order_by(RelatedGroupMember.kind, RelatedGroup.code) + ) + return [(member, group) for member, group in (await session.execute(stmt)).all()] + + +async def close_memberships( + session: AsyncSession, entity_id: uuid.UUID, now: datetime.datetime +) -> list[RelatedGroupMember]: + """Закрывает все текущие членства ЮЛ (SCD2: `valid_to = now`, `is_current = false`); возвращает закрытые.""" + stmt = select(RelatedGroupMember).where( + RelatedGroupMember.legal_entity_id == entity_id, RelatedGroupMember.is_current.is_(True) + ) + members = list((await session.execute(stmt)).scalars().all()) + for member in members: + member.valid_to = now + member.is_current = False + await session.flush() + return members + + +def _current_members_count() -> Any: + """Скалярный подзапрос: число текущих членов группы `RelatedGroup` внешнего запроса.""" + return ( + select(func.count(RelatedGroupMember.id)) + .where(RelatedGroupMember.related_group_id == RelatedGroup.id, RelatedGroupMember.is_current.is_(True)) + .correlate(RelatedGroup) + .scalar_subquery() + ) + + +async def list_related_groups(session: AsyncSession, company_group_id: uuid.UUID) -> list[tuple[RelatedGroup, int]]: + """ГСК/ГСЗ ГК (по `name`, `id`) с числом текущих членов; видимость ГК проверяет вызывающий.""" + stmt = ( + select(RelatedGroup, _current_members_count()) + .where(RelatedGroup.company_group_id == company_group_id) + .order_by(RelatedGroup.name, RelatedGroup.id) + ) + return [(group, count) for group, count in (await session.execute(stmt)).all()] + + +async def get_related_group(session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID) -> RelatedGroup | None: + """ГСК/ГСЗ по id; видимость — через ГК (§10.1); `None` и для несуществующей, и для невидимой. + + `CompanyGroup` присоединяется явно: `company_groups_visible` фильтрует по колонке ГК и без JOIN дала бы + декартово произведение (T4.1). + """ + stmt = ( + select(RelatedGroup) + .join(CompanyGroup, CompanyGroup.id == RelatedGroup.company_group_id) + .where(RelatedGroup.id == group_id) + ) + return (await session.execute(company_groups_visible(stmt, scope))).scalar_one_or_none() + + +async def count_current_members(session: AsyncSession, related_group_id: uuid.UUID) -> int: + """Число текущих членов группы.""" + stmt = select(func.count(RelatedGroupMember.id)).where( + RelatedGroupMember.related_group_id == related_group_id, RelatedGroupMember.is_current.is_(True) + ) + return (await session.execute(stmt)).scalar_one() + + +async def has_current_members(session: AsyncSession, related_group_id: uuid.UUID) -> bool: + """Есть ли у группы хотя бы один текущий член.""" + return await count_current_members(session, related_group_id) > 0 + + +async def current_membership_of_kind( + session: AsyncSession, entity_id: uuid.UUID, kind: RelatedGroupKind +) -> RelatedGroupMember | None: + """Текущее членство ЮЛ в группе вида `kind` (не больше одного — частичный уникальный индекс v5.14).""" + stmt = select(RelatedGroupMember).where( + RelatedGroupMember.legal_entity_id == entity_id, + RelatedGroupMember.kind == kind, + RelatedGroupMember.is_current.is_(True), + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def current_membership_in_group( + session: AsyncSession, related_group_id: uuid.UUID, entity_id: uuid.UUID +) -> RelatedGroupMember | None: + """Текущее членство ЮЛ в конкретной группе.""" + stmt = select(RelatedGroupMember).where( + RelatedGroupMember.related_group_id == related_group_id, + RelatedGroupMember.legal_entity_id == entity_id, + RelatedGroupMember.is_current.is_(True), + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def current_members( + session: AsyncSession, related_group_id: uuid.UUID +) -> list[tuple[RelatedGroupMember, LegalEntity]]: + """Текущие члены группы вместе с ЮЛ, по имени ЮЛ.""" + stmt = ( + select(RelatedGroupMember, LegalEntity) + .join(LegalEntity, LegalEntity.id == RelatedGroupMember.legal_entity_id) + .where(RelatedGroupMember.related_group_id == related_group_id, RelatedGroupMember.is_current.is_(True)) + .order_by(LegalEntity.name, LegalEntity.id) + ) + return [(member, entity) for member, entity in (await session.execute(stmt)).all()] + + +async def list_group_member_rows(session: AsyncSession, related_group_id: uuid.UUID) -> list[RelatedGroupMember]: + """Все строки состава группы (текущие и закрытые), по `valid_from`.""" + stmt = ( + select(RelatedGroupMember) + .where(RelatedGroupMember.related_group_id == related_group_id) + .order_by(RelatedGroupMember.valid_from, RelatedGroupMember.id) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def delete_group_member_rows(session: AsyncSession, related_group_id: uuid.UUID) -> None: + """Удаляет все строки состава группы (вызывать только для группы без текущих членов).""" + await session.execute(delete(RelatedGroupMember).where(RelatedGroupMember.related_group_id == related_group_id)) + + +async def list_aliases(session: AsyncSession, entity_id: uuid.UUID) -> list[LegalEntityAlias]: + """Алиасы ЮЛ по времени добавления.""" + stmt = ( + select(LegalEntityAlias) + .where(LegalEntityAlias.legal_entity_id == entity_id) + .order_by(LegalEntityAlias.created_at, LegalEntityAlias.id) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def find_alias_owner(session: AsyncSession, normalized: str) -> LegalEntityAlias | None: + """Алиас с таким `normalized_alias` у любого ЮЛ — без фильтра видимости: уникальность глобальная (ANL-028).""" + stmt = ( + select(LegalEntityAlias) + .where(LegalEntityAlias.normalized_alias == normalized) + .order_by(LegalEntityAlias.created_at, LegalEntityAlias.id) + .limit(1) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def get_alias(session: AsyncSession, entity_id: uuid.UUID, alias_id: uuid.UUID) -> LegalEntityAlias | None: + """Алиас по id, только если он принадлежит ЮЛ `entity_id`.""" + stmt = select(LegalEntityAlias).where( + LegalEntityAlias.id == alias_id, LegalEntityAlias.legal_entity_id == entity_id + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def get_regional_office(session: AsyncSession, office_id: uuid.UUID) -> RegionalOffice | None: + return await session.get(RegionalOffice, office_id) + + +async def list_regional_offices(session: AsyncSession) -> list[RegionalOffice]: + """Все филиалы по имени.""" + return list((await session.execute(select(RegionalOffice).order_by(RegionalOffice.name))).scalars().all()) + + +async def load_snapshot(session: AsyncSession) -> DirectorySnapshot: + """Снимок справочника для плана импорта (§13.2) тремя запросами, без фильтра видимости (импорт — администратор). + + Филиалы — по имени. ГК — только с `crm_id` (импорт сопоставляет по `iD ГК`), включая архивные: иначе план создал + бы ГК с занятым `crm_id`; ГСК/ГСЗ ГК — первая по `code` группа каждого вида. ЮЛ — по ИНН с текущими членствами + (`is_current`) каждого вида. + """ + offices = { + office.name: OfficeSnap(id=office.id, name=office.name) + for office in (await session.execute(select(RegionalOffice))).scalars() + } + + groups: dict[str, GroupSnap] = {} + group_stmt = ( + select( + CompanyGroup.id, + CompanyGroup.crm_id, + CompanyGroup.name, + CompanyGroup.base_regional_office_id, + RelatedGroup.id, + RelatedGroup.kind, + ) + .outerjoin(RelatedGroup, RelatedGroup.company_group_id == CompanyGroup.id) + .where(CompanyGroup.crm_id.is_not(None)) + .order_by(CompanyGroup.id, RelatedGroup.code) + ) + for group_id, crm_id, name, office_id, related_id, kind in (await session.execute(group_stmt)).all(): + snap = groups.get(crm_id) + if snap is None: + snap = GroupSnap(id=group_id, crm_id=crm_id, name=name, office_id=office_id, gsk_id=None, gsz_id=None) + groups[crm_id] = snap + if kind is RelatedGroupKind.gsk and snap.gsk_id is None: + snap.gsk_id = related_id + elif kind is RelatedGroupKind.gsz and snap.gsz_id is None: + snap.gsz_id = related_id + + entities: dict[str, EntitySnap] = {} + entity_stmt = select( + LegalEntity.id, + LegalEntity.inn, + LegalEntity.name, + LegalEntity.entity_type, + LegalEntity.company_group_id, + RelatedGroupMember.related_group_id, + RelatedGroupMember.kind, + ).outerjoin( + RelatedGroupMember, + and_(RelatedGroupMember.legal_entity_id == LegalEntity.id, RelatedGroupMember.is_current.is_(True)), + ) + for entity_id, inn, name, entity_type, group_id, related_id, kind in (await session.execute(entity_stmt)).all(): + entity = entities.get(inn) + if entity is None: + entity = EntitySnap( + id=entity_id, + inn=inn, + name=name, + entity_type=entity_type, + group_id=group_id, + gsk_group_id=None, + gsz_group_id=None, + ) + entities[inn] = entity + if kind is RelatedGroupKind.gsk: + entity.gsk_group_id = related_id + elif kind is RelatedGroupKind.gsz: + entity.gsz_group_id = related_id + + return DirectorySnapshot(offices_by_name=offices, groups_by_crm=groups, entities_by_inn=entities) diff --git a/api/src/repositories/users.py b/api/src/repositories/users.py index 5c66039..044a57e 100644 --- a/api/src/repositories/users.py +++ b/api/src/repositories/users.py @@ -1,17 +1,23 @@ """Репозиторий пользователей: личность и эффективные права одним запросом (spec authn-authz, D-5, R-17). `effective_permissions(user) = ∪ permission.code по всем активным ролям пользователя` (CLAUDE.md: права — данные). -Код роли в проверках не участвует: роли возвращаются только для показа (`GET /me`). +Код роли в проверках не участвует: роли возвращаются только для показа (`GET /me`, список администратора). +Запросы администрирования (spec users-admin): списки пользователей/ролей/прав, назначения, подсчёт обладателей права. +Функции не делают `commit`: транзакцией запроса управляет `SessionDep`. """ +import uuid +from collections.abc import Collection, Sequence from dataclasses import dataclass +from typing import Any -from sqlalchemy import and_, func, select +from sqlalchemy import Select, and_, delete, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import aliased from src.models.access import AppUser, Permission, Role, RolePermission, UserRole from src.models.directories import RegionalOffice +from src.repositories.directories import LIKE_ESCAPE, escape_like # Достаточно двух строк, чтобы заметить неоднозначность; больше пользователей читать незачем _AMBIGUITY_PROBE_LIMIT = 2 @@ -71,3 +77,185 @@ async def load_principal_data(session: AsyncSession, email: str) -> UserWithAcce roles=tuple(sorted(roles)), permissions=permissions, ) + + +# --- Администрирование (spec users-admin, D-8) ---------------------------------------------------------------------- + + +def _filtered_users(stmt: Select[Any], q: str | None, is_active: bool | None) -> Select[Any]: + if q is not None and q.strip(): + pattern = f"%{escape_like(q.strip())}%" + stmt = stmt.where( + or_(AppUser.email.ilike(pattern, escape=LIKE_ESCAPE), AppUser.full_name.ilike(pattern, escape=LIKE_ESCAPE)) + ) + if is_active is not None: + stmt = stmt.where(AppUser.is_active.is_(is_active)) + return stmt + + +async def list_users( + session: AsyncSession, *, q: str | None, is_active: bool | None, limit: int, offset: int +) -> tuple[list[tuple[AppUser, RegionalOffice]], int]: + """Страница пользователей с филиалом (по ФИО, затем id) и общее число по фильтру.""" + page_stmt = ( + _filtered_users(select(AppUser, RegionalOffice), q, is_active) + .join(RegionalOffice, RegionalOffice.id == AppUser.regional_office_id) + .order_by(AppUser.full_name, AppUser.id) + .limit(limit) + .offset(offset) + ) + total_stmt = _filtered_users(select(func.count(AppUser.id)), q, is_active) + rows = (await session.execute(page_stmt)).all() + total = (await session.execute(total_stmt)).scalar_one() + return [(user, office) for user, office in rows], total + + +async def get_user(session: AsyncSession, user_id: uuid.UUID) -> AppUser | None: + """Пользователь по id.""" + return await session.get(AppUser, user_id) + + +async def email_taken(session: AsyncSession, normalized_email: str) -> bool: + """Есть ли пользователь с таким email без учёта регистра (`normalized_email` — уже `strip().lower()`).""" + stmt = select(AppUser.id).where(func.lower(AppUser.email) == normalized_email).limit(1) + return (await session.execute(stmt)).first() is not None + + +async def role_codes_by_user(session: AsyncSession, user_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, list[str]]: + """Коды НАЗНАЧЕННЫХ ролей (включая неактивные роли) по пользователям, по алфавиту.""" + if not user_ids: + return {} + stmt = ( + select(UserRole.user_id, Role.code) + .join(Role, Role.id == UserRole.role_id) + .where(UserRole.user_id.in_(user_ids)) + .order_by(Role.code) + ) + result: dict[uuid.UUID, list[str]] = {user_id: [] for user_id in user_ids} + for user_id, code in (await session.execute(stmt)).all(): + result[user_id].append(code) + return result + + +async def user_roles(session: AsyncSession, user_id: uuid.UUID) -> list[tuple[UserRole, Role]]: + """Назначения ролей пользователя вместе с ролями.""" + stmt = select(UserRole, Role).join(Role, Role.id == UserRole.role_id).where(UserRole.user_id == user_id) + return [(user_role, role) for user_role, role in (await session.execute(stmt)).all()] + + +async def delete_user_roles(session: AsyncSession, user_id: uuid.UUID, role_ids: Collection[uuid.UUID]) -> None: + """Снимает с пользователя назначения указанных ролей.""" + if role_ids: + await session.execute(delete(UserRole).where(UserRole.user_id == user_id, UserRole.role_id.in_(role_ids))) + + +async def roles_by_codes(session: AsyncSession, codes: Collection[str]) -> list[Role]: + """Роли с указанными кодами (любого статуса).""" + if not codes: + return [] + return list((await session.execute(select(Role).where(Role.code.in_(codes)))).scalars().all()) + + +async def get_role_by_code(session: AsyncSession, code: str) -> Role | None: + """Роль по коду.""" + return (await session.execute(select(Role).where(Role.code == code))).scalar_one_or_none() + + +async def list_roles_with_permissions(session: AsyncSession) -> list[tuple[Role, list[str]]]: + """Все роли (по коду) с кодами их прав (по алфавиту).""" + roles = list((await session.execute(select(Role).order_by(Role.code))).scalars().all()) + stmt = ( + select(RolePermission.role_id, Permission.code) + .join(Permission, Permission.id == RolePermission.permission_id) + .order_by(Permission.code) + ) + codes: dict[uuid.UUID, list[str]] = {role.id: [] for role in roles} + for role_id, code in (await session.execute(stmt)).all(): + codes.setdefault(role_id, []).append(code) + return [(role, codes[role.id]) for role in roles] + + +async def role_permissions(session: AsyncSession, role_id: uuid.UUID) -> list[Permission]: + """Права роли (по коду).""" + stmt = ( + select(Permission) + .join(RolePermission, RolePermission.permission_id == Permission.id) + .where(RolePermission.role_id == role_id) + .order_by(Permission.code) + ) + return list((await session.execute(stmt)).scalars().all()) + + +async def delete_role_permissions( + session: AsyncSession, role_id: uuid.UUID, permission_ids: Collection[uuid.UUID] +) -> None: + """Снимает с роли указанные права.""" + if permission_ids: + await session.execute( + delete(RolePermission).where( + RolePermission.role_id == role_id, RolePermission.permission_id.in_(permission_ids) + ) + ) + + +async def list_permissions(session: AsyncSession) -> list[Permission]: + """Справочник прав (по коду).""" + return list((await session.execute(select(Permission).order_by(Permission.code))).scalars().all()) + + +async def permissions_by_codes(session: AsyncSession, codes: Collection[str]) -> list[Permission]: + """Права с указанными кодами.""" + if not codes: + return [] + return list((await session.execute(select(Permission).where(Permission.code.in_(codes)))).scalars().all()) + + +async def lock_permission(session: AsyncSession, code: str) -> None: + """`SELECT … FOR NO KEY UPDATE` строки права: сериализует проверки «последнего обладателя права» между транзакциями. + + Пока транзакция держит блокировку, конкурентная проверка того же права ждёт её завершения и затем считает уже + зафиксированное состояние (READ COMMITTED: каждый оператор видит данные, зафиксированные до его начала). + `NO KEY UPDATE` не мешает вставкам `role_permission` со ссылкой на это право (FK берёт `FOR KEY SHARE`). + """ + await session.execute(select(Permission.id).where(Permission.code == code).with_for_update(key_share=True)) + + +async def roles_grant_permission(session: AsyncSession, role_ids: Collection[uuid.UUID], code: str) -> bool: + """Даёт ли хотя бы одна из АКТИВНЫХ ролей `role_ids` право `code`.""" + if not role_ids: + return False + stmt = ( + select(Role.id) + .join(RolePermission, RolePermission.role_id == Role.id) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(Role.id.in_(role_ids), Role.is_active.is_(True), Permission.code == code) + .limit(1) + ) + return (await session.execute(stmt)).first() is not None + + +async def count_active_users_with_permission( + session: AsyncSession, + code: str, + *, + excluding_user: uuid.UUID | None = None, + excluding_role: uuid.UUID | None = None, +) -> int: + """Число АКТИВНЫХ пользователей, которым АКТИВНЫЕ роли дают право `code` (как `effective_permissions`). + + `excluding_user` — не считать этого пользователя; `excluding_role` — не считать право, полученное через эту роль + (пользователь учитывается, если право даёт другая его роль). + """ + stmt = ( + select(func.count(func.distinct(AppUser.id))) + .join(UserRole, UserRole.user_id == AppUser.id) + .join(Role, and_(Role.id == UserRole.role_id, Role.is_active.is_(True))) + .join(RolePermission, RolePermission.role_id == Role.id) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(AppUser.is_active.is_(True), Permission.code == code) + ) + if excluding_user is not None: + stmt = stmt.where(AppUser.id != excluding_user) + if excluding_role is not None: + stmt = stmt.where(Role.id != excluding_role) + return (await session.execute(stmt)).scalar_one() diff --git a/api/src/schemas/admin.py b/api/src/schemas/admin.py new file mode 100644 index 0000000..b5e1429 --- /dev/null +++ b/api/src/schemas/admin.py @@ -0,0 +1,150 @@ +"""Схемы API администрирования `/admin/*` (spec users-admin, design D-8): пользователи, роли, матрица прав. + +Поля — как в DBML v5.14 (`app_user`, `role`, `permission`); `external_id` через API не задаётся (появится при первом +входе через РАИСА), поэтому тела запросов запрещают лишние поля. +""" + +import datetime +import uuid +from typing import Self + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator + +from src.models.enums import BusinessSegment, PermissionAction, PermissionObject +from src.schemas.directories import NAME_MAX_LENGTH, RegionalOfficeOut + +# Поля `app_user`, которые не могут быть NULL: явный `null` в PATCH — ошибка валидации, а не «очистить» +_NOT_NULL_UPDATE_FIELDS = ("full_name", "regional_office_id", "is_active") + + +def _strip_full_name(value: str) -> str: + stripped = value.strip() + if not stripped: + raise ValueError("ФИО не может быть пустым") + return stripped + + +def _blank_to_none(value: str | None) -> str | None: + if value is None: + return None + return value.strip() or None + + +class UserCreate(BaseModel): + """Создание пользователя: email уникален без учёта регистра (проверяет сервис), роли — коды активных ролей.""" + + model_config = ConfigDict(extra="forbid") + + email: EmailStr + full_name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) + regional_office_id: uuid.UUID + employee_no: str | None = None + business_segment: BusinessSegment | None = None + roles: list[str] = Field(default_factory=list) + + @field_validator("full_name") + @classmethod + def _full_name(cls, value: str) -> str: + return _strip_full_name(value) + + @field_validator("employee_no") + @classmethod + def _employee_no(cls, value: str | None) -> str | None: + return _blank_to_none(value) + + +class UserUpdate(BaseModel): + """Изменение пользователя; «поле передано» — по `model_fields_set`. + + `employee_no` и `business_segment` можно очистить (`null`); `full_name`, `regional_office_id`, `is_active` — нет. + """ + + model_config = ConfigDict(extra="forbid") + + full_name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH) + employee_no: str | None = None + regional_office_id: uuid.UUID | None = None + business_segment: BusinessSegment | None = None + is_active: bool | None = None + + @field_validator("full_name") + @classmethod + def _full_name(cls, value: str | None) -> str | None: + return None if value is None else _strip_full_name(value) + + @field_validator("employee_no") + @classmethod + def _employee_no(cls, value: str | None) -> str | None: + return _blank_to_none(value) + + @model_validator(mode="after") + def _no_null_for_required(self) -> Self: + nulls = [ + name for name in _NOT_NULL_UPDATE_FIELDS if name in self.model_fields_set and getattr(self, name) is None + ] + if nulls: + raise ValueError(f"поля не могут быть null: {', '.join(nulls)}") + return self + + +class UserOut(BaseModel): + """Пользователь для администратора: филиал целиком и коды назначенных ролей (по алфавиту).""" + + model_config = ConfigDict(use_enum_values=True) + + id: uuid.UUID + email: str | None + full_name: str + employee_no: str | None + regional_office: RegionalOfficeOut + business_segment: BusinessSegment | None + is_active: bool + roles: list[str] + created_at: datetime.datetime | None + updated_at: datetime.datetime | None + + +class UserListQuery(BaseModel): + """Фильтры списка пользователей: `q` — подстрока email или ФИО без учёта регистра, `is_active`.""" + + q: str | None = None + is_active: bool | None = None + + +class RolesSet(BaseModel): + """Новый набор ролей пользователя (коды); заменяет прежний целиком.""" + + model_config = ConfigDict(extra="forbid") + + roles: list[str] + + +class RoleOut(BaseModel): + """Роль с кодами её прав (по алфавиту).""" + + code: str + name: str + description: str | None + is_system: bool + is_active: bool + permissions: list[str] + + +class PermissionOut(BaseModel): + """Право из справочника (только чтение: новые коды не создаются).""" + + model_config = ConfigDict(from_attributes=True, use_enum_values=True) + + code: str + object_type: PermissionObject + action: PermissionAction + name: str + description: str | None + + +class PermissionsSet(BaseModel): + """Новый набор прав роли (коды); заменяет прежний целиком.""" + + model_config = ConfigDict(extra="forbid") + + permissions: list[str] diff --git a/api/src/schemas/common.py b/api/src/schemas/common.py new file mode 100644 index 0000000..688d712 --- /dev/null +++ b/api/src/schemas/common.py @@ -0,0 +1,33 @@ +"""Общие схемы API: страница списка, параметры пагинации, тело доменной ошибки (spec directories-api).""" + +from typing import Generic, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar("T") + +DEFAULT_PAGE_LIMIT = 50 +MAX_PAGE_LIMIT = 200 + + +class Page(BaseModel, Generic[T]): + """Страница списка: элементы, общее число записей по фильтру, запрошенные `limit`/`offset`.""" + + items: list[T] + total: int + limit: int + offset: int + + +class PageParams(BaseModel): + """Параметры пагинации из query: `Annotated[PageParams, Query()]`.""" + + limit: int = Field(DEFAULT_PAGE_LIMIT, ge=1, le=MAX_PAGE_LIMIT) + offset: int = Field(0, ge=0) + + +class ErrorDetail(BaseModel): + """Тело `detail` доменной ошибки (для `responses` в OpenAPI): `{"code": "...", "message": "..."}`.""" + + code: str + message: str diff --git a/api/src/schemas/directories.py b/api/src/schemas/directories.py new file mode 100644 index 0000000..4852613 --- /dev/null +++ b/api/src/schemas/directories.py @@ -0,0 +1,376 @@ +"""Схемы API справочников (spec directories-api): филиалы, ГК, ЮЛ/ИП; поля — как в DBML v5.14.""" + +import datetime +import uuid +from typing import Literal, Self + +from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator + +from src.models.enums import EntityType, LeHistoryEvent, RelatedGroupKind + +NAME_MAX_LENGTH = 500 + + +def _blank_to_none(value: str | None) -> str | None: + """`crm_id`: пробелы по краям срезаются, пустая строка — «нет значения» (иначе пустые `crm_id` конфликтовали бы).""" + if value is None: + return None + stripped = value.strip() + return stripped or None + + +class RegionalOfficeBrief(BaseModel): + """Филиал кратко: профиль `GET /me` (форма ответа Среза 0 — без `is_active`).""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + is_head_office: bool + + +class RegionalOfficeOut(RegionalOfficeBrief): + """Филиал целиком (`GET /directories/regional-offices`, пользователи администратора).""" + + is_active: bool + + +class CompanyGroupCreate(BaseModel): + """Создание ГК: `code` и `valid_from` назначает сервис.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) + crm_id: str | None = None + base_regional_office_id: uuid.UUID | None = None + + @field_validator("name") + @classmethod + def _strip_name(cls, value: str) -> str: + stripped = value.strip() + if not stripped: + raise ValueError("название не может быть пустым") + return stripped + + @field_validator("crm_id") + @classmethod + def _normalize_crm_id(cls, value: str | None) -> str | None: + return _blank_to_none(value) + + +class CompanyGroupUpdate(BaseModel): + """Изменение ГК: «поле передано» определяется по `model_fields_set` (явный `null` у `crm_id` очищает значение).""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH) + crm_id: str | None = None + base_regional_office_id: uuid.UUID | None = None + + @field_validator("name") + @classmethod + def _strip_name(cls, value: str | None) -> str | None: + if value is None: + return None + stripped = value.strip() + if not stripped: + raise ValueError("название не может быть пустым") + return stripped + + @field_validator("crm_id") + @classmethod + def _normalize_crm_id(cls, value: str | None) -> str | None: + return _blank_to_none(value) + + @model_validator(mode="after") + def _name_is_not_null(self) -> Self: + if "name" in self.model_fields_set and self.name is None: + raise ValueError("название нельзя обнулить") + return self + + +class CompanyGroupOut(BaseModel): + """Карточка ГК; `legal_entities_count` — число ЮЛ с `company_group_id` этой ГК.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + code: str + crm_id: str | None + name: str + base_regional_office_id: uuid.UUID | None + is_archived: bool + valid_from: datetime.datetime + legal_entities_count: int = 0 + created_at: datetime.datetime | None + updated_at: datetime.datetime | None + + +class CompanyGroupHistoryOut(BaseModel): + """Прошлый снимок ГК (SCD4).""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + crm_id: str | None + name: str | None + base_regional_office_id: uuid.UUID | None + valid_from: datetime.datetime | None + valid_to: datetime.datetime | None + changed_by: uuid.UUID | None + + +class CompanyGroupFilter(BaseModel): + """Фильтры списка ГК: `q` — подстрока `name`/`crm_id`/`code` без учёта регистра.""" + + q: str | None = None + is_archived: bool | None = None + + +def _strip_required_name(value: str | None) -> str | None: + """Имя: пробелы по краям срезаются; пустое после `strip` — ошибка валидации (`None` пропускается для PATCH).""" + if value is None: + return None + stripped = value.strip() + if not stripped: + raise ValueError("название не может быть пустым") + return stripped + + +class LegalEntityCreate(BaseModel): + """Создание ЮЛ/ИП: `inn` проверяется по `entity_type` в сервисе (`INVALID_INN`); без ГК — монозаёмщик.""" + + model_config = ConfigDict(extra="forbid") + + inn: str + entity_type: EntityType + name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) + company_group_id: uuid.UUID | None = None + regional_office_id: uuid.UUID | None = None + crm_id: str | None = None + + @field_validator("name") + @classmethod + def _strip_name(cls, value: str) -> str | None: + return _strip_required_name(value) + + @field_validator("crm_id") + @classmethod + def _normalize_crm_id(cls, value: str | None) -> str | None: + return _blank_to_none(value) + + +class LegalEntityUpdate(BaseModel): + """Изменение ЮЛ: `inn` и ГК PATCH не меняет (ГК — `move`/`withdraw`); переданные поля — `model_fields_set`.""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH) + entity_type: EntityType | None = None + crm_id: str | None = None + regional_office_id: uuid.UUID | None = None + + @field_validator("name") + @classmethod + def _strip_name(cls, value: str | None) -> str | None: + return _strip_required_name(value) + + @field_validator("crm_id") + @classmethod + def _normalize_crm_id(cls, value: str | None) -> str | None: + return _blank_to_none(value) + + @model_validator(mode="after") + def _required_fields_are_not_null(self) -> Self: + for field in ("name", "entity_type"): + if field in self.model_fields_set and getattr(self, field) is None: + raise ValueError(f"поле {field} нельзя обнулить") + return self + + +class LegalEntityMove(BaseModel): + """Перевод ЮЛ в другую ГК с даты `effective_date` (не раньше сегодняшней).""" + + model_config = ConfigDict(extra="forbid") + + company_group_id: uuid.UUID + effective_date: datetime.date + + +class LegalEntityWithdraw(BaseModel): + """Вывод ЮЛ из состава ГК: `CLOSE` (закрытие, `close_date = effective_date`) или `REMOVE_FROM_GROUP`.""" + + model_config = ConfigDict(extra="forbid") + + event: Literal["CLOSE", "REMOVE_FROM_GROUP"] + effective_date: datetime.date + comment: str | None = None + + +class LegalEntityOut(BaseModel): + """ЮЛ/ИП; `outside_group` — монозаёмщик (`company_group_id IS NULL`), `closed` — заполнен `close_date`.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + inn: str + crm_id: str | None + entity_type: EntityType | None + name: str + company_group_id: uuid.UUID | None + regional_office_id: uuid.UUID | None + close_date: datetime.date | None + valid_from: datetime.datetime + created_at: datetime.datetime | None + updated_at: datetime.datetime | None + + @computed_field # type: ignore[prop-decorator] + @property + def outside_group(self) -> bool: + return self.company_group_id is None + + @computed_field # type: ignore[prop-decorator] + @property + def closed(self) -> bool: + return self.close_date is not None + + +class MembershipOut(BaseModel): + """Текущее членство ЮЛ в ГСК/ГСЗ (для карточки ЮЛ).""" + + related_group_id: uuid.UUID + code: str + kind: RelatedGroupKind + name: str + valid_from: datetime.datetime + + +class AliasCreate(BaseModel): + """Тело `POST …/aliases`: `alias` сохраняется как есть (как в ОСВ), `normalized_alias` считает сервис (§3).""" + + alias: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) + + +class AliasOut(BaseModel): + """Алиас ЮЛ: `alias` — как в ОСВ, `normalized_alias` — по §3.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + alias: str + normalized_alias: str + created_at: datetime.datetime | None + + +class LegalEntityDetail(LegalEntityOut): + """Карточка ЮЛ: текущие членства ГСК/ГСЗ и алиасы.""" + + memberships: list[MembershipOut] = [] + aliases: list[AliasOut] = [] + + +class LegalEntityHistoryOut(BaseModel): + """Прошлый снимок ЮЛ (SCD4) с событием (`UPDATE`, `MOVE_TO_GROUP`, `CLOSE`, `REMOVE_FROM_GROUP`).""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + inn: str | None + crm_id: str | None + entity_type: EntityType | None + name: str | None + company_group_id: uuid.UUID | None + regional_office_id: uuid.UUID | None + close_date: datetime.date | None + event_type: LeHistoryEvent | None + effective_date: datetime.date | None + valid_from: datetime.datetime | None + valid_to: datetime.datetime | None + changed_by: uuid.UUID | None + + +class LegalEntityFilter(BaseModel): + """Фильтры списка ЮЛ: `inn` — точно (после `strip`), `q` — подстрока `name`/`inn`, `closed` — по `close_date`.""" + + company_group_id: uuid.UUID | None = None + outside_group: bool | None = None + inn: str | None = None + q: str | None = None + closed: bool | None = None + + @field_validator("inn") + @classmethod + def _strip_inn(cls, value: str | None) -> str | None: + return _blank_to_none(value) + + +class RelatedGroupCreate(BaseModel): + """Создание ГСК/ГСЗ в ГК: `code` (`GSK-`/`GSZ-NNNNNN`) назначает сервис.""" + + model_config = ConfigDict(extra="forbid") + + kind: RelatedGroupKind + name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH) + created_date: datetime.date | None = None + + @field_validator("name") + @classmethod + def _strip_name(cls, value: str) -> str | None: + return _strip_required_name(value) + + +class RelatedGroupUpdate(BaseModel): + """Изменение ГСК/ГСЗ: `name`, `created_date` (вид и ГК не меняются); переданные поля — `model_fields_set`.""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, min_length=1, max_length=NAME_MAX_LENGTH) + created_date: datetime.date | None = None + + @field_validator("name") + @classmethod + def _strip_name(cls, value: str | None) -> str | None: + return _strip_required_name(value) + + @model_validator(mode="after") + def _name_is_not_null(self) -> Self: + if "name" in self.model_fields_set and self.name is None: + raise ValueError("название нельзя обнулить") + return self + + +class MemberAdd(BaseModel): + """Тело `POST …/related-groups/{id}/members`.""" + + model_config = ConfigDict(extra="forbid") + + legal_entity_id: uuid.UUID + + +class RelatedGroupOut(BaseModel): + """ГСК/ГСЗ; `members_count` — число текущих членов.""" + + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + code: str + company_group_id: uuid.UUID + kind: RelatedGroupKind + name: str + created_date: datetime.date | None + members_count: int = 0 + + +class RelatedGroupMemberOut(BaseModel): + """Текущий член ГСК/ГСЗ.""" + + legal_entity_id: uuid.UUID + inn: str + name: str + valid_from: datetime.datetime + + +class RelatedGroupDetail(RelatedGroupOut): + """Карточка ГСК/ГСЗ со списком текущих членов (по имени ЮЛ).""" + + members: list[RelatedGroupMemberOut] = [] diff --git a/api/src/schemas/directory_import.py b/api/src/schemas/directory_import.py new file mode 100644 index 0000000..7cafcd5 --- /dev/null +++ b/api/src/schemas/directory_import.py @@ -0,0 +1,30 @@ +"""Схема ответа импорта справочников (spec directory-import): отчёт = `ImportReport.to_dict()` (§13.3).""" + +from pydantic import BaseModel + +from src.domain.directory_import import ImportCode + + +class RowResultOut(BaseModel): + """Строка отчёта: номер строки файла, ИНН, код §13.2 (итоговый или сопутствующее событие) и пояснение.""" + + row: int + inn: str + code: ImportCode + message: str + + +class ImportReportOut(BaseModel): + """Счётчики и построчный список; `errors` — число пропущенных строк. Тот же объект — в `audit_event.after_value`.""" + + rows: int + groups_created: int + groups_updated: int + groups_skipped: int + entities_created: int + entities_updated: int + entities_skipped: int + memberships_created: int + offices_created: int + errors: int + lines: list[RowResultOut] diff --git a/api/src/schemas/me.py b/api/src/schemas/me.py index ea11a2a..2365003 100644 --- a/api/src/schemas/me.py +++ b/api/src/schemas/me.py @@ -5,14 +5,7 @@ import uuid from pydantic import BaseModel, ConfigDict from src.models.enums import BusinessSegment - - -class RegionalOfficeOut(BaseModel): - """Филиал пользователя.""" - - id: uuid.UUID - name: str - is_head_office: bool +from src.schemas.directories import RegionalOfficeBrief class MeResponse(BaseModel): @@ -23,7 +16,7 @@ class MeResponse(BaseModel): id: uuid.UUID email: str full_name: str - regional_office: RegionalOfficeOut + regional_office: RegionalOfficeBrief business_segment: BusinessSegment | None roles: list[str] permissions: list[str] diff --git a/api/src/services/aliases.py b/api/src/services/aliases.py new file mode 100644 index 0000000..f539c3d --- /dev/null +++ b/api/src/services/aliases.py @@ -0,0 +1,95 @@ +"""Сервис алиасов ЮЛ (spec directories-api «Алиасы ЮЛ», audit-trail, §3 п. 4). + +Порядок: право (`require` на маршруте) → видимость ЮЛ (`scope`, невидимое = несуществующее) → проверки → запись. +Аудит `LEGAL_ENTITY_CHANGE` / `object_type = legal_entity_alias` пишется в той же транзакции запроса. +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain.aliases import normalize_alias +from src.domain.policy import VisibilityScope +from src.models.directories import LegalEntity, LegalEntityAlias +from src.models.enums import AuditAction, AuditSection +from src.repositories import directories as repo +from src.schemas.directories import AliasCreate +from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND + +OBJECT_TYPE = "legal_entity_alias" +ALIAS_NOT_FOUND = "Алиас не найден" + + +class AliasService: + """Добавление и удаление алиасов ЮЛ/ИП от имени текущего пользователя в его области видимости.""" + + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + self._session = session + self._audit = audit + self._scope = scope + + async def add(self, entity_id: uuid.UUID, data: AliasCreate) -> LegalEntityAlias: + """Новый алиас: `alias` как есть, `normalized_alias` по §3; конфликты — `ALIAS_EXISTS` / `ALIAS_TAKEN`.""" + entity = await self._get_entity(entity_id) + normalized = normalize_alias(data.alias) + if not normalized: + raise DomainValidationError("EMPTY_ALIAS", "Алиас пуст после нормализации (одни ОПФ и знаки препинания)") + owner = await repo.find_alias_owner(self._session, normalized) + if owner is not None: + if owner.legal_entity_id == entity.id: + raise ConflictError("ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас") + # Владелец может быть вне видимости пользователя: спека требует `legal_entity_id`, остальное не раскрываем + raise ConflictError( + "ALIAS_TAKEN", "Такой алиас уже закреплён за другим ЮЛ", legal_entity_id=owner.legal_entity_id + ) + alias = LegalEntityAlias( + legal_entity_id=entity.id, + alias=data.alias, + normalized_alias=normalized, + created_by=self._audit.user_id, + created_at=utc_now(), + ) + self._session.add(alias) + await self._session.flush() + await self._audit.record( + action=AuditAction.LEGAL_ENTITY_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=alias.id, + after_value=self._payload(alias), + ) + return alias + + async def remove(self, entity_id: uuid.UUID, alias_id: uuid.UUID) -> None: + """Удаляет алиас этого ЮЛ (иначе 404); `before_value` аудита — удалённые значения.""" + entity = await self._get_entity(entity_id) + alias = await repo.get_alias(self._session, entity.id, alias_id) + if alias is None: + raise NotFoundError(ALIAS_NOT_FOUND) + before = self._payload(alias) + await self._session.delete(alias) + await self._session.flush() + await self._audit.record( + action=AuditAction.LEGAL_ENTITY_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=alias_id, + before_value=before, + ) + + async def _get_entity(self, entity_id: uuid.UUID) -> LegalEntity: + entity = await repo.get_legal_entity(self._session, self._scope, entity_id) + if entity is None: + raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) + return entity + + @staticmethod + def _payload(alias: LegalEntityAlias) -> dict[str, object]: + return { + "legal_entity_id": alias.legal_entity_id, + "alias": alias.alias, + "normalized_alias": alias.normalized_alias, + } diff --git a/api/src/services/company_groups.py b/api/src/services/company_groups.py new file mode 100644 index 0000000..868671e --- /dev/null +++ b/api/src/services/company_groups.py @@ -0,0 +1,195 @@ +"""Сервис ГК (spec directories-api, directory-policy, audit-trail): создание, изменение со снимком истории, архив. + +Порядок операции: право (`require` на маршруте) → видимость (`get`) → изменение. Все записи — в транзакции запроса, +`commit` делает `SessionDep`; `audit_event` пишется в той же транзакции через `RequestAudit`. +""" + +import datetime +import uuid +from collections.abc import Mapping +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain.policy import VisibilityScope, office_for_create +from src.models.directories import COMPANY_GROUP_CODE_SEQ, CompanyGroup, CompanyGroupHistory +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 + +COMPANY_GROUP_NOT_FOUND = "ГК не найдена" # одинаково для чужой и несуществующей (spec directory-policy) +COMPANY_GROUP_CODE_PREFIX = "GK" +OBJECT_TYPE = "company_group" +TRACKED_FIELDS = ("name", "crm_id", "base_regional_office_id") # поля ГК, изменяемые PATCH и попадающие в историю + + +async def apply_group_update( # noqa: PLR0913 - сессия, аудит, ГК, изменения и момент — без значений по умолчанию + session: AsyncSession, + audit: RequestAudit, + group: CompanyGroup, + changes: Mapping[str, Any], + *, + now: datetime.datetime, + description: str | None = None, +) -> None: + """Изменение полей ГК со снимком истории (SCD4, D-6) и аудитом; общее для PATCH и импорта (R-14). + + `changes` — только фактически изменившиеся поля (проверки — у вызывающего). Снимок ПРЕЖНИХ значений уходит в + `company_group_history` (`valid_to = now`), у живой записи `valid_from = now`; аудит — `ASSIGNMENT_CHANGE` при + смене филиала, иначе `GROUP_CHANGE`. + """ + before = {field: getattr(group, field) for field in changes} + session.add( + CompanyGroupHistory( + company_group_id=group.id, + crm_id=group.crm_id, + name=group.name, + base_regional_office_id=group.base_regional_office_id, + valid_from=group.valid_from, + valid_to=now, + changed_by=audit.user_id, + ) + ) + for field, value in changes.items(): + setattr(group, field, value) + group.valid_from = now + group.updated_at = now + group.updated_by = audit.user_id + await session.flush() + await audit.record( + action=AuditAction.ASSIGNMENT_CHANGE if "base_regional_office_id" in changes else AuditAction.GROUP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=group.id, + description=description, + before_value=before, + after_value=dict(changes), + changed_field=next(iter(changes)) if len(changes) == 1 else None, + ) + + +class CompanyGroupService: + """Операции над ГК от имени текущего пользователя в его области видимости.""" + + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + self._session = session + self._audit = audit + self._scope = scope + + async def create(self, data: CompanyGroupCreate) -> CompanyGroup: + """Новая ГК с кодом `GK-NNNNNN`; филиал — по policy (`office_for_create`); аудит `GROUP_CHANGE`.""" + office_id = office_for_create(self._scope, data.base_regional_office_id) + await self._check_office_exists(office_id) + if data.crm_id is not None: + await self._check_crm_id_free(data.crm_id) + now = utc_now() + group = CompanyGroup( + code=await repo.next_code(self._session, COMPANY_GROUP_CODE_SEQ, COMPANY_GROUP_CODE_PREFIX), + crm_id=data.crm_id, + name=data.name, + base_regional_office_id=office_id, + valid_from=now, + created_at=now, + updated_at=now, + updated_by=self._audit.user_id, + ) + self._session.add(group) + await self._session.flush() + await self._audit.record( + action=AuditAction.GROUP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=group.id, + after_value={ + "code": group.code, + "name": group.name, + "crm_id": group.crm_id, + "base_regional_office_id": group.base_regional_office_id, + }, + ) + return group + + async def update(self, group_id: uuid.UUID, data: CompanyGroupUpdate) -> CompanyGroup: + """Изменение `name`/`crm_id`/`base_regional_office_id`; без фактических изменений ничего не пишется (D-6).""" + group = await self.get(group_id) + changes = await self._changes(group, data) + if not changes: + return group + await apply_group_update(self._session, self._audit, group, changes, now=utc_now()) + return group + + async def set_archived(self, group_id: uuid.UUID, archived: bool) -> CompanyGroup: # noqa: FBT001 + """Архив/разархив ГК; повтор в том же состоянии ничего не пишет.""" + group = await self.get(group_id) + if group.is_archived == archived: + return group + group.is_archived = archived + group.updated_at = utc_now() + group.updated_by = self._audit.user_id + await self._session.flush() + await self._audit.record( + action=AuditAction.GROUP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=group.id, + before_value={"is_archived": not archived}, + after_value={"is_archived": archived}, + changed_field="is_archived", + ) + return group + + async def get(self, group_id: uuid.UUID) -> CompanyGroup: + """ГК в области видимости; невидимая и несуществующая — один и тот же 404.""" + group = await repo.get_company_group(self._session, self._scope, group_id) + if group is None: + raise NotFoundError(COMPANY_GROUP_NOT_FOUND) + return group + + 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`; видимость проверяется как для карточки.""" + group = await self.get(group_id) + return await repo.list_company_group_history(self._session, group.id) + + async def page(self, params: CompanyGroupFilter, page: PageParams) -> tuple[list[tuple[CompanyGroup, int]], int]: + """Страница видимых ГК с числом ЮЛ и общее число по фильтру.""" + return await repo.list_company_groups( + self._session, + self._scope, + q=params.q, + is_archived=params.is_archived, + limit=page.limit, + offset=page.offset, + ) + + async def _changes(self, group: CompanyGroup, data: CompanyGroupUpdate) -> dict[str, Any]: + """Только реально изменившиеся поля из переданных в PATCH (проверки — по ним же).""" + changes: dict[str, Any] = {} + for field in TRACKED_FIELDS: + if field not in data.model_fields_set: + continue + value = getattr(data, field) + if field == "base_regional_office_id": + value = office_for_create(self._scope, value) + if value != getattr(group, field): + changes[field] = value + if changes.get("base_regional_office_id") is not None: + await self._check_office_exists(changes["base_regional_office_id"]) + if changes.get("crm_id") is not None: + await self._check_crm_id_free(changes["crm_id"], except_group_id=group.id) + return changes + + async def _check_office_exists(self, office_id: uuid.UUID | None) -> None: + if office_id is not None and await repo.get_regional_office(self._session, office_id) is None: + raise DomainValidationError("OFFICE_NOT_FOUND", "Филиал не найден") + + async def _check_crm_id_free(self, crm_id: str, *, except_group_id: uuid.UUID | None = None) -> None: + if await repo.crm_id_taken(self._session, crm_id, except_group_id=except_group_id): + raise ConflictError("CRM_ID_EXISTS", "ГК с таким crm_id уже существует") diff --git a/api/src/services/directory_import.py b/api/src/services/directory_import.py new file mode 100644 index 0000000..cf1c6b8 --- /dev/null +++ b/api/src/services/directory_import.py @@ -0,0 +1,285 @@ +"""Сервис импорта справочников из выгрузки ЕЦП CRM (spec directory-import, audit-trail; `05-domain-rules.md` §13.3). + +Чистое ядро (`src.domain.directory_import`) читает файл и строит план над снимком справочника; сервис применяет +операции плана в транзакции запроса (`SessionDep`). Ошибки строк импорт не прерывают (они — коды отчёта), ошибки файла — +422 до любых записей. + +- `dry_run = true`: план применяется и откатывается явным `session.rollback()` — отчёт тот же, что у боевого прогона; + ни `audit_event(DIRECTORY_IMPORT)`, ни файла в S3. Коды ГК/ГСК/ГСЗ из последовательностей при этом расходуются + (последовательности PostgreSQL не транзакционны) — дыры в нумерации допустимы (Review Focus 4). +- `dry_run = false`: `audit_event(DIRECTORY_IMPORT, after_value = отчёт, description = имя файла)`, файл — в S3 + `uploads/imports/{audit_event_id}/{имя}` с записью `s3_object(bucket_role = uploads, created_by = user:{id})`. + Без S3 (dev, `app.state.storage is None`) — предупреждение в лог, `s3_object` не создаётся. +""" + +import asyncio +import datetime +import hashlib +import unicodedata +import uuid +from collections import defaultdict +from pathlib import PurePosixPath +from typing import Any, Protocol + +from fastapi import HTTPException, status +from loguru import logger +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import DomainValidationError +from src.core.config import Settings +from src.domain.directory_import import ( + AddMember, + CreateEntity, + CreateGroup, + CreateOffice, + CreateRelatedGroup, + FileFormatError, + ImportReport, + OnExisting, + Operation, + UpdateEntity, + UpdateGroup, + plan_import, + read_rows, +) +from src.models.directories import ( + COMPANY_GROUP_CODE_SEQ, + RELATED_GROUP_CODE_SEQ, + CompanyGroup, + LegalEntity, + RegionalOffice, + RelatedGroup, + RelatedGroupMember, +) +from src.models.enums import AuditAction, AuditSection, S3BucketRole +from src.models.files import S3Object +from src.repositories import directories as repo +from src.services.company_groups import COMPANY_GROUP_CODE_PREFIX, apply_group_update +from src.services.legal_entities import apply_entity_update +from src.services.related_groups import CODE_PREFIXES + +OBJECT_TYPE = "directory_import" +IMPORT_DESCRIPTION = "импорт ЕЦП" # `description` событий изменения ГК/ЮЛ, сделанных импортом +S3_PREFIX = "uploads/imports" +DEFAULT_FILENAME = "import" +CONTENT_TYPES = { + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "csv": "text/csv", +} +MEGABYTE = 1024 * 1024 +MAX_FILENAME_LENGTH = 200 # символов: ключ S3 `uploads/imports/{uuid}/{имя}` остаётся в пределах 1024 байт +MAX_SUFFIX_LENGTH = 16 + + +class ObjectStorage(Protocol): + """Что импорту нужно от S3 (`src.core.clients.S3Storage`): бакет и запись объекта.""" + + bucket: str + + async def put_object(self, obj_name: str, file_data: bytes) -> Any: ... + + +def check_size(size: int, limit: int) -> None: + """413, если файл больше `IMPORT_MAX_BYTES`.""" + if size > limit: + raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=f"файл больше {limit // MEGABYTE} МБ") + + +_DROPPED_CATEGORIES = frozenset({"Cc", "Cf"}) + + +def safe_filename(filename: str | None) -> str: + """Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3. + + Управляющие (Cc, в т. ч. NUL — его не принимает PostgreSQL) и форматирующие (Cf: bidi-переопределения U+202E, + нулевой ширины U+200B) символы отбрасываются до обрезки длины; длина ограничена + `MAX_FILENAME_LENGTH` с сохранением расширения (T10.4), чтобы имя не давало 500 и ключ S3 не выходил за 1024 байта. + """ + cleaned = "".join( + char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) not in _DROPPED_CATEGORIES + ) + name = PurePosixPath(cleaned).name.strip() + if name in {"", ".", ".."}: + return DEFAULT_FILENAME + if len(name) > MAX_FILENAME_LENGTH: + suffix = PurePosixPath(name).suffix + suffix = suffix if len(suffix) <= MAX_SUFFIX_LENGTH else "" + name = name[: MAX_FILENAME_LENGTH - len(suffix)] + suffix + return name + + +class DirectoryImportService: + """Импорт выгрузки ЕЦП от имени администратора справочников (право `DIRECTORY_ADMIN` — на маршруте).""" + + def __init__( + self, session: AsyncSession, audit: RequestAudit, storage: ObjectStorage | None, settings: Settings + ) -> None: + self._session = session + self._audit = audit + self._storage = storage + self._settings = settings + + async def run(self, *, filename: str, content: bytes, dry_run: bool, on_existing: OnExisting) -> ImportReport: + """Прочитать файл, построить план над снимком справочника, применить; при `dry_run` — откатить.""" + check_size(len(content), self._settings.IMPORT_MAX_BYTES) + name = safe_filename(filename) + try: + # Разбор xlsx на тысячах строк — сотни миллисекунд CPU: не блокируем цикл событий + rows = await asyncio.to_thread(read_rows, name, content) + except FileFormatError as error: + raise DomainValidationError(error.code, error.message, missing=error.missing) from error + snapshot = await repo.load_snapshot(self._session) + plan = plan_import(rows, snapshot, on_existing) + now = utc_now() + await self._apply(plan.operations, now) + if dry_run: + # Транзакция запроса откатывается целиком; `SessionDep` затем ничего не фиксирует + await self._session.rollback() + return plan.report + await self._record(name, content, plan.report, now) + return plan.report + + async def _apply(self, operations: list[Operation], now: datetime.datetime) -> None: + """Операции плана пакетами по типу в порядке зависимостей FK; `flush` после каждого пакета.""" + batches: defaultdict[type, list[Any]] = defaultdict(list) + for operation in operations: + batches[type(operation)].append(operation) + user_id = self._audit.user_id + session = self._session + + session.add_all( + RegionalOffice(id=op.id, name=op.name, is_head_office=False, is_active=True) for op in batches[CreateOffice] + ) + await session.flush() + + session.add_all( + [ + CompanyGroup( + id=op.id, + code=await repo.next_code(session, COMPANY_GROUP_CODE_SEQ, COMPANY_GROUP_CODE_PREFIX), + crm_id=op.crm_id, + name=op.name, + base_regional_office_id=op.office_id, + valid_from=now, + created_at=now, + updated_at=now, + updated_by=user_id, + ) + for op in batches[CreateGroup] + ] + ) + await session.flush() + + await self._update_groups(batches[UpdateGroup], now) + + session.add_all( + LegalEntity( + id=op.id, + inn=op.inn, + entity_type=op.entity_type, + name=op.name, + company_group_id=op.group_id, + regional_office_id=op.office_id, + valid_from=now, + created_at=now, + updated_at=now, + updated_by=user_id, + ) + for op in batches[CreateEntity] + ) + await session.flush() + + await self._update_entities(batches[UpdateEntity], now) + + session.add_all( + [ + RelatedGroup( + id=op.id, + code=await repo.next_code(session, RELATED_GROUP_CODE_SEQ, CODE_PREFIXES[op.kind]), + company_group_id=op.group_id, + kind=op.kind, + name=op.name, + ) + for op in batches[CreateRelatedGroup] + ] + ) + await session.flush() + + session.add_all( + RelatedGroupMember( + related_group_id=op.related_group_id, + legal_entity_id=op.entity_id, + kind=op.kind, + valid_from=now, + valid_to=None, + is_current=True, + created_by=user_id, + ) + for op in batches[AddMember] + ) + await session.flush() + + async def _update_groups(self, operations: list[UpdateGroup], now: datetime.datetime) -> None: + """`UpdateGroup` — той же функцией, что PATCH ГК (R-14): снимок истории и аудит изменения ГК.""" + if not operations: + return + stmt = select(CompanyGroup).where(CompanyGroup.id.in_([op.id for op in operations])) + groups = {group.id: group for group in (await self._session.execute(stmt)).scalars()} + for op in operations: + changes: dict[str, Any] = {} + if op.name is not None: + changes["name"] = op.name + if op.office_id is not None: + changes["base_regional_office_id"] = op.office_id + await apply_group_update( + self._session, self._audit, groups[op.id], changes, now=now, description=IMPORT_DESCRIPTION + ) + + async def _update_entities(self, operations: list[UpdateEntity], now: datetime.datetime) -> None: + """`UpdateEntity` — той же функцией, что PATCH ЮЛ (R-14): снимок `legal_entity_history(UPDATE)` и аудит.""" + if not operations: + return + stmt = select(LegalEntity).where(LegalEntity.id.in_([op.id for op in operations])) + entities = {entity.id: entity for entity in (await self._session.execute(stmt)).scalars()} + for op in operations: + changes: dict[str, Any] = {} + if op.name is not None: + changes["name"] = op.name + if op.entity_type is not None: + changes["entity_type"] = op.entity_type + await apply_entity_update( + self._session, self._audit, entities[op.id], changes, now=now, description=IMPORT_DESCRIPTION + ) + + async def _record(self, name: str, content: bytes, report: ImportReport, now: datetime.datetime) -> None: + """След боевого импорта: событие аудита с отчётом и исходный файл в S3 с записью в `s3_object`.""" + event = await self._audit.record( + action=AuditAction.DIRECTORY_IMPORT, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + description=name, + after_value=report.to_dict(), + ) + if self._storage is None: + logger.warning("S3 не подключено: файл импорта не сохранён. audit_event_id={event_id}", event_id=event.id) + return + key = f"{S3_PREFIX}/{event.id}/{name}" + # Строка реестра — до загрузки: конфликт ключа или ошибка БД не оставят в бакете объект без записи + self._session.add( + S3Object( + id=uuid.uuid4(), + bucket_role=S3BucketRole.uploads, + bucket=self._storage.bucket, + key=key, + content_type=CONTENT_TYPES.get(PurePosixPath(name).suffix.lstrip(".").lower()), + size_bytes=len(content), + sha256=hashlib.sha256(content).hexdigest(), + created_by=f"user:{self._audit.user_id}", + created_at=now, + ) + ) + await self._session.flush() + await self._storage.put_object(key, content) diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py new file mode 100644 index 0000000..404e3ce --- /dev/null +++ b/api/src/services/legal_entities.py @@ -0,0 +1,338 @@ +"""Сервис ЮЛ/ИП (spec directories-api, directory-policy, audit-trail): создание, изменение, перевод, вывод, история. + +Порядок операции: право (`require` на маршруте) → видимость (`get`) → проверки → изменение. Все записи — в транзакции +запроса (`SessionDep`); история (SCD4, D-6) — снимок ПРЕЖНИХ значений в `legal_entity_history` с `event_type` +(`valid_from` прежний, `valid_to = now`), у живой записи `valid_from = now`; при создании история не пишется. +`audit_event` — в той же транзакции через `RequestAudit`: откат запроса откатывает историю и аудит. +""" + +import datetime +import uuid +from collections.abc import Mapping +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain.inn import validate_inn +from src.domain.policy import VisibilityScope, office_for_create +from src.models.directories import CompanyGroup, LegalEntity, LegalEntityHistory +from src.models.enums import AuditAction, AuditSection, LeHistoryEvent +from src.repositories import directories as repo +from src.schemas.common import PageParams +from src.schemas.directories import ( + AliasOut, + LegalEntityCreate, + LegalEntityDetail, + LegalEntityFilter, + LegalEntityMove, + LegalEntityUpdate, + LegalEntityWithdraw, + MembershipOut, +) +from src.services.company_groups import COMPANY_GROUP_NOT_FOUND + +LEGAL_ENTITY_NOT_FOUND = "ЮЛ не найдено" # одинаково для чужого и несуществующего (spec directory-policy) +OBJECT_TYPE = "legal_entity" +MEMBER_OBJECT_TYPE = "related_group_member" +TRACKED_FIELDS = ("name", "entity_type", "crm_id", "regional_office_id") # поля ЮЛ, изменяемые PATCH + + +def _today() -> datetime.date: + """«Сегодня» для проверки `effective_date` — дата UTC (часовой пояс пользователя в Срезе 1 не известен).""" + return utc_now().date() + + +def snapshot_entity( # noqa: PLR0913 - снимок ЮЛ: сессия, автор, ЮЛ, событие, момент и дата события + session: AsyncSession, + user_id: uuid.UUID, + entity: LegalEntity, + event: LeHistoryEvent, + now: datetime.datetime, + *, + effective_date: datetime.date | None, +) -> LegalEntityHistory: + """Снимок ТЕКУЩИХ (ещё не изменённых) значений ЮЛ, `valid_to = now`; вызывать до изменений и `touch_entity`.""" + snapshot = LegalEntityHistory( + legal_entity_id=entity.id, + inn=entity.inn, + crm_id=entity.crm_id, + entity_type=entity.entity_type, + name=entity.name, + company_group_id=entity.company_group_id, + regional_office_id=entity.regional_office_id, + close_date=entity.close_date, + event_type=event, + effective_date=effective_date, + valid_from=entity.valid_from, + valid_to=now, + changed_by=user_id, + ) + session.add(snapshot) + return snapshot + + +async def touch_entity(session: AsyncSession, user_id: uuid.UUID, entity: LegalEntity, now: datetime.datetime) -> None: + """Новая версия живой записи: `valid_from = now` (= `valid_to` снимка), `updated_*`; `flush`.""" + entity.valid_from = now + entity.updated_at = now + entity.updated_by = user_id + await session.flush() + + +async def apply_entity_update( # noqa: PLR0913 - сессия, аудит, ЮЛ, изменения и момент — без значений по умолчанию + session: AsyncSession, + audit: RequestAudit, + entity: LegalEntity, + changes: Mapping[str, Any], + *, + now: datetime.datetime, + description: str | None = None, +) -> None: + """Изменение полей ЮЛ со снимком `legal_entity_history(UPDATE)` и аудитом `LEGAL_ENTITY_CHANGE`; общее для PATCH и + импорта (R-14). `changes` — только фактически изменившиеся поля (проверки — у вызывающего).""" + before = {field: getattr(entity, field) for field in changes} + snapshot_entity(session, audit.user_id, entity, LeHistoryEvent.UPDATE, now, effective_date=None) + for field, value in changes.items(): + setattr(entity, field, value) + await touch_entity(session, audit.user_id, entity, now) + await audit.record( + action=AuditAction.LEGAL_ENTITY_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=entity.id, + description=description, + before_value=before, + after_value=dict(changes), + changed_field=next(iter(changes)) if len(changes) == 1 else None, + ) + + +class LegalEntityService: + """Операции над ЮЛ/ИП от имени текущего пользователя в его области видимости.""" + + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + self._session = session + self._audit = audit + self._scope = scope + + async def create(self, data: LegalEntityCreate) -> LegalEntity: + """Новое ЮЛ/ИП; ГК — только видимая (иначе 404); филиал по умолчанию — филиал ГК; аудит LEGAL_ENTITY_CHANGE.""" + inn = validate_inn(data.inn, data.entity_type) + group = await self._visible_group(data.company_group_id) if data.company_group_id is not None else None + if await repo.get_legal_entity_by_inn(self._session, inn) is not None: + raise ConflictError("INN_EXISTS", "ЮЛ с таким ИНН уже существует") + if data.crm_id is not None: + await self._check_crm_id_free(data.crm_id) + requested_office = data.regional_office_id + if requested_office is None and group is not None: + requested_office = group.base_regional_office_id + # Филиал не может завести ЮЛ (в т.ч. монозаёмщика) на чужой филиал; без филиала — свой (§10.1) + office_id = office_for_create(self._scope, requested_office) + await self._check_office_exists(office_id) + now = utc_now() + entity = LegalEntity( + inn=inn, + crm_id=data.crm_id, + entity_type=data.entity_type, + name=data.name, + company_group_id=group.id if group is not None else None, + regional_office_id=office_id, + valid_from=now, + created_at=now, + updated_at=now, + updated_by=self._audit.user_id, + ) + self._session.add(entity) + await self._session.flush() + await self._audit.record( + action=AuditAction.LEGAL_ENTITY_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=entity.id, + after_value={ + "inn": entity.inn, + "entity_type": entity.entity_type, + "name": entity.name, + "crm_id": entity.crm_id, + "company_group_id": entity.company_group_id, + "regional_office_id": entity.regional_office_id, + }, + ) + return entity + + async def update(self, entity_id: uuid.UUID, data: LegalEntityUpdate) -> LegalEntity: + """Изменение `name`/`entity_type`/`crm_id`/`regional_office_id`; без фактических изменений ничего не пишется.""" + entity = await self._get_entity(entity_id) + changes = await self._changes(entity, data) + if not changes: + return entity + await apply_entity_update(self._session, self._audit, entity, changes, now=utc_now()) + return entity + + async def move(self, entity_id: uuid.UUID, data: LegalEntityMove) -> LegalEntity: + """Перевод в другую видимую ГК: снимок `MOVE_TO_GROUP`, закрытие членств ГСК/ГСЗ, аудит ЮЛ и членств.""" + entity = await self._get_entity(entity_id) + self._check_effective_date(data.effective_date) + target = await self._visible_group(data.company_group_id) + if entity.close_date is not None: + raise ConflictError("LEGAL_ENTITY_CLOSED", "ЮЛ закрыто — перевод невозможен") + if entity.company_group_id == target.id: + raise ConflictError("SAME_COMPANY_GROUP", "ЮЛ уже входит в эту ГК") + before = {"company_group_id": entity.company_group_id} + now = utc_now() + snapshot_entity( + self._session, + self._audit.user_id, + entity, + LeHistoryEvent.MOVE_TO_GROUP, + now, + effective_date=data.effective_date, + ) + entity.company_group_id = target.id + await touch_entity(self._session, self._audit.user_id, entity, now) + await self._close_memberships(entity, now) + await self._audit.record( + action=AuditAction.LEGAL_ENTITY_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=entity.id, + description=f"{LeHistoryEvent.MOVE_TO_GROUP.value}, effective_date={data.effective_date.isoformat()}", + before_value=before, + after_value={"company_group_id": entity.company_group_id}, + changed_field="company_group_id", + ) + return entity + + async def withdraw(self, entity_id: uuid.UUID, data: LegalEntityWithdraw) -> LegalEntity: + """Вывод из состава ГК (`REMOVE_FROM_GROUP`) или закрытие (`CLOSE`, `close_date = effective_date`).""" + entity = await self._get_entity(entity_id) + self._check_effective_date(data.effective_date) + event = LeHistoryEvent(data.event) + if event is LeHistoryEvent.CLOSE and entity.close_date is not None: + raise ConflictError("ALREADY_CLOSED", "ЮЛ уже закрыто") + if event is LeHistoryEvent.REMOVE_FROM_GROUP and entity.company_group_id is None: + raise ConflictError("NOT_IN_COMPANY_GROUP", "ЮЛ не входит в ГК") + changes: dict[str, Any] = {"company_group_id": None} + if event is LeHistoryEvent.CLOSE: + changes["close_date"] = data.effective_date + changes = {field: value for field, value in changes.items() if getattr(entity, field) != value} + before = {field: getattr(entity, field) for field in changes} + now = utc_now() + snapshot_entity(self._session, self._audit.user_id, entity, event, now, effective_date=data.effective_date) + for field, value in changes.items(): + setattr(entity, field, value) + await touch_entity(self._session, self._audit.user_id, entity, now) + await self._close_memberships(entity, now, comment=data.comment) + await self._audit.record( + action=AuditAction.LEGAL_ENTITY_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=entity.id, + description=f"{event.value}, effective_date={data.effective_date.isoformat()}", + before_value=before, + after_value=changes, + comment=data.comment, + changed_field=next(iter(changes)) if len(changes) == 1 else None, + ) + return entity + + async def get(self, entity_id: uuid.UUID) -> LegalEntityDetail: + """Карточка: ЮЛ, текущие членства ГСК/ГСЗ и алиасы; невидимое и несуществующее — один и тот же 404.""" + entity = await self._get_entity(entity_id) + detail = LegalEntityDetail.model_validate(entity) + detail.memberships = [ + MembershipOut( + related_group_id=group.id, + code=group.code, + kind=group.kind, + name=group.name, + valid_from=member.valid_from, + ) + for member, group in await repo.current_memberships(self._session, entity.id) + ] + 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); видимость — как для карточки.""" + entity = await self._get_entity(entity_id) + return await repo.list_legal_entity_history(self._session, entity.id) + + async def page(self, flt: LegalEntityFilter, page: PageParams) -> tuple[list[LegalEntity], int]: + """Страница видимых ЮЛ и общее число по фильтру.""" + return await repo.list_legal_entities( + self._session, + self._scope, + company_group_id=flt.company_group_id, + outside_group=flt.outside_group, + inn=flt.inn, + q=flt.q, + closed=flt.closed, + limit=page.limit, + offset=page.offset, + ) + + async def _get_entity(self, entity_id: uuid.UUID) -> LegalEntity: + entity = await repo.get_legal_entity(self._session, self._scope, entity_id) + if entity is None: + raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) + return entity + + async def _visible_group(self, group_id: uuid.UUID) -> CompanyGroup: + """ГК в области видимости; чужая — тот же 404, что несуществующая (Review Focus 3: не 422).""" + group = await repo.get_company_group(self._session, self._scope, group_id) + if group is None: + raise NotFoundError(COMPANY_GROUP_NOT_FOUND) + return group + + @staticmethod + def _check_effective_date(effective_date: datetime.date) -> None: + # ASSUMPTION(OQ-21): дата перевода/закрытия не раньше сегодняшней (ANL-046); «сегодня» — по UTC + if effective_date < _today(): + raise DomainValidationError("EFFECTIVE_DATE_IN_PAST", "Дата не может быть раньше сегодняшней") + + async def _close_memberships(self, entity: LegalEntity, now: datetime.datetime, comment: str | None = None) -> None: + """Закрывает текущие членства ГСК/ГСЗ ЮЛ; по каждому — `MEMBERSHIP_CHANGE`.""" + for member in await repo.close_memberships(self._session, entity.id, now): + identity = {"related_group_id": member.related_group_id, "legal_entity_id": member.legal_entity_id} + await self._audit.record( + action=AuditAction.MEMBERSHIP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=MEMBER_OBJECT_TYPE, + object_id=member.id, + before_value={**identity, "is_current": True, "valid_to": None}, + after_value={**identity, "is_current": False, "valid_to": member.valid_to}, + comment=comment, + ) + + async def _changes(self, entity: LegalEntity, data: LegalEntityUpdate) -> dict[str, Any]: + """Только реально изменившиеся поля из переданных в PATCH (проверки — по ним же).""" + changes: dict[str, Any] = {} + for field in TRACKED_FIELDS: + if field not in data.model_fields_set: + continue + value = getattr(data, field) + if value == getattr(entity, field): + continue # то же значение — не изменение (Review Focus 1), даже если филиал ЮЛ чужой для пользователя + if field == "regional_office_id": + value = office_for_create(self._scope, value) # policy — только для фактической смены филиала + if value != getattr(entity, field): + changes[field] = value + if "entity_type" in changes: + validate_inn(entity.inn, changes["entity_type"]) # длина ИНН должна подходить новому типу + if changes.get("regional_office_id") is not None: + await self._check_office_exists(changes["regional_office_id"]) + if changes.get("crm_id") is not None: + await self._check_crm_id_free(changes["crm_id"], except_entity_id=entity.id) + return changes + + async def _check_office_exists(self, office_id: uuid.UUID | None) -> None: + if office_id is not None and await repo.get_regional_office(self._session, office_id) is None: + raise DomainValidationError("OFFICE_NOT_FOUND", "Филиал не найден") + + async def _check_crm_id_free(self, crm_id: str, *, except_entity_id: uuid.UUID | None = None) -> None: + if await repo.legal_entity_crm_id_taken(self._session, crm_id, except_entity_id=except_entity_id): + raise ConflictError("CRM_ID_EXISTS", "ЮЛ с таким crm_id уже существует") diff --git a/api/src/services/related_groups.py b/api/src/services/related_groups.py new file mode 100644 index 0000000..812990f --- /dev/null +++ b/api/src/services/related_groups.py @@ -0,0 +1,238 @@ +"""Сервис ГСК/ГСЗ и их состава (spec directories-api «ГСК и ГСЗ — группы и состав», directory-policy, audit-trail). + +Порядок операции: право (`require` на маршруте) → видимость (группа — через её ГК, ЮЛ — по `scope`; невидимое = +несуществующее, 404) → проверки → запись. Состав — SCD2: исключение закрывает строку (`valid_to = now`, +`is_current = false`), повторное включение — новая строка. `related_group_member.kind` всегда равен `kind` группы +(v5.14): ЮЛ может одновременно состоять в одной ГСК и одной ГСЗ. Каждая мутация пишет `MEMBERSHIP_CHANGE` +(`object_type` `related_group` / `related_group_member`) в транзакции запроса через `RequestAudit`. +""" + +import uuid +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.domain.policy import VisibilityScope +from src.models.directories import RELATED_GROUP_CODE_SEQ, CompanyGroup, LegalEntity, RelatedGroup, RelatedGroupMember +from src.models.enums import AuditAction, AuditSection, RelatedGroupKind +from src.repositories import directories as repo +from src.schemas.directories import ( + MemberAdd, + RelatedGroupCreate, + RelatedGroupDetail, + RelatedGroupMemberOut, + RelatedGroupOut, + RelatedGroupUpdate, +) +from src.services.company_groups import COMPANY_GROUP_NOT_FOUND +from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND + +RELATED_GROUP_NOT_FOUND = "ГСК/ГСЗ не найдена" # одинаково для чужой и несуществующей (spec directory-policy) +MEMBERSHIP_NOT_FOUND = "ЮЛ не входит в эту группу" +OBJECT_TYPE = "related_group" +MEMBER_OBJECT_TYPE = "related_group_member" +CODE_PREFIXES = {RelatedGroupKind.gsk: "GSK", RelatedGroupKind.gsz: "GSZ"} +TRACKED_FIELDS = ("name", "created_date") # поля группы, изменяемые PATCH + + +class RelatedGroupService: + """Операции над ГСК/ГСЗ и их составом от имени текущего пользователя в его области видимости.""" + + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + self._session = session + self._audit = audit + self._scope = scope + + async def create(self, company_group_id: uuid.UUID, data: RelatedGroupCreate) -> RelatedGroup: + """Новая ГСК/ГСЗ видимой ГК с кодом `GSK-`/`GSZ-NNNNNN`; аудит `MEMBERSHIP_CHANGE`.""" + company_group = await self._visible_company_group(company_group_id) + group = RelatedGroup( + code=await repo.next_code(self._session, RELATED_GROUP_CODE_SEQ, CODE_PREFIXES[data.kind]), + company_group_id=company_group.id, + kind=data.kind, + name=data.name, + created_date=data.created_date, + ) + self._session.add(group) + await self._session.flush() + await self._audit.record( + action=AuditAction.MEMBERSHIP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=group.id, + after_value=self._group_payload(group), + ) + return group + + async def update(self, group_id: uuid.UUID, data: RelatedGroupUpdate) -> RelatedGroup: + """Изменение `name`/`created_date`; без фактических изменений ничего не пишется.""" + group = await self._get_group(group_id) + changes: dict[str, Any] = { + field: getattr(data, field) + for field in TRACKED_FIELDS + if field in data.model_fields_set and getattr(data, field) != getattr(group, field) + } + if not changes: + return group + before = {field: getattr(group, field) for field in changes} + for field, value in changes.items(): + setattr(group, field, value) + await self._session.flush() + await self._audit.record( + action=AuditAction.MEMBERSHIP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=group.id, + before_value=before, + after_value=changes, + changed_field=next(iter(changes)) if len(changes) == 1 else None, + ) + return group + + async def delete(self, group_id: uuid.UUID) -> None: + """Удаляет группу без текущих членов вместе с закрытыми строками состава; `before_value` — группа и состав. + + ASSUMPTION(OQ-34): удаляется только пустая ГСК/ГСЗ (есть текущие члены → 409 `GROUP_NOT_EMPTY`); история + состава исчезает вместе с группой и сохраняется только в `before_value` аудита. + """ + group = await self._get_group(group_id) + if await repo.has_current_members(self._session, group.id): + raise ConflictError("GROUP_NOT_EMPTY", "В группе есть текущие члены — сначала исключите их") + rows = await repo.list_group_member_rows(self._session, group.id) + before = { + **self._group_payload(group), + "members": [{"id": row.id, **self._member_payload(row)} for row in rows], + } + await repo.delete_group_member_rows(self._session, group.id) + await self._session.delete(group) + await self._session.flush() + await self._audit.record( + action=AuditAction.MEMBERSHIP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=OBJECT_TYPE, + object_id=group_id, + before_value=before, + ) + + async def add_member(self, group_id: uuid.UUID, data: MemberAdd) -> tuple[RelatedGroupMember, LegalEntity]: + """Текущее членство ЮЛ с `kind` группы; ЮЛ — той же ГК; не более одной группы каждого вида у ЮЛ.""" + group = await self._get_group(group_id) + entity = await repo.get_legal_entity(self._session, self._scope, data.legal_entity_id) + if entity is None: + raise NotFoundError(LEGAL_ENTITY_NOT_FOUND) + # ЮЛ без ГК (монозаёмщик, закрытое — Review Focus 4) тоже «не в той же ГК» + if entity.company_group_id != group.company_group_id: + raise DomainValidationError("DIFFERENT_COMPANY_GROUP", "ЮЛ не входит в ГК этой группы") + current = await repo.current_membership_of_kind(self._session, entity.id, group.kind) + if current is not None: + if current.related_group_id == group.id: + raise ConflictError("MEMBER_EXISTS", "ЮЛ уже входит в эту группу") + raise ConflictError( + "MEMBER_OF_OTHER_GROUP", + "ЮЛ уже входит в другую группу этого вида", + related_group_id=current.related_group_id, + ) + member = RelatedGroupMember( + related_group_id=group.id, + legal_entity_id=entity.id, + kind=group.kind, # v5.14: kind строки = kind группы + valid_from=utc_now(), + valid_to=None, + is_current=True, + created_by=self._audit.user_id, + ) + self._session.add(member) + await self._session.flush() + await self._audit.record( + action=AuditAction.MEMBERSHIP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=MEMBER_OBJECT_TYPE, + object_id=member.id, + after_value=self._member_payload(member), + ) + return member, entity + + async def remove_member(self, group_id: uuid.UUID, legal_entity_id: uuid.UUID) -> None: + """Исключение (SCD2): текущая строка закрывается `valid_to = now`, `is_current = false`; нет членства — 404.""" + group = await self._get_group(group_id) + member = await repo.current_membership_in_group(self._session, group.id, legal_entity_id) + if member is None: + raise NotFoundError(MEMBERSHIP_NOT_FOUND) + before = self._member_payload(member) + member.valid_to = utc_now() + member.is_current = False + await self._session.flush() + await self._audit.record( + action=AuditAction.MEMBERSHIP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type=MEMBER_OBJECT_TYPE, + object_id=member.id, + before_value=before, + after_value=self._member_payload(member), + ) + + async def get(self, group_id: uuid.UUID) -> RelatedGroupDetail: + """Карточка группы с текущими членами; невидимая и несуществующая — один и тот же 404.""" + group = await self._get_group(group_id) + members = [ + RelatedGroupMemberOut( + legal_entity_id=entity.id, inn=entity.inn, name=entity.name, valid_from=member.valid_from + ) + for member, entity in await repo.current_members(self._session, group.id) + ] + detail = RelatedGroupDetail.model_validate(group) + detail.members = members + detail.members_count = len(members) + return detail + + async def summary(self, group: RelatedGroup) -> RelatedGroupOut: + """Группа с числом текущих членов (ответ на создание/изменение).""" + out = RelatedGroupOut.model_validate(group) + out.members_count = await repo.count_current_members(self._session, group.id) + return out + + async def list_for(self, company_group_id: uuid.UUID) -> list[RelatedGroupOut]: + """ГСК/ГСЗ видимой ГК по имени с числом текущих членов.""" + company_group = await self._visible_company_group(company_group_id) + result = [] + for group, count in await repo.list_related_groups(self._session, company_group.id): + out = RelatedGroupOut.model_validate(group) + out.members_count = count + result.append(out) + return result + + async def _get_group(self, group_id: uuid.UUID) -> RelatedGroup: + group = await repo.get_related_group(self._session, self._scope, group_id) + if group is None: + raise NotFoundError(RELATED_GROUP_NOT_FOUND) + return group + + async def _visible_company_group(self, company_group_id: uuid.UUID) -> CompanyGroup: + company_group = await repo.get_company_group(self._session, self._scope, company_group_id) + if company_group is None: + raise NotFoundError(COMPANY_GROUP_NOT_FOUND) + return company_group + + @staticmethod + def _group_payload(group: RelatedGroup) -> dict[str, Any]: + return { + "code": group.code, + "company_group_id": group.company_group_id, + "kind": group.kind, + "name": group.name, + "created_date": group.created_date, + } + + @staticmethod + def _member_payload(member: RelatedGroupMember) -> dict[str, Any]: + return { + "related_group_id": member.related_group_id, + "legal_entity_id": member.legal_entity_id, + "kind": member.kind, + "valid_from": member.valid_from, + "valid_to": member.valid_to, + "is_current": member.is_current, + } diff --git a/api/src/services/users_admin.py b/api/src/services/users_admin.py new file mode 100644 index 0000000..3bef7f9 --- /dev/null +++ b/api/src/services/users_admin.py @@ -0,0 +1,302 @@ +"""Сервис администрирования (spec users-admin, audit-trail; design D-8; `05-domain-rules.md` §10). + +Пользователи (создание, изменение, деактивация — без удаления), назначение ролей, матрица «роль × право». Каждая +мутация пишет `audit_event` в транзакции запроса (`USER_CHANGE` / `USER_ROLE_CHANGE` — раздел `USERS`, +`ROLE_PERMISSION_CHANGE` — `ACCESS`); повтор того же состояния ничего не пишет. + +Защита `LAST_ADMIN`: после операции у активных пользователей должно остаться право `USERS_ADMIN`, полученное через +активную роль (как в `effective_permissions`). Проверяется на деактивации пользователя, замене его ролей и снятии +права с роли. Код роли не читается — только право. + +Гонки: мутируемая строка (`app_user` / `role`) блокируется `FOR NO KEY UPDATE`, затем перед подсчётом — строка права +`USERS_ADMIN` (`repo.lock_permission`): проверки «последнего администратора» выполняются по очереди, вторая видит +зафиксированный результат первой. Порядок блокировок всегда «объект → право». Именно `NO KEY UPDATE`, а не +`FOR UPDATE`: вставки `audit_event.user_id`, `user_role.role_id`, `role_permission.granted_by` берут на родительские +строки `FOR KEY SHARE` (проверка FK), который `FOR UPDATE` блокирует — два администратора, правящие друг друга, ловили +бы взаимную блокировку (deadlock) на вставке аудита. +""" + +import uuid +from collections.abc import Iterable +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.clock import utc_now +from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError +from src.models.access import AppUser, Permission, Role, RolePermission, UserRole +from src.models.directories import RegionalOffice +from src.models.enums import AuditAction, AuditSection +from src.repositories import directories as directories_repo +from src.repositories import users as repo +from src.schemas.admin import UserCreate, UserUpdate +from src.schemas.common import PageParams + +USERS_ADMIN = "USERS_ADMIN" # право, которое не должно пропасть у всех активных пользователей (D-8) +USER_OBJECT = "app_user" +ROLE_OBJECT = "role" +USER_NOT_FOUND = "Пользователь не найден" +ROLE_NOT_FOUND = "Роль не найдена" +LAST_ADMIN_MESSAGE = "Нельзя оставить систему без активного пользователя с правом USERS_ADMIN" +UPDATE_FIELDS = ("full_name", "employee_no", "regional_office_id", "business_segment", "is_active") + +UserWithRoles = tuple[AppUser, RegionalOffice, list[str]] + + +def _unique(codes: Iterable[str]) -> list[str]: + """Коды без дубликатов в порядке первого появления.""" + return list(dict.fromkeys(codes)) + + +class UsersAdminService: + """Операции администратора; права (`USERS_ADMIN`) проверяет маршрут, сервис — инварианты и аудит.""" + + def __init__(self, session: AsyncSession, audit: RequestAudit, actor_id: uuid.UUID) -> None: + self._session = session + self._audit = audit + self._actor_id = actor_id + + # --- пользователи --------------------------------------------------------------------------------------------- + + async def list_users( + self, q: str | None, is_active: bool | None, page: PageParams + ) -> tuple[list[UserWithRoles], int]: + """Страница пользователей с филиалом и кодами назначенных ролей; поиск `q` по email/ФИО.""" + rows, total = await repo.list_users( + self._session, q=q, is_active=is_active, limit=page.limit, offset=page.offset + ) + roles = await repo.role_codes_by_user(self._session, [user.id for user, _ in rows]) + return [(user, office, roles[user.id]) for user, office in rows], total + + async def user_view(self, user_id: uuid.UUID) -> UserWithRoles: + """Пользователь с филиалом и ролями (для ответа); нет — 404.""" + user = await repo.get_user(self._session, user_id) + if user is None: + raise NotFoundError(USER_NOT_FOUND) + office = await self._session.get(RegionalOffice, user.regional_office_id) + if office is None: # FK NOT NULL: недостижимо при целостной БД + raise NotFoundError("Филиал пользователя не найден") + roles = await repo.role_codes_by_user(self._session, [user.id]) + return user, office, roles[user.id] + + async def create_user(self, data: UserCreate) -> AppUser: + """Новый пользователь с ролями; email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён.""" + email = str(data.email).strip() + if await repo.email_taken(self._session, email.lower()): + raise ConflictError("EMAIL_EXISTS", "Пользователь с таким email уже существует") + await self._check_office(data.regional_office_id) + codes = _unique(data.roles) + roles = await self._active_roles(codes) + now = utc_now() + user = AppUser( + email=email, + full_name=data.full_name, + employee_no=data.employee_no, + regional_office_id=data.regional_office_id, + business_segment=data.business_segment, + is_active=True, + created_at=now, + updated_at=now, + ) + self._session.add(user) + await self._session.flush() + self._session.add_all(UserRole(user_id=user.id, role_id=role.id) for role in roles) + await self._session.flush() + await self._audit.record( + action=AuditAction.USER_CHANGE, + section=AuditSection.USERS, + object_type=USER_OBJECT, + object_id=user.id, + after_value={ + "email": user.email, + "full_name": user.full_name, + "employee_no": user.employee_no, + "regional_office_id": user.regional_office_id, + "business_segment": user.business_segment, + "roles": codes, + }, + ) + return user + + async def update_user(self, user_id: uuid.UUID, data: UserUpdate) -> AppUser: + """Изменение полей и деактивация; без фактических изменений ничего не пишется. + + Деактивация последнего активного обладателя `USERS_ADMIN` (в том числе самого себя) — 409 `LAST_ADMIN`. + """ + user = await self._lock_user(user_id) + changes: dict[str, Any] = {} + for field in UPDATE_FIELDS: + if field in data.model_fields_set and getattr(data, field) != getattr(user, field): + changes[field] = getattr(data, field) + if not changes: + return user + if "regional_office_id" in changes: + await self._check_office(changes["regional_office_id"]) + if changes.get("is_active") is False: + await self._ensure_admin_remains(excluding_user=user.id) + before = {field: getattr(user, field) for field in changes} + for field, value in changes.items(): + setattr(user, field, value) + user.updated_at = utc_now() + await self._session.flush() + await self._audit.record( + action=AuditAction.USER_CHANGE, + section=AuditSection.USERS, + object_type=USER_OBJECT, + object_id=user.id, + before_value=before, + after_value=changes, + changed_field=next(iter(changes)) if len(changes) == 1 else None, + ) + return user + + async def set_roles(self, user_id: uuid.UUID, codes: list[str]) -> list[str]: + """Заменяет набор ролей пользователя; возвращает новые коды по алфавиту. + + Неизвестный или неактивный код — 422 `UNKNOWN_ROLE`; если после замены ни у одного активного пользователя не + останется `USERS_ADMIN` — 409 `LAST_ADMIN`. Аудит `USER_ROLE_CHANGE`: before — прежние коды, after — новые + в порядке запроса. + """ + user = await self._lock_user(user_id) + new_codes = _unique(codes) + new_roles = await self._active_roles(new_codes) + current = await repo.user_roles(self._session, user.id) + old_ids = {role.id for _, role in current} + new_ids = {role.id for role in new_roles} + if old_ids == new_ids: + return sorted(new_codes) + removed = old_ids - new_ids + if removed: + await self._ensure_admin_remains(excluding_user=user.id, kept_roles=new_ids if user.is_active else None) + await repo.delete_user_roles(self._session, user.id, removed) + self._session.add_all(UserRole(user_id=user.id, role_id=role_id) for role_id in new_ids - old_ids) + await self._session.flush() + await self._audit.record( + action=AuditAction.USER_ROLE_CHANGE, + section=AuditSection.USERS, + object_type=USER_OBJECT, + object_id=user.id, + before_value={"roles": sorted(role.code for _, role in current)}, + after_value={"roles": new_codes}, + changed_field="roles", + ) + return sorted(new_codes) + + # --- роли и права --------------------------------------------------------------------------------------------- + + async def list_roles(self) -> list[tuple[Role, list[str]]]: + """Все роли (по коду) с кодами прав.""" + return await repo.list_roles_with_permissions(self._session) + + async def list_permissions(self) -> list[Permission]: + """Справочник прав (по коду).""" + return await repo.list_permissions(self._session) + + async def role_view(self, code: str) -> tuple[Role, list[str]]: + """Роль с кодами прав; нет — 404.""" + role = await repo.get_role_by_code(self._session, code) + if role is None: + raise NotFoundError(ROLE_NOT_FOUND) + return role, [permission.code for permission in await repo.role_permissions(self._session, role.id)] + + async def set_role_permissions(self, code: str, permission_codes: list[str]) -> list[str]: + """Заменяет набор прав роли; возвращает новые коды по алфавиту. + + Неизвестная роль — 404; неизвестный код права — 422 `UNKNOWN_PERMISSION` (новые права не создаются); снятие + `USERS_ADMIN`, после которого его не останется ни у одного активного пользователя, — 409 `LAST_ADMIN`. + """ + stmt = ( + select(Role) + .where(Role.code == code) + .with_for_update(key_share=True) + .execution_options(populate_existing=True) + ) + role = (await self._session.execute(stmt)).scalar_one_or_none() + if role is None: + raise NotFoundError(ROLE_NOT_FOUND) + new_codes = _unique(permission_codes) + found = { + permission.code: permission for permission in await repo.permissions_by_codes(self._session, new_codes) + } + unknown = [item for item in new_codes if item not in found] + if unknown: + raise DomainValidationError( + "UNKNOWN_PERMISSION", f"Неизвестные коды прав: {', '.join(unknown)}", permissions=unknown + ) + current = {permission.code: permission for permission in await repo.role_permissions(self._session, role.id)} + if set(current) == set(found): + return sorted(found) + removed = set(current) - set(found) + if USERS_ADMIN in removed: + await self._ensure_admin_remains(excluding_role=role.id) + await repo.delete_role_permissions(self._session, role.id, {current[item].id for item in removed}) + now = utc_now() + self._session.add_all( + RolePermission(role_id=role.id, permission_id=found[item].id, granted_by=self._actor_id, granted_at=now) + for item in set(found) - set(current) + ) + await self._session.flush() + await self._audit.record( + action=AuditAction.ROLE_PERMISSION_CHANGE, + section=AuditSection.ACCESS, + object_type=ROLE_OBJECT, + object_id=role.id, + before_value={"permissions": sorted(current)}, + after_value={"permissions": sorted(found)}, + changed_field="permissions", + ) + return sorted(found) + + # --- проверки ------------------------------------------------------------------------------------------------- + + async def _lock_user(self, user_id: uuid.UUID) -> AppUser: + """Пользователь под `FOR NO KEY UPDATE` (конкурентные правки одного пользователя идут по очереди); нет — 404.""" + stmt = ( + select(AppUser) + .where(AppUser.id == user_id) + .with_for_update(key_share=True) + .execution_options(populate_existing=True) + ) + user = (await self._session.execute(stmt)).scalar_one_or_none() + if user is None: + raise NotFoundError(USER_NOT_FOUND) + return user + + async def _active_roles(self, codes: list[str]) -> list[Role]: + """Активные роли по кодам; неизвестные и неактивные — 422 `UNKNOWN_ROLE` со списком кодов.""" + roles = {role.code: role for role in await repo.roles_by_codes(self._session, codes) if role.is_active} + unknown = [code for code in codes if code not in roles] + if unknown: + raise DomainValidationError( + "UNKNOWN_ROLE", f"Неизвестные или неактивные роли: {', '.join(unknown)}", roles=unknown + ) + return [roles[code] for code in codes] + + async def _check_office(self, office_id: uuid.UUID) -> None: + """Филиал из тела запроса должен существовать — 422 `OFFICE_NOT_FOUND` (как у ГК/ЮЛ, R-8).""" + if await directories_repo.get_regional_office(self._session, office_id) is None: + raise DomainValidationError("OFFICE_NOT_FOUND", "Филиал не найден") + + async def _ensure_admin_remains( + self, + *, + excluding_user: uuid.UUID | None = None, + excluding_role: uuid.UUID | None = None, + kept_roles: set[uuid.UUID] | None = None, + ) -> None: + """409 `LAST_ADMIN`, если после операции ни у одного активного пользователя не останется `USERS_ADMIN`. + + Сначала блокируется строка права (см. докстринг модуля), затем — уже по зафиксированному состоянию — считаются + ДРУГИЕ обладатели права: без `excluding_user` или без права через `excluding_role`. `kept_roles` — роли, которые + останутся у активного `excluding_user`: если одна из них даёт `USERS_ADMIN`, право у него сохраняется. + """ + await repo.lock_permission(self._session, USERS_ADMIN) + if kept_roles is not None and await repo.roles_grant_permission(self._session, kept_roles, USERS_ADMIN): + return + others = await repo.count_active_users_with_permission( + self._session, USERS_ADMIN, excluding_user=excluding_user, excluding_role=excluding_role + ) + if others == 0: + raise ConflictError("LAST_ADMIN", LAST_ADMIN_MESSAGE) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 3f5c8df..7109763 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -4,17 +4,40 @@ трогается (`make check` не требует БД). Тестовая БД сессии — отдельная свежая `armcore_pytest_` на сервере из `.env.test`: мигрируется alembic один раз на сессию и удаляется в конце; рабочая БД `armka` не используется. Изоляция тестов — внешняя транзакция с SAVEPOINT: `commit()` внутри теста ничего не фиксирует. + +`committed_sessionmaker` / `committed_app` / `committed_client` / `committed_storage` — для тестов, которым нужна сессия +запроса «как в проде»: отдельная временная БД с ЗАФИКСИРОВАННЫМ seed и сессия `async with session.begin()` без +SAVEPOINT-обёртки. Нужны там, где сервис делает явный `session.rollback()` (`dry_run` импорта): внутри `db_session` +такой откат снял бы корневой SAVEPOINT вместе с seed. Приложение собирается той же `_build_application`, что и `app`; +S3 — `FakeStorage` (объекты в словаре). """ import uuid -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AsyncExitStack from pathlib import Path +from types import SimpleNamespace +from typing import Any import asyncpg +import httpx import pytest import pytest_asyncio -from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import ( + AsyncConnection, + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from scripts.seed import run_seed +from src.api.router import build_api_router +from src.core.auth import build_verifier +from src.core.config import get_settings +from src.core.exception_handlers import register_exception_handlers +from src.db.db import get_async_session from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir from tests.pg_helpers import ( # scratch_db_url — реэкспорт фикстуры, чтобы pytest её нашёл alembic_async, @@ -90,3 +113,135 @@ async def db_session(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]: await session.close() await transaction.rollback() await connection.close() + + +AuthClientFactory = Callable[[str], Awaitable[httpx.AsyncClient]] + + +@pytest_asyncio.fixture +async def seeded(db_session: AsyncSession) -> AsyncSession: + """`db_session` с засеянными справочниками и dev-пользователями (`run_seed(dev=True)`).""" + await run_seed(db_session, dev=True) + await db_session.flush() + return db_session + + +def _build_application( + session_dependency: Callable[[], AsyncIterator[AsyncSession]], monkeypatch: pytest.MonkeyPatch +) -> FastAPI: + """Приложение на `build_api_router` с dev-эмитентом (`DEBUG` и `AUTH_DEV_ISSUER`) и переопределённой сессией БД. + + Предупреждение «Включён dev-эмитент…» из `build_verifier` (`src.core.auth.logger`) подавлено (T3.1). + """ + monkeypatch.setattr("src.core.auth.logger", SimpleNamespace(warning=lambda *args, **kwargs: None)) + settings = get_settings().model_copy(update={"DEBUG": True, "AUTH_DEV_ISSUER": True}) + application = FastAPI() + register_exception_handlers(application) + application.include_router(build_api_router(settings)) + application.dependency_overrides[get_async_session] = session_dependency + application.state.token_verifier = build_verifier(settings) + application.state.storage = None + return application + + +@pytest.fixture +def app(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> FastAPI: + """Приложение поверх `db_session` (см. `_build_application`); без lifespan, `storage` отсутствует. + + Override сессии повторяет транзакцию запроса: `begin_nested()` — ошибка в запросе откатывает лишь до SAVEPOINT, + данные теста сохраняются. + """ + + async def override_session() -> AsyncIterator[AsyncSession]: + async with db_session.begin_nested(): # как транзакция запроса: ошибка → откат до SAVEPOINT + yield db_session + + return _build_application(override_session, monkeypatch) + + +async def _client_factory(application: FastAPI, stack: AsyncExitStack, email: str) -> httpx.AsyncClient: + """Клиент с dev-токеном пользователя `email`, `base_url=.../api/v1`; закрывается вместе со `stack`.""" + transport = httpx.ASGITransport(app=application) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as anonymous: + response = await anonymous.post("/api/v1/dev/token", json={"email": email}) + assert response.status_code == 200, response.text # noqa: PLR2004,S101 + token = response.json()["access_token"] + client = httpx.AsyncClient( + transport=transport, base_url="http://test/api/v1", headers={"Authorization": f"Bearer {token}"} + ) + return await stack.enter_async_context(client) + + +@pytest_asyncio.fixture +async def auth_client(app: FastAPI, seeded: AsyncSession) -> AsyncIterator[AuthClientFactory]: # noqa: ARG001 + """Фабрика клиентов: `await auth_client("ka_rf@armka.local")` — клиент с dev-токеном, `base_url=.../api/v1`. + + Клиенты закрываются в teardown. Dev-пользователи seed: ka_rf, rka, client_manager, ka_go, curator_go, expert, admin + (`@armka.local`). + """ + async with AsyncExitStack() as stack: + + async def factory(email: str) -> httpx.AsyncClient: + return await _client_factory(app, stack, email) + + yield factory + + +class FakeStorage: + """Заглушка `S3Storage`: тот же интерфейс `bucket`/`put_object(obj_name, file_data)`, объекты — в словаре.""" + + def __init__(self, bucket: str = "arm-ka") -> None: + self.bucket = bucket + self.objects: dict[str, bytes] = {} + + async def put_object(self, obj_name: str, file_data: bytes) -> None: + self.objects[obj_name] = file_data + + +@pytest_asyncio.fixture +async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """Отдельная временная БД: `alembic upgrade head` и seed (dev) ЗАФИКСИРОВАНЫ; удаляется после теста.""" + name = f"armcore_import_{uuid.uuid4().hex[:12]}" + url = (await create_scratch_db(name)).render_as_string(hide_password=False) + engine = create_async_engine(url, connect_args={"server_settings": {"search_path": "dkb"}}) + try: + await alembic_async(url, "upgrade", "head") + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session, session.begin(): + await run_seed(session, dev=True) + yield maker + finally: + await engine.dispose() + await drop_scratch_db(name) + + +@pytest.fixture +def committed_app(committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch) -> FastAPI: + """Приложение с сессией запроса как в проде (`session.begin()` без SAVEPOINT); `storage` — `FakeStorage`.""" + + async def production_like_session() -> AsyncIterator[AsyncSession]: + async with committed_sessionmaker() as session, session.begin(): + yield session + + application = _build_application(production_like_session, monkeypatch) + application.state.storage = FakeStorage() + return application + + +@pytest.fixture +def committed_storage(committed_app: FastAPI) -> FakeStorage: + """`FakeStorage` приложения `committed_app`: что реально «записано в S3».""" + storage: Any = committed_app.state.storage + assert isinstance(storage, FakeStorage) # noqa: S101 + return storage + + +@pytest_asyncio.fixture +async def committed_client(committed_app: FastAPI) -> AsyncIterator[AuthClientFactory]: + """Фабрика клиентов `committed_app` (как `auth_client`, но на БД с зафиксированным seed).""" + async with AsyncExitStack() as stack: + + async def factory(email: str) -> httpx.AsyncClient: + return await _client_factory(committed_app, stack, email) + + yield factory diff --git a/api/tests/factories.py b/api/tests/factories.py index 949edbf..c98b579 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -1,5 +1,8 @@ """Фабрики тестовых данных для БД-тестов. +- `make_regional_office`, `make_company_group`, `make_legal_entity`, `make_related_group`, `make_membership`, + `make_user` — справочники Среза 1 (`flush`, без `commit`): откатываются фикстурой `db_session`. Идентификаторы по + умолчанию случайные (ИНН, коды, имена филиалов), поэтому фабрики можно вызывать несколько раз в одном тесте. - `make_osv_entry` — минимальная цепочка FK поверх сессии (`flush`, без `commit`): откатывается фикстурой `db_session`. - `CommittedJobs` — задания распознавания, ЗАФИКСИРОВАННЫЕ в БД (`commit`): для фоновых процессов обмена, которые работают в собственных транзакциях и не видят чужую незафиксированную транзакцию. Всё созданное удаляется @@ -8,20 +11,31 @@ import datetime import decimal +import secrets import uuid +from collections.abc import Sequence from typing import Any from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from src.models.directories import LegalEntity +from src.models.access import AppUser, Role, UserRole +from src.models.directories import ( + CompanyGroup, + LegalEntity, + RegionalOffice, + RelatedGroup, + RelatedGroupMember, +) from src.models.enums import ( DocumentStatus, DocumentType, + EntityType, FileStatus, FileType, RecognitionPipeline, RecognitionStatus, + RelatedGroupKind, S3BucketRole, ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile @@ -30,6 +44,121 @@ from src.models.osv import OsvEntry DEFAULT_FILE_TYPES = {RecognitionPipeline.tabular: FileType.XLSX, RecognitionPipeline.image: FileType.PDF} +def _now() -> datetime.datetime: + return datetime.datetime.now(datetime.UTC) + + +async def make_regional_office( + session: AsyncSession, *, name: str | None = None, is_head_office: bool = False +) -> RegionalOffice: + """Филиал; имя по умолчанию уникальное (`regional_office.name` UNIQUE).""" + office = RegionalOffice(name=name or f"Филиал {uuid.uuid4().hex[:8]}", is_head_office=is_head_office) + session.add(office) + await session.flush() + return office + + +async def make_company_group( + session: AsyncSession, + *, + name: str = "ГК Тест", + office: RegionalOffice | None = None, + crm_id: str | None = None, + code: str | None = None, +) -> CompanyGroup: + """ГК; `code` по умолчанию `GK-T<6 hex>`, `valid_from` — сейчас.""" + group = CompanyGroup( + code=code or f"GK-T{uuid.uuid4().hex[:6]}", + crm_id=crm_id, + name=name, + base_regional_office_id=office.id if office is not None else None, + valid_from=_now(), + ) + session.add(group) + await session.flush() + return group + + +async def make_legal_entity( # noqa: PLR0913 - сигнатура фабрики фиксирована планом + session: AsyncSession, + *, + name: str = "ООО Тест", + inn: str | None = None, + entity_type: EntityType = EntityType.ЮЛ, + group: CompanyGroup | None = None, + office: RegionalOffice | None = None, + close_date: datetime.date | None = None, +) -> LegalEntity: + """ЮЛ; ИНН по умолчанию — 10 случайных цифр, филиал по умолчанию — базовый филиал группы.""" + office_id = office.id if office is not None else (group.base_regional_office_id if group is not None else None) + entity = LegalEntity( + inn=inn or "".join(secrets.choice("0123456789") for _ in range(10)), + entity_type=entity_type, + name=name, + company_group_id=group.id if group is not None else None, + regional_office_id=office_id, + close_date=close_date, + valid_from=_now(), + ) + session.add(entity) + await session.flush() + return entity + + +async def make_related_group( + session: AsyncSession, *, group: CompanyGroup, kind: RelatedGroupKind = RelatedGroupKind.gsk, name: str = "ГСК Тест" +) -> RelatedGroup: + """ГСК/ГСЗ группы `group`; код `GSK-T…`/`GSZ-T…`.""" + related = RelatedGroup( + code=f"{kind.value.upper()}-T{uuid.uuid4().hex[:6]}", + company_group_id=group.id, + kind=kind, + name=name, + created_date=_now().date(), + ) + session.add(related) + await session.flush() + return related + + +async def make_membership( + session: AsyncSession, *, related_group: RelatedGroup, entity: LegalEntity, is_current: bool = True +) -> RelatedGroupMember: + """Членство ЮЛ в ГСК/ГСЗ; `kind` копируется из группы (v5.14).""" + now = _now() + member = RelatedGroupMember( + related_group_id=related_group.id, + legal_entity_id=entity.id, + kind=related_group.kind, + valid_from=now, + valid_to=None if is_current else now, + is_current=is_current, + ) + session.add(member) + await session.flush() + return member + + +async def make_user( # noqa: PLR0913 - сигнатура фабрики фиксирована планом + session: AsyncSession, + *, + email: str, + office: RegionalOffice, + roles: Sequence[str] = (), + full_name: str = "Тест Тестов", + is_active: bool = True, +) -> AppUser: + """Пользователь с ролями по `Role.code` (роли должны быть засеяны: фикстура `seeded`).""" + user = AppUser(email=email, full_name=full_name, regional_office_id=office.id, is_active=is_active) + session.add(user) + await session.flush() + for code in roles: + role_id = (await session.execute(select(Role.id).where(Role.code == code))).scalar_one() + session.add(UserRole(user_id=user.id, role_id=role_id)) + await session.flush() + return user + + async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry: """Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`. diff --git a/api/tests/test_api_admin.py b/api/tests/test_api_admin.py new file mode 100644 index 0000000..18356d3 --- /dev/null +++ b/api/tests/test_api_admin.py @@ -0,0 +1,628 @@ +"""API администрирования `/admin/*` (spec users-admin, audit-trail; design D-8; Review Focus 5 фазы 2). + +Пользователи (создание, изменение, деактивация), назначение ролей, матрица «роль × право», аудит и защита `LAST_ADMIN`: +последнее право `USERS_ADMIN` у активных пользователей нельзя снять ни деактивацией, ни заменой ролей, ни снятием +права с роли. Новые пользователи — с доменом `example.com`: `EmailStr` не принимает зарезервированный `.local` +dev-пользователей. +""" + +import asyncio +import uuid +from collections.abc import Sequence +from typing import Any + +import httpx +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.api.dependencies.audit import RequestAudit +from src.common.exceptions import DomainError +from src.models.access import AppUser, Permission, Role, RolePermission, UserRole +from src.models.audit_ex import AuditEvent +from src.models.directories import RegionalOffice +from src.models.enums import AuditAction, AuditSection +from src.repositories import users as users_repo +from src.services.audit import AuditService +from src.services.users_admin import UsersAdminService +from tests.conftest import AuthClientFactory +from tests.factories import make_user + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 + +ADMIN = "admin@armka.local" +USERS = "/admin/users" +ROSTOV = "Ростовский региональный филиал" +HEAD_OFFICE = "Головной офис" +PERMISSIONS_TOTAL = 16 # seed: 16 кодов прав (§10) +EXPERT_PERMISSIONS = ["CONSOLIDATION_VIEW", "DIRECTORY_VIEW", "DOCUMENT_VIEW", "EX_VIEW", "KFO_VIEW"] + + +async def _office(session: AsyncSession, name: str) -> RegionalOffice: + return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one() + + +async def _user_id(session: AsyncSession, email: str) -> uuid.UUID: + return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one() + + +async def _role_codes(session: AsyncSession, user_id: uuid.UUID) -> list[str]: + stmt = select(Role.code).join(UserRole, UserRole.role_id == Role.id).where(UserRole.user_id == user_id) + return sorted((await session.execute(stmt)).scalars().all()) + + +async def _events(session: AsyncSession, action: AuditAction) -> Sequence[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == action).order_by(AuditEvent.occurred_at, AuditEvent.id) + return (await session.execute(stmt)).scalars().all() + + +async def _create(client: httpx.AsyncClient, **body: Any) -> dict[str, Any]: + response = await client.post(USERS, json=body) + assert response.status_code == HTTP_CREATED, response.text + created: dict[str, Any] = response.json() + return created + + +def _detail_code(response: httpx.Response) -> str: + code: str = response.json()["detail"]["code"] + return code + + +# --- Пользователи ------------------------------------------------------------------------------------------------ + + +@pytest.mark.integration +async def test_create_user_with_role_gives_role_permissions_in_me( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + admin = await auth_client(ADMIN) + rostov = await _office(seeded, ROSTOV) + created = await _create( + admin, + email=" Ivan.Petrov@Example.com ", + full_name="Иван Петров", + regional_office_id=str(rostov.id), + employee_no="E-1", + business_segment="DKB", + roles=["KA_RF"], + ) + assert created["email"] == "Ivan.Petrov@example.com" # регистр локальной части сохранён (EmailStr: домен строчными) + assert created["roles"] == ["KA_RF"] + assert created["is_active"] is True + assert created["regional_office"] == { + "id": str(rostov.id), + "name": ROSTOV, + "is_head_office": False, + "is_active": True, + } + assert set(created) == { + "id", + "email", + "full_name", + "employee_no", + "regional_office", + "business_segment", + "is_active", + "roles", + "created_at", + "updated_at", + } + user_id = uuid.UUID(created["id"]) + assert await _role_codes(seeded, user_id) == ["KA_RF"] + user = await seeded.get(AppUser, user_id) + assert user is not None and user.external_id is None and user.created_at is not None + + events = await _events(seeded, AuditAction.USER_CHANGE) + assert len(events) == 1 + event = events[0] + assert (event.section, event.object_type, event.object_id) == (AuditSection.USERS, "app_user", user_id) + assert event.user_id == await _user_id(seeded, ADMIN) + assert event.after_value == { + "email": "Ivan.Petrov@example.com", + "full_name": "Иван Петров", + "employee_no": "E-1", + "regional_office_id": str(rostov.id), + "business_segment": "DKB", + "roles": ["KA_RF"], + } + + reference = (await (await auth_client("ka_rf@armka.local")).get("/me")).json() + me = await (await auth_client("ivan.petrov@example.com")).get("/me") + assert me.status_code == HTTP_OK, me.text + assert me.json()["roles"] == ["KA_RF"] + assert me.json()["permissions"] == reference["permissions"] + + +@pytest.mark.integration +async def test_create_user_email_in_other_case_conflicts(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + admin = await auth_client(ADMIN) + office_id = str((await _office(seeded, HEAD_OFFICE)).id) + await _create(admin, email="anna@example.com", full_name="Анна", regional_office_id=office_id) + response = await admin.post( + USERS, json={"email": "ANNA@Example.COM", "full_name": "Анна 2", "regional_office_id": office_id} + ) + assert response.status_code == HTTP_CONFLICT + assert _detail_code(response) == "EMAIL_EXISTS" + total = (await seeded.execute(select(func.count()).where(func.lower(AppUser.email) == "anna@example.com"))).scalar() + assert total == 1 + + +@pytest.mark.integration +async def test_create_user_validation(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + admin = await auth_client(ADMIN) + office_id = str((await _office(seeded, HEAD_OFFICE)).id) + base = {"email": "v@example.com", "full_name": "В", "regional_office_id": office_id} + + unknown_role = await admin.post(USERS, json={**base, "roles": ["KA_RF", "NO_SUCH_ROLE"]}) + assert unknown_role.status_code == HTTP_UNPROCESSABLE + assert unknown_role.json()["detail"]["code"] == "UNKNOWN_ROLE" + assert unknown_role.json()["detail"]["roles"] == ["NO_SUCH_ROLE"] + + unknown_office = await admin.post(USERS, json={**base, "regional_office_id": str(uuid.uuid4())}) + assert unknown_office.status_code == HTTP_UNPROCESSABLE + assert _detail_code(unknown_office) == "OFFICE_NOT_FOUND" + + for body in ( + {**base, "external_id": "raisa-1"}, # external_id через API не задаётся + {**base, "email": "not-an-email"}, + {**base, "full_name": " "}, + ): + response = await admin.post(USERS, json=body) + assert response.status_code == HTTP_UNPROCESSABLE, body + assert (await seeded.execute(select(AppUser).where(AppUser.email == "v@example.com"))).first() is None + assert await _events(seeded, AuditAction.USER_CHANGE) == [] + + +@pytest.mark.integration +async def test_list_users_search_filter_and_page(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + office = await _office(seeded, ROSTOV) + await make_user(seeded, email="Zoya_Search@example.com", office=office, roles=["KA_RF", "EXPERT"], full_name="Зоя") + await make_user(seeded, email="off@example.com", office=office, full_name="Зоя Отключённая", is_active=False) + await make_user(seeded, email="zoyaXs@example.com", office=office) # совпал бы с `zoya_s`, если `_` — шаблон LIKE + admin = await auth_client(ADMIN) + + everyone = (await admin.get(USERS)).json() + assert set(everyone) == {"items", "total", "limit", "offset"} + assert everyone["total"] == len(everyone["items"]) >= 10 # noqa: PLR2004 - 7 dev-пользователей + 3 новых + + by_email = (await admin.get(USERS, params={"q": "zoya_s"})).json() # `_` ищется буквально, без регистра + assert [item["email"] for item in by_email["items"]] == ["Zoya_Search@example.com"] # `zoyaXs@…` не найден + assert by_email["items"][0]["roles"] == ["EXPERT", "KA_RF"] + assert by_email["items"][0]["regional_office"]["name"] == ROSTOV + + by_name = (await admin.get(USERS, params={"q": "зоя"})).json() + assert by_name["total"] == 2 # noqa: PLR2004 + inactive = (await admin.get(USERS, params={"q": "зоя", "is_active": "false"})).json() + assert [item["email"] for item in inactive["items"]] == ["off@example.com"] + assert inactive["items"][0]["roles"] == [] + + page = (await admin.get(USERS, params={"limit": 2, "offset": 1})).json() + assert (len(page["items"]), page["limit"], page["offset"], page["total"]) == (2, 2, 1, everyone["total"]) + assert page["items"] == everyone["items"][1:3] + + +@pytest.mark.integration +async def test_patch_user_fields_audit_and_noop(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + rostov = await _office(seeded, ROSTOV) + head = await _office(seeded, HEAD_OFFICE) + user = await make_user(seeded, email="p@example.com", office=rostov, roles=["KA_RF"], full_name="Пётр") + admin = await auth_client(ADMIN) + + response = await admin.patch( + f"{USERS}/{user.id}", json={"full_name": "Пётр Петров", "regional_office_id": str(head.id)} + ) + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert body["full_name"] == "Пётр Петров" + assert body["regional_office"]["id"] == str(head.id) + assert body["roles"] == ["KA_RF"] + events = await _events(seeded, AuditAction.USER_CHANGE) + assert len(events) == 1 + assert (events[0].section, events[0].object_type, events[0].object_id) == (AuditSection.USERS, "app_user", user.id) + assert events[0].before_value == {"full_name": "Пётр", "regional_office_id": str(rostov.id)} + assert events[0].after_value == {"full_name": "Пётр Петров", "regional_office_id": str(head.id)} + assert events[0].changed_field is None + + single = await admin.patch(f"{USERS}/{user.id}", json={"employee_no": "E-7"}) + assert single.status_code == HTTP_OK + last = (await _events(seeded, AuditAction.USER_CHANGE))[-1] + assert (last.changed_field, last.before_value, last.after_value) == ( + "employee_no", + {"employee_no": None}, + {"employee_no": "E-7"}, + ) + + noop = await admin.patch(f"{USERS}/{user.id}", json={"full_name": "Пётр Петров", "employee_no": "E-7"}) + assert noop.status_code == HTTP_OK + assert len(await _events(seeded, AuditAction.USER_CHANGE)) == 2 # noqa: PLR2004 - no-op не пишет аудит + + for bad in ({"email": "x@example.com"}, {"full_name": None}, {"is_active": None}, {"regional_office_id": None}): + assert (await admin.patch(f"{USERS}/{user.id}", json=bad)).status_code == HTTP_UNPROCESSABLE, bad + office_missing = await admin.patch(f"{USERS}/{user.id}", json={"regional_office_id": str(uuid.uuid4())}) + assert office_missing.status_code == HTTP_UNPROCESSABLE + assert _detail_code(office_missing) == "OFFICE_NOT_FOUND" + assert (await admin.patch(f"{USERS}/{uuid.uuid4()}", json={"full_name": "Х"})).status_code == HTTP_NOT_FOUND + + +@pytest.mark.integration +async def test_deactivated_user_loses_access(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + user_id = await _user_id(seeded, "expert@armka.local") + expert = await auth_client("expert@armka.local") + admin = await auth_client(ADMIN) + response = await admin.patch(f"{USERS}/{user_id}", json={"is_active": False}) + assert response.status_code == HTTP_OK + assert response.json()["is_active"] is False + assert (await expert.get("/me")).status_code == HTTP_FORBIDDEN + event = (await _events(seeded, AuditAction.USER_CHANGE))[-1] + assert (event.changed_field, event.before_value, event.after_value) == ( + "is_active", + {"is_active": True}, + {"is_active": False}, + ) + + +# --- Назначение ролей -------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_put_roles_replaces_set_with_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + user = await make_user(seeded, email="r@example.com", office=await _office(seeded, ROSTOV), roles=["KA_RF"]) + admin = await auth_client(ADMIN) + + response = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": ["KA_GO", "EXPERT", "KA_GO"]}) + assert response.status_code == HTTP_OK, response.text + assert response.json()["roles"] == ["EXPERT", "KA_GO"] + assert await _role_codes(seeded, user.id) == ["EXPERT", "KA_GO"] + rows = (await seeded.execute(select(func.count()).where(UserRole.user_id == user.id))).scalar() + assert rows == 2 # noqa: PLR2004 + + events = await _events(seeded, AuditAction.USER_ROLE_CHANGE) + assert len(events) == 1 + event = events[0] + assert (event.section, event.object_type, event.object_id) == (AuditSection.USERS, "app_user", user.id) + assert event.before_value == {"roles": ["KA_RF"]} + assert event.after_value == {"roles": ["KA_GO", "EXPERT"]} + + same = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": ["EXPERT", "KA_GO"]}) + assert same.status_code == HTTP_OK + assert len(await _events(seeded, AuditAction.USER_ROLE_CHANGE)) == 1 # тот же набор — без аудита + + cleared = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": []}) + assert cleared.status_code == HTTP_OK + assert await _role_codes(seeded, user.id) == [] + + +@pytest.mark.integration +async def test_put_roles_rejects_unknown_and_inactive_role( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + user = await make_user(seeded, email="u@example.com", office=await _office(seeded, ROSTOV), roles=["KA_RF"]) + seeded.add(Role(code="RETIRED", name="Упразднённая", is_system=False, is_active=False)) + await seeded.flush() + admin = await auth_client(ADMIN) + + for codes, bad in ((["KA_GO", "NOPE"], ["NOPE"]), (["RETIRED"], ["RETIRED"])): + response = await admin.put(f"{USERS}/{user.id}/roles", json={"roles": codes}) + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "UNKNOWN_ROLE" + assert response.json()["detail"]["roles"] == bad + assert await _role_codes(seeded, user.id) == ["KA_RF"] + missing = await admin.put(f"{USERS}/{uuid.uuid4()}/roles", json={"roles": ["KA_GO"]}) + assert missing.status_code == HTTP_NOT_FOUND + + +# --- LAST_ADMIN -------------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_last_admin_cannot_drop_own_admin_role(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + admin_id = await _user_id(seeded, ADMIN) + admin = await auth_client(ADMIN) + response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]}) + assert response.status_code == HTTP_CONFLICT + assert _detail_code(response) == "LAST_ADMIN" + assert await _role_codes(seeded, admin_id) == ["ADMIN"] + assert await _events(seeded, AuditAction.USER_ROLE_CHANGE) == [] + + +@pytest.mark.integration +async def test_inactive_user_or_inactive_role_is_not_an_admin_source( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + head = await _office(seeded, HEAD_OFFICE) + await make_user(seeded, email="sleeping.admin@example.com", office=head, roles=["ADMIN"], is_active=False) + retired = Role(code="RETIRED_ADMIN", name="Бывшая админская", is_system=False, is_active=False) + seeded.add(retired) + await seeded.flush() + users_admin = (await seeded.execute(select(Permission.id).where(Permission.code == "USERS_ADMIN"))).scalar_one() + seeded.add(RolePermission(role_id=retired.id, permission_id=users_admin)) + await make_user(seeded, email="retired.admin@example.com", office=head, roles=["RETIRED_ADMIN"]) + + admin_id = await _user_id(seeded, ADMIN) + admin = await auth_client(ADMIN) + response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]}) + assert response.status_code == HTTP_CONFLICT + assert _detail_code(response) == "LAST_ADMIN" + + +@pytest.mark.integration +async def test_second_admin_allows_dropping_own_admin_role( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + await make_user( + seeded, email="second.admin@example.com", office=await _office(seeded, HEAD_OFFICE), roles=["ADMIN"] + ) + admin_id = await _user_id(seeded, ADMIN) + admin = await auth_client(ADMIN) + response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]}) + assert response.status_code == HTTP_OK, response.text + assert await _role_codes(seeded, admin_id) == ["EXPERT"] + assert (await admin.get("/admin/roles")).status_code == HTTP_FORBIDDEN # права пересчитаны на следующем запросе + + +async def _grant_users_admin(session: AsyncSession, role_code: str, *, is_active: bool = True) -> None: + """Даёт роли право `USERS_ADMIN` напрямую в БД (и задаёт `is_active` роли) — как после правки матрицы прав.""" + role = (await session.execute(select(Role).where(Role.code == role_code))).scalar_one() + permission_id = (await session.execute(select(Permission.id).where(Permission.code == "USERS_ADMIN"))).scalar_one() + session.add(RolePermission(role_id=role.id, permission_id=permission_id)) + role.is_active = is_active + await session.flush() + + +@pytest.mark.integration +async def test_sole_admin_moved_to_active_role_with_users_admin_stays_admin( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Ветка `kept_roles`: оставшаяся активная роль даёт `USERS_ADMIN` — другого администратора не требуется (T12.1).""" + await _grant_users_admin(seeded, "EXPERT") + admin_id = await _user_id(seeded, ADMIN) + admin = await auth_client(ADMIN) + response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]}) + assert response.status_code == HTTP_OK, response.text + assert await _role_codes(seeded, admin_id) == ["EXPERT"] + assert (await admin.get(USERS)).status_code == HTTP_OK # право USERS_ADMIN сохранено через EXPERT + assert len(await _events(seeded, AuditAction.USER_ROLE_CHANGE)) == 1 + + +@pytest.mark.integration +async def test_sole_admin_cannot_be_moved_to_inactive_role_with_users_admin( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Неактивная роль с `USERS_ADMIN` не даёт права и не назначается: набор ролей и администратор не меняются (T12.1). + + Сервис отсекает её раньше проверки `LAST_ADMIN` — 422 `UNKNOWN_ROLE`; сам фильтр `is_active` в `kept_roles`-проверке + (`roles_grant_permission`) защитный и до неё через API недостижим, поэтому проверяется отдельно. + """ + await _grant_users_admin(seeded, "EXPERT", is_active=False) + admin_id = await _user_id(seeded, ADMIN) + admin = await auth_client(ADMIN) + response = await admin.put(f"{USERS}/{admin_id}/roles", json={"roles": ["EXPERT"]}) + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert _detail_code(response) == "UNKNOWN_ROLE" + assert await _role_codes(seeded, admin_id) == ["ADMIN"] + assert await _events(seeded, AuditAction.USER_ROLE_CHANGE) == [] + assert (await admin.get(USERS)).status_code == HTTP_OK + + roles = { + role.code: role.id + for role in (await seeded.execute(select(Role).where(Role.code.in_(["ADMIN", "EXPERT"])))).scalars() + } + assert await users_repo.roles_grant_permission(seeded, {roles["ADMIN"]}, "USERS_ADMIN") is True + assert await users_repo.roles_grant_permission(seeded, {roles["EXPERT"]}, "USERS_ADMIN") is False # неактивная роль + + +@pytest.mark.integration +async def test_last_admin_cannot_deactivate_self(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Review Focus 5: последний активный с `USERS_ADMIN` не может деактивировать себя; при втором — может.""" + admin_id = await _user_id(seeded, ADMIN) + admin = await auth_client(ADMIN) + response = await admin.patch(f"{USERS}/{admin_id}", json={"is_active": False, "full_name": "Не применится"}) + assert response.status_code == HTTP_CONFLICT + assert _detail_code(response) == "LAST_ADMIN" + user = await seeded.get(AppUser, admin_id) + assert user is not None + await seeded.refresh(user) + assert user.is_active is True and user.full_name != "Не применится" + assert await _events(seeded, AuditAction.USER_CHANGE) == [] + + await make_user( + seeded, email="second.admin@example.com", office=await _office(seeded, HEAD_OFFICE), roles=["ADMIN"] + ) + allowed = await admin.patch(f"{USERS}/{admin_id}", json={"is_active": False}) + assert allowed.status_code == HTTP_OK, allowed.text + assert allowed.json()["is_active"] is False + assert (await admin.get(USERS)).status_code == HTTP_FORBIDDEN # отключённый пользователь — 403 + + +@pytest.mark.integration +async def test_deactivating_other_admin_respects_last_admin( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + second = await make_user( + seeded, email="second.admin@example.com", office=await _office(seeded, HEAD_OFFICE), roles=["ADMIN"] + ) + admin = await auth_client(ADMIN) + second_client = await auth_client("second.admin@example.com") + assert (await admin.patch(f"{USERS}/{second.id}", json={"is_active": False})).status_code == HTTP_OK + # второй отключён — первый снова последний + admin_id = await _user_id(seeded, ADMIN) + response = await admin.patch(f"{USERS}/{admin_id}", json={"is_active": False}) + assert response.status_code == HTTP_CONFLICT + assert _detail_code(response) == "LAST_ADMIN" + assert (await second_client.get(USERS)).status_code == HTTP_FORBIDDEN + + +# --- Матрица прав ------------------------------------------------------------------------------------------------ + + +@pytest.mark.integration +async def test_roles_and_permissions_listing(auth_client: AuthClientFactory) -> None: + admin = await auth_client(ADMIN) + permissions = (await admin.get("/admin/permissions")).json() + assert len(permissions) == PERMISSIONS_TOTAL + codes = [item["code"] for item in permissions] + assert codes == sorted(codes) and "USERS_ADMIN" in codes + users_admin = next(item for item in permissions if item["code"] == "USERS_ADMIN") + assert set(users_admin) == {"code", "object_type", "action", "name", "description"} + assert (users_admin["object_type"], users_admin["action"]) == ("USERS", "ADMIN") + + roles = {item["code"]: item for item in (await admin.get("/admin/roles")).json()} + assert {"ADMIN", "EXPERT", "KA_RF", "KA_GO"} <= set(roles) + assert roles["EXPERT"]["permissions"] == EXPERT_PERMISSIONS + assert "USERS_ADMIN" in roles["ADMIN"]["permissions"] + assert set(roles["ADMIN"]) == {"code", "name", "description", "is_system", "is_active", "permissions"} + assert roles["ADMIN"]["is_system"] is True + + +@pytest.mark.integration +async def test_role_permissions_change_reaches_me_and_audit( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + admin = await auth_client(ADMIN) + expert = await auth_client("expert@armka.local") + assert "EXPORT" not in (await expert.get("/me")).json()["permissions"] + + response = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": [*EXPERT_PERMISSIONS, "EXPORT"]}) + assert response.status_code == HTTP_OK, response.text + assert response.json()["permissions"] == sorted([*EXPERT_PERMISSIONS, "EXPORT"]) + + role = (await seeded.execute(select(Role).where(Role.code == "EXPERT"))).scalar_one() + row = ( + await seeded.execute( + select(RolePermission) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(RolePermission.role_id == role.id, Permission.code == "EXPORT") + ) + ).scalar_one() + admin_id = await _user_id(seeded, ADMIN) + assert row.granted_by == admin_id and row.granted_at is not None + assert "EXPORT" in (await expert.get("/me")).json()["permissions"] + + events = await _events(seeded, AuditAction.ROLE_PERMISSION_CHANGE) + assert len(events) == 1 + event = events[0] + assert (event.section, event.object_type, event.object_id, event.user_id) == ( + AuditSection.ACCESS, + "role", + role.id, + admin_id, + ) + assert event.before_value == {"permissions": EXPERT_PERMISSIONS} + assert event.after_value == {"permissions": sorted([*EXPERT_PERMISSIONS, "EXPORT"])} + + same = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": [*EXPERT_PERMISSIONS, "EXPORT"]}) + assert same.status_code == HTTP_OK + assert len(await _events(seeded, AuditAction.ROLE_PERMISSION_CHANGE)) == 1 + + shrink = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": ["DIRECTORY_VIEW"]}) + assert shrink.status_code == HTTP_OK + assert (await expert.get("/me")).json()["permissions"] == ["DIRECTORY_VIEW"] + + +@pytest.mark.integration +async def test_role_permissions_validation_and_last_admin(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + admin = await auth_client(ADMIN) + roles = {item["code"]: item for item in (await admin.get("/admin/roles")).json()} + without_users_admin = [code for code in roles["ADMIN"]["permissions"] if code != "USERS_ADMIN"] + + last = await admin.put("/admin/roles/ADMIN/permissions", json={"permissions": without_users_admin}) + assert last.status_code == HTTP_CONFLICT + assert _detail_code(last) == "LAST_ADMIN" + + unknown = await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": ["KFO_VIEW", "FLY"]}) + assert unknown.status_code == HTTP_UNPROCESSABLE + assert unknown.json()["detail"]["code"] == "UNKNOWN_PERMISSION" + assert unknown.json()["detail"]["permissions"] == ["FLY"] + missing = await admin.put("/admin/roles/NO_SUCH/permissions", json={"permissions": []}) + assert missing.status_code == HTTP_NOT_FOUND + assert await _events(seeded, AuditAction.ROLE_PERMISSION_CHANGE) == [] + + # USERS_ADMIN есть у другой роли активного пользователя — снять с ADMIN можно + await admin.put("/admin/roles/EXPERT/permissions", json={"permissions": [*EXPERT_PERMISSIONS, "USERS_ADMIN"]}) + allowed = await admin.put("/admin/roles/ADMIN/permissions", json={"permissions": without_users_admin}) + assert allowed.status_code == HTTP_OK, allowed.text + assert "USERS_ADMIN" not in allowed.json()["permissions"] + + +# --- Права доступа ----------------------------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_admin_routes_forbidden_without_users_admin(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + user_id = await _user_id(seeded, "ka_rf@armka.local") + calls = [ + ("GET", USERS, None), + ("POST", USERS, {"email": "x@example.com", "full_name": "Х", "regional_office_id": str(uuid.uuid4())}), + ("PATCH", f"{USERS}/{user_id}", {"full_name": "Х"}), + ("PATCH", f"{USERS}/{uuid.uuid4()}", {"full_name": "Х"}), + ("PUT", f"{USERS}/{user_id}/roles", {"roles": ["ADMIN"]}), + ("GET", "/admin/roles", None), + ("GET", "/admin/permissions", None), + ("PUT", "/admin/roles/KA_GO/permissions", {"permissions": ["USERS_ADMIN"]}), + ("PUT", "/admin/roles/NO_SUCH/permissions", {"permissions": []}), + ("PATCH", f"{USERS}/not-a-uuid", {"bad": 1}), + ] + for method, path, body in calls: + response = await client.request(method, path, json=body) + assert response.status_code == HTTP_FORBIDDEN, (method, path, response.text) + assert response.json() == {"detail": "недостаточно прав: требуется USERS_ADMIN"} + assert await _role_codes(seeded, user_id) == ["KA_RF"] + + +# --- Гонка двух администраторов ---------------------------------------------------------------------------------- + + +async def _remove_admin_role(maker: async_sessionmaker[AsyncSession], actor_id: uuid.UUID, target_id: uuid.UUID) -> str: + """Отдельная транзакция «как запрос»: `actor_id` заменяет роли `target_id` на `[EXPERT]`; итог — `ok` или код.""" + try: + async with maker() as session, session.begin(): + audit = RequestAudit(service=AuditService(session), user_id=actor_id, ip_address=None, user_agent=None) + await UsersAdminService(session, audit, actor_id).set_roles(target_id, ["EXPERT"]) + except DomainError as error: + return error.code + return "ok" + + +@pytest.mark.integration +async def test_concurrent_mutual_admin_removal_keeps_one_admin( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +) -> None: + """Два администратора в параллельных транзакциях снимают ADMIN друг с друга: успевает ровно один (D-8). + + Без блокировки обе транзакции насчитали бы «другого» администратора и обе зафиксировались бы — ноль администраторов. + Проверка на уровне сервиса: обе транзакции стартуют одновременно, без предварительной аутентификации запроса. + Барьер перед `lock_permission` гарантирует, что обе транзакции уже прочитали своих пользователей и дошли до проверки + до того, как любая из них зафиксируется (без барьера первая могла бы успеть целиком и гонки бы не было). + """ + barrier = asyncio.Barrier(2) + original_lock = users_repo.lock_permission + + async def lock_after_barrier(session: AsyncSession, code: str) -> None: + async with asyncio.timeout(10): + await barrier.wait() + await original_lock(session, code) + + monkeypatch.setattr(users_repo, "lock_permission", lock_after_barrier) + + async with committed_sessionmaker() as session, session.begin(): + first_id = await _user_id(session, ADMIN) + second = await make_user( + session, email="second.admin@example.com", office=await _office(session, HEAD_OFFICE), roles=["ADMIN"] + ) + second_id = second.id + + results = await asyncio.gather( + _remove_admin_role(committed_sessionmaker, first_id, second_id), + _remove_admin_role(committed_sessionmaker, second_id, first_id), + ) + assert sorted(results) == ["LAST_ADMIN", "ok"] + + async with committed_sessionmaker() as session: + remaining = await users_repo.count_active_users_with_permission(session, "USERS_ADMIN") + assert remaining == 1 diff --git a/api/tests/test_api_aliases.py b/api/tests/test_api_aliases.py new file mode 100644 index 0000000..c9d8f6c --- /dev/null +++ b/api/tests/test_api_aliases.py @@ -0,0 +1,233 @@ +"""API алиасов ЮЛ (spec directories-api «Алиасы ЮЛ», audit-trail, directory-policy).""" + +import uuid +from collections.abc import Sequence +from typing import Any + +import httpx +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.audit_ex import AuditEvent +from src.models.directories import LegalEntityAlias, RegionalOffice +from src.models.enums import AuditAction, AuditSection +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_NO_CONTENT = 204 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 +ENTITIES = "/directories/legal-entities" +OBJECT_TYPE = "legal_entity_alias" +KRASNODAR = "Краснодарский региональный филиал" + + +async def _events(session: AsyncSession) -> Sequence[AuditEvent]: + stmt = ( + select(AuditEvent).where(AuditEvent.object_type == OBJECT_TYPE).order_by(AuditEvent.occurred_at, AuditEvent.id) + ) + return (await session.execute(stmt)).scalars().all() + + +async def _alias_count(session: AsyncSession) -> int: + return (await session.execute(select(func.count()).select_from(LegalEntityAlias))).scalar_one() + + +async def _add(client: httpx.AsyncClient, entity_id: Any, alias: str) -> httpx.Response: + return await client.post(f"{ENTITIES}/{entity_id}/aliases", json={"alias": alias}) + + +@pytest.mark.integration +async def test_alias_is_normalized_and_repeat_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Сценарий спеки «Нормализация».""" + entity = await make_legal_entity(seeded, name="ООО Пекарня") + client = await auth_client("ka_go@armka.local") + + created = await _add(client, entity.id, "ООО «Пекарня Счастливочная»") + assert created.status_code == HTTP_CREATED, created.text + body = created.json() + assert (body["alias"], body["normalized_alias"]) == ("ООО «Пекарня Счастливочная»", "пекарня счастливочная") + assert body["created_at"] + + repeat = await _add(client, entity.id, "Пекарня Счастливочная, ООО") + assert repeat.status_code == HTTP_CONFLICT + assert repeat.json()["detail"]["code"] == "ALIAS_EXISTS" + assert await _alias_count(seeded) == 1 + assert len(await _events(seeded)) == 1 # неудачная попытка аудита не пишет + + card = (await client.get(f"{ENTITIES}/{entity.id}")).json() + assert [alias["id"] for alias in card["aliases"]] == [body["id"]] + + +@pytest.mark.integration +async def test_create_alias_audit_and_created_by(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + entity = await make_legal_entity(seeded) + client = await auth_client("ka_go@armka.local") + body = (await _add(client, entity.id, 'АО "РУССКИЙ БИСКВИТ"')).json() + + (event,) = await _events(seeded) + assert event.action == AuditAction.LEGAL_ENTITY_CHANGE + assert (event.section, str(event.object_id)) == (AuditSection.DIRECTORIES, body["id"]) + assert event.before_value is None + assert event.after_value == { + "legal_entity_id": str(entity.id), + "alias": 'АО "РУССКИЙ БИСКВИТ"', + "normalized_alias": "русский бисквит", + } + row = (await seeded.execute(select(LegalEntityAlias))).scalar_one() + assert row.created_by == event.user_id + assert row.created_by is not None + + +@pytest.mark.integration +async def test_alias_of_another_entity_is_taken_with_owner_id_only( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + owner = await make_legal_entity(seeded, name="ООО Владелец") + other = await make_legal_entity(seeded, name="ООО Другое") + client = await auth_client("ka_go@armka.local") + assert (await _add(client, owner.id, "ООО «Пекарня»")).status_code == HTTP_CREATED + + response = await _add(client, other.id, "пекарня, ао") + assert response.status_code == HTTP_CONFLICT + detail = response.json()["detail"] + assert detail["code"] == "ALIAS_TAKEN" + assert detail["legal_entity_id"] == str(owner.id) + assert set(detail) == {"code", "message", "legal_entity_id"} # ни имени, ни ИНН владельца + assert "Владелец" not in response.text + assert owner.inn not in response.text + assert await _alias_count(seeded) == 1 + assert len(await _events(seeded)) == 1 + + +@pytest.mark.integration +async def test_taken_alias_of_invisible_owner_discloses_only_id( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Владелец в чужом филиале: пользователь филиала получает только его `legal_entity_id` (решение по спеке).""" + krasnodar = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == KRASNODAR))).scalar_one() + foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar) + foreign = await make_legal_entity(seeded, name="ООО Чужое", group=foreign_group) + go_client = await auth_client("ka_go@armka.local") + assert (await _add(go_client, foreign.id, "Пекарня")).status_code == HTTP_CREATED + + rostov = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name.like("Ростов%")))).scalar_one() + own_group = await make_company_group(seeded, name="ГК Ростов", office=rostov) + own = await make_legal_entity(seeded, name="ООО Своё", group=own_group) + rostov_client = await auth_client("ka_rf@armka.local") + response = await _add(rostov_client, own.id, "ООО Пекарня") + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"] == { + "code": "ALIAS_TAKEN", + "message": response.json()["detail"]["message"], + "legal_entity_id": str(foreign.id), + } + assert "Чужое" not in response.text + + +@pytest.mark.integration +async def test_empty_normalization_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + entity = await make_legal_entity(seeded) + client = await auth_client("ka_go@armka.local") + for alias in ("ООО", "«»", " "): + response = await _add(client, entity.id, alias) + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "EMPTY_ALIAS" + assert await _alias_count(seeded) == 0 + assert not await _events(seeded) + + +@pytest.mark.integration +async def test_alias_body_validation(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + entity = await make_legal_entity(seeded) + client = await auth_client("ka_go@armka.local") + assert (await _add(client, entity.id, "")).status_code == HTTP_UNPROCESSABLE + assert (await _add(client, entity.id, "а" * 501)).status_code == HTTP_UNPROCESSABLE + assert (await client.post(f"{ENTITIES}/{entity.id}/aliases", json={})).status_code == HTTP_UNPROCESSABLE + assert await _alias_count(seeded) == 0 + + +@pytest.mark.integration +async def test_delete_alias_writes_audit_with_before_value( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + entity = await make_legal_entity(seeded) + editor = await auth_client("ka_go@armka.local") + alias_id = (await _add(editor, entity.id, "ООО «Пекарня»")).json()["id"] + + # DIRECTORY_DELETE есть только у администратора; редактор справочников получает 403 и алиас остаётся + forbidden = await editor.delete(f"{ENTITIES}/{entity.id}/aliases/{alias_id}") + assert forbidden.status_code == HTTP_FORBIDDEN + assert await _alias_count(seeded) == 1 + + admin = await auth_client("admin@armka.local") + response = await admin.delete(f"{ENTITIES}/{entity.id}/aliases/{alias_id}") + assert response.status_code == HTTP_NO_CONTENT + assert response.content == b"" + assert await _alias_count(seeded) == 0 + + created, deleted = await _events(seeded) + assert deleted.action == AuditAction.LEGAL_ENTITY_CHANGE + assert (deleted.section, str(deleted.object_id)) == (AuditSection.DIRECTORIES, alias_id) + assert deleted.before_value == { + "legal_entity_id": str(entity.id), + "alias": "ООО «Пекарня»", + "normalized_alias": "пекарня", + } + assert deleted.after_value is None + assert created.object_id == deleted.object_id # запись о создании остаётся (append-only) + + # освободившийся алиас можно добавить снова + assert (await _add(editor, entity.id, "Пекарня")).status_code == HTTP_CREATED + + +@pytest.mark.integration +async def test_delete_unknown_or_foreign_alias_is_404(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + first = await make_legal_entity(seeded) + second = await make_legal_entity(seeded) + editor = await auth_client("ka_go@armka.local") + alias_id = (await _add(editor, first.id, "Первое")).json()["id"] + + admin = await auth_client("admin@armka.local") + for entity_id, target in ((second.id, alias_id), (first.id, uuid.uuid4()), (uuid.uuid4(), alias_id)): + response = await admin.delete(f"{ENTITIES}/{entity_id}/aliases/{target}") + assert response.status_code == HTTP_NOT_FOUND + assert await _alias_count(seeded) == 1 + assert len(await _events(seeded)) == 1 + + +@pytest.mark.integration +async def test_invisible_entity_aliases_are_404_like_missing( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + krasnodar = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == KRASNODAR))).scalar_one() + foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar) + foreign = await make_legal_entity(seeded, group=foreign_group) + go_client = await auth_client("ka_go@armka.local") + alias_id = (await _add(go_client, foreign.id, "Чужой алиас")).json()["id"] + + client = await auth_client("ka_rf@armka.local") + missing = await _add(client, uuid.uuid4(), "Что угодно") + assert missing.status_code == HTTP_NOT_FOUND + response = await _add(client, foreign.id, "Что угодно") + assert response.status_code == HTTP_NOT_FOUND + assert response.json() == missing.json() + assert await _alias_count(seeded) == 1 + + manager = await auth_client("client_manager@armka.local") # Краснодар видит ЮЛ, но права DELETE нет + assert (await manager.delete(f"{ENTITIES}/{foreign.id}/aliases/{alias_id}")).status_code == HTTP_FORBIDDEN + + +@pytest.mark.integration +async def test_expert_cannot_add_alias(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + entity = await make_legal_entity(seeded) + client = await auth_client("expert@armka.local") + response = await _add(client, entity.id, "Пекарня") + assert response.status_code == HTTP_FORBIDDEN + assert await _alias_count(seeded) == 0 + assert not await _events(seeded) diff --git a/api/tests/test_api_company_groups.py b/api/tests/test_api_company_groups.py new file mode 100644 index 0000000..2bf27dc --- /dev/null +++ b/api/tests/test_api_company_groups.py @@ -0,0 +1,295 @@ +"""API ГК и филиалов (spec directories-api, audit-trail): код, история, no-op PATCH, права, список, архив.""" + +import datetime +import re +import uuid +from collections.abc import Sequence +from typing import Any + +import httpx +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.audit_ex import AuditEvent +from src.models.directories import CompanyGroup, CompanyGroupHistory +from src.models.enums import AuditAction, AuditSection +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_FORBIDDEN = 403 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 +CODE_PATTERN = re.compile(r"^GK-\d{6}$") +KRASNODAR = "Краснодарский региональный филиал" +HEAD_OFFICE = "Головной офис" +GROUPS = "/directories/company-groups" + + +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 _office_id(client: httpx.AsyncClient, name: str) -> str: + response = await client.get("/directories/regional-offices") + assert response.status_code == HTTP_OK, response.text + return next(item["id"] for item in response.json() if item["name"] == name) + + +async def _create(client: httpx.AsyncClient, **body: Any) -> dict[str, Any]: + response = await client.post(GROUPS, json=body) + assert response.status_code == HTTP_CREATED, response.text + created: dict[str, Any] = response.json() + return created + + +@pytest.mark.integration +async def test_regional_offices_list(auth_client: AuthClientFactory) -> None: + client = await auth_client("expert@armka.local") + response = await client.get("/directories/regional-offices") + assert response.status_code == HTTP_OK + offices = {item["name"]: item for item in response.json()} + assert offices[HEAD_OFFICE]["is_head_office"] is True + assert offices[KRASNODAR]["is_head_office"] is False + assert set(offices[KRASNODAR]) == {"id", "name", "is_head_office", "is_active"} + + +@pytest.mark.integration +async def test_create_generates_codes_and_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + first = await _create(client, name="ГК Первая") + second = await _create(client, name="ГК Вторая", crm_id="CRM-1") + assert CODE_PATTERN.match(first["code"]) and CODE_PATTERN.match(second["code"]) + assert first["code"] != second["code"] + assert first["valid_from"] and first["legal_entities_count"] == 0 + + events = await _events(seeded, AuditAction.GROUP_CHANGE) + assert len(events) == 2 # noqa: PLR2004 + assert {e.after_value["code"] for e in events} == {first["code"], second["code"]} + event = next(e for e in events if e.after_value["code"] == second["code"]) + assert (event.section, event.object_type, str(event.object_id)) == ( + AuditSection.DIRECTORIES, + "company_group", + second["id"], + ) + assert event.after_value == { + "code": second["code"], + "name": "ГК Вторая", + "crm_id": "CRM-1", + "base_regional_office_id": None, + } + assert event.user_id is not None + + +@pytest.mark.integration +async def test_patch_office_writes_history_and_assignment_audit( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + client = await auth_client("ka_go@armka.local") + rostov = await _office_id(client, "Ростовский региональный филиал") + krasnodar = await _office_id(client, KRASNODAR) + group = await _create(client, name="ГК Перевод", base_regional_office_id=rostov) + + response = await client.patch(f"{GROUPS}/{group['id']}", json={"base_regional_office_id": krasnodar}) + assert response.status_code == HTTP_OK, response.text + updated = response.json() + assert updated["base_regional_office_id"] == krasnodar + assert updated["valid_from"] != group["valid_from"] + + history = (await seeded.execute(select(CompanyGroupHistory))).scalars().all() + assert len(history) == 1 + snapshot = history[0] + assert str(snapshot.base_regional_office_id) == rostov + assert snapshot.name == "ГК Перевод" + assert snapshot.valid_to is not None + assert snapshot.valid_from == datetime.datetime.fromisoformat(group["valid_from"]) + + (event,) = await _events(seeded, AuditAction.ASSIGNMENT_CHANGE) + assert event.before_value == {"base_regional_office_id": rostov} + assert event.after_value == {"base_regional_office_id": krasnodar} + assert event.changed_field == "base_regional_office_id" + assert (event.section, event.object_type) == (AuditSection.DIRECTORIES, "company_group") + + +@pytest.mark.integration +async def test_patch_name_audit_is_group_change_with_diff(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + group = await _create(client, name="А") + response = await client.patch(f"{GROUPS}/{group['id']}", json={"name": "Б"}) + assert response.status_code == HTTP_OK + events = await _events(seeded, AuditAction.GROUP_CHANGE) + changed = events[-1] + assert (changed.changed_field, changed.before_value, changed.after_value) == ( + "name", + {"name": "А"}, + {"name": "Б"}, + ) + assert not await _events(seeded, AuditAction.ASSIGNMENT_CHANGE) + + +@pytest.mark.integration +async def test_duplicate_crm_id_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + await _create(client, name="ГК-1", crm_id="CRM-DUP") + response = await client.post(GROUPS, json={"name": "ГК-2", "crm_id": "CRM-DUP"}) + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "CRM_ID_EXISTS" + assert len(await _events(seeded, AuditAction.GROUP_CHANGE)) == 1 # аудит только у первой + + other = await _create(client, name="ГК-3") + clash = await client.patch(f"{GROUPS}/{other['id']}", json={"crm_id": "CRM-DUP"}) + assert clash.status_code == HTTP_CONFLICT + assert clash.json()["detail"]["code"] == "CRM_ID_EXISTS" + + +@pytest.mark.integration +async def test_noop_patch_writes_no_history_and_no_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + group = await _create(client, name="ГК Без изменений", crm_id="CRM-NOOP") + audit_before = len(await _events(seeded, AuditAction.GROUP_CHANGE, AuditAction.ASSIGNMENT_CHANGE)) + + response = await client.patch(f"{GROUPS}/{group['id']}", json={"name": "ГК Без изменений", "crm_id": "CRM-NOOP"}) + assert response.status_code == HTTP_OK + assert response.json()["valid_from"] == group["valid_from"] + assert (await seeded.execute(select(func.count()).select_from(CompanyGroupHistory))).scalar_one() == 0 + assert len(await _events(seeded, AuditAction.GROUP_CHANGE, AuditAction.ASSIGNMENT_CHANGE)) == audit_before + + +@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(GROUPS, json={"name": "ГК Эксперта"}) + assert response.status_code == HTTP_FORBIDDEN + assert not await _events(seeded, AuditAction.GROUP_CHANGE) + assert (await client.get(GROUPS)).status_code == HTTP_OK # чтение разрешено + + +@pytest.mark.integration +async def test_list_search_filters_and_pagination(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + await make_company_group(seeded, name="Русский Бисквит") + await make_company_group(seeded, name="Другая группа") + archived = await _create(client, name="Архивная бисквитная") + assert (await client.post(f"{GROUPS}/{archived['id']}/archive")).status_code == HTTP_OK + + by_name = (await client.get(GROUPS, params={"q": "БИСКВИТ"})).json() + assert {item["name"] for item in by_name["items"]} == {"Русский Бисквит", "Архивная бисквитная"} + assert by_name["total"] == 2 # noqa: PLR2004 + assert [item["name"] for item in by_name["items"]] == sorted(item["name"] for item in by_name["items"]) + + only_archived = (await client.get(GROUPS, params={"q": "бисквит", "is_archived": "true"})).json() + assert [item["name"] for item in only_archived["items"]] == ["Архивная бисквитная"] + active = (await client.get(GROUPS, params={"q": "бисквит", "is_archived": "false"})).json() + assert [item["name"] for item in active["items"]] == ["Русский Бисквит"] + + beyond = await client.get(GROUPS, params={"q": "бисквит", "offset": 10}) + assert beyond.status_code == HTTP_OK + assert beyond.json()["items"] == [] and beyond.json()["total"] == 2 # noqa: PLR2004 + assert beyond.json()["offset"] == 10 # noqa: PLR2004 + + assert (await client.get(GROUPS, params={"limit": 201})).status_code == HTTP_UNPROCESSABLE + assert (await client.get(GROUPS, params={"offset": -1})).status_code == HTTP_UNPROCESSABLE + + +@pytest.mark.integration +async def test_list_search_by_code_and_crm_id_escapes_like( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """R-2: `q` ищет по `name`/`crm_id`/`code`; `%` и `_` в запросе — обычные символы.""" + client = await auth_client("ka_go@armka.local") + group = await _create(client, name="ГК Поиск", crm_id="CRM-Zeta-77") + await _create(client, name="ГК Прочая", crm_id="CRM-Other") + await make_company_group(seeded, name="ГК 100%", code="GK-XPCT") + + by_code = (await client.get(GROUPS, params={"q": group["code"].lower()})).json() + assert [item["id"] for item in by_code["items"]] == [group["id"]] + by_crm = (await client.get(GROUPS, params={"q": "zeta-77"})).json() + assert [item["id"] for item in by_crm["items"]] == [group["id"]] + + percent = (await client.get(GROUPS, params={"q": "%"})).json() + assert [item["name"] for item in percent["items"]] == ["ГК 100%"] # `%` не «любая строка» + underscore = (await client.get(GROUPS, params={"q": "ГК_П"})).json() + assert underscore["items"] == [] and underscore["total"] == 0 # `_` не «любой символ» + + +@pytest.mark.integration +async def test_list_counts_legal_entities(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + group = await make_company_group(seeded, name="ГК с ЮЛ") + empty = await make_company_group(seeded, name="ГК пустая") + await make_legal_entity(seeded, group=group) + await make_legal_entity(seeded, group=group) + await make_legal_entity(seeded, group=None) + + items = {i["id"]: i for i in (await client.get(GROUPS)).json()["items"]} + assert items[str(group.id)]["legal_entities_count"] == 2 # noqa: PLR2004 + assert items[str(empty.id)]["legal_entities_count"] == 0 + card = (await client.get(f"{GROUPS}/{group.id}")).json() + assert card["legal_entities_count"] == 2 # noqa: PLR2004 + + +@pytest.mark.integration +async def test_archive_unarchive_toggle_and_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + group = await _create(client, name="ГК Архив") + archived = await client.post(f"{GROUPS}/{group['id']}/archive") + assert archived.status_code == HTTP_OK and archived.json()["is_archived"] is True + restored = await client.post(f"{GROUPS}/{group['id']}/unarchive") + assert restored.status_code == HTTP_OK and restored.json()["is_archived"] is False + + events = [e for e in await _events(seeded, AuditAction.GROUP_CHANGE) if e.changed_field == "is_archived"] + assert [(e.before_value, e.after_value) for e in events] == [ + ({"is_archived": False}, {"is_archived": True}), + ({"is_archived": True}, {"is_archived": False}), + ] + + +@pytest.mark.integration +async def test_history_is_ordered_by_valid_from_desc(auth_client: AuthClientFactory) -> None: + """R-3: снимки по убыванию `valid_from`.""" + client = await auth_client("ka_go@armka.local") + group = await _create(client, name="Имя 0") + for index in (1, 2, 3): + response = await client.patch(f"{GROUPS}/{group['id']}", json={"name": f"Имя {index}"}) + assert response.status_code == HTTP_OK + + history = (await client.get(f"{GROUPS}/{group['id']}/history")).json() + assert [row["name"] for row in history] == ["Имя 2", "Имя 1", "Имя 0"] + valid_from = [row["valid_from"] for row in history] + assert valid_from == sorted(valid_from, reverse=True) + assert set(history[0]) == { + "id", + "crm_id", + "name", + "base_regional_office_id", + "valid_from", + "valid_to", + "changed_by", + } + + +@pytest.mark.integration +async def test_validation_and_unknown_group(auth_client: AuthClientFactory) -> None: + client = await auth_client("ka_go@armka.local") + assert (await client.post(GROUPS, json={"name": ""})).status_code == HTTP_UNPROCESSABLE + group = await _create(client, name="ГК") + extra = await client.patch(f"{GROUPS}/{group['id']}", json={"code": "GK-000001"}) + assert extra.status_code == HTTP_UNPROCESSABLE # extra="forbid" + missing = await client.patch(f"{GROUPS}/{uuid.uuid4()}", json={"name": "X"}) + assert missing.status_code == 404 # noqa: PLR2004 + assert missing.json()["detail"]["message"] == "ГК не найдена" + + +@pytest.mark.integration +async def test_create_rejects_unknown_fields(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """M-8: `extra="forbid"` — служебное поле (`code`) в теле создания даёт 422 (массив ошибок), ГК не создаётся.""" + client = await auth_client("ka_go@armka.local") + before = (await seeded.execute(select(func.count()).select_from(CompanyGroup))).scalar_one() + response = await client.post(GROUPS, json={"name": "ГК Лишнее поле", "code": "GK-999999"}) + assert response.status_code == HTTP_UNPROCESSABLE, response.text + assert isinstance(response.json()["detail"], list) + count = (await seeded.execute(select(func.count()).select_from(CompanyGroup))).scalar_one() + assert count == before diff --git a/api/tests/test_api_constraint_races.py b/api/tests/test_api_constraint_races.py new file mode 100644 index 0000000..aa12fcd --- /dev/null +++ b/api/tests/test_api_constraint_races.py @@ -0,0 +1,223 @@ +"""Гонка pre-check/INSERT по уникальным ограничениям отдаёт тот же 409 с кодом, что и pre-check (T6.5, T7.1, T12.7). + +Гонка воспроизводится без параллелизма: pre-check сервиса подменяется заглушкой «свободно», а дубликат отправляется +обычным запросом — вставку отклоняет сама БД (`IntegrityError` → `integrity_error_handler`). В теле ответа — только код +и постоянный текст, без значений ключей и идентификаторов. +""" + +import uuid +from typing import Any + +import httpx +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import RegionalOffice +from src.models.enums import RelatedGroupKind +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_CONFLICT = 409 +GO = "ka_go@armka.local" +ADMIN = "admin@armka.local" +GROUPS = "/directories/company-groups" +ENTITIES = "/directories/legal-entities" +RELATED = "/directories/related-groups" +USERS = "/admin/users" +DIRECTORIES_REPO = "src.repositories.directories" +USERS_REPO = "src.repositories.users" +GENERIC_DETAIL = "нарушение целостности данных в БД" + + +def _free(value: Any) -> Any: + """Заглушка pre-check-а: «занято/существует» не обнаружено.""" + + async def check(*_args: Any, **_kwargs: Any) -> Any: + return value + + return check + + +def _assert_coded(response: httpx.Response, code: str, message: str, *secrets: str) -> None: + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json() == {"detail": {"code": code, "message": message}} + for secret in secrets: + assert secret not in response.text + + +@pytest.mark.integration +async def test_company_group_crm_id_race( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + await make_company_group(seeded, crm_id="CRM-RACE-1") + client = await auth_client(GO) + monkeypatch.setattr(f"{DIRECTORIES_REPO}.crm_id_taken", _free(False)) + + response = await client.post(GROUPS, json={"name": "ГК Гонка", "crm_id": "CRM-RACE-1"}) + + _assert_coded(response, "CRM_ID_EXISTS", "ГК с таким crm_id уже существует", "CRM-RACE-1") + + +@pytest.mark.integration +async def test_company_group_crm_id_race_on_patch( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + await make_company_group(seeded, crm_id="CRM-RACE-2") + other = await make_company_group(seeded, name="ГК Другая") + client = await auth_client(GO) + monkeypatch.setattr(f"{DIRECTORIES_REPO}.crm_id_taken", _free(False)) + + response = await client.patch(f"{GROUPS}/{other.id}", json={"crm_id": "CRM-RACE-2"}) + + _assert_coded(response, "CRM_ID_EXISTS", "ГК с таким crm_id уже существует", "CRM-RACE-2") + + +@pytest.mark.integration +async def test_legal_entity_inn_race( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + existing = await make_legal_entity(seeded, inn="7701234567") + client = await auth_client(GO) + monkeypatch.setattr(f"{DIRECTORIES_REPO}.get_legal_entity_by_inn", _free(None)) + + response = await client.post(ENTITIES, json={"inn": "7701234567", "entity_type": "ЮЛ", "name": "ООО Дубль"}) + + _assert_coded(response, "INN_EXISTS", "ЮЛ с таким ИНН уже существует", "7701234567", str(existing.id)) + + +@pytest.mark.integration +async def test_legal_entity_crm_id_race( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + entity = await make_legal_entity(seeded, inn="7701234567") + entity.crm_id = "LE-RACE-1" + await seeded.flush() + client = await auth_client(GO) + monkeypatch.setattr(f"{DIRECTORIES_REPO}.legal_entity_crm_id_taken", _free(False)) + + response = await client.post( + ENTITIES, json={"inn": "7707083893", "entity_type": "ЮЛ", "name": "ООО Новое", "crm_id": "LE-RACE-1"} + ) + + _assert_coded(response, "CRM_ID_EXISTS", "ЮЛ с таким crm_id уже существует", "LE-RACE-1", "7707083893") + + +@pytest.mark.integration +async def test_alias_race( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + entity = await make_legal_entity(seeded) + client = await auth_client(GO) + first = await client.post(f"{ENTITIES}/{entity.id}/aliases", json={"alias": "ООО «Пекарня Гонка»"}) + assert first.status_code == HTTP_CREATED, first.text + monkeypatch.setattr(f"{DIRECTORIES_REPO}.find_alias_owner", _free(None)) + + response = await client.post(f"{ENTITIES}/{entity.id}/aliases", json={"alias": "Пекарня Гонка, ООО"}) + + _assert_coded(response, "ALIAS_EXISTS", "У этого ЮЛ уже есть такой алиас", "пекарня гонка", str(entity.id)) + + +@pytest.mark.integration +async def test_user_email_race( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + office = (await seeded.execute(select(RegionalOffice).limit(1))).scalar_one() + admin = await auth_client(ADMIN) + body = {"email": "race.user@example.com", "full_name": "Гонка Гонкин", "regional_office_id": str(office.id)} + assert (await admin.post(USERS, json=body)).status_code == HTTP_CREATED + monkeypatch.setattr(f"{USERS_REPO}.email_taken", _free(False)) + + response = await admin.post(USERS, json=body) + + _assert_coded( + response, "EMAIL_EXISTS", "Пользователь с таким email уже существует", "race.user@example.com", "Гонка" + ) + + +@pytest.mark.integration +async def test_user_email_race_with_other_case_is_not_caught( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + """Известное ограничение: DB-ограничение чувствительно к регистру — дубликат в другом регистре гонку не ловит.""" + office = (await seeded.execute(select(RegionalOffice).limit(1))).scalar_one() + admin = await auth_client(ADMIN) + body = {"email": "Race.Case@example.com", "full_name": "Гонка Регистр", "regional_office_id": str(office.id)} + assert (await admin.post(USERS, json=body)).status_code == HTTP_CREATED + monkeypatch.setattr(f"{USERS_REPO}.email_taken", _free(False)) + + response = await admin.post(USERS, json={**body, "email": "race.case@example.com"}) + + assert response.status_code == HTTP_CREATED # индекс по точному значению: дубль в другом регистре проходит + + +@pytest.mark.integration +async def test_membership_same_group_race_reports_group_of_this_kind( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + group = await make_company_group(seeded) + entity = await make_legal_entity(seeded, group=group) + gsk = await make_related_group(seeded, group=group) + await make_membership(seeded, related_group=gsk, entity=entity) + client = await auth_client(GO) + monkeypatch.setattr(f"{DIRECTORIES_REPO}.current_membership_of_kind", _free(None)) + + response = await client.post(f"{RELATED}/{gsk.id}/members", json={"legal_entity_id": str(entity.id)}) + + # Первым срабатывает индекс (ЮЛ, вид), общий для «той же» и «другой» группы: код один, текст нейтральный + _assert_coded(response, "MEMBER_OF_OTHER_GROUP", "ЮЛ уже входит в группу этого вида", str(entity.id), str(gsk.id)) + + +@pytest.mark.integration +async def test_membership_other_group_race( + auth_client: AuthClientFactory, seeded: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + group = await make_company_group(seeded) + entity = await make_legal_entity(seeded, group=group) + first = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsk, name="ГСК 1") + second = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsk, name="ГСК 2") + await make_membership(seeded, related_group=first, entity=entity) + client = await auth_client(GO) + monkeypatch.setattr(f"{DIRECTORIES_REPO}.current_membership_of_kind", _free(None)) + + response = await client.post(f"{RELATED}/{second.id}/members", json={"legal_entity_id": str(entity.id)}) + + _assert_coded(response, "MEMBER_OF_OTHER_GROUP", "ЮЛ уже входит в группу этого вида", str(entity.id), str(first.id)) + + +@pytest.mark.integration +async def test_race_rolls_back_and_next_request_works( + committed_client: AuthClientFactory, monkeypatch: pytest.MonkeyPatch +) -> None: + """Сессия как в проде (без SAVEPOINT): после проваленного flush откат, следующий запрос работает.""" + client = await committed_client(GO) + created = await client.post(GROUPS, json={"name": "ГК Первая", "crm_id": "CRM-TX-1"}) + assert created.status_code == HTTP_CREATED, created.text + monkeypatch.setattr(f"{DIRECTORIES_REPO}.crm_id_taken", _free(False)) + + raced = await client.post(GROUPS, json={"name": "ГК Вторая", "crm_id": "CRM-TX-1"}) + _assert_coded(raced, "CRM_ID_EXISTS", "ГК с таким crm_id уже существует", "CRM-TX-1") + monkeypatch.undo() + + follow_up = await client.post(GROUPS, json={"name": "ГК Третья", "crm_id": "CRM-TX-2"}) + assert follow_up.status_code == HTTP_CREATED, follow_up.text + listing = await client.get(GROUPS) + assert listing.status_code == HTTP_OK + names = sorted(item["name"] for item in listing.json()["items"]) + assert names == ["ГК Первая", "ГК Третья"] # неудавшаяся «Вторая» не сохранилась + + +@pytest.mark.integration +async def test_foreign_key_violation_stays_generic_conflict( + auth_client: AuthClientFactory, monkeypatch: pytest.MonkeyPatch +) -> None: + """Нарушение FK (не unique) — прежний общий 409 строкой.""" + client = await auth_client(ADMIN) + monkeypatch.setattr(f"{DIRECTORIES_REPO}.get_regional_office", _free(object())) + + response = await client.post(GROUPS, json={"name": "ГК Без филиала", "base_regional_office_id": str(uuid.uuid4())}) + + assert response.status_code == HTTP_CONFLICT, response.text + assert response.json() == {"detail": GENERIC_DETAIL} diff --git a/api/tests/test_api_directory_import.py b/api/tests/test_api_directory_import.py new file mode 100644 index 0000000..fc8d60c --- /dev/null +++ b/api/tests/test_api_directory_import.py @@ -0,0 +1,594 @@ +"""API импорта справочников из выгрузки ЕЦП (spec directory-import, audit-trail; `05-domain-rules.md` §13.3). + +Тесты боевого импорта идут поверх фикстуры `app` (транзакция теста с SAVEPOINT). `dry_run` проверяется на отдельной +временной БД без SAVEPOINT-обёртки (`committed_client`, `committed_sessionmaker`, `committed_storage` из `conftest.py`): +сервис делает явный `session.rollback()`, а внутри SAVEPOINT-обёртки `db_session` такой откат снимает корневую точку +сохранения сессии теста вместе с seed — поведение отличалось бы от прода (там откатывается только транзакция запроса). +Поэтому `dry_run` гоняется так же, как в проде: сессия запроса `async with session.begin()`, данные seed зафиксированы. +""" + +import re +import uuid +from pathlib import Path +from typing import Any + +import httpx +import pytest +from fastapi import FastAPI +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.core.config import get_settings +from src.models.access import AppUser +from src.models.audit_ex import AuditEvent +from src.models.directories import ( + CompanyGroup, + CompanyGroupHistory, + LegalEntity, + LegalEntityHistory, + RegionalOffice, + RelatedGroup, + RelatedGroupMember, +) +from src.models.enums import AuditAction, AuditSection, EntityType, LeHistoryEvent, RelatedGroupKind, S3BucketRole +from src.models.files import S3Object +from src.services.directory_import import MAX_FILENAME_LENGTH, safe_filename +from tests.conftest import AuthClientFactory, FakeStorage +from tests.factories import make_company_group, make_legal_entity + +HTTP_OK = 200 +HTTP_FORBIDDEN = 403 +HTTP_TOO_LARGE = 413 +HTTP_UNPROCESSABLE = 422 +IMPORT = "/directories/import" +ADMIN = "admin@armka.local" +IMPORT_MAX_BYTES = 20 * 1024 * 1024 +XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + +RB_FILE = "ecp-export-russkiy-biskvit.xlsx" +RB_CRM_ID = "1-AZFL6BQ" +RB_GROUP = "Русский Бисквит" +RB_OFFICE = "Санкт-Петербургский РФ" +RB_FL_ROW = 12 +RB_ROWS = 21 +RB_ENTITIES = 20 # 18 ЮЛ + 2 ИП (R-15) +RB_LEGAL_ENTITIES = 18 +RB_GSK_MEMBERS = ["ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО", "РУССКИЙ БИСКВИТ, АО"] +RB_COUNTERS = { + "rows": RB_ROWS, + "groups_created": 1, + "groups_updated": 0, + "groups_skipped": 0, + "entities_created": RB_ENTITIES, + "entities_updated": 0, + "entities_skipped": 0, + "memberships_created": 2, + "offices_created": 1, + "errors": 1, +} +COUNTER_KEYS = tuple(RB_COUNTERS) +FINAL_CODES = { + "UNSUPPORTED_TYPE", + "INVALID_INN", + "DUPLICATE_IN_FILE", + "LE_IN_OTHER_GROUP", + "GK_NAME_CONFLICT", + "LE_CREATED", + "LE_UPDATED", + "LE_EXISTS", +} +DIRECTORY_TABLES = ( + RegionalOffice, + CompanyGroup, + CompanyGroupHistory, + LegalEntity, + LegalEntityHistory, + RelatedGroup, + RelatedGroupMember, +) +TRACE_TABLES = (AuditEvent, S3Object) + +HEADER = ["ИНН", "iD ГК", "Базовый РФ ГК в CRM", "Группа компаний", "Организация", "ГСК", "ГСЗ", "Тип"] +KRASNODAR = "Краснодарский региональный филиал" + + +@pytest.fixture +def storage(app: FastAPI) -> FakeStorage: + fake = FakeStorage() + app.state.storage = fake + return fake + + +def _csv(*rows: list[str], header: list[str] = HEADER) -> bytes: + return ("\n".join(";".join(cells) for cells in [header, *rows]) + "\n").encode("utf-8") + + +def _row( # noqa: PLR0913 - ячейки строки выгрузки, необязательные — keyword-only + inn: str, crm_id: str, group: str, entity: str, *, office: str = KRASNODAR, gsk: str = "" +) -> list[str]: + return [inn, crm_id, office, group, entity, gsk, "", "ЮЛ"] + + +async def _post( # noqa: PLR0913 - поля multipart запроса импорта, необязательные — keyword-only + client: httpx.AsyncClient, + filename: str, + content: bytes, + *, + dry_run: bool | None, + on_existing: str | None = "skip", + content_type: str = "text/csv", +) -> httpx.Response: + data: dict[str, str] = {} + if dry_run is not None: + data["dry_run"] = "true" if dry_run else "false" + if on_existing is not None: + data["on_existing"] = on_existing + return await client.post(IMPORT, files={"file": (filename, content, content_type)}, data=data) + + +async def _report(client: httpx.AsyncClient, filename: str, content: bytes, **kwargs: Any) -> dict[str, Any]: + response = await _post(client, filename, content, **kwargs) + assert response.status_code == HTTP_OK, response.text + report: dict[str, Any] = response.json() + return report + + +async def _counts( + session: AsyncSession, models: tuple[type[Any], ...] = DIRECTORY_TABLES + TRACE_TABLES +) -> dict[str, int]: + return { + model.__tablename__: (await session.execute(select(func.count()).select_from(model))).scalar_one() + for model in models + } + + +def _finals(report: dict[str, Any]) -> dict[int, str]: + """Итоговый код каждой строки (ровно один на строку).""" + finals: dict[int, str] = {} + for line in report["lines"]: + if line["code"] in FINAL_CODES: + assert line["row"] not in finals, line + finals[line["row"]] = line["code"] + return finals + + +def _codes(report: dict[str, Any]) -> list[str]: + return [line["code"] for line in report["lines"]] + + +async def _user_id(session: AsyncSession, email: str) -> uuid.UUID: + return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one() + + +async def _import_events(session: AsyncSession) -> list[AuditEvent]: + stmt = ( + select(AuditEvent) + .where(AuditEvent.action == AuditAction.DIRECTORY_IMPORT) + .order_by(AuditEvent.occurred_at, AuditEvent.id) + ) + return list((await session.execute(stmt)).scalars().all()) + + +# --------------------------------------------------------------------------- боевой импорт + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_russkiy_biskvit_export_creates_directory( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, fixtures_dir: Path +) -> None: + """Срез РБ на базе после seed: 1 ГК, 1 РФ, 20 ЮЛ/ИП, 1 ГСК с двумя членами; аудит и файл в S3.""" + client = await auth_client(ADMIN) + content = (fixtures_dir / "directories" / RB_FILE).read_bytes() + before = await _counts(seeded) + + report = await _report(client, RB_FILE, content, dry_run=False, content_type=XLSX_TYPE) + + assert {key: report[key] for key in COUNTER_KEYS} == RB_COUNTERS + assert _finals(report) == {n: "UNSUPPORTED_TYPE" if n == RB_FL_ROW else "LE_CREATED" for n in range(2, 23)} + assert "DUPLICATE_IN_FILE" not in _codes(report) + + after = await _counts(seeded) + delta = {table: after[table] - before[table] for table in after} + assert delta == { + "regional_office": 1, + "company_group": 1, + "company_group_history": 0, + "legal_entity": RB_ENTITIES, + "legal_entity_history": 0, + "related_group": 1, + "related_group_member": 2, + "audit_event": 1, + "s3_object": 1, + } + + group = (await seeded.execute(select(CompanyGroup).where(CompanyGroup.crm_id == RB_CRM_ID))).scalar_one() + office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == RB_OFFICE))).scalar_one() + assert group.name == RB_GROUP + assert re.fullmatch(r"GK-\d{6}", group.code) + assert group.base_regional_office_id == office.id + assert (office.is_head_office, office.is_active) == (False, True) + entities = list( + (await seeded.execute(select(LegalEntity).where(LegalEntity.company_group_id == group.id))).scalars().all() + ) + assert len(entities) == RB_ENTITIES + assert [e.entity_type for e in entities].count(EntityType.ЮЛ) == RB_LEGAL_ENTITIES + assert {e.regional_office_id for e in entities} == {office.id} + admin_id = await _user_id(seeded, ADMIN) + assert {e.updated_by for e in entities} == {admin_id} + + related = (await seeded.execute(select(RelatedGroup).where(RelatedGroup.company_group_id == group.id))).scalar_one() + assert (related.kind, related.name) == (RelatedGroupKind.gsk, RB_GROUP) + assert re.fullmatch(r"GSK-\d{6}", related.code) + members = ( + await seeded.execute( + select(RelatedGroupMember, LegalEntity) + .join(LegalEntity, LegalEntity.id == RelatedGroupMember.legal_entity_id) + .where(RelatedGroupMember.related_group_id == related.id) + ) + ).all() + assert sorted(entity.name for _, entity in members) == RB_GSK_MEMBERS + assert all(m.kind is RelatedGroupKind.gsk and m.is_current and m.created_by == admin_id for m, _ in members) + + (event,) = await _import_events(seeded) + assert (event.section, event.object_type, event.description, event.user_id) == ( + AuditSection.DIRECTORIES, + "directory_import", + RB_FILE, + admin_id, + ) + assert event.after_value is not None + assert event.after_value["rows"] == RB_ROWS + assert event.after_value == report + + key = f"uploads/imports/{event.id}/{RB_FILE}" + s3_object = (await seeded.execute(select(S3Object).where(S3Object.key == key))).scalar_one() + assert (s3_object.bucket_role, s3_object.bucket, s3_object.size_bytes, s3_object.created_by) == ( + S3BucketRole.uploads, + "arm-ka", + len(content), + f"user:{admin_id}", + ) + assert s3_object.content_type == XLSX_TYPE + assert storage.objects == {key: content} + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_repeated_import_with_skip_is_idempotent( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, fixtures_dir: Path +) -> None: + """Повтор того же файла с `skip`: все применённые строки — `LE_EXISTS`, ГК — `GK_EXISTS`, справочник не меняется.""" + client = await auth_client(ADMIN) + content = (fixtures_dir / "directories" / RB_FILE).read_bytes() + await _report(client, RB_FILE, content, dry_run=False) + before = await _counts(seeded, DIRECTORY_TABLES) + + report = await _report(client, RB_FILE, content, dry_run=False) + + assert await _counts(seeded, DIRECTORY_TABLES) == before + assert _finals(report) == {n: "UNSUPPORTED_TYPE" if n == RB_FL_ROW else "LE_EXISTS" for n in range(2, 23)} + assert _codes(report).count("GK_EXISTS") == 1 + assert {key: report[key] for key in COUNTER_KEYS} == { + **dict.fromkeys(COUNTER_KEYS, 0), + "rows": RB_ROWS, + "groups_skipped": 1, + "entities_skipped": RB_ENTITIES, + "errors": 1, + } + assert len(await _import_events(seeded)) == 2 # noqa: PLR2004 + assert len(storage.objects) == 2 # noqa: PLR2004 - у каждого импорта свой ключ (id события аудита) + + +@pytest.mark.integration +async def test_update_strategy_updates_names_with_history( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """`skip` не трогает изменённое имя (`LE_EXISTS`); `update` — имя обновлено, снимки истории, `LE_UPDATED`.""" + client = await auth_client(ADMIN) + await _report(client, "first.csv", _csv(_row("7701000001", "1-IMP01", "ГК Импорт", "ООО Альфа")), dry_run=False) + changed = _csv(_row("7701000001", "1-IMP01", "ГК Импорт Новая", "ООО Альфа Плюс")) + + skipped = await _report(client, "changed.csv", changed, dry_run=False, on_existing="skip") + entity = (await seeded.execute(select(LegalEntity).where(LegalEntity.inn == "7701000001"))).scalar_one() + assert _finals(skipped) == {2: "LE_EXISTS"} + assert "GK_EXISTS" in _codes(skipped) + assert entity.name == "ООО Альфа" + + updated = await _report(client, "changed.csv", changed, dry_run=False, on_existing="update") + + assert _finals(updated) == {2: "LE_UPDATED"} + assert "GK_UPDATED" in _codes(updated) + assert (updated["entities_updated"], updated["groups_updated"]) == (1, 1) + await seeded.refresh(entity) + group = (await seeded.execute(select(CompanyGroup).where(CompanyGroup.crm_id == "1-IMP01"))).scalar_one() + assert (entity.name, group.name) == ("ООО Альфа Плюс", "ГК Импорт Новая") + (snapshot,) = ( + (await seeded.execute(select(LegalEntityHistory).where(LegalEntityHistory.legal_entity_id == entity.id))) + .scalars() + .all() + ) + assert (snapshot.event_type, snapshot.name) == (LeHistoryEvent.UPDATE, "ООО Альфа") + (group_snapshot,) = ( + (await seeded.execute(select(CompanyGroupHistory).where(CompanyGroupHistory.company_group_id == group.id))) + .scalars() + .all() + ) + assert group_snapshot.name == "ГК Импорт" + changes = ( + ( + await seeded.execute( + select(AuditEvent).where( + AuditEvent.action.in_([AuditAction.LEGAL_ENTITY_CHANGE, AuditAction.GROUP_CHANGE]), + AuditEvent.object_id.in_([entity.id, group.id]), + ) + ) + ) + .scalars() + .all() + ) + assert {(e.action, e.description, e.changed_field) for e in changes} == { + (AuditAction.LEGAL_ENTITY_CHANGE, "импорт ЕЦП", "name"), + (AuditAction.GROUP_CHANGE, "импорт ЕЦП", "name"), + } + + +@pytest.mark.integration +async def test_entity_in_other_group_is_not_moved( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + """ИНН уже в другой ГК → `LE_IN_OTHER_GROUP`, ЮЛ не переведено, остальные строки применяются.""" + other = await make_company_group(seeded, name="ГК Чужая", crm_id="1-OTHER") + entity = await make_legal_entity(seeded, name="ООО Чужое", inn="7702000002", group=other) + client = await auth_client(ADMIN) + content = _csv( + _row("7702000002", "1-NEW01", "ГК Новая", "ООО Чужое"), + _row("7702000003", "1-NEW01", "ГК Новая", "ООО Своё"), + ) + + report = await _report(client, "other.csv", content, dry_run=False) + + assert _finals(report) == {2: "LE_IN_OTHER_GROUP", 3: "LE_CREATED"} + await seeded.refresh(entity) + await seeded.refresh(other) + assert (entity.company_group_id, entity.name) == (other.id, "ООО Чужое") + assert other.name == "ГК Чужая" + + +@pytest.mark.integration +async def test_import_without_storage_writes_audit_only(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """dev без S3 (`app.state.storage is None`): импорт применяется, аудит пишется, `s3_object` не создаётся.""" + client = await auth_client(ADMIN) + before = await _counts(seeded, TRACE_TABLES) + + report = await _report( + client, "nos3.csv", _csv(_row("7703000001", "1-NOS3", "ГК Без S3", "ООО Бета")), dry_run=False + ) + + assert report["entities_created"] == 1 + assert await _counts(seeded, TRACE_TABLES) == {"audit_event": before["audit_event"] + 1, "s3_object": 0} + + +# --------------------------------------------------------------------------- ошибки файла и права + + +@pytest.mark.integration +async def test_missing_column_is_422( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage +) -> None: + client = await auth_client(ADMIN) + before = await _counts(seeded) + content = _csv(_row("7704000001", "1-MISS", "ГК", "ООО")[:-1], header=HEADER[:-1]) + + response = await _post(client, "no-type.csv", content, dry_run=False) + + assert response.status_code == HTTP_UNPROCESSABLE, response.text + detail = response.json()["detail"] + assert (detail["code"], detail["missing"]) == ("MISSING_COLUMNS", ["Тип"]) + assert await _counts(seeded) == before + assert storage.objects == {} + + +@pytest.mark.integration +async def test_cp1251_csv_is_422_not_500(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Review Focus 3: csv из Excel в cp1251 → 422 `UNSUPPORTED_FILE` «ожидается UTF-8».""" + client = await auth_client(ADMIN) + content = _csv(_row("7705000001", "1-CP", "ГК Кодировка", "ООО Кодировка")).decode("utf-8").encode("cp1251") + + response = await _post(client, "cp1251.csv", content, dry_run=False) + + assert response.status_code == HTTP_UNPROCESSABLE, response.text + detail = response.json()["detail"] + assert detail["code"] == "UNSUPPORTED_FILE" + assert "UTF-8" in detail["message"] + + +@pytest.mark.integration +async def test_file_over_limit_is_413(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + assert get_settings().IMPORT_MAX_BYTES == IMPORT_MAX_BYTES + client = await auth_client(ADMIN) + before = await _counts(seeded) + + response = await _post(client, "big.csv", b"x" * (IMPORT_MAX_BYTES + 1), dry_run=False) + + assert response.status_code == HTTP_TOO_LARGE, response.text + assert await _counts(seeded) == before + + +@pytest.mark.integration +async def test_import_requires_directory_admin(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + client = await auth_client("ka_go@armka.local") + before = await _counts(seeded) + + response = await _post(client, "x.csv", _csv(_row("7706000001", "1-DENY", "ГК", "ООО")), dry_run=False) + + assert response.status_code == HTTP_FORBIDDEN + assert "DIRECTORY_ADMIN" in response.json()["detail"] + assert await _counts(seeded) == before + + +# --------------------------------------------------------------------------- dry_run (как в проде, без SAVEPOINT) + + +@pytest.mark.integration +async def test_dry_run_leaves_no_trace_and_repeats_identically( + committed_client: AuthClientFactory, + committed_sessionmaker: async_sessionmaker[AsyncSession], + committed_storage: FakeStorage, +) -> None: + """`dry_run` (по умолчанию): отчёт как у боевого, ни строк справочника, ни аудита, ни `s3_object`; повтор `dry_run` + даёт тот же отчёт (Review Focus 4: коды из последовательностей расходуются, но не видны и не ломают прогон).""" + client = await committed_client(ADMIN) + content = _csv( + _row("7707000001", "1-DRY01", "ГК Проба", "ООО Проба", office="Новый РФ", gsk="входит в ГСК"), + _row("7707000002", "1-DRY01", "ГК Проба", "ООО Проба-2", office="Новый РФ", gsk="входит в ГСК"), + _row("7707000001", "1-DRY01", "ГК Проба", "ООО Проба (дубль)", office="Новый РФ"), + ) + + async def counts() -> dict[str, int]: + async with committed_sessionmaker() as session: + return await _counts(session) + + before = await counts() + + first = await _report(client, "dry.csv", content, dry_run=None, on_existing=None) # значения по умолчанию + assert await counts() == before + second = await _report(client, "dry.csv", content, dry_run=True) + assert await counts() == before + assert committed_storage.objects == {} # T10.1: dry_run не пишет файл в S3 + assert second == first + assert {key: first[key] for key in COUNTER_KEYS} == { + **dict.fromkeys(COUNTER_KEYS, 0), + "rows": 3, + "groups_created": 1, + "entities_created": 2, + "memberships_created": 2, + "offices_created": 1, + "errors": 1, + } + assert _finals(first) == {2: "LE_CREATED", 3: "LE_CREATED", 4: "DUPLICATE_IN_FILE"} + + real = await _report(client, "dry.csv", content, dry_run=False) + + assert len(committed_storage.objects) == 1 # T10.1: боевой прогон — ровно один объект + assert real == first # отчёт dry_run идентичен боевому (§13.3) + after = await counts() + assert {table: after[table] - before[table] for table in after} == { + "regional_office": 1, + "company_group": 1, + "company_group_history": 0, + "legal_entity": 2, + "legal_entity_history": 0, + "related_group": 1, + "related_group_member": 2, + "audit_event": 1, + "s3_object": 1, + } + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_dry_run_on_russkiy_biskvit_file_leaves_no_trace( + committed_client: AuthClientFactory, + committed_sessionmaker: async_sessionmaker[AsyncSession], + committed_storage: FakeStorage, + fixtures_dir: Path, +) -> None: + """T10.2: `dry_run` на реальном xlsx (не только csv): 200, 21 строка, таблицы и S3 без изменений.""" + client = await committed_client(ADMIN) + content = (fixtures_dir / "directories" / RB_FILE).read_bytes() + async with committed_sessionmaker() as session: + before = await _counts(session) + + report = await _report(client, RB_FILE, content, dry_run=True, content_type=XLSX_TYPE) + + assert report["rows"] == RB_ROWS + assert (report["groups_created"], report["entities_created"]) == (1, RB_ENTITIES) + async with committed_sessionmaker() as session: + assert await _counts(session) == before + assert committed_storage.objects == {} + + +@pytest.mark.integration +async def test_dry_run_with_update_leaves_history_and_audit_untouched( + committed_client: AuthClientFactory, committed_sessionmaker: async_sessionmaker[AsyncSession] +) -> None: + """T10.3: путь `update` пишет снимки истории и аудит ДО отката — после `dry_run` их нет, имена прежние.""" + client = await committed_client(ADMIN) + await _report(client, "first.csv", _csv(_row("7708000001", "1-DRYUP", "ГК Старая", "ООО Старое")), dry_run=False) + changed = _csv(_row("7708000001", "1-DRYUP", "ГК Новая", "ООО Новое")) + + async def state() -> tuple[dict[str, int], list[str]]: + async with committed_sessionmaker() as session: + names = [ + *(await session.execute(select(CompanyGroup.name).where(CompanyGroup.crm_id == "1-DRYUP"))).scalars(), + *(await session.execute(select(LegalEntity.name).where(LegalEntity.inn == "7708000001"))).scalars(), + ] + return await _counts(session), names + + before, names_before = await state() + assert names_before == ["ГК Старая", "ООО Старое"] + + report = await _report(client, "changed.csv", changed, dry_run=True, on_existing="update") + + assert _finals(report) == {2: "LE_UPDATED"} # план применялся: история и аудит изменения были записаны и откатились + assert (report["groups_updated"], report["entities_updated"]) == (1, 1) + after, names_after = await state() + assert after == before + assert after["company_group_history"] == 0 + assert names_after == names_before + + +# --------------------------------------------------------------------------- имя файла (T10.4) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("a\x00b.csv", "ab.csv"), + ("\x00", "import"), + ("../../etc/x\x07y.xlsx", "xy.xlsx"), + ("C:\\dir\\f.xlsx", "f.xlsx"), + ("..", "import"), + (None, "import"), + ("тест.csv", "тест.csv"), + ("evil\u202efdp.csv", "evilfdp.csv"), + ("a\u200bb\u200d.xlsx", "ab.xlsx"), + ], +) +def test_safe_filename_drops_control_and_format_characters(raw: str | None, expected: str) -> None: + assert safe_filename(raw) == expected + + +def test_safe_filename_drops_format_characters_before_length_cap() -> None: + # невидимые символы не занимают место в лимите: после их удаления имя ровно в пределе и не обрезается + padded = "x" * (MAX_FILENAME_LENGTH - len(".csv")) + "\u200b" * 50 + ".csv" + assert safe_filename(padded) == "x" * (MAX_FILENAME_LENGTH - len(".csv")) + ".csv" + + +def test_safe_filename_caps_length_keeping_extension() -> None: + long_name = safe_filename("я" * 1500 + ".xlsx") + assert len(long_name) == MAX_FILENAME_LENGTH + assert long_name.endswith(".xlsx") + # гигантское «расширение» отбрасывается + assert len(safe_filename("x" * 1500 + "." + "e" * 500)) == MAX_FILENAME_LENGTH + + +@pytest.mark.integration +@pytest.mark.parametrize("raw_name", ["a\x00b.csv", "я" * 1500 + ".csv"], ids=["nul", "over-1024-bytes"]) +async def test_hostile_filename_does_not_produce_500( + auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage, raw_name: str +) -> None: + """T10.4: NUL и имя длиннее 1024 байт в `filename` multipart — не 500: имя очищено, ключ S3 в пределах лимита.""" + client = await auth_client(ADMIN) + + report = await _report(client, raw_name, _csv(_row("7709000001", "1-NAME", "ГК Имя", "ООО Имя")), dry_run=False) + + assert report["entities_created"] == 1 + (event,) = await _import_events(seeded) + assert event.description is not None + assert "\x00" not in event.description + assert len(event.description) <= MAX_FILENAME_LENGTH + assert event.description.endswith(".csv") + (key,) = storage.objects + assert len(key.encode()) <= 1024 # noqa: PLR2004 - предел длины ключа S3 diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py new file mode 100644 index 0000000..f4b180e --- /dev/null +++ b/api/tests/test_api_legal_entities.py @@ -0,0 +1,578 @@ +"""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.models.audit_ex import AuditEvent +from src.models.directories import ( + LegalEntity, + LegalEntityAlias, + LegalEntityHistory, + RegionalOffice, + RelatedGroupMember, +) +from src.models.enums import AuditAction, AuditSection, EntityType, RelatedGroupKind +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group + +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: + """«Сегодня» сервиса — дата UTC (тест не зависит от часового пояса машины).""" + return datetime.datetime.now(datetime.UTC).date() + + +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", + "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", + "crm_id", + "entity_type", + "name", + "company_group_id", + "regional_office_id", + "close_date", + "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 diff --git a/api/tests/test_api_policy.py b/api/tests/test_api_policy.py new file mode 100644 index 0000000..3b37734 --- /dev/null +++ b/api/tests/test_api_policy.py @@ -0,0 +1,82 @@ +"""Политика видимости на API ГК (spec directory-policy): филиал видит своё, чужое — 404, создание из филиала.""" + +import uuid + +import httpx +import pytest +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import RegionalOffice +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_NOT_FOUND = 404 +HTTP_UNPROCESSABLE = 422 +ROSTOV = "Ростовский региональный филиал" +KRASNODAR = "Краснодарский региональный филиал" +GROUPS = "/directories/company-groups" + + +async def _office(session: AsyncSession, name: str) -> RegionalOffice: + return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == name))).scalar_one() + + +@pytest.mark.integration +async def test_branch_user_sees_only_own_groups(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + rostov, krasnodar = await _office(seeded, ROSTOV), await _office(seeded, KRASNODAR) + group_a = await make_company_group(seeded, name="ГК А", office=rostov) + group_b = await make_company_group(seeded, name="ГК Б", office=krasnodar) + + branch = await auth_client("ka_rf@armka.local") # Ростовский филиал + page = (await branch.get(GROUPS)).json() + assert page["total"] == 1 + assert [item["id"] for item in page["items"]] == [str(group_a.id)] + + head = await auth_client("ka_go@armka.local") + ids = {item["id"] for item in (await head.get(GROUPS)).json()["items"]} + assert {str(group_a.id), str(group_b.id)} <= ids + + +@pytest.mark.integration +async def test_foreign_group_is_indistinguishable_from_missing( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + krasnodar = await _office(seeded, KRASNODAR) + group_b = await make_company_group(seeded, name="ГК Б", office=krasnodar) + branch = await auth_client("ka_rf@armka.local") + + foreign = await branch.get(f"{GROUPS}/{group_b.id}") + missing = await branch.get(f"{GROUPS}/{uuid.uuid4()}") + assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND + assert foreign.json() == missing.json() + + # то же для остальных операций над чужой ГК + for call in ( + branch.patch(f"{GROUPS}/{group_b.id}", json={"name": "Взлом"}), + branch.post(f"{GROUPS}/{group_b.id}/archive"), + branch.post(f"{GROUPS}/{group_b.id}/unarchive"), + branch.get(f"{GROUPS}/{group_b.id}/history"), + ): + response = await call + assert response.status_code == HTTP_NOT_FOUND + assert response.json() == missing.json() + + +@pytest.mark.integration +async def test_branch_user_creates_group_in_own_office_only( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + rostov, krasnodar = await _office(seeded, ROSTOV), await _office(seeded, KRASNODAR) + branch: httpx.AsyncClient = await auth_client("ka_rf@armka.local") + + created = await branch.post(GROUPS, json={"name": "ГК Из филиала"}) + assert created.status_code == HTTP_CREATED + assert created.json()["base_regional_office_id"] == str(rostov.id) + assert (await branch.get(f"{GROUPS}/{created.json()['id']}")).status_code == HTTP_OK # видна создателю + + foreign = await branch.post(GROUPS, json={"name": "ГК Чужая", "base_regional_office_id": str(krasnodar.id)}) + assert foreign.status_code == HTTP_UNPROCESSABLE + assert foreign.json()["detail"]["code"] == "OFFICE_NOT_ALLOWED" diff --git a/api/tests/test_api_related_groups.py b/api/tests/test_api_related_groups.py new file mode 100644 index 0000000..5fc06ed --- /dev/null +++ b/api/tests/test_api_related_groups.py @@ -0,0 +1,458 @@ +"""API ГСК/ГСЗ и их состава (spec directories-api «ГСК и ГСЗ — группы и состав», directory-policy, audit-trail). + +Сценарии спеки: ГСК+ГСЗ одновременно, вторая ГСК, повторное включение, удаление непустой группы; Review Focus 4 — +закрытый ЮЛ (`close_date`, без ГК) → 422 `DIFFERENT_COMPANY_GROUP`. +""" + +import datetime +import re +import uuid +from collections.abc import Sequence +from typing import Any + +import httpx +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.audit_ex import AuditEvent +from src.models.directories import RegionalOffice, RelatedGroup, RelatedGroupMember +from src.models.enums import AuditAction, AuditSection, RelatedGroupKind +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_legal_entity, make_membership, make_related_group + +HTTP_OK = 200 +HTTP_CREATED = 201 +HTTP_NO_CONTENT = 204 +HTTP_FORBIDDEN = 403 +HTTP_NOT_FOUND = 404 +HTTP_CONFLICT = 409 +HTTP_UNPROCESSABLE = 422 +GROUPS = "/directories/company-groups" +RELATED = "/directories/related-groups" +ENTITIES = "/directories/legal-entities" +KRASNODAR = "Краснодарский региональный филиал" +GO = "ka_go@armka.local" +ADMIN = "admin@armka.local" +KA_RF = "ka_rf@armka.local" +EXPERT = "expert@armka.local" + + +async def _events(session: AsyncSession, object_type: str | None = None) -> Sequence[AuditEvent]: + stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE) + if object_type is not None: + stmt = stmt.where(AuditEvent.object_type == object_type) + return (await session.execute(stmt.order_by(AuditEvent.occurred_at, AuditEvent.id))).scalars().all() + + +async def _member_rows(session: AsyncSession, **filters: Any) -> list[RelatedGroupMember]: + stmt = select(RelatedGroupMember).filter_by(**filters).order_by(RelatedGroupMember.valid_from) + return list((await session.execute(stmt)).scalars().all()) + + +async def _office(session: AsyncSession, name_like: str) -> RegionalOffice: + return (await session.execute(select(RegionalOffice).where(RegionalOffice.name.like(name_like)))).scalar_one() + + +async def _create( + client: httpx.AsyncClient, group_id: Any, kind: str = "gsk", name: str = "ГСК А", **extra: Any +) -> httpx.Response: + return await client.post(f"{GROUPS}/{group_id}/related-groups", json={"kind": kind, "name": name, **extra}) + + +async def _add(client: httpx.AsyncClient, related_id: Any, entity_id: Any) -> httpx.Response: + return await client.post(f"{RELATED}/{related_id}/members", json={"legal_entity_id": str(entity_id)}) + + +@pytest.mark.integration +async def test_create_gsk_and_gsz_codes_and_audit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded, name="ГК Бисквит") + client = await auth_client(GO) + + gsk = await _create(client, group.id, "gsk", " ГСК Бисквит ", created_date="2025-01-15") + gsz = await _create(client, group.id, "gsz", "ГСЗ Бисквит") + assert gsk.status_code == HTTP_CREATED, gsk.text + assert gsz.status_code == HTTP_CREATED, gsz.text + gsk_body, gsz_body = gsk.json(), gsz.json() + assert re.fullmatch(r"GSK-\d{6}", gsk_body["code"]) + assert re.fullmatch(r"GSZ-\d{6}", gsz_body["code"]) + assert gsk_body == { + "id": gsk_body["id"], + "code": gsk_body["code"], + "company_group_id": str(group.id), + "kind": "gsk", + "name": "ГСК Бисквит", + "created_date": "2025-01-15", + "members_count": 0, + } + assert gsz_body["created_date"] is None + + events = await _events(seeded, "related_group") + assert [str(event.object_id) for event in events] == [gsk_body["id"], gsz_body["id"]] + first = events[0] + assert (first.section, first.before_value) == (AuditSection.DIRECTORIES, None) + assert first.after_value == { + "code": gsk_body["code"], + "company_group_id": str(group.id), + "kind": "gsk", + "name": "ГСК Бисквит", + "created_date": "2025-01-15", + } + assert first.user_id is not None + + +@pytest.mark.integration +async def test_create_validation(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded) + client = await auth_client(GO) + assert (await _create(client, group.id, "gsk", " ")).status_code == HTTP_UNPROCESSABLE + assert (await _create(client, group.id, "gk", "Имя")).status_code == HTTP_UNPROCESSABLE + missing = await _create(client, uuid.uuid4()) + assert missing.status_code == HTTP_NOT_FOUND + assert missing.json()["detail"]["message"] == "ГК не найдена" + assert (await seeded.execute(select(func.count()).select_from(RelatedGroup))).scalar_one() == 0 + assert not await _events(seeded) + + +@pytest.mark.integration +async def test_member_of_gsk_and_gsz_at_the_same_time(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Сценарий спеки «Одновременно в ГСК и ГСЗ»; `kind` строки = `kind` группы (в БД).""" + group = await make_company_group(seeded) + entity = await make_legal_entity(seeded, name="АО Русский бисквит", group=group) + client = await auth_client(GO) + gsk = (await _create(client, group.id, "gsk", "ГСК")).json() + gsz = (await _create(client, group.id, "gsz", "ГСЗ")).json() + + first = await _add(client, gsk["id"], entity.id) + second = await _add(client, gsz["id"], entity.id) + assert first.status_code == HTTP_CREATED, first.text + assert second.status_code == HTTP_CREATED, second.text + assert first.json() == { + "legal_entity_id": str(entity.id), + "inn": entity.inn, + "name": "АО Русский бисквит", + "valid_from": first.json()["valid_from"], + } + + rows = await _member_rows(seeded, legal_entity_id=entity.id, is_current=True) + assert sorted((str(row.related_group_id), row.kind) for row in rows) == sorted( + [(gsk["id"], RelatedGroupKind.gsk), (gsz["id"], RelatedGroupKind.gsz)] + ) + for row in rows: + related = await seeded.get(RelatedGroup, row.related_group_id) + assert related is not None + assert row.kind == related.kind + assert row.valid_to is None + assert row.created_by is not None + + card = (await client.get(f"{ENTITIES}/{entity.id}")).json() + assert sorted(membership["kind"] for membership in card["memberships"]) == ["gsk", "gsz"] + + events = await _events(seeded, "related_group_member") + assert len(events) == 2 # noqa: PLR2004 + assert {event.object_id for event in events} == {row.id for row in rows} + assert events[0].before_value is None + assert events[0].after_value is not None + assert events[0].after_value["legal_entity_id"] == str(entity.id) + assert events[0].after_value["is_current"] is True + + +@pytest.mark.integration +async def test_second_gsk_is_conflict_with_current_group_id( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """Сценарий спеки «Вторая ГСК запрещена»: 409 с id ГСК A, данные не меняются.""" + group = await make_company_group(seeded) + entity = await make_legal_entity(seeded, group=group) + client = await auth_client(GO) + gsk_a = (await _create(client, group.id, "gsk", "ГСК A")).json() + gsk_b = (await _create(client, group.id, "gsk", "ГСК B")).json() + assert (await _add(client, gsk_a["id"], entity.id)).status_code == HTTP_CREATED + + response = await _add(client, gsk_b["id"], entity.id) + assert response.status_code == HTTP_CONFLICT + detail = response.json()["detail"] + assert detail["code"] == "MEMBER_OF_OTHER_GROUP" + assert detail["related_group_id"] == gsk_a["id"] + rows = await _member_rows(seeded, legal_entity_id=entity.id) + assert [str(row.related_group_id) for row in rows] == [gsk_a["id"]] + assert len(await _events(seeded, "related_group_member")) == 1 + + +@pytest.mark.integration +async def test_repeat_in_same_group_is_member_exists(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded) + entity = await make_legal_entity(seeded, group=group) + client = await auth_client(GO) + gsz = (await _create(client, group.id, "gsz", "ГСЗ")).json() + assert (await _add(client, gsz["id"], entity.id)).status_code == HTTP_CREATED + + response = await _add(client, gsz["id"], entity.id) + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "MEMBER_EXISTS" + assert len(await _member_rows(seeded, legal_entity_id=entity.id)) == 1 + + +@pytest.mark.integration +async def test_remove_and_readd_creates_new_row(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Сценарий спеки «Повторное включение после исключения» (SCD2).""" + group = await make_company_group(seeded) + entity = await make_legal_entity(seeded, group=group) + client = await auth_client(GO) + gsk = (await _create(client, group.id)).json() + assert (await _add(client, gsk["id"], entity.id)).status_code == HTTP_CREATED + + removed = await client.delete(f"{RELATED}/{gsk['id']}/members/{entity.id}") + assert removed.status_code == HTTP_NO_CONTENT, removed.text + (closed,) = await _member_rows(seeded, legal_entity_id=entity.id) + assert closed.is_current is False + assert closed.valid_to is not None + detail = (await client.get(f"{RELATED}/{gsk['id']}")).json() + assert (detail["members_count"], detail["members"]) == (0, []) + + again = await _add(client, gsk["id"], entity.id) + assert again.status_code == HTTP_CREATED, again.text + rows = await _member_rows(seeded, legal_entity_id=entity.id) + assert len(rows) == 2 # noqa: PLR2004 + old, new = rows + assert (old.id, old.is_current) == (closed.id, False) + assert old.valid_to is not None + assert (new.is_current, new.valid_to, new.kind) == (True, None, RelatedGroupKind.gsk) + + events = await _events(seeded, "related_group_member") + assert len(events) == 3 # noqa: PLR2004 - добавление, исключение, повторное добавление + removal = events[1] + assert removal.object_id == closed.id + assert removal.before_value is not None + assert removal.after_value is not None + assert (removal.before_value["is_current"], removal.after_value["is_current"]) == (True, False) + assert removal.after_value["valid_to"] is not None + + +@pytest.mark.integration +async def test_remove_not_a_member_is_404(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded) + entity = await make_legal_entity(seeded, group=group) + related = await make_related_group(seeded, group=group) + await make_membership(seeded, related_group=related, entity=entity, is_current=False) + client = await auth_client(GO) + for entity_id in (entity.id, uuid.uuid4()): + response = await client.delete(f"{RELATED}/{related.id}/members/{entity_id}") + assert response.status_code == HTTP_NOT_FOUND + assert not await _events(seeded) + + +@pytest.mark.integration +async def test_delete_non_empty_group_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Сценарий спеки «Удаление непустой группы».""" + group = await make_company_group(seeded) + entity = await make_legal_entity(seeded, group=group) + related = await make_related_group(seeded, group=group) + await make_membership(seeded, related_group=related, entity=entity) + client = await auth_client(ADMIN) + + response = await client.delete(f"{RELATED}/{related.id}") + assert response.status_code == HTTP_CONFLICT + assert response.json()["detail"]["code"] == "GROUP_NOT_EMPTY" + assert await seeded.get(RelatedGroup, related.id) is not None + assert not await _events(seeded) + + +@pytest.mark.integration +async def test_delete_empty_group_removes_closed_rows(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded) + entity = await make_legal_entity(seeded, group=group) + related = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ пустая") + closed = await make_membership(seeded, related_group=related, entity=entity, is_current=False) + related_id, code = related.id, related.code + client = await auth_client(ADMIN) + + response = await client.delete(f"{RELATED}/{related_id}") + assert response.status_code == HTTP_NO_CONTENT, response.text + seeded.expire_all() + assert (await seeded.execute(select(RelatedGroup).where(RelatedGroup.id == related_id))).first() is None + assert not await _member_rows(seeded, related_group_id=related_id) + + (event,) = await _events(seeded, "related_group") + assert event.object_id == related_id + assert event.after_value is None + assert event.before_value is not None + assert event.before_value["code"] == code + assert event.before_value["kind"] == "gsz" + assert event.before_value["name"] == "ГСЗ пустая" + assert [row["id"] for row in event.before_value["members"]] == [str(closed.id)] + + assert (await client.get(f"{RELATED}/{related_id}")).status_code == HTTP_NOT_FOUND + + +@pytest.mark.integration +async def test_delete_requires_directory_delete(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded) + related = await make_related_group(seeded, group=group) + client = await auth_client(GO) # KA_GO: VIEW + EDIT, без DELETE + assert (await client.delete(f"{RELATED}/{related.id}")).status_code == HTTP_FORBIDDEN + assert await seeded.get(RelatedGroup, related.id) is not None + + +@pytest.mark.integration +async def test_entity_of_other_company_group_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded, name="ГК 1") + other = await make_company_group(seeded, name="ГК 2") + foreign = await make_legal_entity(seeded, group=other) + mono = await make_legal_entity(seeded, name="ООО Монозаёмщик") + client = await auth_client(GO) + gsk = (await _create(client, group.id)).json() + for entity in (foreign, mono): + response = await _add(client, gsk["id"], entity.id) + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "DIFFERENT_COMPANY_GROUP" + assert not await _member_rows(seeded) + + +@pytest.mark.integration +async def test_closed_entity_without_group_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Review Focus 4: закрытый ЮЛ (`close_date`, `company_group_id = NULL`) не в той же ГК.""" + group = await make_company_group(seeded) + closed = await make_legal_entity( + seeded, name="ООО Закрытое", close_date=datetime.date(2025, 1, 1), office=await _office(seeded, "Головной%") + ) + assert closed.company_group_id is None + client = await auth_client(GO) + gsk = (await _create(client, group.id)).json() + + response = await _add(client, gsk["id"], closed.id) + assert response.status_code == HTTP_UNPROCESSABLE + assert response.json()["detail"]["code"] == "DIFFERENT_COMPANY_GROUP" + assert not await _member_rows(seeded) + assert not await _events(seeded, "related_group_member") + + +@pytest.mark.integration +async def test_patch_name_and_created_date(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded) + client = await auth_client(GO) + gsk = (await _create(client, group.id, "gsk", "Старое")).json() + + response = await client.patch(f"{RELATED}/{gsk['id']}", json={"name": " Новое ", "created_date": "2024-12-31"}) + assert response.status_code == HTTP_OK, response.text + body = response.json() + assert (body["name"], body["created_date"], body["kind"], body["code"]) == ( + "Новое", + "2024-12-31", + "gsk", + gsk["code"], + ) + events = await _events(seeded, "related_group") + assert len(events) == 2 # noqa: PLR2004 - создание и изменение + assert events[1].before_value == {"name": "Старое", "created_date": None} + assert events[1].after_value == {"name": "Новое", "created_date": "2024-12-31"} + + same = await client.patch(f"{RELATED}/{gsk['id']}", json={"name": "Новое"}) + assert same.status_code == HTTP_OK + assert len(await _events(seeded, "related_group")) == 2 # noqa: PLR2004 - no-op без аудита + + one = await client.patch(f"{RELATED}/{gsk['id']}", json={"name": "Третье"}) + assert one.status_code == HTTP_OK + assert (await _events(seeded, "related_group"))[-1].changed_field == "name" + + for bad in ({"name": " "}, {"name": None}, {"kind": "gsz"}): + assert (await client.patch(f"{RELATED}/{gsk['id']}", json=bad)).status_code == HTTP_UNPROCESSABLE + + +@pytest.mark.integration +async def test_list_and_detail_with_current_members(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded) + other_group = await make_company_group(seeded, name="Другая ГК") + first = await make_legal_entity(seeded, name="Б ЮЛ", group=group) + second = await make_legal_entity(seeded, name="А ЮЛ", group=group) + gone = await make_legal_entity(seeded, name="В ЮЛ", group=group) + gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="Б ГСЗ") + gsk = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsk, name="А ГСК") + await make_related_group(seeded, group=other_group, name="Чужая") + await make_membership(seeded, related_group=gsk, entity=first) + await make_membership(seeded, related_group=gsk, entity=second) + await make_membership(seeded, related_group=gsk, entity=gone, is_current=False) + client = await auth_client(EXPERT) # только DIRECTORY_VIEW, ГО + + listed = await client.get(f"{GROUPS}/{group.id}/related-groups") + assert listed.status_code == HTTP_OK, listed.text + assert [(row["name"], row["members_count"]) for row in listed.json()] == [("А ГСК", 2), ("Б ГСЗ", 0)] + + detail = await client.get(f"{RELATED}/{gsk.id}") + assert detail.status_code == HTTP_OK + body = detail.json() + assert body["members_count"] == 2 # noqa: PLR2004 + assert [member["name"] for member in body["members"]] == ["А ЮЛ", "Б ЮЛ"] + assert set(body["members"][0]) == {"legal_entity_id", "inn", "name", "valid_from"} + assert body["members"][0]["inn"] == second.inn + assert (await client.get(f"{RELATED}/{gsz.id}")).json()["members"] == [] + + +@pytest.mark.integration +async def test_expert_cannot_edit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + group = await make_company_group(seeded) + entity = await make_legal_entity(seeded, group=group) + related = await make_related_group(seeded, group=group) + client = await auth_client(EXPERT) + assert (await _create(client, group.id)).status_code == HTTP_FORBIDDEN + assert (await client.patch(f"{RELATED}/{related.id}", json={"name": "X"})).status_code == HTTP_FORBIDDEN + assert (await _add(client, related.id, entity.id)).status_code == HTTP_FORBIDDEN + assert (await client.delete(f"{RELATED}/{related.id}/members/{entity.id}")).status_code == HTTP_FORBIDDEN + assert not await _member_rows(seeded) + assert not await _events(seeded) + + +@pytest.mark.integration +async def test_branch_user_gets_404_for_foreign_company_group( + auth_client: AuthClientFactory, seeded: AsyncSession +) -> None: + """`ka_rf` (Ростов) и группа ГК Краснодара — 404 с тем же телом, что у несуществующего id.""" + krasnodar = await _office(seeded, KRASNODAR) + foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar) + foreign_entity = await make_legal_entity(seeded, group=foreign_group) + foreign = await make_related_group(seeded, group=foreign_group) + await make_membership(seeded, related_group=foreign, entity=foreign_entity) + client = await auth_client(KA_RF) + + missing = await client.get(f"{RELATED}/{uuid.uuid4()}") + assert missing.status_code == HTTP_NOT_FOUND + responses = [ + await client.get(f"{RELATED}/{foreign.id}"), + await client.patch(f"{RELATED}/{foreign.id}", json={"name": "X"}), + await _add(client, foreign.id, foreign_entity.id), + await client.delete(f"{RELATED}/{foreign.id}/members/{foreign_entity.id}"), + ] + for response in responses: + assert response.status_code == HTTP_NOT_FOUND, response.text + assert response.json() == missing.json() + + missing_group = await client.get(f"{GROUPS}/{uuid.uuid4()}/related-groups") + for response in ( + await client.get(f"{GROUPS}/{foreign_group.id}/related-groups"), + await _create(client, foreign_group.id), + ): + assert response.status_code == HTTP_NOT_FOUND + assert response.json() == missing_group.json() + assert (await seeded.get(RelatedGroup, foreign.id)) is not None + assert len(await _member_rows(seeded, is_current=True)) == 1 + assert not await _events(seeded) + + +@pytest.mark.integration +async def test_branch_user_works_in_own_company_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + rostov = await _office(seeded, "Ростов%") + krasnodar = await _office(seeded, KRASNODAR) + own_group = await make_company_group(seeded, name="ГК Ростов", office=rostov) + own = await make_legal_entity(seeded, group=own_group) + foreign_group = await make_company_group(seeded, name="ГК Краснодар", office=krasnodar) + foreign_entity = await make_legal_entity(seeded, group=foreign_group) + client = await auth_client(KA_RF) + + gsk = await _create(client, own_group.id) + assert gsk.status_code == HTTP_CREATED, gsk.text + assert (await _add(client, gsk.json()["id"], own.id)).status_code == HTTP_CREATED + invisible = await _add(client, gsk.json()["id"], foreign_entity.id) + assert invisible.status_code == HTTP_NOT_FOUND # ЮЛ вне видимости — 404, не 422 и не утечка + assert invisible.json()["detail"]["message"] == "ЮЛ не найдено" + rows = await _member_rows(seeded, is_current=True) + assert [row.legal_entity_id for row in rows] == [own.id] diff --git a/api/tests/test_auth_deps.py b/api/tests/test_auth_deps.py index db37f87..1e0da35 100644 --- a/api/tests/test_auth_deps.py +++ b/api/tests/test_auth_deps.py @@ -34,7 +34,8 @@ from src.models.access import AppUser, Role, UserRole from src.models.directories import RegionalOffice from src.models.enums import BusinessSegment from src.repositories.users import AmbiguousUserError, load_principal_data -from src.schemas.me import MeResponse, RegionalOfficeOut +from src.schemas.directories import RegionalOfficeBrief +from src.schemas.me import MeResponse SECRET = "test-secret-for-auth-deps-0123456789abcdef" OTHER_SECRET = "foreign-secret-for-auth-deps-0123456789ab" @@ -487,7 +488,7 @@ async def test_repository_raises_on_ambiguity(seeded: AsyncSession) -> None: def test_me_response_schema_serializes_enum_and_null_segment() -> None: - office = RegionalOfficeOut(id=uuid.uuid4(), name="ГО", is_head_office=True) + office = RegionalOfficeBrief(id=uuid.uuid4(), name="ГО", is_head_office=True) fields: dict[str, Any] = { "id": uuid.uuid4(), "email": "a@b.c", diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index 3b45f17..1a38b8b 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None: def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None: lines = dbml_file.read_text(encoding="utf-8").splitlines() - newer = tmp_path / "data-model-v5.14.dbml" - # шапка 5.14 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора - newer.write_text(lines[0].replace("v5.13", "v5.14") + "\nTable broken {{{\n", encoding="utf-8") - assert run_check(newer) == (1, ["версия DBML 5.14 не совпадает с версией ORM 5.13: обновите модели"]) + newer = tmp_path / "data-model-v5.15.dbml" + # шапка 5.15 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора + newer.write_text(lines[0].replace("v5.14", "v5.15") + "\nTable broken {{{\n", encoding="utf-8") + assert run_check(newer) == (1, ["версия DBML 5.15 не совпадает с версией ORM 5.14: обновите модели"]) def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None: - newer = tmp_path / "data-model-v5.14.dbml" - newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.13", "v5.14", 1), encoding="utf-8") + newer = tmp_path / "data-model-v5.15.dbml" + newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.14", "v5.15", 1), encoding="utf-8") result = _run_cli(str(newer)) assert result.returncode == 1 - assert result.stdout == "версия DBML 5.14 не совпадает с версией ORM 5.13: обновите модели\n" + assert result.stdout == "версия DBML 5.15 не совпадает с версией ORM 5.14: обновите модели\n" def test_header_without_version_is_usage_error(tmp_path: Path) -> None: diff --git a/api/tests/test_dbml_parity.py b/api/tests/test_dbml_parity.py index 49aa0d0..883ba25 100644 --- a/api/tests/test_dbml_parity.py +++ b/api/tests/test_dbml_parity.py @@ -167,9 +167,9 @@ def test_nullable_pk_unique_and_order_mismatches_are_detected() -> None: def test_missing_extra_and_wrong_where_indexes_are_detected() -> None: metadata = clone_metadata() member = metadata.tables["dkb.related_group_member"] - index = next(i for i in member.indexes if i.name == "uq_related_group_member_entity_current") + index = next(i for i in member.indexes if i.name == "uq_related_group_member_entity_kind_current") index.dialect_options["postgresql"]["where"] = text("is_current AND valid_to IS NULL") - label = "related_group_member: индекс (legal_entity_id)" + label = "related_group_member: индекс (legal_entity_id, kind)" assert find_problems(metadata, {"related_group_member"}) == [ f"{label}: ожидалось unique WHERE is_current / фактически unique WHERE is_current and valid_to is null" ] diff --git a/api/tests/test_domain_aliases.py b/api/tests/test_domain_aliases.py new file mode 100644 index 0000000..2866653 --- /dev/null +++ b/api/tests/test_domain_aliases.py @@ -0,0 +1,52 @@ +"""Нормализация алиаса ЮЛ (§3 п. 4): lower, ё→е, без пунктуации/кавычек/ОПФ, схлопнутые пробелы.""" + +import pytest + +from src.domain.aliases import LEGAL_FORMS, normalize_alias + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("ООО «Пекарня Счастливочная»", "пекарня счастливочная"), + ("Пекарня Счастливочная, ООО", "пекарня счастливочная"), + ('АО "РУССКИЙ БИСКВИТ"', "русский бисквит"), + ("ИП Иванов И.И.", "иванов и и"), + ("Пекарня Счастливочная ООО", "пекарня счастливочная"), + (" Пекарня «Счастливочная» ", "пекарня счастливочная"), + ("Ёлка и Ёж", "елка и еж"), + ("ЁЛКА", "елка"), + ("„Пекарня“ ПАО", "пекарня"), + ("Пекарня\tСчастливочная\n", "пекарня счастливочная"), + ], +) +def test_normalize_alias(raw: str, expected: str) -> None: + assert normalize_alias(raw) == expected + + +@pytest.mark.parametrize("raw", ["ООО", "ООО «АО»", " ип, ооо ", "«»", "", " "]) +def test_only_legal_forms_or_punctuation_gives_empty(raw: str) -> None: + assert normalize_alias(raw) == "" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [("Плооо", "плооо"), ("Оооптторг", "оооптторг"), ("Саоп ао", "саоп"), ("ТСЖ Ипподром", "ипподром")], +) +def test_legal_form_is_removed_only_as_whole_token(raw: str, expected: str) -> None: + assert normalize_alias(raw) == expected + + +def test_same_name_in_different_spelling_is_equal() -> None: + forms = [ + "ООО «Пекарня Счастливочная»", + "Пекарня Счастливочная, ООО", + "пекарня счастливочная", + "ООО Пекарня Счастливочная", + ] + assert len({normalize_alias(form) for form in forms}) == 1 + + +def test_legal_forms_are_lowercase_tokens() -> None: + assert {"ооо", "ао", "пао", "зао", "ип"} <= LEGAL_FORMS + assert all(form == form.lower() and " " not in form for form in LEGAL_FORMS) diff --git a/api/tests/test_domain_directory_import.py b/api/tests/test_domain_directory_import.py new file mode 100644 index 0000000..87a86fb --- /dev/null +++ b/api/tests/test_domain_directory_import.py @@ -0,0 +1,762 @@ +"""Ядро импорта справочников из выгрузки ЕЦП CRM (§13.1–13.2, spec directory-import): чтение файла и план без БД.""" + +import csv +import io +import re +import uuid +import zipfile +from collections.abc import Callable +from pathlib import Path +from typing import TypeVar + +import openpyxl +import pytest + +from src.domain import directory_import +from src.domain.directory_import import ( + AddMember, + CreateEntity, + CreateGroup, + CreateOffice, + CreateRelatedGroup, + DirectorySnapshot, + EntitySnap, + FileFormatError, + GroupSnap, + ImportCode, + ImportPlan, + ImportRow, + OfficeSnap, + OnExisting, + UpdateEntity, + UpdateGroup, + normalize_cell, + normalize_header, + plan_import, + read_rows, +) +from src.models.enums import EntityType, RelatedGroupKind + +T = TypeVar("T") + +HEADERS = ["ИНН", "iD ГК", "Базовый РФ ГК в CRM", "Группа компаний", "Организация", "ГСК", "ГСЗ", "Тип"] +FINAL_CODES = { + ImportCode.UNSUPPORTED_TYPE, + ImportCode.INVALID_INN, + ImportCode.DUPLICATE_IN_FILE, + ImportCode.LE_IN_OTHER_GROUP, + ImportCode.GK_NAME_CONFLICT, + ImportCode.LE_CREATED, + ImportCode.LE_UPDATED, + ImportCode.LE_EXISTS, +} + + +# ---------------------------------------------------------------- нормализация + + +def test_normalize_header_lowercases_and_drops_spaces() -> None: + assert normalize_header("Базовый РФ ГК в CRM") == "базовыйрфгквcrm" + assert normalize_header(" iD ГК ") == "idгк" + assert normalize_header("Входит в ГСК") == "входитвгск" + + +def test_normalize_header_treats_yo_as_ye() -> None: + assert normalize_header("Объём Ёлки") == "объемелки" + + +def test_normalize_header_of_none_is_empty() -> None: + assert normalize_header(None) == "" + + +def test_normalize_cell_numeric_inn_has_no_fraction() -> None: + """Review Focus 2: ИНН из xlsx числом (float) → строка без `.0`.""" + assert normalize_cell(3528052718.0) == "3528052718" + assert normalize_cell(352819549855.0) == "352819549855" + assert normalize_cell(3528052718) == "3528052718" + + +def test_normalize_cell_none_and_strings() -> None: + assert normalize_cell(None) == "" + assert normalize_cell(" ЮЛ ") == "ЮЛ" + + +# ---------------------------------------------------------------- чтение файла + + +def _csv_bytes(rows: list[list[str]], delimiter: str = ";", bom: bool = True) -> bytes: + buffer = io.StringIO() + writer = csv.writer(buffer, delimiter=delimiter, lineterminator="\n") + writer.writerows(rows) + return buffer.getvalue().encode("utf-8-sig" if bom else "utf-8") + + +def _data_row(inn: str = "3528052718", type_: str = "ЮЛ") -> list[str]: + return [ + inn, + "1-AZFL6BQ", + "Санкт-Петербургский РФ", + "Русский Бисквит", + "РУССКИЙ БИСКВИТ, АО", + "входит в ГСК", + "", + type_, + ] + + +@pytest.mark.parametrize("delimiter", [";", ","]) +def test_read_csv_with_either_delimiter(delimiter: str) -> None: + content = _csv_bytes([HEADERS, _data_row()], delimiter=delimiter) + rows = read_rows("export.csv", content) + assert rows == [ + ImportRow( + row=2, + inn="3528052718", + crm_id="1-AZFL6BQ", + office_name="Санкт-Петербургский РФ", + group_name="Русский Бисквит", + entity_name="РУССКИЙ БИСКВИТ, АО", + gsk_flag="входит в ГСК", + gsz_flag="", + type_raw="ЮЛ", + ) + ] + + +def test_read_csv_without_bom() -> None: + rows = read_rows("export.CSV", _csv_bytes([HEADERS, _data_row()], bom=False)) + assert [r.inn for r in rows] == ["3528052718"] + + +def test_read_csv_columns_reordered_and_extra_column_ignored() -> None: + headers = ["Лишняя", "Тип", "Организация", "Группа компаний", "iD ГК", "ИНН"] + content = _csv_bytes([headers, ["x", "ИП", "ИП Юганов", "Русский Бисквит", "1-AZFL6BQ", "352819549855"]]) + (row,) = read_rows("export.csv", content) + assert (row.inn, row.type_raw, row.entity_name, row.group_name, row.crm_id) == ( + "352819549855", + "ИП", + "ИП Юганов", + "Русский Бисквит", + "1-AZFL6BQ", + ) + assert (row.office_name, row.gsk_flag, row.gsz_flag) == ("", "", "") + + +def test_read_csv_skips_empty_lines_and_keeps_file_row_numbers() -> None: + content = _csv_bytes([HEADERS, _data_row("3528052718"), ["", "", "", "", "", "", "", ""], _data_row("3528104476")]) + rows = read_rows("export.csv", content) + assert [(r.row, r.inn) for r in rows] == [(2, "3528052718"), (4, "3528104476")] + + +def test_read_csv_missing_type_column() -> None: + content = _csv_bytes([HEADERS[:-1], _data_row()[:-1]]) + with pytest.raises(FileFormatError) as error: + read_rows("export.csv", content) + assert error.value.code == "MISSING_COLUMNS" + assert error.value.missing == ["Тип"] + + +def test_read_csv_not_utf8_is_unsupported() -> None: + """Review Focus 3: csv в cp1251 → UNSUPPORTED_FILE «ожидается UTF-8», не 500.""" + content = ";".join(HEADERS).encode("cp1251") + with pytest.raises(FileFormatError) as error: + read_rows("export.csv", content) + assert error.value.code == "UNSUPPORTED_FILE" + assert "UTF-8" in error.value.message + + +def test_read_txt_is_unsupported() -> None: + with pytest.raises(FileFormatError) as error: + read_rows("export.txt", _csv_bytes([HEADERS, _data_row()])) + assert error.value.code == "UNSUPPORTED_FILE" + + +def test_read_broken_xlsx_is_unsupported() -> None: + with pytest.raises(FileFormatError) as error: + read_rows("export.xlsx", b"not a zip at all") + assert error.value.code == "UNSUPPORTED_FILE" + + +def test_read_xlsx_header_in_second_row_numeric_inn_and_trailing_empty_rows() -> None: + """Review Focus 1–2: заголовок в первой непустой строке, ИНН числом, 3 пустые строки в хвосте пропускаются.""" + workbook = openpyxl.Workbook() + sheet = workbook.active + assert sheet is not None + for column, header in enumerate(HEADERS, start=1): + sheet.cell(row=2, column=column, value=header) + first: list[float | str | None] = [ + 3528052718.0, + "1-AZFL6BQ", + "Санкт-Петербургский РФ", + "Русский Бисквит", + "АО", + "входит в ГСК", + None, + "ЮЛ", + ] + second: list[float | str | None] = [ + 352819549855, + "1-AZFL6BQ", + "Санкт-Петербургский РФ", + "Русский Бисквит", + "ИП", + None, + None, + "ИП", + ] + for column, value in enumerate(first, start=1): + sheet.cell(row=3, column=column, value=value) + for column, value in enumerate(second, start=1): + sheet.cell(row=4, column=column, value=value) + for row in (5, 6, 7): + sheet.cell(row=row, column=1, value="") + sheet.cell(row=row, column=8, value=None) + buffer = io.BytesIO() + workbook.save(buffer) + + rows = read_rows("export.xlsx", buffer.getvalue()) + + assert [(r.row, r.inn, r.type_raw) for r in rows] == [(3, "3528052718", "ЮЛ"), (4, "352819549855", "ИП")] + assert rows[0].gsk_flag == "входит в ГСК" + + +def test_read_csv_row_is_file_line_where_record_starts() -> None: + """Поле в кавычках с переводом строки не сдвигает номера: запись после него — со своей строки файла.""" + content = _csv_bytes( + [HEADERS, _data_row("3528052718")[:4] + ["АО\nмногострочное"] + _data_row()[5:], _data_row("3528104476")] + ) + rows = read_rows("export.csv", content) + assert [(r.row, r.inn) for r in rows] == [(2, "3528052718"), (4, "3528104476")] + assert rows[0].entity_name == "АО\nмногострочное" + + +# ---------------------------------------------------------------- повреждённые и «раздутые» xlsx (T9.1, T9.2) + + +def _valid_xlsx() -> bytes: + workbook = openpyxl.Workbook() + sheet = workbook.active + assert sheet is not None + sheet.append(HEADERS) + sheet.append(_data_row()) + buffer = io.BytesIO() + workbook.save(buffer) + return buffer.getvalue() + + +def _rewrite_xlsx(content: bytes, member: str, change: Callable[[bytes], bytes]) -> bytes: + """Тот же zip (валидный архив), в котором часть `member` заменена результатом `change`.""" + source = zipfile.ZipFile(io.BytesIO(content)) + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as target: + for info in source.infolist(): + data = source.read(info.filename) + target.writestr(info.filename, change(data) if info.filename == member else data) + return buffer.getvalue() + + +def _bad_cell_ref(data: bytes) -> bytes: + return data.replace(b'r="A2"', b'r="AAAAA2"') + + +def _no_sheets(data: bytes) -> bytes: + return re.sub(rb".*", b"", data, flags=re.DOTALL) + + +@pytest.mark.parametrize( + ("member", "change"), + [ + pytest.param("xl/worksheets/sheet1.xml", lambda data: data[: len(data) // 2], id="лист-обрезан"), + pytest.param("xl/worksheets/sheet1.xml", lambda data: b"\x00garbage<<", id="лист-мусор"), + pytest.param("xl/workbook.xml", lambda data: b"garbage", id="книга-мусор"), + pytest.param("xl/styles.xml", lambda data: b" None: + content = _rewrite_xlsx(_valid_xlsx(), member, change) + with pytest.raises(FileFormatError) as error: + read_rows("export.xlsx", content) + assert error.value.code == "UNSUPPORTED_FILE" + assert error.value.message == "файл xlsx не читается" + + +def test_rewritten_valid_xlsx_is_still_read() -> None: + """Контроль помощника: перепаковка без изменений даёт читаемый файл.""" + content = _rewrite_xlsx(_valid_xlsx(), "xl/worksheets/sheet1.xml", lambda data: data) + assert [r.inn for r in read_rows("export.xlsx", content)] == ["3528052718"] + + +def test_read_xlsx_with_far_row_index_is_rejected_by_row_limit() -> None: + """Одна строка данных с r="2000000": пустые строки до неё считаются, чтение останавливается на пределе.""" + + def far_row(data: bytes) -> bytes: + data = re.sub(rb'r="([A-Z]+)2"', rb'r="\g<1>2000000"', data) + return data.replace(b' None: + monkeypatch.setattr(directory_import, "MAX_IMPORT_ROWS", 3) + within = _csv_bytes([HEADERS, ["", "", "", "", "", "", "", ""], _data_row()]) + assert len(read_rows("export.csv", within)) == 1 + beyond = _csv_bytes([HEADERS, ["", "", "", "", "", "", "", ""], ["", "", "", "", "", "", "", ""], _data_row()]) + with pytest.raises(FileFormatError) as error: + read_rows("export.csv", beyond) + assert error.value.code == "UNSUPPORTED_FILE" + + +def test_xlsx_uncompressed_size_bomb_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None: + """Сумма распакованных размеров частей zip выше предела → отказ до разбора (предел уменьшен для скорости).""" + limit = 1024 * 1024 + monkeypatch.setattr(directory_import, "MAX_UNCOMPRESSED_BYTES", limit) + buffer = io.BytesIO(_valid_xlsx()) + with zipfile.ZipFile(buffer, "a", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("xl/padding.bin", b"\x00" * (limit + 1)) + content = buffer.getvalue() + assert len(content) < limit // 10 + with pytest.raises(FileFormatError) as error: + read_rows("export.xlsx", content) + assert error.value.code == "UNSUPPORTED_FILE" + assert error.value.message == "файл xlsx слишком велик после распаковки" + + +# ---------------------------------------------------------------- план импорта: помощники + + +def _row( # noqa: PLR0913 - строка выгрузки: все поля значимы для теста + row: int, + *, + inn: str = "3528052718", + type_raw: str = "ЮЛ", + crm_id: str = "1-AZFL6BQ", + group_name: str = "Русский Бисквит", + office_name: str = "Санкт-Петербургский РФ", + entity_name: str = "РУССКИЙ БИСКВИТ, АО", + gsk_flag: str = "", + gsz_flag: str = "", +) -> ImportRow: + return ImportRow( + row=row, + inn=inn, + crm_id=crm_id, + office_name=office_name, + group_name=group_name, + entity_name=entity_name, + gsk_flag=gsk_flag, + gsz_flag=gsz_flag, + type_raw=type_raw, + ) + + +def _empty() -> DirectorySnapshot: + return DirectorySnapshot(offices_by_name={}, groups_by_crm={}, entities_by_inn={}) + + +def _final_codes(plan: ImportPlan) -> dict[int, ImportCode]: + finals: dict[int, ImportCode] = {} + for line in plan.report.lines: + if line.code in FINAL_CODES: + assert line.row not in finals, f"строка {line.row}: два итоговых кода" + finals[line.row] = line.code + return finals + + +def _events(plan: ImportPlan, row: int) -> list[ImportCode]: + return [line.code for line in plan.report.lines if line.row == row and line.code not in FINAL_CODES] + + +def _ops(plan: ImportPlan, kind: type[T]) -> list[T]: + return [op for op in plan.operations if isinstance(op, kind)] + + +OFFICE_ID = uuid.uuid4() +GROUP_ID = uuid.uuid4() +OTHER_GROUP_ID = uuid.uuid4() +ENTITY_ID = uuid.uuid4() +GSK_ID = uuid.uuid4() + + +def _existing( + entity_group: uuid.UUID | None = GROUP_ID, + group_name: str = "Русский Бисквит", + gsk_member: uuid.UUID | None = None, +) -> DirectorySnapshot: + return DirectorySnapshot( + offices_by_name={"Санкт-Петербургский РФ": OfficeSnap(id=OFFICE_ID, name="Санкт-Петербургский РФ")}, + groups_by_crm={ + "1-AZFL6BQ": GroupSnap( + id=GROUP_ID, crm_id="1-AZFL6BQ", name=group_name, office_id=OFFICE_ID, gsk_id=None, gsz_id=None + ), + "1-OTHER": GroupSnap( + id=OTHER_GROUP_ID, crm_id="1-OTHER", name="Другая", office_id=None, gsk_id=None, gsz_id=None + ), + }, + entities_by_inn={ + "3528052718": EntitySnap( + id=ENTITY_ID, + inn="3528052718", + name="РУССКИЙ БИСКВИТ, АО", + entity_type=EntityType.ЮЛ, + group_id=entity_group, + gsk_group_id=gsk_member, + gsz_group_id=None, + ) + }, + ) + + +# ---------------------------------------------------------------- план импорта: по коду + + +@pytest.mark.parametrize( + ("rows", "snapshot", "expected_final", "expected_events"), + [ + pytest.param([_row(2, type_raw="ФЛ")], _empty(), ImportCode.UNSUPPORTED_TYPE, [], id="UNSUPPORTED_TYPE-ФЛ"), + pytest.param([_row(2, type_raw="нерез.")], _empty(), ImportCode.UNSUPPORTED_TYPE, [], id="UNSUPPORTED_TYPE"), + pytest.param([_row(2, type_raw="")], _empty(), ImportCode.UNSUPPORTED_TYPE, [], id="UNSUPPORTED_TYPE-пусто"), + pytest.param([_row(2, inn="35280527")], _empty(), ImportCode.INVALID_INN, [], id="INVALID_INN-короткий"), + pytest.param( + [_row(2, inn="3528052718", type_raw="ИП")], _empty(), ImportCode.INVALID_INN, [], id="INVALID_INN-ИП-10" + ), + pytest.param([_row(2, inn="LV12345678")], _empty(), ImportCode.INVALID_INN, [], id="INVALID_INN-буквы"), + pytest.param([_row(2, crm_id="")], _empty(), ImportCode.GK_NAME_CONFLICT, [], id="GK_NAME_CONFLICT-пустой"), + pytest.param( + [_row(2)], + _empty(), + ImportCode.LE_CREATED, + [ImportCode.RF_CREATED, ImportCode.GK_CREATED], + id="LE_CREATED", + ), + pytest.param([_row(2)], _existing(), ImportCode.LE_EXISTS, [ImportCode.GK_EXISTS], id="LE_EXISTS"), + pytest.param( + [_row(2)], + _existing(entity_group=OTHER_GROUP_ID), + ImportCode.LE_IN_OTHER_GROUP, + [ImportCode.GK_EXISTS], + id="LE_IN_OTHER_GROUP", + ), + pytest.param( + [_row(2)], + _existing(entity_group=None), + ImportCode.LE_IN_OTHER_GROUP, + [ImportCode.GK_EXISTS], + id="LE_IN_OTHER_GROUP-без-ГК", + ), + pytest.param( + [_row(2, gsk_flag="входит в ГСК")], + _empty(), + ImportCode.LE_CREATED, + [ImportCode.RF_CREATED, ImportCode.GK_CREATED, ImportCode.GSK_CREATED, ImportCode.MEMBER_ADDED], + id="GSK_CREATED+MEMBER_ADDED", + ), + pytest.param( + [_row(2, gsz_flag="Входит в ГСЗ")], + _empty(), + ImportCode.LE_CREATED, + [ImportCode.RF_CREATED, ImportCode.GK_CREATED, ImportCode.GSZ_CREATED, ImportCode.MEMBER_ADDED], + id="GSZ_CREATED+MEMBER_ADDED", + ), + pytest.param( + [_row(2, gsk_flag="не входит в ГСК")], + _empty(), + ImportCode.LE_CREATED, + [ImportCode.RF_CREATED, ImportCode.GK_CREATED, ImportCode.UNKNOWN_FLAG], + id="UNKNOWN_FLAG", + ), + ], +) +def test_plan_row_code( + rows: list[ImportRow], + snapshot: DirectorySnapshot, + expected_final: ImportCode, + expected_events: list[ImportCode], +) -> None: + plan = plan_import(rows, snapshot, OnExisting.skip) + assert _final_codes(plan) == {2: expected_final} + assert _events(plan, 2) == expected_events + assert plan.report.rows == 1 + + +def test_skipped_rows_count_as_errors_and_produce_no_operations() -> None: + rows = [_row(2, type_raw="ФЛ"), _row(3, inn="12"), _row(4, crm_id="")] + plan = plan_import(rows, _empty(), OnExisting.skip) + assert plan.operations == [] + assert plan.report.errors == len(rows) + assert plan.report.rows == len(rows) + + +def test_duplicate_in_file_after_first_occurrence() -> None: + rows = [_row(2, inn="352819549855", type_raw="ИП"), _row(3, inn="352819549855", type_raw="ип")] + plan = plan_import(rows, _empty(), OnExisting.skip) + assert _final_codes(plan) == {2: ImportCode.LE_CREATED, 3: ImportCode.DUPLICATE_IN_FILE} + assert plan.report.errors == 1 + assert len(_ops(plan, CreateEntity)) == 1 + + +def test_inn_with_spaces_is_normalized() -> None: + plan = plan_import([_row(2, inn=" 3528 052 718 ")], _empty(), OnExisting.skip) + (entity,) = _ops(plan, CreateEntity) + assert entity.inn == "3528052718" + assert plan.report.lines[-1].inn == "3528052718" + + +def test_gk_name_conflict_inside_file() -> None: + rows = [_row(2), _row(3, inn="3528104476", group_name="Русский Бисквит 2")] + plan = plan_import(rows, _empty(), OnExisting.skip) + assert _final_codes(plan) == {2: ImportCode.LE_CREATED, 3: ImportCode.GK_NAME_CONFLICT} + assert len(_ops(plan, CreateGroup)) == 1 + assert len(_ops(plan, CreateEntity)) == 1 + assert plan.report.errors == 1 + + +def test_empty_snapshot_creates_office_group_entity_with_links() -> None: + plan = plan_import([_row(2, type_raw="юл")], _empty(), OnExisting.skip) + (office,) = _ops(plan, CreateOffice) + (group,) = _ops(plan, CreateGroup) + (entity,) = _ops(plan, CreateEntity) + assert office.name == "Санкт-Петербургский РФ" + assert (group.crm_id, group.name, group.office_id) == ("1-AZFL6BQ", "Русский Бисквит", office.id) + assert (entity.inn, entity.name, entity.entity_type) == ("3528052718", "РУССКИЙ БИСКВИТ, АО", EntityType.ЮЛ) + assert (entity.group_id, entity.office_id) == (group.id, office.id) + report = plan.report + assert (report.offices_created, report.groups_created, report.entities_created, report.errors) == (1, 1, 1, 0) + rf_line = next(line for line in report.lines if line.code == ImportCode.RF_CREATED) + assert (rf_line.row, rf_line.message) == (2, "создан филиал «Санкт-Петербургский РФ»") + + +def test_empty_office_name_gives_group_without_office() -> None: + plan = plan_import([_row(2, office_name="")], _empty(), OnExisting.skip) + assert _ops(plan, CreateOffice) == [] + (group,) = _ops(plan, CreateGroup) + (entity,) = _ops(plan, CreateEntity) + assert group.office_id is None + assert entity.office_id is None + + +def test_existing_office_is_reused() -> None: + snapshot = _empty() + snapshot.offices_by_name["Санкт-Петербургский РФ"] = OfficeSnap(id=OFFICE_ID, name="Санкт-Петербургский РФ") + plan = plan_import([_row(2)], snapshot, OnExisting.skip) + assert _ops(plan, CreateOffice) == [] + (group,) = _ops(plan, CreateGroup) + assert group.office_id == OFFICE_ID + assert plan.report.offices_created == 0 + + +def test_two_rows_of_one_group_create_it_once() -> None: + rows = [_row(2), _row(3, inn="3528104476", entity_name="ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО")] + plan = plan_import(rows, _empty(), OnExisting.skip) + (group,) = _ops(plan, CreateGroup) + assert len(_ops(plan, CreateOffice)) == 1 + assert [e.group_id for e in _ops(plan, CreateEntity)] == [group.id, group.id] + assert plan.report.groups_created == 1 + assert _events(plan, 3) == [] + + +def test_flag_on_two_entities_creates_one_related_group_and_two_members() -> None: + rows = [_row(2, gsk_flag="входит в ГСК"), _row(3, inn="3528104476", gsk_flag="входит в ГСК")] + plan = plan_import(rows, _empty(), OnExisting.skip) + (group,) = _ops(plan, CreateGroup) + (related,) = _ops(plan, CreateRelatedGroup) + assert (related.group_id, related.kind, related.name) == (group.id, RelatedGroupKind.gsk, "Русский Бисквит") + members = _ops(plan, AddMember) + assert [m.related_group_id for m in members] == [related.id, related.id] + assert [m.kind for m in members] == [RelatedGroupKind.gsk, RelatedGroupKind.gsk] + assert [m.entity_id for m in members] == [e.id for e in _ops(plan, CreateEntity)] + assert plan.report.memberships_created == len(rows) + + +def test_existing_related_group_of_group_is_reused() -> None: + snapshot = _existing() + snapshot.groups_by_crm["1-AZFL6BQ"].gsk_id = GSK_ID + plan = plan_import([_row(2, inn="3528104476", gsk_flag="входит в ГСК")], snapshot, OnExisting.skip) + assert _ops(plan, CreateRelatedGroup) == [] + (member,) = _ops(plan, AddMember) + assert member.related_group_id == GSK_ID + + +def test_existing_membership_is_kept() -> None: + snapshot = _existing(gsk_member=GSK_ID) + snapshot.groups_by_crm["1-AZFL6BQ"].gsk_id = GSK_ID + plan = plan_import([_row(2, gsk_flag="входит в ГСК")], snapshot, OnExisting.skip) + assert _ops(plan, AddMember) == [] + assert _events(plan, 2) == [ImportCode.GK_EXISTS] + + +def test_membership_in_other_related_group_is_not_moved() -> None: + other_gsk = uuid.uuid4() + plan = plan_import([_row(2, gsk_flag="входит в ГСК")], _existing(gsk_member=other_gsk), OnExisting.skip) + assert _ops(plan, AddMember) == [] + assert ImportCode.MEMBER_ADDED not in _events(plan, 2) + + +def test_entity_in_other_group_gets_no_membership() -> None: + plan = plan_import([_row(2, gsk_flag="входит в ГСК")], _existing(entity_group=OTHER_GROUP_ID), OnExisting.skip) + assert _ops(plan, AddMember) == [] + assert _ops(plan, CreateRelatedGroup) == [] + assert plan.report.errors == 1 + + +def test_changed_names_with_skip_are_left_as_is() -> None: + rows = [_row(2, entity_name="РУССКИЙ БИСКВИТ, ПАО")] + plan = plan_import(rows, _existing(group_name="Старое имя"), OnExisting.skip) + assert plan.operations == [] + assert _final_codes(plan) == {2: ImportCode.LE_EXISTS} + assert _events(plan, 2) == [ImportCode.GK_EXISTS] + report = plan.report + assert (report.groups_skipped, report.entities_skipped, report.groups_updated, report.entities_updated) == ( + 1, + 1, + 0, + 0, + ) + + +def test_changed_names_with_update_produce_updates() -> None: + rows = [_row(2, entity_name="РУССКИЙ БИСКВИТ, ПАО")] + plan = plan_import(rows, _existing(group_name="Старое имя"), OnExisting.update) + assert plan.operations == [ + UpdateGroup(id=GROUP_ID, name="Русский Бисквит", office_id=None), + UpdateEntity(id=ENTITY_ID, name="РУССКИЙ БИСКВИТ, ПАО", entity_type=None), + ] + assert _final_codes(plan) == {2: ImportCode.LE_UPDATED} + assert _events(plan, 2) == [ImportCode.GK_UPDATED] + report = plan.report + assert (report.groups_updated, report.entities_updated, report.groups_skipped, report.entities_skipped) == ( + 1, + 1, + 0, + 0, + ) + + +def test_update_without_differences_is_exists() -> None: + plan = plan_import([_row(2)], _existing(), OnExisting.update) + assert plan.operations == [] + assert _final_codes(plan) == {2: ImportCode.LE_EXISTS} + assert _events(plan, 2) == [ImportCode.GK_EXISTS] + + +def test_update_moves_group_to_new_office_and_new_entity_follows() -> None: + rows = [_row(2, inn="3528104476", office_name="Вологодский РФ")] + plan = plan_import(rows, _existing(), OnExisting.update) + (office,) = _ops(plan, CreateOffice) + (update,) = _ops(plan, UpdateGroup) + (entity,) = _ops(plan, CreateEntity) + assert update == UpdateGroup(id=GROUP_ID, name=None, office_id=office.id) + assert (entity.group_id, entity.office_id) == (GROUP_ID, office.id) + + +def test_empty_office_in_file_keeps_group_office_for_new_entity() -> None: + rows = [_row(2, inn="3528104476", office_name="")] + plan = plan_import(rows, _existing(), OnExisting.update) + assert _ops(plan, UpdateGroup) == [] + (entity,) = _ops(plan, CreateEntity) + assert entity.office_id == OFFICE_ID + + +def test_update_changes_entity_type() -> None: + snapshot = _existing() + snapshot.entities_by_inn["352819549855"] = EntitySnap( + id=ENTITY_ID, + inn="352819549855", + name="ИП Юганов", + entity_type=EntityType.ЮЛ, + group_id=GROUP_ID, + gsk_group_id=None, + gsz_group_id=None, + ) + plan = plan_import( + [_row(2, inn="352819549855", type_raw="ИП", entity_name="ИП Юганов")], snapshot, OnExisting.update + ) + assert plan.operations == [UpdateEntity(id=ENTITY_ID, name=None, entity_type=EntityType.ИП)] + + +def test_group_events_once_per_group() -> None: + rows = [_row(2), _row(3, inn="3528104476")] + plan = plan_import(rows, _existing(group_name="Старое имя"), OnExisting.update) + assert len(_ops(plan, UpdateGroup)) == 1 + assert _events(plan, 2) == [ImportCode.GK_UPDATED] + assert _events(plan, 3) == [] + assert plan.report.groups_updated == 1 + + +def test_report_to_dict() -> None: + plan = plan_import([_row(2, type_raw="ФЛ")], _empty(), OnExisting.skip) + data = plan.report.to_dict() + assert data["rows"] == 1 + assert data["errors"] == 1 + assert data["groups_created"] == 0 + assert data["lines"] == [ + {"row": 2, "inn": "3528052718", "code": "UNSUPPORTED_TYPE", "message": data["lines"][0]["message"]} + ] + assert isinstance(data["lines"][0]["code"], str) + assert set(data) == { + "rows", + "groups_created", + "groups_updated", + "groups_skipped", + "entities_created", + "entities_updated", + "entities_skipped", + "memberships_created", + "offices_created", + "errors", + "lines", + } + + +# ---------------------------------------------------------------- реальная выгрузка + + +# Ожидания по фактическому содержимому `ecp-export-russkiy-biskvit.xlsx` (лист «Выписки_01.04.26», строки 2–22): +# строка 11 — «ИП Юганов Максим Анатольевич» (ИП, ИНН 352819549855), строка 12 — «Юганов Максим Анатольевич» (ФЛ, тот же +# ИНН). Тип проверяется раньше дубля (§13.2), поэтому строка 12 → UNSUPPORTED_TYPE, DUPLICATE_IN_FILE в срезе нет. +# Остальные 20 строк — 18 ЮЛ и 2 ИП (строки 11, 14) → LE_CREATED. +RB_ROWS = 21 +RB_FL_ROW = 12 +RB_LEGAL_ENTITIES = 18 +RB_SOLE_PROPRIETORS = 2 +RB_EXPECTED_FINALS = { + number: ImportCode.UNSUPPORTED_TYPE if number == RB_FL_ROW else ImportCode.LE_CREATED for number in range(2, 23) +} +RB_GSK_MEMBERS = ["ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО", "РУССКИЙ БИСКВИТ, АО"] + + +@pytest.mark.fixtures +def test_russkiy_biskvit_export_on_empty_directory(fixtures_dir: Path) -> None: + """Срез «Русский Бисквит» на пустом справочнике: 1 ГК, 1 РФ, 20 лиц (18 ЮЛ + 2 ИП), 1 ГСК с двумя членами.""" + path = fixtures_dir / "directories" / "ecp-export-russkiy-biskvit.xlsx" + rows = read_rows(path.name, path.read_bytes()) + by_row = {r.row: r for r in rows} + assert (by_row[11].inn, by_row[11].type_raw) == ("352819549855", "ИП") + assert (by_row[RB_FL_ROW].inn, by_row[RB_FL_ROW].type_raw) == ("352819549855", "ФЛ") + assert [r.type_raw for r in rows].count("ЮЛ") == RB_LEGAL_ENTITIES + assert [r.type_raw for r in rows].count("ИП") == RB_SOLE_PROPRIETORS + + plan = plan_import(rows, _empty(), OnExisting.skip) + report = plan.report + + assert report.rows == RB_ROWS + assert _final_codes(plan) == RB_EXPECTED_FINALS + assert (report.groups_created, report.offices_created, report.errors) == (1, 1, 1) + assert report.entities_created == RB_LEGAL_ENTITIES + RB_SOLE_PROPRIETORS + (group,) = _ops(plan, CreateGroup) + assert (group.crm_id, group.name) == ("1-AZFL6BQ", "Русский Бисквит") + (office,) = _ops(plan, CreateOffice) + assert office.name == "Санкт-Петербургский РФ" + (related,) = _ops(plan, CreateRelatedGroup) + assert (related.kind, related.name) == (RelatedGroupKind.gsk, "Русский Бисквит") + entities = {e.id: e.name for e in _ops(plan, CreateEntity)} + members = _ops(plan, AddMember) + assert sorted(entities[m.entity_id] for m in members) == RB_GSK_MEMBERS + assert report.memberships_created == len(RB_GSK_MEMBERS) + assert all(m.kind is RelatedGroupKind.gsk for m in members) diff --git a/api/tests/test_domain_inn.py b/api/tests/test_domain_inn.py new file mode 100644 index 0000000..5ddff28 --- /dev/null +++ b/api/tests/test_domain_inn.py @@ -0,0 +1,49 @@ +"""ИНН: нормализация и проверка длины по типу (spec directories-api; ASSUMPTION(OQ-88) — только длина).""" + +import pytest + +from src.common.exceptions import DomainValidationError +from src.domain.inn import INN_LENGTH, normalize_inn, validate_inn +from src.models.enums import EntityType + + +def test_legal_entity_inn_has_ten_digits() -> None: + assert validate_inn("3528052718", EntityType.ЮЛ) == "3528052718" + + +def test_sole_proprietor_inn_has_twelve_digits() -> None: + assert validate_inn("352805271812", EntityType.ИП) == "352805271812" + + +def test_leading_zero_is_valid_and_kept() -> None: + """Review Focus 2: ИНН — строка, ведущий ноль не теряется.""" + assert validate_inn("0123456789", EntityType.ЮЛ) == "0123456789" + + +def test_ten_digits_for_sole_proprietor_is_invalid() -> None: + with pytest.raises(DomainValidationError) as error: + validate_inn("3528052718", EntityType.ИП) + assert error.value.code == "INVALID_INN" + assert error.value.message == "ИНН ИП — ровно 12 цифр" + + +@pytest.mark.parametrize("raw", ["35280527AB", "LV12345678", "нерез.", "", "35280527181"]) +def test_non_digits_or_wrong_length_is_invalid(raw: str) -> None: + with pytest.raises(DomainValidationError) as error: + validate_inn(raw, EntityType.ЮЛ) + assert error.value.code == "INVALID_INN" + + +def test_non_ascii_digits_are_invalid() -> None: + """Арабско-индийские цифры проходят `str.isdigit`, но ИНН — только 0–9.""" + with pytest.raises(DomainValidationError): + validate_inn("٣٥٢٨٠٥٢٧١٨", EntityType.ЮЛ) + + +def test_spaces_inside_are_removed() -> None: + assert normalize_inn(" 3528 052\t718 ") == "3528052718" + assert validate_inn("35 28 05 27 18", EntityType.ЮЛ) == "3528052718" + + +def test_lengths_by_entity_type() -> None: + assert INN_LENGTH == {EntityType.ЮЛ: 10, EntityType.ИП: 12} diff --git a/api/tests/test_domain_policy.py b/api/tests/test_domain_policy.py new file mode 100644 index 0000000..557293f --- /dev/null +++ b/api/tests/test_domain_policy.py @@ -0,0 +1,270 @@ +"""Policy видимости справочников (§10.1 `05-domain-rules.md`, spec directory-policy, task 2.1). + +Unit-часть — без БД: область видимости из данных о филиале, SQL-фильтры (компиляция `Select` под PostgreSQL с +подставленными значениями), проверки отдельных объектов и выбор филиала при создании ГК. +Integration-часть — семантика SQL-фильтров на настоящей БД: какие именно ГК и ЮЛ попадают в выборку, и что SQL-фильтр +совпадает с проверкой отдельного объекта (`*_is_visible`). +""" + +import re +import uuid +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from fastapi import status as http_status +from sqlalchemy import Select, func, select +from sqlalchemy.dialects import postgresql +from sqlalchemy.ext.asyncio import AsyncSession + +from src.common.exceptions import DomainValidationError +from src.domain.policy import ( + VisibilityScope, + company_group_is_visible, + company_groups_visible, + legal_entities_visible, + legal_entity_is_visible, + office_for_create, + scope_for, + scope_of, +) +from src.models.directories import CompanyGroup, LegalEntity +from tests.factories import make_company_group, make_legal_entity, make_regional_office + +OFFICE_X = uuid.UUID("00000000-0000-0000-0000-0000000000aa") +OFFICE_Y = uuid.UUID("00000000-0000-0000-0000-0000000000bb") +GLOBAL = VisibilityScope(office_id=None) +BRANCH_X = VisibilityScope(office_id=OFFICE_X) + + +def _sql(stmt: Select[Any]) -> str: + """SQL под PostgreSQL с подставленными значениями, пробелы схлопнуты в один.""" + compiled = stmt.compile(dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}) + return re.sub(r"\s+", " ", str(compiled)).strip() + + +def _where(stmt: Select[Any]) -> str: + """Только часть после WHERE (или пустая строка).""" + sql = _sql(stmt) + return sql.split(" WHERE ", 1)[1] if " WHERE " in sql else "" + + +# --- область видимости --- + + +def test_head_office_scope_is_global() -> None: + scope = scope_for(is_head_office=True, regional_office_id=OFFICE_X) + assert scope.is_global + assert scope.office_id is None + + +def test_branch_scope_is_bound_to_its_office() -> None: + scope = scope_for(is_head_office=False, regional_office_id=OFFICE_X) + assert not scope.is_global + assert scope.office_id == OFFICE_X + + +@pytest.mark.parametrize(("is_head_office", "expected"), [(True, GLOBAL), (False, BRANCH_X)]) +def test_scope_of_principal_uses_office_data_not_role(is_head_office: bool, expected: VisibilityScope) -> None: + # Роль заведомо «чужая» уровню: область зависит только от филиала (права — данные, код роли не читается) + principal = SimpleNamespace( + user=SimpleNamespace(regional_office_id=OFFICE_X), + regional_office=SimpleNamespace(id=OFFICE_X, is_head_office=is_head_office), + roles=("KA_RF",) if is_head_office else ("KA_GO",), + permissions=frozenset({"DIRECTORY_VIEW"}), + ) + assert scope_of(cast(Any, principal)) == expected + + +# --- SQL-фильтр ГК --- + + +def test_company_groups_global_returns_statement_unchanged() -> None: + stmt = select(CompanyGroup) + filtered = company_groups_visible(stmt, GLOBAL) + assert filtered is stmt + assert _where(filtered) == "" + + +def test_company_groups_branch_filters_by_base_office() -> None: + filtered = company_groups_visible(select(CompanyGroup), BRANCH_X) + assert _where(filtered) == f"dkb.company_group.base_regional_office_id = '{OFFICE_X}'" + + +def test_company_groups_filter_composes_with_existing_where() -> None: + stmt = select(CompanyGroup).where(CompanyGroup.is_archived.is_(False)) + where = _where(company_groups_visible(stmt, BRANCH_X)) + assert where == ( + f"dkb.company_group.is_archived IS false AND dkb.company_group.base_regional_office_id = '{OFFICE_X}'" + ) + + +# --- SQL-фильтр ЮЛ --- + + +def test_legal_entities_global_returns_statement_unchanged() -> None: + stmt = select(LegalEntity) + filtered = legal_entities_visible(stmt, GLOBAL) + assert filtered is stmt + assert _where(filtered) == "" + + +def test_legal_entities_branch_filter_is_group_of_office_or_mono_borrower_of_office() -> None: + where = _where(legal_entities_visible(select(LegalEntity), BRANCH_X)) + office = f"'{OFFICE_X}'" + # ЮЛ в ГК своего филиала ИЛИ (монозаёмщик И филиал ЮЛ — свой); единственное условие на запрос, обе ветви — по X + assert where == ( + "dkb.legal_entity.company_group_id IN (SELECT dkb.company_group.id FROM dkb.company_group " + f"WHERE dkb.company_group.base_regional_office_id = {office}) " + f"OR dkb.legal_entity.company_group_id IS NULL AND dkb.legal_entity.regional_office_id = {office}" + ) + assert " OR " in where + assert "IS NULL" in where + + +def test_legal_entities_branch_filter_does_not_multiply_rows_or_clash_with_caller_join() -> None: + # Вызывающий сам присоединяет ГК (например, для имени группы): фильтр не добавляет второй FROM/JOIN company_group + stmt = select(LegalEntity, CompanyGroup.name).outerjoin( + CompanyGroup, CompanyGroup.id == LegalEntity.company_group_id + ) + sql = _sql(legal_entities_visible(stmt, BRANCH_X)) + from_clause = sql.split(" WHERE ", 1)[0].split(" FROM ", 1)[1] + assert from_clause == ( + "dkb.legal_entity LEFT OUTER JOIN dkb.company_group ON dkb.company_group.id = dkb.legal_entity.company_group_id" + ) + + +def test_legal_entities_filter_works_for_count_statement() -> None: + stmt = select(func.count()).select_from(LegalEntity) + sql = _sql(legal_entities_visible(stmt, BRANCH_X)) + assert sql.startswith("SELECT count(*) AS count_1 FROM dkb.legal_entity WHERE ") + + +# --- проверка отдельной ГК --- + + +@pytest.mark.parametrize( + ("scope", "group_office", "visible"), + [ + (GLOBAL, OFFICE_Y, True), + (GLOBAL, None, True), + (BRANCH_X, OFFICE_X, True), + (BRANCH_X, OFFICE_Y, False), + (BRANCH_X, None, False), + ], +) +def test_company_group_is_visible(scope: VisibilityScope, group_office: uuid.UUID | None, visible: bool) -> None: + group = CompanyGroup(code="GK-000001", name="ГК", base_regional_office_id=group_office) + assert company_group_is_visible(group, scope) is visible + + +# --- проверка отдельного ЮЛ --- + + +@pytest.mark.parametrize( + ("scope", "in_group", "group_office", "entity_office", "visible"), + [ + # монозаёмщик своего / чужого / без филиала + (BRANCH_X, False, None, OFFICE_X, True), + (BRANCH_X, False, None, OFFICE_Y, False), + (BRANCH_X, False, None, None, False), + # ЮЛ в ГК своего / чужого филиала; филиал самого ЮЛ не важен — решает ГК + (BRANCH_X, True, OFFICE_X, OFFICE_Y, True), + (BRANCH_X, True, OFFICE_Y, OFFICE_X, False), + (BRANCH_X, True, None, OFFICE_X, False), + # ГО видит всё + (GLOBAL, False, None, OFFICE_Y, True), + (GLOBAL, True, OFFICE_Y, OFFICE_Y, True), + ], +) +def test_legal_entity_is_visible( + scope: VisibilityScope, + in_group: bool, + group_office: uuid.UUID | None, + entity_office: uuid.UUID | None, + visible: bool, +) -> None: + entity = LegalEntity( + inn="7700000000", + name="ООО Тест", + company_group_id=uuid.uuid4() if in_group else None, + regional_office_id=entity_office, + ) + assert legal_entity_is_visible(entity, group_office, scope) is visible + + +# --- филиал при создании ГК --- + + +@pytest.mark.parametrize("requested", [None, OFFICE_Y]) +def test_office_for_create_head_office_keeps_requested(requested: uuid.UUID | None) -> None: + assert office_for_create(GLOBAL, requested) == requested + + +def test_office_for_create_branch_defaults_to_own_office() -> None: + assert office_for_create(BRANCH_X, None) == OFFICE_X + + +def test_office_for_create_branch_accepts_own_office() -> None: + assert office_for_create(BRANCH_X, OFFICE_X) == OFFICE_X + + +def test_office_for_create_branch_rejects_foreign_office() -> None: + with pytest.raises(DomainValidationError) as exc_info: + office_for_create(BRANCH_X, OFFICE_Y) + assert exc_info.value.code == "OFFICE_NOT_ALLOWED" + assert exc_info.value.status == http_status.HTTP_422_UNPROCESSABLE_CONTENT + assert exc_info.value.message == "ГК можно закрепить только за своим филиалом" + + +# --- семантика SQL-фильтров на настоящей БД --- + + +@pytest.mark.integration +async def test_sql_filters_match_object_checks_on_real_db(db_session: AsyncSession) -> None: + office_x = await make_regional_office(db_session) + office_y = await make_regional_office(db_session) + group_x = await make_company_group(db_session, name="ГК X", office=office_x) + group_y = await make_company_group(db_session, name="ГК Y", office=office_y) + group_none = await make_company_group(db_session, name="ГК без филиала") + entities = { + "в ГК X": await make_legal_entity(db_session, group=group_x), + "в ГК X, филиал ЮЛ Y": await make_legal_entity(db_session, group=group_x, office=office_y), + "в ГК Y": await make_legal_entity(db_session, group=group_y), + "в ГК Y, филиал ЮЛ X": await make_legal_entity(db_session, group=group_y, office=office_x), + "в ГК без филиала, филиал ЮЛ X": await make_legal_entity(db_session, group=group_none, office=office_x), + "монозаёмщик X": await make_legal_entity(db_session, office=office_x), + "монозаёмщик Y": await make_legal_entity(db_session, office=office_y), + "монозаёмщик без филиала": await make_legal_entity(db_session), + } + names_by_id = {entity.id: name for name, entity in entities.items()} + groups = {group.id: group for group in (group_x, group_y, group_none)} + branch_x = scope_for(is_head_office=False, regional_office_id=office_x.id) + head = scope_for(is_head_office=True, regional_office_id=office_x.id) + + async def visible_entities(scope: VisibilityScope) -> set[str]: + stmt = legal_entities_visible(select(LegalEntity.id).where(LegalEntity.id.in_(names_by_id)), scope) + return {names_by_id[row] for row in (await db_session.scalars(stmt)).all()} + + async def visible_groups(scope: VisibilityScope) -> set[uuid.UUID]: + stmt = company_groups_visible(select(CompanyGroup.id).where(CompanyGroup.id.in_(groups)), scope) + return set((await db_session.scalars(stmt)).all()) + + assert await visible_entities(branch_x) == {"в ГК X", "в ГК X, филиал ЮЛ Y", "монозаёмщик X"} + assert await visible_entities(head) == set(entities) + assert await visible_groups(branch_x) == {group_x.id} + assert await visible_groups(head) == set(groups) + + # SQL-фильтр и проверка отдельного объекта согласованы + for scope in (branch_x, head): + expected_entities = { + name + for name, entity in entities.items() + if legal_entity_is_visible( + entity, + groups[entity.company_group_id].base_regional_office_id if entity.company_group_id else None, + scope, + ) + } + assert await visible_entities(scope) == expected_entities + assert await visible_groups(scope) == {gid for gid, g in groups.items() if company_group_is_visible(g, scope)} diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index 21811fa..694e815 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -1,4 +1,4 @@ -"""Enum'ы ORM против блоков `Enum {}` DBML v5.13: имена, значения и их порядок (без БД).""" +"""Enum'ы ORM против блоков `Enum {}` DBML v5.14: имена, значения и их порядок (без БД).""" import enum import re @@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 27 def test_dbml_version_format() -> None: assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) - assert DBML_VERSION == "5.13" + assert DBML_VERSION == "5.14" assert f"v{DBML_VERSION}" in dbml_path().name diff --git a/api/tests/test_exception_handlers.py b/api/tests/test_exception_handlers.py index b52f736..7080d25 100644 --- a/api/tests/test_exception_handlers.py +++ b/api/tests/test_exception_handlers.py @@ -9,9 +9,12 @@ import httpx import pytest from fastapi import FastAPI from loguru import logger +from sqlalchemy import UniqueConstraint from sqlalchemy.exc import IntegrityError, OperationalError, SQLAlchemyError +from src.common.constraint_conflicts import UNIQUE_CONSTRAINT_CONFLICTS from src.core.exception_handlers import LOG_STATEMENT_LIMIT, register_exception_handlers +from src.models.base import Base SECRET = "ivan.petrov@rshb.ru" @@ -104,3 +107,128 @@ async def test_long_statement_is_truncated(records: list[Any]) -> None: statement = record["extra"]["statement"] assert len(statement) <= LOG_STATEMENT_LIMIT + 2 # repr добавляет кавычки assert "WHERE email" not in statement + + +# --- гонки уникальных ограничений: реестр `common/constraint_conflicts.py` ------------------------------------------- + + +class _AsyncpgCause(Exception): + """Как `asyncpg.UniqueViolationError`: структурированное имя ограничения; текст — с DETAIL и значением.""" + + def __init__(self, constraint_name: str | None, sqlstate: str = "23505") -> None: + super().__init__(f'duplicate key value violates unique constraint "x"\nDETAIL: Key (email)=({SECRET}) exists.') + self.constraint_name = constraint_name + self.sqlstate = sqlstate + + +class _AdaptedDriverError(Exception): + """Как `AsyncAdapt_asyncpg_dbapi.IntegrityError`: `sqlstate` на адаптере, `__cause__` — исключение asyncpg.""" + + def __init__(self, cause: _AsyncpgCause) -> None: + super().__init__(str(cause)) + self.sqlstate = cause.sqlstate + self.__cause__ = cause + + +class _Diag: + def __init__(self, constraint_name: str) -> None: + self.constraint_name = constraint_name + + +class _PsycopgError(Exception): + """Как `psycopg.errors.UniqueViolation`: `sqlstate` и `diag.constraint_name` на самом исключении.""" + + sqlstate = "23505" + + def __init__(self, constraint_name: str) -> None: + super().__init__(f"duplicate key: {SECRET}") + self.diag = _Diag(constraint_name) + + +def _integrity(orig: Exception) -> IntegrityError: + return IntegrityError("INSERT INTO dkb.app_user (email) VALUES ($1)", (SECRET,), orig) + + +def _app_raising(exc: Exception) -> FastAPI: + app = FastAPI() + register_exception_handlers(app) + + @app.get("/boom") + async def boom() -> None: + raise exc + + return app + + +async def _get_raising(exc: Exception) -> httpx.Response: + transport = httpx.ASGITransport(app=_app_raising(exc)) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.get("/boom") + + +def test_registry_keys_exist_as_unique_constraints_or_indexes_in_metadata() -> None: + """Переименование ограничения в ORM ломает реестр молча (гонка снова отдаёт общий 409) — ловим здесь.""" + unique_names: set[str] = set() + for table in Base.metadata.tables.values(): + unique_names |= {c.name for c in table.constraints if isinstance(c, UniqueConstraint) and c.name} + unique_names |= {i.name for i in table.indexes if i.unique and i.name} + assert set(UNIQUE_CONSTRAINT_CONFLICTS) <= unique_names + + +def test_registry_codes_and_messages_match_service_precheck_texts() -> None: + codes = {name: conflict.code for name, conflict in UNIQUE_CONSTRAINT_CONFLICTS.items()} + assert codes == { + "uq_company_group_crm_id": "CRM_ID_EXISTS", + "uq_legal_entity_crm_id": "CRM_ID_EXISTS", + "uq_legal_entity_inn": "INN_EXISTS", + "uq_legal_entity_alias_legal_entity_id_normalized_alias": "ALIAS_EXISTS", + "uq_app_user_email": "EMAIL_EXISTS", + "uq_related_group_member_group_entity_current": "MEMBER_EXISTS", + "uq_related_group_member_entity_kind_current": "MEMBER_OF_OTHER_GROUP", + } + # у pre-check-ов постоянные тексты — тот же `message`, динамических значений в реестре нет + assert UNIQUE_CONSTRAINT_CONFLICTS["uq_legal_entity_inn"].message == "ЮЛ с таким ИНН уже существует" + assert UNIQUE_CONSTRAINT_CONFLICTS["uq_app_user_email"].message == "Пользователь с таким email уже существует" + + +@pytest.mark.parametrize( + "orig", + [ + _AdaptedDriverError(_AsyncpgCause("uq_app_user_email")), + _PsycopgError("uq_app_user_email"), + ], + ids=["asyncpg-cause", "psycopg-diag"], +) +async def test_known_unique_violation_gives_coded_conflict_and_logs_code(records: list[Any], orig: Exception) -> None: + response = await _get_raising(_integrity(orig)) + + assert response.status_code == HTTPStatus.CONFLICT + assert response.json() == { + "detail": {"code": "EMAIL_EXISTS", "message": "Пользователь с таким email уже существует"} + } + assert SECRET not in response.text + (record,) = [r for r in records if r["level"].name == "ERROR"] + assert "resolved_code=EMAIL_EXISTS" in record["message"] + assert SECRET not in record["message"] + assert "uq_app_user_email" not in response.text + + +@pytest.mark.parametrize( + "orig", + [ + _AdaptedDriverError(_AsyncpgCause("uq_unknown_constraint")), # unique, но не из реестра + _AdaptedDriverError(_AsyncpgCause(None)), # имя не определилось + _AdaptedDriverError(_AsyncpgCause("uq_app_user_email", sqlstate="23503")), # FK, не unique + _AdaptedDriverError(_AsyncpgCause("uq_app_user_email", sqlstate="23514")), # check violation + _DriverError("no constraint name"), + ], + ids=["unknown-unique", "no-name", "foreign-key", "check", "bare-driver-error"], +) +async def test_other_integrity_errors_stay_generic(records: list[Any], orig: Exception) -> None: + response = await _get_raising(_integrity(orig)) + + assert response.status_code == HTTPStatus.CONFLICT + assert response.json() == {"detail": "нарушение целостности данных в БД"} + (record,) = [r for r in records if r["level"].name == "ERROR"] + assert "resolved_code=None" in record["message"] + assert SECRET not in record["message"] diff --git a/api/tests/test_factories.py b/api/tests/test_factories.py new file mode 100644 index 0000000..c04d5f5 --- /dev/null +++ b/api/tests/test_factories.py @@ -0,0 +1,112 @@ +"""Фабрики справочников и фикстуры `app` / `auth_client` / `seeded` (задача 1.3): проверка до API-тестов.""" + +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.access import UserRole +from src.models.enums import EntityType, RelatedGroupKind +from tests.conftest import AuthClientFactory +from tests.factories import ( + make_company_group, + make_legal_entity, + make_membership, + make_regional_office, + make_related_group, + make_user, +) + +HTTP_OK = 200 +INN_LENGTH = 10 + + +@pytest.mark.integration +async def test_directory_factories_build_consistent_chain(db_session: AsyncSession) -> None: + office = await make_regional_office(db_session) + head = await make_regional_office(db_session, name="ГО Тест", is_head_office=True) + group = await make_company_group(db_session, office=office, crm_id="CRM-1") + entity = await make_legal_entity(db_session, group=group) + other = await make_legal_entity(db_session, name="ИП Тест", entity_type=EntityType.ИП, office=head) + gsk = await make_related_group(db_session, group=group) + gsz = await make_related_group(db_session, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ Тест") + member = await make_membership(db_session, related_group=gsk, entity=entity) + # v5.14: одно ЮЛ одновременно в ГСК и ГСЗ + member_gsz = await make_membership(db_session, related_group=gsz, entity=entity) + + assert head.is_head_office is True + assert office.is_head_office is False + assert group.code.startswith("GK-T") + assert group.base_regional_office_id == office.id + assert group.valid_from is not None + assert entity.regional_office_id == office.id # по умолчанию — филиал группы + assert entity.entity_type == EntityType.ЮЛ + assert len(entity.inn) == INN_LENGTH + assert entity.inn != other.inn + assert other.regional_office_id == head.id + assert other.company_group_id is None + assert gsk.code.startswith("GSK-T") + assert gsz.code.startswith("GSZ-T") + assert member.kind == RelatedGroupKind.gsk + assert member.is_current is True + assert member.valid_to is None + assert member_gsz.kind == RelatedGroupKind.gsz + + +@pytest.mark.integration +async def test_membership_not_current_is_closed(db_session: AsyncSession) -> None: + group = await make_company_group(db_session) + entity = await make_legal_entity(db_session, group=group) + related = await make_related_group(db_session, group=group) + closed = await make_membership(db_session, related_group=related, entity=entity, is_current=False) + again = await make_membership(db_session, related_group=related, entity=entity) # закрытая версия не мешает + assert closed.is_current is False + assert closed.valid_to is not None + assert again.is_current is True + + +@pytest.mark.integration +async def test_second_current_membership_in_same_kind_is_rejected(db_session: AsyncSession) -> None: + """Фабрики не обходят ограничения БД: ЮЛ в двух ГСК одновременно — нарушение partial UNIQUE (v5.14).""" + group = await make_company_group(db_session) + entity = await make_legal_entity(db_session, group=group) + first = await make_related_group(db_session, group=group) + second = await make_related_group(db_session, group=group, name="ГСК-2") + await make_membership(db_session, related_group=first, entity=entity) + with pytest.raises(IntegrityError): + async with db_session.begin_nested(): + await make_membership(db_session, related_group=second, entity=entity) + + +@pytest.mark.integration +async def test_make_user_assigns_seeded_roles(seeded: AsyncSession) -> None: + office = await make_regional_office(seeded) + user = await make_user(seeded, email="factory@armka.local", office=office, roles=["KA_RF", "EXPERT"]) + plain = await make_user(seeded, email="plain@armka.local", office=office, is_active=False) + count = len((await seeded.execute(select(UserRole).where(UserRole.user_id == user.id))).scalars().all()) + assert count == 2 # noqa: PLR2004 + assert user.regional_office_id == office.id + assert plain.is_active is False + assert not (await seeded.execute(select(UserRole).where(UserRole.user_id == plain.id))).scalars().all() + + +@pytest.mark.integration +async def test_auth_client_gets_me_for_dev_user(auth_client: AuthClientFactory) -> None: + client = await auth_client("ka_rf@armka.local") + response = await client.get("/me") + assert response.status_code == HTTP_OK, response.text + assert response.json()["email"] == "ka_rf@armka.local" + assert response.json()["roles"] == ["KA_RF"] + + +@pytest.mark.integration +async def test_auth_client_sees_data_created_by_factories( + db_session: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Пользователь, созданный фабрикой в той же сессии, получает токен и проходит `/me` (одна `db_session`).""" + office = await make_regional_office(db_session) + await make_user(db_session, email="made@armka.local", office=office, roles=["KA_RF"]) + client = await auth_client("made@armka.local") + response = await client.get("/me") + assert response.status_code == HTTP_OK, response.text + assert response.json()["regional_office"]["id"] == str(office.id) diff --git a/api/tests/test_import_full_export.py b/api/tests/test_import_full_export.py new file mode 100644 index 0000000..1cd3c44 --- /dev/null +++ b/api/tests/test_import_full_export.py @@ -0,0 +1,104 @@ +"""Полная выгрузка ЕЦП (8400 строк): `dry_run` укладывается в 60 с (spec directory-import, сценарий «Полная выгрузка»). + +Тест идёт на отдельной временной БД с зафиксированным seed (`committed_client`): `dry_run` делает явный +`session.rollback()`, поэтому «таблицы не выросли» осмысленно только вне SAVEPOINT-обёртки `db_session` (см. docstring +`tests/conftest.py`). Файл выгрузки — из `ARM_KA_DOCS` либо соседнего `docs`; нет файла — пропуск с причиной. +""" + +import os +import time +from collections import Counter +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.models.audit_ex import AuditEvent +from src.models.directories import ( + CompanyGroup, + CompanyGroupHistory, + LegalEntity, + LegalEntityHistory, + RegionalOffice, + RelatedGroup, + RelatedGroupMember, +) +from src.models.files import S3Object +from tests.conftest import AuthClientFactory, FakeStorage + +ADMIN = "admin@armka.local" +EXPORT_RELATIVE = Path("source/biq/ecp-export-lagal-entity.xlsx") +XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +HTTP_OK = 200 +TIME_LIMIT_SECONDS = 60 +EXPORT_ROWS = 8400 +EXPORT_OFFICES = 53 +EXPORT_GROUPS = 344 # R-15: из 345 ГК файла одна целиком состоит из строк-дублей и не создаётся +MIN_UNSUPPORTED_TYPE_ROWS = 500 +TOUCHED_TABLES = ( + RegionalOffice, + CompanyGroup, + CompanyGroupHistory, + LegalEntity, + LegalEntityHistory, + RelatedGroup, + RelatedGroupMember, + AuditEvent, + S3Object, +) + + +def _export_path() -> Path: + docs = os.environ.get("ARM_KA_DOCS") + return (Path(docs) if docs else Path(__file__).resolve().parents[3] / "docs") / EXPORT_RELATIVE + + +async def _counts(session: AsyncSession) -> dict[str, int]: + return { + model.__tablename__: (await session.execute(select(func.count()).select_from(model))).scalar_one() + for model in TOUCHED_TABLES + } + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_full_ecp_export_dry_run_within_time_limit( + committed_client: AuthClientFactory, + committed_sessionmaker: async_sessionmaker[AsyncSession], + committed_storage: FakeStorage, +) -> None: + """`dry_run` выгрузки из 8400 строк: ≤ 60 с, ожидаемые счётчики и коды, ни одна таблица справочника не выросла.""" + path = _export_path() + if not path.is_file(): + pytest.skip(f"нет выгрузки ЕЦП: {path} (задайте ARM_KA_DOCS или положите docs рядом с arm-core)") + content = path.read_bytes() + client = await committed_client(ADMIN) + async with committed_sessionmaker() as session: + before = await _counts(session) + + started = time.perf_counter() + response = await client.post( + "/directories/import", + files={"file": (path.name, content, XLSX_TYPE)}, + data={"dry_run": "true", "on_existing": "skip"}, + ) + elapsed = time.perf_counter() - started + + assert response.status_code == HTTP_OK, response.text + assert elapsed <= TIME_LIMIT_SECONDS, f"dry_run занял {elapsed:.1f} с (лимит {TIME_LIMIT_SECONDS} с)" + print(f"\nfull export dry_run: {elapsed:.2f} s") # noqa: T201 - замер времени виден при `pytest -s` + report: dict[str, Any] = response.json() + assert report["rows"] == EXPORT_ROWS + assert report["offices_created"] == EXPORT_OFFICES + assert report["groups_created"] == EXPORT_GROUPS + codes = Counter(line["code"] for line in report["lines"]) + assert codes["RF_CREATED"] == EXPORT_OFFICES + assert codes["UNSUPPORTED_TYPE"] >= MIN_UNSUPPORTED_TYPE_ROWS + assert codes["INVALID_INN"] > 0 + assert codes["DUPLICATE_IN_FILE"] > 0 + + async with committed_sessionmaker() as session: + assert await _counts(session) == before + assert committed_storage.objects == {} diff --git a/api/tests/test_me_and_dev_token.py b/api/tests/test_me_and_dev_token.py index b773e8e..22e051b 100644 --- a/api/tests/test_me_and_dev_token.py +++ b/api/tests/test_me_and_dev_token.py @@ -1,7 +1,8 @@ """`GET /api/v1/me` и `POST /api/v1/dev/token` (spec authn-authz, задача 18). -Мини-приложения собираются в тесте на `build_api_router(settings)` под разные сочетания `DEBUG` / `AUTH_DEV_ISSUER` -(без lifespan, сессия — `db_session` с откатом); отдельный тест проходит через полный `src.app.app`. +Основное приложение — общие фикстуры `app` / `auth_client` / `seeded` (`tests/conftest.py`, dev-эмитент включён). +Мини-приложения под другие сочетания `DEBUG` / `AUTH_DEV_ISSUER` собирает локальный `_build_app` (без lifespan, +сессия — `db_session` с откатом); отдельный тест проходит через полный `src.app.app`. """ import importlib @@ -16,7 +17,6 @@ from fastapi import FastAPI from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from scripts.seed import run_seed from src.api.dependencies.auth import current_user from src.api.router import build_api_router, router from src.core.auth import DevHs256Verifier, build_verifier @@ -25,6 +25,7 @@ from src.core.exception_handlers import register_exception_handlers from src.db.db import get_async_session from src.models.access import AppUser from src.schemas.me import MeResponse +from tests.conftest import AuthClientFactory HTTP_OK = 200 HTTP_UNAUTHORIZED = 401 @@ -87,18 +88,6 @@ def _build_app(session: AsyncSession, settings: Settings) -> FastAPI: return app -@pytest.fixture -async def seeded(db_session: AsyncSession) -> AsyncSession: - await run_seed(db_session, dev=True) - await db_session.flush() - return db_session - - -@pytest.fixture -async def dev_app(seeded: AsyncSession) -> FastAPI: - return _build_app(seeded, _settings(debug=True, dev_issuer=True)) - - async def _dev_token(client: httpx.AsyncClient, email: str) -> str: response = await client.post("/api/v1/dev/token", json={"email": email}) assert response.status_code == HTTP_OK, response.text @@ -109,9 +98,9 @@ async def _dev_token(client: httpx.AsyncClient, email: str) -> str: @pytest.mark.integration -async def test_me_for_ka_rf_matches_matrix(dev_app: FastAPI, seeded: AsyncSession) -> None: +async def test_me_for_ka_rf_matches_matrix(app: FastAPI, seeded: AsyncSession) -> None: """Сценарий «Ответ для dev-пользователя»: roles = ["KA_RF"], permissions = права KA_RF по §10, отсортированы.""" - async with _client(dev_app) as client: + async with _client(app) as client: token = await _dev_token(client, "ka_rf@armka.local") response = await client.get("/api/v1/me", headers=_bearer(token)) assert response.status_code == HTTP_OK, response.text @@ -135,10 +124,9 @@ async def test_me_for_ka_rf_matches_matrix(dev_app: FastAPI, seeded: AsyncSessio @pytest.mark.integration -async def test_me_for_admin(dev_app: FastAPI) -> None: - async with _client(dev_app) as client: - token = await _dev_token(client, "admin@armka.local") - response = await client.get("/api/v1/me", headers=_bearer(token)) +async def test_me_for_admin(auth_client: AuthClientFactory) -> None: + client = await auth_client("admin@armka.local") + response = await client.get("/me") assert response.status_code == HTTP_OK body = response.json() assert body["roles"] == ["ADMIN"] @@ -146,50 +134,52 @@ async def test_me_for_admin(dev_app: FastAPI) -> None: @pytest.mark.integration -async def test_me_head_office_flag(dev_app: FastAPI) -> None: +async def test_me_head_office_flag(auth_client: AuthClientFactory) -> None: """У пользователя головного офиса `is_head_office` = true, у филиала — false (по данным seed).""" - async with _client(dev_app) as client: - flags = {} - for email in ("ka_go@armka.local", "ka_rf@armka.local"): - token = await _dev_token(client, email) - body = (await client.get("/api/v1/me", headers=_bearer(token))).json() - flags[email] = body["regional_office"]["is_head_office"] + flags = {} + for email in ("ka_go@armka.local", "ka_rf@armka.local"): + client = await auth_client(email) + body = (await client.get("/me")).json() + flags[email] = body["regional_office"]["is_head_office"] assert flags["ka_go@armka.local"] is True assert flags["ka_rf@armka.local"] is False @pytest.mark.integration -async def test_me_accepts_token_in_any_email_case(dev_app: FastAPI) -> None: +@pytest.mark.usefixtures("seeded") +async def test_me_accepts_token_in_any_email_case(app: FastAPI) -> None: verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET) - async with _client(dev_app) as client: + async with _client(app) as client: response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("KA_RF@Armka.Local"))) assert response.status_code == HTTP_OK assert response.json()["email"] == "ka_rf@armka.local" @pytest.mark.integration -async def test_me_without_token_is_401(dev_app: FastAPI) -> None: - async with _client(dev_app) as client: +@pytest.mark.usefixtures("seeded") +async def test_me_without_token_is_401(app: FastAPI) -> None: + async with _client(app) as client: response = await client.get("/api/v1/me") assert response.status_code == HTTP_UNAUTHORIZED assert response.headers["www-authenticate"] == "Bearer" @pytest.mark.integration -async def test_me_unknown_user_is_403(dev_app: FastAPI) -> None: +@pytest.mark.usefixtures("seeded") +async def test_me_unknown_user_is_403(app: FastAPI) -> None: verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET) - async with _client(dev_app) as client: + async with _client(app) as client: response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("nobody@armka.local"))) assert response.status_code == HTTP_FORBIDDEN @pytest.mark.integration -async def test_me_user_without_roles_has_empty_lists(dev_app: FastAPI, seeded: AsyncSession) -> None: +async def test_me_user_without_roles_has_empty_lists(app: FastAPI, seeded: AsyncSession) -> None: office_id = (await seeded.execute(select(AppUser.regional_office_id).limit(1))).scalar_one() seeded.add(AppUser(email="norole@armka.local", full_name="Без Ролей", regional_office_id=office_id)) await seeded.flush() verifier = DevHs256Verifier(get_settings().AUTH_DEV_SECRET) - async with _client(dev_app) as client: + async with _client(app) as client: response = await client.get("/api/v1/me", headers=_bearer(verifier.issue_token("norole@armka.local"))) assert response.status_code == HTTP_OK assert response.json()["roles"] == [] @@ -208,8 +198,9 @@ def test_me_route_has_no_permission_requirement() -> None: @pytest.mark.integration -async def test_dev_token_for_admin(dev_app: FastAPI) -> None: - async with _client(dev_app) as client: +@pytest.mark.usefixtures("seeded") +async def test_dev_token_for_admin(app: FastAPI) -> None: + async with _client(app) as client: response = await client.post("/api/v1/dev/token", json={"email": "admin@armka.local"}) assert response.status_code == HTTP_OK body = response.json() @@ -221,8 +212,9 @@ async def test_dev_token_for_admin(dev_app: FastAPI) -> None: @pytest.mark.integration -async def test_dev_token_is_hs256_for_8_hours(dev_app: FastAPI) -> None: - async with _client(dev_app) as client: +@pytest.mark.usefixtures("seeded") +async def test_dev_token_is_hs256_for_8_hours(app: FastAPI) -> None: + async with _client(app) as client: token = await _dev_token(client, "ka_rf@armka.local") header = jwt.get_unverified_header(token) payload = jwt.decode(token, options={"verify_signature": False}) @@ -232,39 +224,41 @@ async def test_dev_token_is_hs256_for_8_hours(dev_app: FastAPI) -> None: @pytest.mark.integration -async def test_dev_token_email_case_insensitive(dev_app: FastAPI) -> None: - async with _client(dev_app) as client: +@pytest.mark.usefixtures("seeded") +async def test_dev_token_email_case_insensitive(app: FastAPI) -> None: + async with _client(app) as client: token = await _dev_token(client, " KA_RF@Armka.Local ") me = await client.get("/api/v1/me", headers=_bearer(token)) assert me.status_code == HTTP_OK @pytest.mark.integration -async def test_dev_token_unknown_user_is_404(dev_app: FastAPI) -> None: - async with _client(dev_app) as client: +@pytest.mark.usefixtures("seeded") +async def test_dev_token_unknown_user_is_404(app: FastAPI) -> None: + async with _client(app) as client: response = await client.post("/api/v1/dev/token", json={"email": "nobody@armka.local"}) assert response.status_code == HTTP_NOT_FOUND assert response.json() == USER_NOT_FOUND @pytest.mark.integration -async def test_dev_token_inactive_user_is_404(dev_app: FastAPI, seeded: AsyncSession) -> None: +async def test_dev_token_inactive_user_is_404(app: FastAPI, seeded: AsyncSession) -> None: user = (await seeded.execute(select(AppUser).where(AppUser.email == "expert@armka.local"))).scalar_one() user.is_active = False await seeded.flush() - async with _client(dev_app) as client: + async with _client(app) as client: response = await client.post("/api/v1/dev/token", json={"email": "expert@armka.local"}) assert response.status_code == HTTP_NOT_FOUND assert response.json() == USER_NOT_FOUND @pytest.mark.integration -async def test_dev_token_ambiguous_email_is_error(dev_app: FastAPI, seeded: AsyncSession) -> None: +async def test_dev_token_ambiguous_email_is_error(app: FastAPI, seeded: AsyncSession) -> None: """Два пользователя с email, различающимся регистром, — отказ (fail-closed), токен не выпускается.""" office_id = (await seeded.execute(select(AppUser.regional_office_id).limit(1))).scalar_one() seeded.add(AppUser(email="KA_RF@armka.local", full_name="Двойник", regional_office_id=office_id)) await seeded.flush() - async with _client(dev_app) as client: + async with _client(app) as client: response = await client.post("/api/v1/dev/token", json={"email": "ka_rf@armka.local"}) assert response.status_code == 409 # noqa: PLR2004 assert response.json() == {"detail": "неоднозначная учётная запись"} @@ -274,15 +268,17 @@ async def test_dev_token_ambiguous_email_is_error(dev_app: FastAPI, seeded: Asyn @pytest.mark.parametrize( "body", [{}, {"email": ""}, {"email": " "}, {"email": 5}, {"email": None}, {"mail": "a@b.c"}] ) -async def test_dev_token_invalid_body_is_422(dev_app: FastAPI, body: dict[str, Any]) -> None: - async with _client(dev_app) as client: +@pytest.mark.usefixtures("seeded") +async def test_dev_token_invalid_body_is_422(app: FastAPI, body: dict[str, Any]) -> None: + async with _client(app) as client: response = await client.post("/api/v1/dev/token", json=body) assert response.status_code == HTTP_UNPROCESSABLE @pytest.mark.integration -async def test_dev_token_not_json_is_422(dev_app: FastAPI) -> None: - async with _client(dev_app) as client: +@pytest.mark.usefixtures("seeded") +async def test_dev_token_not_json_is_422(app: FastAPI) -> None: + async with _client(app) as client: response = await client.post( "/api/v1/dev/token", content=b"not-json", headers={"content-type": "application/json"} ) diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 18b1692..dc3f0ee 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -1,4 +1,5 @@ -"""Миграция 0001 (DBML v5.13) на PostgreSQL 16: полный цикл, пустой autogenerate, схема = схеме ORM. +"""Миграции 0001 (DBML v5.13) и 0002 (v5.14) на PostgreSQL 16: полный цикл, откат на шаг, пустой autogenerate, +схема = схеме ORM. Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из @@ -6,6 +7,7 @@ CLI: `env.py` вызывает `asyncio.run(...)`, а внутри event loop py перенастраивает логирование тестов через `fileConfig`. Каждая проверка идёт на своей временной БД (`scratch_db_url`). """ +import datetime import uuid from typing import Any @@ -28,7 +30,8 @@ from tests.test_models_part_07_09 import ( _check_active_lock_is_unique, ) -HEAD = "0001_dbml_v5_13" +HEAD = "0002_dbml_v5_14" +PREVIOUS = "0001_dbml_v5_13" VERSION_TABLE = "alembic_version" FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job" @@ -150,6 +153,109 @@ async def test_full_cycle_upgrade_downgrade_upgrade(scratch_db_url: str) -> None await engine.dispose() +@pytest.mark.integration +async def test_downgrade_one_step(scratch_db_url: str) -> None: + """Сценарий «Откат на один шаг»: head → 0001 возвращает схему v5.13 (без kind, старый индекс и audit_action).""" + engine = create_async_engine(scratch_db_url) + try: + await alembic(scratch_db_url, "upgrade", "head") + await alembic(scratch_db_url, "downgrade", PREVIOUS) + async with engine.connect() as conn: + assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [PREVIOUS] + kind_columns = await _scalars( + conn, + "SELECT column_name FROM information_schema.columns WHERE table_schema = 'dkb' " + "AND table_name = 'related_group_member' AND column_name = 'kind'", + ) + assert kind_columns == [] + indexes = set( + await _scalars( + conn, + "SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' AND tablename = 'related_group_member'", + ) + ) + assert "uq_related_group_member_entity_current" in indexes + assert "uq_related_group_member_entity_kind_current" not in indexes + [labels] = await _scalars(conn, "SELECT enum_range(NULL::dkb.audit_action)::text[]") + assert "LEGAL_ENTITY_CHANGE" not in labels + assert "USER_CHANGE" not in labels + sequences = await _scalars( + conn, "SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'dkb'" + ) + assert sequences == [] + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + await _assert_full_model(conn) + [labels] = await _scalars(conn, "SELECT enum_range(NULL::dkb.audit_action)::text[]") + assert labels == [m.value for m in ENUMS["audit_action"]] + sequences = set( + await _scalars( + conn, "SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'dkb'" + ) + ) + assert sequences == {"company_group_code_seq", "related_group_code_seq"} + finally: + await engine.dispose() + + +@pytest.mark.integration +async def test_upgrade_0002_keeps_membership_and_fills_kind(scratch_db_url: str) -> None: + """0002 пересоздаёт `related_group_member`: строки v5.13 сохраняются, `kind` = `related_group.kind` группы.""" + engine = create_async_engine(scratch_db_url) + now = datetime.datetime.now(datetime.UTC) + ids = {k: uuid.uuid4() for k in ("ro", "cg", "le", "gsk", "gsz", "m1", "m2")} + try: + await alembic(scratch_db_url, "upgrade", PREVIOUS) + async with engine.begin() as conn: + await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:ro, 'ГО')"), ids) + await conn.execute( + text("INSERT INTO dkb.company_group (id, code, name, valid_from) VALUES (:cg, 'G1', 'ГК', :now)"), + {**ids, "now": now}, + ) + await conn.execute( + text("INSERT INTO dkb.legal_entity (id, inn, name, valid_from) VALUES (:le, '1', 'ЮЛ', :now)"), + {**ids, "now": now}, + ) + for kind in ("gsk", "gsz"): + await conn.execute( + text( + "INSERT INTO dkb.related_group (id, code, company_group_id, kind, name) " + "VALUES (:g, :code, :cg, :kind, 'Группа')" + ), + {"g": ids[kind], "code": kind, "cg": ids["cg"], "kind": kind}, + ) + # v5.13: одно текущее членство (в ГСЗ) и закрытое (в ГСК) + member = text( + "INSERT INTO dkb.related_group_member (id, related_group_id, legal_entity_id, valid_from, valid_to, " + "is_current) VALUES (:id, :g, :le, :now, :to, :cur)" + ) + await conn.execute( + member, {"id": ids["m1"], "g": ids["gsz"], "le": ids["le"], "now": now, "to": None, "cur": True} + ) + await conn.execute( + member, {"id": ids["m2"], "g": ids["gsk"], "le": ids["le"], "now": now, "to": now, "cur": False} + ) + + await alembic(scratch_db_url, "upgrade", "head") + async with engine.connect() as conn: + rows = ( + await conn.execute( + text("SELECT id, related_group_id, kind::text, valid_to, is_current FROM dkb.related_group_member") + ) + ).all() + assert {tuple(r) for r in rows} == { + (ids["m1"], ids["gsz"], "gsz", None, True), + (ids["m2"], ids["gsk"], "gsk", now, False), + } + leftovers = await _scalars( + conn, "SELECT relname FROM pg_class WHERE relnamespace = 'dkb'::regnamespace AND relname LIKE '%v5_13'" + ) + assert leftovers == [] # временная копия удалена + finally: + await engine.dispose() + + @pytest.mark.integration async def test_autogenerate_finds_nothing_after_upgrade(scratch_db_url: str) -> None: """Сценарий «Автогенерация ничего не находит»: compare_metadata пуст, `alembic check` не видит операций.""" @@ -228,13 +334,13 @@ async def test_migrated_schema_behaviour(scratch_db_url: str) -> None: text( "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'dkb' AND indexname IN " "('uq_document_file_type_account', 'uq_consolidation_lock_version_active', " - "'uq_related_group_member_entity_current', 'uq_related_group_member_group_entity_current')" + "'uq_related_group_member_entity_kind_current', 'uq_related_group_member_group_entity_current')" ) ) indexdefs = {row[0]: row[1] for row in rows} assert "NULLS NOT DISTINCT" in indexdefs["uq_document_file_type_account"] assert "WHERE (released_at IS NULL)" in indexdefs["uq_consolidation_lock_version_active"] - for name in ("uq_related_group_member_entity_current", "uq_related_group_member_group_entity_current"): + for name in ("uq_related_group_member_entity_kind_current", "uq_related_group_member_group_entity_current"): assert indexdefs[name].startswith("CREATE UNIQUE INDEX") assert "WHERE is_current" in indexdefs[name] @@ -307,7 +413,12 @@ def test_offline_sql_creates_schema_before_version_table() -> None: assert "CREATE SCHEMA IF NOT EXISTS" not in output # требует права CREATE на базу даже при существующей схеме assert schema_at < output.index("CREATE TABLE dkb.alembic_version") assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") - assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{HEAD}')" in output + assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output + # с двумя ревизиями версия head пишется UPDATE'ом после 0001 — офлайн-SQL доходит до head + assert ( + f"UPDATE dkb.alembic_version SET version_num='{HEAD}' WHERE dkb.alembic_version.version_num = '{PREVIOUS}'" + in output + ) def test_cluster_commands_use_service_database() -> None: diff --git a/api/tests/test_models_part_01_03.py b/api/tests/test_models_part_01_03.py index 040e897..8100ec0 100644 --- a/api/tests/test_models_part_01_03.py +++ b/api/tests/test_models_part_01_03.py @@ -1,4 +1,4 @@ -"""ORM частей 01–03 DBML v5.13 (справочники, доступ, учёт): чётность с DBML без БД и create_all на PostgreSQL.""" +"""ORM частей 01–03 DBML v5.14 (справочники, доступ, учёт): чётность с DBML без БД и create_all на PostgreSQL.""" import datetime import uuid @@ -109,7 +109,7 @@ async def test_create_all_on_scratch_db(scratch_db_url: str) -> None: ) ) } - for name in ("uq_related_group_member_group_entity_current", "uq_related_group_member_entity_current"): + for name in ("uq_related_group_member_group_entity_current", "uq_related_group_member_entity_kind_current"): assert "UNIQUE" in indexdefs[name] assert "WHERE is_current" in indexdefs[name] pk = await conn.run_sync(lambda c: inspect(c).get_pk_constraint("role_permission", schema="dkb")) @@ -122,9 +122,9 @@ async def test_create_all_on_scratch_db(scratch_db_url: str) -> None: async def _check_related_group_member_uniqueness(engine: AsyncEngine) -> None: - """ЮЛ — только в одной ГСК/ГСЗ среди текущих; закрытая версия не мешает (ТЗ 6.1, ANL-030).""" + """ЮЛ — в одной ГСК и одной ГСЗ одновременно (v5.14, решение владельца 04.10.2026); закрытая версия не мешает.""" now = datetime.datetime.now(datetime.UTC) - ids = {k: uuid.uuid4() for k in ("ro", "cg", "le", "g1", "g2", "m1", "m2", "m3")} + ids = {k: uuid.uuid4() for k in ("ro", "cg", "le", "g1", "g2", "g3", "m1", "m2", "m3", "m4")} async with engine.begin() as conn: await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:ro, 'ГО')"), ids) await conn.execute( @@ -135,22 +135,28 @@ async def _check_related_group_member_uniqueness(engine: AsyncEngine) -> None: text("INSERT INTO dkb.legal_entity (id, inn, name, valid_from) VALUES (:le, '1', 'ЮЛ', :now)"), {**ids, "now": now}, ) - for g, code in (("g1", "GSK-1"), ("g2", "GSK-2")): + for g, code, kind in (("g1", "GSK-1", "gsk"), ("g2", "GSK-2", "gsk"), ("g3", "GSZ-1", "gsz")): await conn.execute( text( "INSERT INTO dkb.related_group (id, code, company_group_id, kind, name) " - "VALUES (:g, :code, :cg, 'gsk', 'ГСК')" + "VALUES (:g, :code, :cg, :kind, 'ГСК/ГСЗ')" ), - {"g": ids[g], "code": code, "cg": ids["cg"]}, + {"g": ids[g], "code": code, "cg": ids["cg"], "kind": kind}, ) insert = text( - "INSERT INTO dkb.related_group_member (id, related_group_id, legal_entity_id, valid_from, is_current) " - "VALUES (:id, :g, :le, :now, :cur)" + "INSERT INTO dkb.related_group_member (id, related_group_id, legal_entity_id, kind, valid_from, is_current) " + "VALUES (:id, :g, :le, :kind, :now, :cur)" ) + le, gsk, gsz = ids["le"], "gsk", "gsz" async with engine.begin() as conn: - await conn.execute(insert, {"id": ids["m1"], "g": ids["g1"], "le": ids["le"], "now": now, "cur": True}) - # закрытая версия в другой группе — допустима - await conn.execute(insert, {"id": ids["m2"], "g": ids["g2"], "le": ids["le"], "now": now, "cur": False}) + # текущее членство в ГСК g1 + await conn.execute(insert, {"id": ids["m1"], "g": ids["g1"], "le": le, "kind": gsk, "now": now, "cur": True}) + # текущее членство того же ЮЛ в ГСЗ g3 — допустимо с v5.14 + await conn.execute(insert, {"id": ids["m2"], "g": ids["g3"], "le": le, "kind": gsz, "now": now, "cur": True}) + # закрытая версия в другой ГСК — допустима + await conn.execute(insert, {"id": ids["m3"], "g": ids["g2"], "le": le, "kind": gsk, "now": now, "cur": False}) with pytest.raises(IntegrityError): - async with engine.begin() as conn: # второе текущее членство того же ЮЛ — нарушение - await conn.execute(insert, {"id": ids["m3"], "g": ids["g2"], "le": ids["le"], "now": now, "cur": True}) + async with engine.begin() as conn: # второе текущее членство того же ЮЛ в ГСК — нарушение + await conn.execute( + insert, {"id": ids["m4"], "g": ids["g2"], "le": le, "kind": gsk, "now": now, "cur": True} + ) diff --git a/api/tests/test_models_part_04_06.py b/api/tests/test_models_part_04_06.py index 9126293..4bd8e9a 100644 --- a/api/tests/test_models_part_04_06.py +++ b/api/tests/test_models_part_04_06.py @@ -1,4 +1,4 @@ -"""ORM частей 04–06 DBML v5.13 (файлы, ОСВ, отчётность): чётность с DBML без БД и create_all на PostgreSQL.""" +"""ORM частей 04–06 DBML v5.14 (файлы, ОСВ, отчётность): чётность с DBML без БД и create_all на PostgreSQL.""" import datetime import decimal diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index c6f75de..49340e3 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -1,4 +1,4 @@ -"""ORM частей 07–09 DBML v5.13 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц.""" +"""ORM частей 07–09 DBML v5.14 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц.""" import datetime @@ -112,7 +112,7 @@ def test_ex_snapshot_has_no_fk_to_consolidation() -> None: def test_all_43_tables_registered_and_match_dbml_names() -> None: - """Состав metadata == таблицы DBML v5.13 (имена буквально), всего 43, все в схеме dkb.""" + """Состав metadata == таблицы DBML v5.14 (имена буквально), всего 43, все в схеме dkb.""" tables = list(Base.metadata.sorted_tables) assert len(tables) == EXPECTED_TABLE_COUNT assert all(t.schema == "dkb" for t in tables) diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py new file mode 100644 index 0000000..56c350f --- /dev/null +++ b/api/tests/test_openapi_routes.py @@ -0,0 +1,83 @@ +"""OpenAPI содержит все маршруты Среза 1 (specs directories-api, directory-import, users-admin) без дублей моделей. + +Схема строится из объявленных маршрутов без БД и сети. Состав маршрутов — по спекам change `slice-1-directories`; +проверка нужна, чтобы маршрут не пропал при переносе роутеров и чтобы `RegionalOfficeOut` (R-13) был одной моделью. +""" + +import importlib +from typing import Any + +import pytest + +app_module = importlib.import_module("src.app") + +D = "/api/v1/directories" +A = "/api/v1/admin" + +# путь → методы (нижний регистр, как в OpenAPI) +EXPECTED_ROUTES: dict[str, set[str]] = { + "/api/v1/me": {"get"}, + # directories-api: филиалы, ГК, ГСК/ГСЗ, ЮЛ/ИП + f"{D}/regional-offices": {"get"}, + f"{D}/company-groups": {"get", "post"}, + f"{D}/company-groups/{{group_id}}": {"get", "patch"}, + f"{D}/company-groups/{{group_id}}/archive": {"post"}, + f"{D}/company-groups/{{group_id}}/unarchive": {"post"}, + f"{D}/company-groups/{{group_id}}/history": {"get"}, + f"{D}/company-groups/{{company_group_id}}/related-groups": {"get", "post"}, + f"{D}/related-groups/{{group_id}}": {"get", "patch", "delete"}, + f"{D}/related-groups/{{group_id}}/members": {"post"}, + f"{D}/related-groups/{{group_id}}/members/{{legal_entity_id}}": {"delete"}, + f"{D}/legal-entities": {"get", "post"}, + f"{D}/legal-entities/{{entity_id}}": {"get", "patch"}, + f"{D}/legal-entities/{{entity_id}}/move": {"post"}, + f"{D}/legal-entities/{{entity_id}}/withdraw": {"post"}, + f"{D}/legal-entities/{{entity_id}}/history": {"get"}, + f"{D}/legal-entities/{{entity_id}}/aliases": {"post"}, + f"{D}/legal-entities/{{entity_id}}/aliases/{{alias_id}}": {"delete"}, + # directory-import + f"{D}/import": {"post"}, + # users-admin + f"{A}/users": {"get", "post"}, + f"{A}/users/{{user_id}}": {"patch"}, + f"{A}/users/{{user_id}}/roles": {"put"}, + f"{A}/roles": {"get"}, + f"{A}/permissions": {"get"}, + f"{A}/roles/{{code}}/permissions": {"put"}, +} + +# Модели, которые обязаны быть в схеме ровно один раз под коротким именем +SINGLE_MODELS = ["RegionalOfficeOut", "CompanyGroupOut", "LegalEntityOut", "UserOut", "RoleOut", "ImportReportOut"] + + +@pytest.fixture(scope="module") +def schema() -> dict[str, Any]: + spec: dict[str, Any] = app_module.app.openapi() + return spec + + +@pytest.mark.parametrize("path", sorted(EXPECTED_ROUTES)) +def test_spec_route_is_in_openapi(schema: dict[str, Any], path: str) -> None: + assert path in schema["paths"], f"в OpenAPI нет маршрута {path}" + assert set(schema["paths"][path]) >= EXPECTED_ROUTES[path], (path, sorted(schema["paths"][path])) + + +def test_no_unexpected_routes_under_directories_and_admin(schema: dict[str, Any]) -> None: + """Маршрутов сверх спек нет: новые маршруты сначала попадают в спеку и в этот список.""" + actual = {path for path in schema["paths"] if path.startswith((D, A))} + expected = {path for path in EXPECTED_ROUTES if path.startswith((D, A))} + assert actual == expected + for path in actual: + assert set(schema["paths"][path]) == EXPECTED_ROUTES[path], path + + +def test_components_have_no_duplicated_models(schema: dict[str, Any]) -> None: + """Одна модель — одно имя: нет автоимён вида `src__schemas__x__RegionalOfficeOut` и суффиксов `-Input`/`-Output`. + + Имена — ключи словаря, поэтому дубликатов быть не может; реальный страж — проверка на `__`/`-` (FastAPI даёт такие + имена, когда две разные модели совпали по имени или одна модель разошлась на вход/выход). + """ + names = list(schema["components"]["schemas"]) + for model in SINGLE_MODELS: + assert model in names, model + assert not [name for name in names if "__" in name or "-" in name], names diff --git a/api/tests/test_uow_and_errors.py b/api/tests/test_uow_and_errors.py new file mode 100644 index 0000000..9d95f08 --- /dev/null +++ b/api/tests/test_uow_and_errors.py @@ -0,0 +1,385 @@ +"""Инфраструктура запросов Среза 1 (tasks 1.2): транзакция на запрос, доменные ошибки, `RequestAudit`, пагинация. + +Unit-часть — без БД: формат ответов доменных ошибок (`{"detail": {"code", "message", …}}`), прежний формат +`HTTPException`, схемы `Page`/`PageParams`, делегирование `RequestAudit.record` и сборка `get_request_audit`. +Integration-часть — единственное место, где проверяется НАСТОЯЩИЙ commit (SAVEPOINT-сессия `db_session` его не +ловит): своя временная БД, `src.db.db.async_session_maker` подменён на фабрику сессий этой БД, видимость строк — +из другой сессии (другого соединения). +""" + +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass +from http import HTTPStatus +from types import SimpleNamespace +from typing import Annotated, Any, cast + +import httpx +import pytest +import pytest_asyncio +from fastapi import Depends, FastAPI, HTTPException, Query +from fastapi import status as http_status +from fastapi.routing import APIRoute +from pydantic import ValidationError +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine +from starlette.types import Message, Receive, Scope, Send + +from src.api.dependencies.audit import RequestAudit, get_request_audit +from src.api.dependencies.auth import current_user +from src.api.dependencies.db import SessionDep +from src.api.router import build_api_router +from src.common.exceptions import ConflictError, DomainError, DomainValidationError, NotFoundError +from src.core.config import get_settings +from src.core.exception_handlers import register_exception_handlers +from src.db.db import get_async_session +from src.models.directories import RegionalOffice +from src.models.enums import AuditAction, AuditSection +from src.schemas.common import ErrorDetail, Page, PageParams +from src.schemas.directories import RegionalOfficeBrief +from src.services.audit import AuditService +from tests.pg_helpers import alembic_async + +CONFLICT_ID = uuid.UUID("00000000-0000-0000-0000-00000000c0de") + + +def _client(app: FastAPI) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") + + +# --- Доменные ошибки --------------------------------------------------------------------------------------------- + + +def _errors_app() -> FastAPI: + app = FastAPI() + register_exception_handlers(app) + + @app.get("/not-found") + async def not_found() -> None: + raise NotFoundError("ГК не найдена") + + @app.get("/not-found-default") + async def not_found_default() -> None: + raise NotFoundError() + + @app.get("/conflict") + async def conflict() -> None: + raise ConflictError("INN_EXISTS", "ИНН уже есть", legal_entity_id=CONFLICT_ID) + + @app.get("/invalid") + async def invalid() -> None: + raise DomainValidationError("INVALID_INN", "ИНН должен содержать 10 или 12 цифр") + + @app.get("/forbidden") + async def forbidden() -> None: + raise HTTPException(HTTPStatus.FORBIDDEN, "x") + + @app.get("/page") + async def page(params: Annotated[PageParams, Query()]) -> dict[str, int]: + return {"limit": params.limit, "offset": params.offset} + + return app + + +async def _get(app: FastAPI, path: str) -> httpx.Response: + async with _client(app) as client: + return await client.get(path) + + +@pytest.mark.parametrize( + ("path", "status", "detail"), + [ + ("/not-found", HTTPStatus.NOT_FOUND, {"code": "NOT_FOUND", "message": "ГК не найдена"}), + ("/not-found-default", HTTPStatus.NOT_FOUND, {"code": "NOT_FOUND", "message": "объект не найден"}), + ( + "/conflict", + HTTPStatus.CONFLICT, + {"code": "INN_EXISTS", "message": "ИНН уже есть", "legal_entity_id": str(CONFLICT_ID)}, + ), + ( + "/invalid", + http_status.HTTP_422_UNPROCESSABLE_CONTENT, + {"code": "INVALID_INN", "message": "ИНН должен содержать 10 или 12 цифр"}, + ), + ], +) +async def test_domain_errors_format(path: str, status: int, detail: dict[str, Any]) -> None: + response = await _get(_errors_app(), path) + assert response.status_code == status + assert response.json() == {"detail": detail} + + +async def test_http_exception_keeps_string_detail() -> None: + response = await _get(_errors_app(), "/forbidden") + assert response.status_code == HTTPStatus.FORBIDDEN + assert response.json() == {"detail": "x"} + + +def test_domain_error_fields() -> None: + error = ConflictError("INN_EXISTS", "ИНН уже есть", legal_entity_id=CONFLICT_ID) + assert isinstance(error, DomainError) + assert (error.status, error.code, error.message, error.extra) == ( + HTTPStatus.CONFLICT, + "INN_EXISTS", + "ИНН уже есть", + {"legal_entity_id": CONFLICT_ID}, + ) + assert NotFoundError().extra == {} + invalid = DomainValidationError("INVALID_INN", "плохой ИНН", inn="1") + assert (invalid.status, invalid.extra) == (http_status.HTTP_422_UNPROCESSABLE_CONTENT, {"inn": "1"}) + assert str(invalid) == "плохой ИНН" + + +def test_domain_error_extra_cannot_shadow_code_or_message() -> None: + with pytest.raises(ValueError, match="code"): + DomainError(HTTPStatus.CONFLICT, "A", "b", {"code": "B"}) + with pytest.raises(ValueError, match="message"): + DomainError(HTTPStatus.CONFLICT, "A", "b", {"message": "c"}) + + +# --- Пагинация --------------------------------------------------------------------------------------------------- + + +def test_page_serializes() -> None: + office = RegionalOfficeBrief(id=CONFLICT_ID, name="ГО", is_head_office=True) + page = Page[RegionalOfficeBrief](items=[office], total=7, limit=1, offset=3) + assert page.model_dump(mode="json") == { + "items": [{"id": str(CONFLICT_ID), "name": "ГО", "is_head_office": True}], + "total": 7, + "limit": 1, + "offset": 3, + } + + +def test_page_params_bounds() -> None: + assert PageParams().model_dump() == {"limit": 50, "offset": 0} + assert PageParams(limit=200).limit == 200 # noqa: PLR2004 - граница из спеки + for bad in ({"limit": 201}, {"limit": 0}, {"offset": -1}): + with pytest.raises(ValidationError): + PageParams(**bad) + + +async def test_page_params_as_query() -> None: + app = _errors_app() + ok = await _get(app, "/page?limit=10&offset=5") + assert ok.json() == {"limit": 10, "offset": 5} + assert (await _get(app, "/page")).json() == {"limit": 50, "offset": 0} + too_big = await _get(app, "/page?limit=201") + assert too_big.status_code == http_status.HTTP_422_UNPROCESSABLE_CONTENT + + +def test_error_detail_schema() -> None: + assert ErrorDetail(code="NOT_FOUND", message="объект не найден").model_dump() == { + "code": "NOT_FOUND", + "message": "объект не найден", + } + + +# --- RequestAudit ------------------------------------------------------------------------------------------------ + + +@dataclass +class _RecordingService: + calls: list[dict[str, Any]] + + async def record(self, **kwargs: Any) -> str: + self.calls.append(kwargs) + return "event" + + +async def test_request_audit_delegates_with_request_context() -> None: + service = _RecordingService(calls=[]) + user_id = uuid.uuid4() + audit = RequestAudit( + service=cast(AuditService, service), user_id=user_id, ip_address="10.0.0.1", user_agent="pytest-agent" + ) + object_id = uuid.uuid4() + result = await audit.record( + action=AuditAction.GROUP_CHANGE, + section=AuditSection.DIRECTORIES, + object_type="company_group", + object_id=object_id, + before_value={"name": "А"}, + after_value={"name": "Б"}, + changed_field="name", + ) + assert result == "event" + assert service.calls == [ + { + "action": AuditAction.GROUP_CHANGE, + "section": AuditSection.DIRECTORIES, + "object_type": "company_group", + "object_id": object_id, + "description": None, + "before_value": {"name": "А"}, + "after_value": {"name": "Б"}, + "comment": None, + "result": None, + "changed_field": "name", + "user_id": user_id, + "ip_address": "10.0.0.1", + "user_agent": "pytest-agent", + } + ] + + +async def test_get_request_audit_takes_user_ip_and_user_agent_from_request() -> None: + user_id = uuid.uuid4() + sessions: list[object] = [] + app = FastAPI() + register_exception_handlers(app) + + @app.get("/audit") + async def audit_context(audit: Annotated[RequestAudit, Depends(get_request_audit)]) -> dict[str, Any]: + return { + "user_id": str(audit.user_id), + "ip": audit.ip_address, + "ua": audit.user_agent, + "service": type(audit.service).__name__, + } + + async def fake_session() -> AsyncIterator[object]: + session = object() + sessions.append(session) + yield session + + app.dependency_overrides[get_async_session] = fake_session + app.dependency_overrides[current_user] = lambda: SimpleNamespace(user=SimpleNamespace(id=user_id)) + async with _client(app) as client: + response = await client.get("/audit", headers={"User-Agent": "pytest-agent/1.0"}) + assert response.status_code == HTTPStatus.OK + assert response.json() == { + "user_id": str(user_id), + "ip": "127.0.0.1", + "ua": "pytest-agent/1.0", + "service": "AuditService", + } + assert len(sessions) == 1 + + +# --- Одна сессия на запрос: зависимость БД объявляется только через SessionDep --------------------------------- + + +def _session_scopes(dependant: Any) -> list[str | None]: + found: list[str | None] = [] + for sub in dependant.dependencies: + if sub.call is get_async_session: + found.append(sub.scope) + found.extend(_session_scopes(sub)) + return found + + +def test_api_routes_declare_session_with_function_scope() -> None: + """Все маршруты API берут сессию через `SessionDep` (`scope="function"`). + + `Depends(get_async_session)` без scope даёт ДРУГОЙ ключ кэша зависимостей FastAPI — вторую сессию и вторую + транзакцию в том же запросе, и commit после отправки ответа. Тест ловит такое объявление в любом маршруте. + """ + settings = get_settings().model_copy(update={"DEBUG": True, "AUTH_DEV_ISSUER": True, "AUTH_DEV_SECRET": "s" * 32}) + routes = [route for route in build_api_router(settings).routes if isinstance(route, APIRoute)] + scopes = [scope for route in routes for scope in _session_scopes(route.dependant)] + assert scopes, "ни один маршрут не использует сессию БД — тест ничего не проверяет" + assert set(scopes) == {"function"} + + +# --- Integration: настоящий commit/rollback ---------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def uow_maker( + scratch_db_url: str, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """Фабрика сессий на свежую мигрированную БД; `get_async_session` открывает сессии из неё.""" + await alembic_async(scratch_db_url, "upgrade", "head") + engine: AsyncEngine = create_async_engine(scratch_db_url, connect_args={"server_settings": {"search_path": "dkb"}}) + maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + monkeypatch.setattr("src.db.db.async_session_maker", maker) + try: + yield maker + finally: + await engine.dispose() + + +def _uow_app() -> FastAPI: + app = FastAPI() + register_exception_handlers(app) + + @app.post("/ok", status_code=HTTPStatus.CREATED) + async def ok(name: str, session: SessionDep) -> dict[str, str]: + session.add(RegionalOffice(name=name)) + await session.flush() + return {"name": name} + + @app.post("/fail") + async def fail(name: str, session: SessionDep) -> None: + session.add(RegionalOffice(name=name)) + await session.flush() + raise ConflictError("TEST_CONFLICT", "проверка отката") + + @app.post("/fail-http") + async def fail_http(name: str, session: SessionDep) -> None: + session.add(RegionalOffice(name=name)) + await session.flush() + raise HTTPException(HTTPStatus.FORBIDDEN, "нет доступа") + + @app.post("/no-flush", status_code=HTTPStatus.CREATED) + async def no_flush(name: str, session: SessionDep) -> dict[str, str]: + # без flush: INSERT уходит в БД только при commit — ошибка commit обязана стать ответом + session.add(RegionalOffice(name=name)) + return {"name": name} + + return app + + +async def _office_exists(maker: async_sessionmaker[AsyncSession], name: str) -> bool: + async with maker() as other: + found = await other.scalar(select(RegionalOffice.id).where(RegionalOffice.name == name)) + return found is not None + + +@pytest.mark.integration +async def test_success_commits(uow_maker: async_sessionmaker[AsyncSession]) -> None: + async with _client(_uow_app()) as client: + response = await client.post("/ok", params={"name": "Филиал UoW"}) + assert response.status_code == HTTPStatus.CREATED + assert await _office_exists(uow_maker, "Филиал UoW") + + +@pytest.mark.integration +@pytest.mark.parametrize(("path", "status"), [("/fail", HTTPStatus.CONFLICT), ("/fail-http", HTTPStatus.FORBIDDEN)]) +async def test_exception_rolls_back(uow_maker: async_sessionmaker[AsyncSession], path: str, status: int) -> None: + async with _client(_uow_app()) as client: + response = await client.post(path, params={"name": f"Филиал {path}"}) + assert response.status_code == status + assert not await _office_exists(uow_maker, f"Филиал {path}") + + +@pytest.mark.integration +async def test_commit_happens_before_response_is_sent(uow_maker: async_sessionmaker[AsyncSession]) -> None: + """В момент `http.response.start` строка уже видна другим соединениям: клиент не получит 201 раньше commit.""" + inner = _uow_app() + seen_at_start: list[bool] = [] + + async def app(scope: Scope, receive: Receive, send: Send) -> None: + async def watching_send(message: Message) -> None: + if message["type"] == "http.response.start": + seen_at_start.append(await _office_exists(uow_maker, "Филиал до ответа")) + await send(message) + + await inner(scope, receive, watching_send) + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client: + response = await client.post("/ok", params={"name": "Филиал до ответа"}) + assert response.status_code == HTTPStatus.CREATED + assert seen_at_start == [True] + + +@pytest.mark.integration +async def test_commit_failure_becomes_error_response(uow_maker: async_sessionmaker[AsyncSession]) -> None: + """Нарушение уникальности, всплывшее только при commit, — ответ 409, а не 201 с потерянными данными.""" + async with uow_maker() as session, session.begin(): + session.add(RegionalOffice(name="Филиал-дубль")) + async with _client(_uow_app()) as client: + response = await client.post("/no-flush", params={"name": "Филиал-дубль"}) + assert response.status_code == HTTPStatus.CONFLICT + assert response.json() == {"detail": "нарушение целостности данных в БД"} diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-ledger.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-ledger.md new file mode 100644 index 0000000..4428b90 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-ledger.md @@ -0,0 +1,297 @@ +# Ledger Среза 1 (справочники) — SDD фазы 1 и 2, финальное ревью + +Скопирован из `.superpowers/sdd/` перед удалением рабочих каталогов. Ветка feat/5-slice-1-directories, база Среза 44806c8, HEAD на момент копирования 7f891dd (+ этот коммит). +Маркеры: T. — находки ревью задач; C/I/M-n — финальное ревью; R-n — рулинги (design #n+11 для R-12..., см. design.md); O-n — наблюдения. + +--- +## Фаза 1 + +# SDD ledger — plan: docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md + +Spec: openspec/changes/slice-1-directories/ (reachable). Branch feat/5-slice-1-directories, worktree .wt/slice-1/arm-core. Base at start: 44806c8. +Нумерация: R-1 уже в design.md (#12). Рулинги контроллера с R-2; decisions в design.md с №13. +Модели: T1,T2,T4,T6,T8 + ревью — opus; T3,T5,T7 + ревью — sonnet. + +## Pre-flight: таблица конфликтов + +| Пара/задача | produces vs consumes | находка | +|---|---|---| +| T1 -> всё | `RelatedGroupMember.kind`, `*_CODE_SEQ`, `AuditAction.*` | T5/T8 импортируют `COMPANY_GROUP_CODE_SEQ`/`RELATED_GROUP_CODE_SEQ` из `src/models/directories.py` — совпадает | +| T2 -> T3,T5..T8 | `get_async_session` (commit), `DomainError`, `RequestAudit`, `Page/PageParams` | T3 override `get_async_session` через `begin_nested` — совместимо с R-1. tasks.md 1.2 говорит `get_uow_session` — расхождение уже снято R-1 (design #12). dev_auth/auth.current_user используют `get_async_session`, коммитов не делают (проверено grep) | +| T2 | существующий `common/exceptions.py` (`NotFoundError(Exception)`, `CouldntCreateError`) | `NotFoundError` перепишется в подкласс `DomainError`; `CouldntCreateError` нигде не используется (grep) — удалить безопасно | +| T3 -> T5..T8 | фабрики `make_*`, `auth_client(email)`, `app` | сигнатуры совпадают с использованием в T5/T6/T8 тестах (`office=`, `group=`, `related_group=`) | +| T4 -> T5,T6,T8 | `VisibilityScope`, `*_visible`, `office_for_create` | `legal_entity_is_visible`/`company_group_is_visible` в репозиториях не нужны (фильтр в SQL) — остаются как plan-mandated pure helpers; не дефект | +| T5 -> T6,T7,T8 | `repositories/directories.py`, `get_company_group(scope)`, `get_scope`, `next_code` | T6 и T8 добавляют в тот же файл/роутер: последовательно, конфликтов нет | +| T6 -> T7,T8 | `get_legal_entity(scope)`, `close_memberships` | T7 добавляет маршруты в `legal_entities.py` T6; T8 использует `get_legal_entity` | +| T5 vs spec | plan Task 5 тест 6: `q` по имени; spec directories-api: `q` по `name`/`crm_id`/`code` | R-2: спека главнее — `q` ищет по name/crm_id/code | +| T5 vs spec | plan не указывает порядок history; spec: по убыванию `valid_from` | R-3: history ГК (и ЮЛ) — по убыванию `valid_from` | +| T6 vs spec | spec `q` ЮЛ: подстрока имени/ИНН; plan: `q` без уточнения | R-4: `q` ищет по name и inn | +| T7 vs spec | plan `LEGAL_FORMS` шире spec (ООО/АО/ПАО/ЗАО/ИП) | §3 «ОПФ (ООО, АО, ПАО, ИП…)» — многоточие, расширенный список допустим; не конфликт | +| T8 vs spec | plan `delete` удаляет закрытые строки состава; spec молчит | допустимо (OQ-34), FK иначе блокирует; без рулинга | +| внутренняя согласованность T1..T8 | тесты vs код vs файлы | T1: файлы перечислены полностью; тест уникальности ГСК+ГСЗ согласован с индексом. T2: интеграционный тест через scratch_db_url согласован с кодом. T3–T8: тесты соответствуют интерфейсам. Противоречий нет | +| Global Constraints vs rubric | verbatim-дубли/пустые тесты | не обнаружено | + +Rulings (pre-flight): +- R-2: `q` в списке ГК ищет по name/crm_id/code (спека) — план говорит только об имени — цена: нет, надмножество. (design #13) +- R-3: история ГК/ЮЛ отдаётся по убыванию `valid_from` (спека) — цена: нет. (design #14) +- R-4: `q` ЮЛ ищет по name и inn (спека) — цена: нет. (design #15) + +Task 1: dispatched (opus, implementer ab4cdc613d6744c3f), BASE=13d11c0 +Task 1: implementer DONE_WITH_CONCERNS (commit 458392e). Ruling: R-5 (design #16) — миграция 0002 пересоздаёт related_group_member (copy→drop→create в порядке DBML→insert), а не ADD COLUMN: тест Среза 0 сверяет порядок колонок с create_all; цена ошибки — вернуть ADD COLUMN и ослабить сравнение. Также поправлены test_dbml_parity и test_offline_sql (offline UPDATE вместо INSERT) — вынужденно. Dispatching review (opus). +Task 1: review (opus) — spec OK, quality Approved; Critical/Important: none. + T1.1 minor: нет LOCK TABLE перед CTAS в 0002 (потеря параллельной записи; сейчас риск 0) — deferred + T1.2 minor: пересоздание теряет GRANT/RLS/триггеры вне Alembic; нет в docstring миграции — deferred (отражено в design #16) + T1.3 minor: docstring downgrade не упоминает падение при ЮЛ в ГСК и ГСЗ одновременно — deferred + T1.4 minor: тест сохранения данных не проверяет legal_entity_id/valid_from/created_by — deferred + T1.5 minor: в плане `-k related_group_member` не выбирает тестов (использовали -k create_all) — deferred +Task 1: complete (commits 13d11c0..458392e, review clean; R-5 = design #16 в 3a0ee80, D-1 уточнён c80e029) +Task 2: dispatched (opus, implementer aab564bc92cf06234), BASE=c80e029 +Task 2: implementer DONE (commit 5976986). Ruling R-6 (design #17): SessionDep (scope="function") — обязателен для всех следующих задач (`session: SessionDep` из src/api/dependencies/db.py; guard-тест). Прочие решения T2 (№2-5 отчёта: extra с code/message → ValueError, статусы через fastapi.status, константы Page) — мелкие, приняты. Observation O-1: tasks.md 1.2 всё ещё называет get_uow_session/get_audit_service (устарело по R-1/R-6; править при архивации). O-2: индекс codegraph отстаёт — Read/Grep. +Task 2: review (opus) — spec OK, Approved; Critical/Important: none. + T2.1 minor: тест одной сессии не проверяет общность сессии current_user и маршрута (держится на ключе кэша FastAPI + guard-тесте) — deferred + T2.2 minor: guard-тест обходит только build_api_router, не src.app.app.routes — deferred + T2.3 minor: tasks.md 1.2 называет get_uow_session/get_audit_service (устарело) — deferred (правка текста tasks.md при финале фазы; O-1) + T2.4 minor: DomainError не переживает pickle/copy (args) — deferred +Task 2: complete (commits c80e029..5976986, review clean; R-6 = design #17 в cd6cca3) +Task 3: dispatched (sonnet, implementer ac5cf3be1f90b1c92), BASE=cd6cca3 +Task 3: implementer DONE (8eb8592). Concern: warning build_verifier не подавлен вне test_me (O-3 кандидат). Review dispatched (sonnet). +Task 3: review (sonnet) — spec OK, Approved; Critical/Important: none. + T3.1 minor: warning build_verifier ("Включён dev-эмитент") не подавлен в общей фикстуре app (только в test_me autouse) — deferred → поручено Task 5 (конфтест app: подавить через monkeypatch src.core.auth.logger, убрать локальный autouse); статус `fixed` фиксируется в Task 5 + T3.2 minor: pytest.raises(IntegrityError) в test_factories не проверяет конкретное ограничение — deferred + T3.3 minor: AuthClientFactory импортируется из tests.conftest — deferred + T3.4 minor: docstring conftest не упоминает новые фикстуры — deferred +Task 3: complete (commits cd6cca3..8eb8592, review clean) +Task 4: dispatched (opus, implementer afd5eb6c8cf3e7fb9), BASE=8eb8592 +Task 4: implementer DONE (d142c6f). Решения: legal_entities_visible через подзапрос IN (а не outerjoin) — разрешено (семантика та же); company_groups_visible требует CompanyGroup во FROM (учесть в Task 8 get_related_group — join явно); O-4: tasks.md 2.1 называет apply_scope_*/assert_office_allowed (устарело, брифы опираются на plan names). Review dispatched (opus). +Task 4: review (opus) — spec OK, Approved; Critical/Important: none. Ruling R-7 (design #18): подзапрос IN в legal_entities_visible; ГК/монозаёмщики без филиала видны только ГО. + T4.1 minor: company_groups_visible молча даёт декартово произведение, если CompanyGroup нет во FROM — deferred (Task 8 get_related_group обязан join'ить CompanyGroup явно; передано в бриф) + T4.2 minor: тест с JOIN вызывающего проверяет только FROM, не WHERE подзапроса — deferred + T4.3 minor: лишние проверки подстрок в тесте — deferred + T4.4 minor: tasks.md 2.1 называет apply_scope_*/assert_office_allowed — deferred (синхронизировать текст tasks.md в конце фазы; O-4) +Task 4: complete (commits 8eb8592..d142c6f, review clean) +Task 5: dispatched (sonnet, implementer ae35074f0d4d0fe82), BASE=01187b5 +Task 5: implementer DONE (d85ecd2, 80e8905). T3.1: fixed d85ecd2. Решения исполнителя приняты: R-8 (design #19). +Task 5: review (sonnet) — spec OK, Approved; Critical/Important: none. + T5.1 minor (informational): глобальный pre-check crm_id раскрывает существование строки crm_id — rejected как дефект (спека требует глобальную уникальность 409), записан в design #19 + T5.2 minor: эндпоинт regional_offices вызывает repo напрямую (мимо services) — deferred + T5.3 minor: эндпоинт ГК пересобирает PageParams/CompanyGroupFilter из CompanyGroupListQuery — deferred + T5.4 minor: set_archived(bool) с noqa FBT вместо двух методов — deferred + T5.5 minor: OFFICE_NOT_FOUND не проверяет is_active (учтено в design #19) — deferred + T5.6 minor: пробелы тестов (multi-field PATCH, повторный archive без аудита, неизменность чужой ГК, collation в сортировке) — deferred + T5.7 minor: conftest заменяет logger заглушкой только с warning (logger.disable устойчивее) — deferred +Task 5: complete (commits 01187b5..80e8905, review clean) +O-5: RegionalOfficeOut дублируется в schemas/me.py и schemas/directories.py — OpenAPI-компоненты с длинными именами; решить в фазе 2 (фронт/OpenAPI). +Task 6: dispatched (opus, implementer a8549ee04ec10f373), BASE=69769cc +Task 6: implementer DONE (347b46e). Review (opus): spec OK, Needs fixes. + T6.1 IMPORTANT: office_for_create применяется к regional_office_id до сравнения с текущим — PATCH с тем же значением от пользователя филиала даёт 422 (нарушает Review Focus 1) — fix round 1 dispatched (resume implementer a8549ee04ec10f373); fix base 347b46e + T6.2 minor (M-1): тест отката не доказывает откат аудита (аудит пишется после close_memberships) — deferred + T6.3 minor (M-2): сообщение OFFICE_NOT_ALLOWED про ГК возвращается и для ЮЛ — deferred + T6.4 minor (M-3): дублирование _strip_required_name/_check_office_exists/_now между ГК и ЮЛ — deferred (вынести в Task 8 если третий потребитель) + T6.5 minor (M-4): pre-check INN/CRM_ID не защищён от гонки (IntegrityError → 409 общим обработчиком, текст общий) — deferred +Task 6: fix round 1/5 (1 addressed, 0 open — T6.1 fixed 462a30f; commits 347b46e..462a30f); re-review (sonnet): all addressed, no new Critical/Important. + T6.6 minor (re-review): явный null от пользователя филиала при текущем офисе NULL пропускается как no-op (до фикса — превращался в свой офис); не покрыто тестом — deferred +Task 6: complete (commits 69769cc..462a30f, review clean after 1 fix round). Решения исполнителя приняты: R-9 (design #20). +Task 7: dispatched (sonnet, implementer aa1f12b4cd4ecdaac), BASE=e03f979 +Task 7: implementer DONE (7007c51, a19875d). Решения: ALIAS_TAKEN отдаёт только legal_entity_id владельца; чужой/несуществующий алиас — одинаковый 404; alias хранится дословно, пробельная строка → EMPTY_ALIAS. O-6: DIRECTORY_DELETE по seed только у ADMIN (DELETE алиасов/ГСК доступен только admin) — матрица Среза 0, не менялась; отметить человеку. Review dispatched (sonnet). +Task 7: review (sonnet) — spec OK, Approved; Critical/Important: none. Решения исполнителя приняты: R-10 (design #21). + T7.1 minor: гонка двух POST одного алиаса → IntegrityError → общий 409 без кода ALIAS_EXISTS (в отчёте исполнителя ошибочно указано 500) — deferred + T7.2 minor: find_alias_owner LIMIT 1 при теоретических дублях может дать ALIAS_TAKEN вместо ALIAS_EXISTS — rejected (сервис сам не допускает дублей; только гипотетические legacy-данные) +Task 7: complete (commits e03f979..a19875d, review clean) +Task 8: dispatched (opus, implementer a8179e82b2dfb35b5), BASE=69d641c +Task 8: implementer DONE (e3ea074, 88a4b6d, a04d9db). Решения: add_member возвращает (член, ЮЛ); список ГСК/ГСЗ массивом (не Page); DELETE members = DIRECTORY_EDIT по спеке; состав в before_value при удалении группы; новый src/common/clock.py (utc_now). Review dispatched (opus). +Task 8: review (opus) — spec OK, Approved; Critical/Important: none. Решения исполнителя приняты: R-11 (design #22). + T8.1 minor: удаление группы, на которую ссылаются consolidation/ex_snapshot (FK), даст 500 вместо 409 — deferred (к OQ-34, Срез 3/7: GROUP_IN_USE) + T8.2 minor: гонки pre-check → IntegrityError → общий 409/500 без доменного кода (общая проблема всех сервисов справочников; см. T6.5, T7.1) — deferred + T8.3 minor: список ГСК/ГСЗ — массив, а не Page (Global Constraints: списки {items,total,limit,offset}) — deferred (решить в фазе 2 до фронта) + T8.4 minor: нет теста DELETE чужой ГСК пользователем филиала — deferred +Task 8: complete (commits 69d641c..a04d9db, review clean) +O-1/O-4: tasks.md 1.2/2.1 приведены к фактическим именам (commit после design #22). + +--- +## Фаза 2 и финальное ревью + +# SDD ledger — plan: docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md + +Spec: openspec/changes/slice-1-directories/ (reachable). Branch feat/5-slice-1-directories, worktree .wt/slice-1/arm-core. Base фазы 2: b5a8324 (начало Среза 1: 44806c8). +Ledger фазы 1: ../2026-10-04-slice-1-directories-phase1/progress.md (T1.1–T8.4, O-1…O-5, R-2…R-11). +Нумерация: рулинги R-12 (design #23), R-13 (design #24) приняты сессией; далее R-14 (design #25+). Наблюдения O-7+ (O-6 фазы 1 — DIRECTORY_DELETE только у ADMIN). Находки T9.k…T15.k. +Модели: T9,T10,T12,T14 + ревью — opus; T11,T13,T15 + ревью — sonnet; финальное ревью — opus. + +## Pre-flight: таблица конфликтов + +| Пара/задача | produces vs consumes | находка | +|---|---|---| +| T9 -> T10 | ядро `plan_import`, `ImportReport.to_dict`, `read_rows`, `FileFormatError`, операции | T10 потребляет все операции и `DirectorySnapshot`; совпадает. Снимок строит T10 (`load_snapshot`) | +| T10 vs код фазы 1 | план требует `apply_group_update(session, audit, group, *, name, office_id, now)` и `apply_entity_update(...)` «вынесенные» из сервисов; в коде есть только классы `CompanyGroupService`/`LegalEntityService` (функций нет) | R-14 (ниже): T10 выносит внутреннюю логику update в модульные функции в `services/*.py`, `CompanyGroupService.update`/`LegalEntityService.update` вызывают их; поведение API и тесты фазы 1 не меняются | +| T10 vs spec | план: `dry_run` «`await session.rollback()` … в тестах — откат SAVEPOINT»; фикстура `app` оборачивает запрос тестов в `begin_nested` | rollback внутри SAVEPOINT-обёртки корректен; T10 обязан убедиться тестом подсчёта строк, что откат реально откатывает вставки (а не только expire) | +| T10 vs T9 (Review Focus 4) | повторный dry_run расходует `nextval` | допустимо (дыры в кодах), тест сравнивает отчёты, не коды | +| T10 vs спека | план: «`storage is None` — S3Object не создаётся» | согласовано с dev без S3; не конфликт | +| T11 -> T10 | fixtures-тест использует эндпоинт T10; пороги `offices_created == 53`, `groups_created == 345`, UNSUPPORTED_TYPE ≥ 500 | цифры плана не проверены — ожидание исполнителю: сверить с фактом и при расхождении с планом описать (не подгонять код под числа без анализа) -> R-14b? нет: ruling не нужен, фиксируется в отчёте | +| T12 vs R-13 | `UserOut.regional_office: RegionalOfficeOut` | R-13 применяется в T12 (одна модель) | +| T12 vs spec users-admin | план: `LAST_ADMIN` при снятии роли/права/деактивации; спека D-8 «защита от снятия USERS_ADMIN с себя/последней роли» | план строже/равен спеке; смысл — «последнее право USERS_ADMIN у активных»; проверка перед записью. Конфликта нет | +| T12 vs Global Constraints | «пользователей не удалять; email lower unique» — в DBML уникальность email? | исполнитель проверяет DBML/индекс (`lower(email)`); если уникальный индекс только по email — проверка на уровне сервиса (план так и требует) | +| T12 -> T13/T14 | `UserOut`, `RoleOut`, `PermissionOut`, `Page[UserOut]`, `/directories/regional-offices` | T13 types.ts дублирует контракт вручную; T14 использует. Совпадение имён полей проверяет ревью T13/T14 по схемам T12 | +| T13 -> T14 | `apiFetch`, `ApiError`, `useMe`, `AuthGate`, `DevLogin` | T14 использует `useMe`/`api`; Admin.tsx вызывает `useMe()` повторно (второй запрос /me) — допустимо; ревью T14 проверит отсутствие запросов `/admin/*` без права | +| T13 vs Global Constraints | `web/src/utils.ts` «удалить или не трогать» | не трогать, если не мешает; не дефект | +| T14 vs spec | spec users-admin «минимальный экран»: sidebar-группа «Система» | совпадает с планом | +| T15 vs T9..T14 | README/CLAUDE.md документируют фактические имена; `tasks.md` все `[x]`; design.md — рулинги | рулинги вносит контроллер по ходу (после каждой задачи), T15 лишь сверяет | +| T15 vs scope | `make check-full` требует `../fixtures`/`../docs`, compose, docker | доступны по постановке | +| внутренняя согласованность T9 | тест `-k`/ожидания: `report.rows==21, groups_created==1, offices_created==1, entities_created==18, ГСК с 2 членами` — план велит «зафиксировать по факту», не угадывать | OK; `type_raw` для ФЛ: `UNSUPPORTED_TYPE`; 21 строка: 18 ЮЛ + ФЛ/дубль — по факту | +| внутренняя согласованность T10 | тест «срез РБ»: «в БД 1 ГК, 18 ЮЛ, 1 ГСК (2 члена)» vs Task 9 | совпадает | +| внутренняя согласованность T12 | Review Focus 5: самодеактивация | покрыт тестом и требованием | +| Global Constraints vs rubric | verbatim-дубли/пустые тесты | не обнаружено | + +Rulings (pre-flight): +- R-12 (design #23), R-13 (design #24): приняты сессией. +- R-14: T10 выносит update-логику ГК/ЮЛ в модульные функции `apply_group_update`/`apply_entity_update` (в плане названы как существующие, но их нет) — альтернатива «дублировать код update в сервисе импорта» нарушает DRY; цена ошибки: небольшой рефакторинг фазы 1 в T10, регрессия закрыта тестами фазы 1; откат — вернуть дублирование. (design #25 — будет внесён после T10 вместе с остальными решениями исполнителя) + +Task 9: dispatched (opus, implementer a92fc138de6b6b79f), BASE=dd176a3 +Task 9: implementer DONE_WITH_CONCERNS (df8a528). Concerns: фактически 20 ЮЛ/ИП (не 18), DUPLICATE_IN_FILE в файле РБ не возникает; полная выгрузка: groups_created=344 (ГК 1-5AL0CDM целиком DUPLICATE_IN_FILE), LE_IN_OTHER_GROUP невозможен на пустой БД. Пины openpyxl, et_xmlfile, types-openpyxl. 12 решений исполнителя (report). +Rulings: R-14 (design #25) apply_group_update/apply_entity_update выносятся в T10; R-15 (design #26) 20 ЮЛ/ИП, groups_created=344, LE_IN_OTHER_GROUP на пустой БД недостижим — спека/план/tasks.md исправлены; R-16 (design #27) решения исполнителя T9 приняты. Commit chore: см. git log (после df8a528). +Task 9: review (opus) — spec OK; Needs fixes. + T9.1 IMPORTANT (I-1): повреждённый xlsx (битый sheet/workbook/styles XML, неверная ссылка ячейки) → сырое ParseError/ValueError вместо FileFormatError UNSUPPORTED_FILE — fix round 1 + T9.2 IMPORTANT (I-2): нет лимита строк/размера — xlsx 5 КБ с r="2000000" съедает 297 МБ (материализация пустых строк) — fix round 1 (стриминг, лимит строк, проверка суммарного zip file_size) + T9.3 minor (M-1): числовой ИНН теряет ведущий ноль (0274… → 9 цифр → INVALID_INN, видимый отказ) — deferred (follow-up/OQ-88) + T9.4 minor (M-2): csv row = номер записи, а не строки файла (reader.line_num) — включено в fix round 1 (дёшево) + T9.5 minor (M-3): CreateRelatedGroup возможна без последующего AddMember (пустая группа) — deferred, зафиксировано в design #27 + T9.6 minor (M-4): пустые Организация/Группа компаний при непустом iD создают сущность с name="" — deferred (вопрос OQ; допущение в README Task 15) + T9.7 minor (M-5): при существующей ГК + skip новый РФ из файла всё равно создаётся (CreateOffice без ссылок) — deferred (буквальный порядок §13.2) + T9.8 minor (M-6): параметризованный тест не покрывает DUPLICATE_IN_FILE/LE_UPDATED/GK_UPDATED/in-file GK_NAME_CONFLICT (покрыты отдельными тестами); нет теста GK-обновление + LE_IN_OTHER_GROUP — deferred +Task 9: fix round 1/5 dispatched (resume a92fc138de6b6b79f); FIX_BASE=7f14e70 +Task 9: fix round 1/5 (3 addressed, 0 open — T9.1 fixed 1364f43, T9.2 fixed 1364f43, T9.4 fixed 1364f43; commits 7f14e70..1364f43); re-review (sonnet): all addressed, no new Critical/Important. + T9.9 minor (re-review): workbook.close() в генераторе _xlsx_rows происходит при GC, не детерминированно (BytesIO, fd не течёт) — deferred + T9.10 minor (re-review): широкий кортеж исключений вокруг итерации может скрыть программную ошибку как «плохой файл» — deferred + T9.11 minor (re-review): MAX_UNCOMPRESSED_BYTES=200 МиБ щедрый; sharedStrings грузится целиком; не ограничены колонки/длина ячейки — deferred (кандидат на 50 МиБ) +O-7: MAX_IMPORT_ROWS=100_000 считает и пустые строки: xlsx с отформатированными пустыми хвостами >100k будет отвергнут (UX: пояснить в README Task 15). +Task 9: complete (commits dd176a3..1364f43, review clean after 1 fix round). Решения исполнителя: R-16 (design #27). +Task 10: dispatched (opus, implementer ad5722f28c02ecade), BASE=1364f43 +Task 10: implementer DONE_WITH_CONCERNS (e373dfc refactor R-14, c9c3b69 feature). Concerns: rollback под SAVEPOINT-фикстурой сносит seed — dry_run-тест на отдельной БД; 413 после spool multipart Starlette; архивные ГК в снимке; HTTPException(413) из сервиса. 12 решений (report). Review dispatched (opus). +Task 10: review (opus) — spec OK, Approved; Critical/Important: none. Решения исполнителя приняты: R-17 (design #28). + T10.1 minor: dry_run-тест не проверяет FakeStorage.objects == {} («ни файла») — deferred → pre-final cleanup batch (дёшево, инвариант) + T10.2 minor: нет теста dry_run=true на файле РБ (спека «Реальная выгрузка принята») — deferred → cleanup batch + T10.3 minor: нет теста dry_run + on_existing=update (откат истории/аудита) — deferred → cleanup batch + T10.4 minor: safe_filename не ограничивает длину/управляющие символы → NUL или >1024 байт даст 500 вместо 422 — deferred → cleanup batch + T10.5 minor: HTTPException(413) в сервисном слое (по брифу) — deferred + T10.6 minor: committed_sessionmaker/committed_client в файле теста, дублируют фабрику conftest — deferred → cleanup batch (вынести в conftest) + T10.7 minor: архивные ГК в снимке, при update переименовываются без разархивации; OQ — deferred (design #28; вопрос человеку: добавить в 07-open-questions.md в ../docs) + T10.8 minor: update-режим на масштабе: ≈3 round-trip на изменённую строку; 1.1 с измерено только для skip — deferred (профилировать в Task 11 по необходимости) + T10.9 minor: LE_IN_OTHER_GROUP раскрывает имя ЮЛ другой ГК при выдаче DIRECTORY_ADMIN филиальному пользователю — deferred (design #28) + T10.10: решения исполнителя перенесены в design #28 — fixed (commit chore R-17) +Task 10: complete (commits 1364f43..c9c3b69, review clean) +Task 11: dispatched (sonnet, implementer aede0ffb3cd46d81b), BASE=58075e9; в диспетчеризацию включён cleanup T10.1–T10.4, T10.6 (commit A) + тест полной выгрузки (commit B) +Task 11: implementer DONE (c793b42 cleanup T10.1-4,6; c9cd839 test). dry_run 8400 строк = 1.14 с, тест не skip. Review dispatched (sonnet). +Task 11: review (sonnet) — spec OK, Approved; Critical/Important: none. Решения исполнителя приняты: R-18 (design #29). T10.1, T10.2, T10.3, T10.4, T10.6 — fixed c793b42. + T11.1 minor: print с замером времени (noqa T201) вместо record_property/logger — deferred + T11.2 minor: таймер охватывает только HTTP-запрос, в docstring не оговорено — deferred + T11.3 minor: safe_filename чистит только Cc; Cf (bidi U+202E, zero-width) остаются в имени/аудите/ключе S3 — deferred (design #29) + T11.4 minor: после усечения имя может кончаться пробелом перед расширением — deferred + T11.5 minor: кейс «nul» в HTTP-тесте проходит и на старом коде (регрессию ловит unit-тест) — deferred +O-8: полная выгрузка 8400 строк, dry_run = 1.14 с (лимит 60 с). +Task 11: complete (commits 58075e9..c9cd839, review clean) +Task 12: dispatched (opus, implementer a696b70ed168ae6f6), BASE=83746e6 +Task 12: implementer DONE (4dd966b R-13 refactor, 800f3a4 feature). Отклонение от R-13: RegionalOfficeBrief (для /me) + RegionalOfficeOut(Brief) с is_active в schemas/directories.py (иначе /me получает is_active и ломает тест); 422 OFFICE_NOT_FOUND (R-8); FOR NO KEY UPDATE на строке права/роли (FOR UPDATE дедлочит на FK аудита); EmailStr отвергает .local; гонка email с разным регистром (open). Review dispatched (opus). +Task 12: review (opus) — spec OK, Approved; Critical/Important: none. Решения исполнителя приняты: R-19 (design #30), включая отклонения от R-13 (иерархия Brief/Out) и 422 OFFICE_NOT_FOUND. + T12.1 minor: не покрыта ветка kept_roles (замена ADMIN на другую активную роль с USERS_ADMIN → 200; на неактивную → 409) — deferred → cleanup batch + T12.2 minor: гонка проверена только set_roles×set_roles — deferred (опц.) + T12.3 minor: тест экранирования `_` не доказывает экранирование (нет строки, различающейся только в позиции `_`) — deferred → cleanup batch + T12.4 minor: нет assert кода LAST_ADMIN в test_api_admin.py:403 — deferred → cleanup batch + T12.5 minor: USER_ROLE_CHANGE before сортирован, after в порядке запроса — deferred (задокументировано, design #30) + T12.6 minor: _check_office_exists теперь в трёх сервисах — deferred (вынести в repositories/directories) + T12.7 (concerns исполнителя): гонка создания email в разном регистре → оба создаются, логин → 403 fail-closed; одинаковый email-гонка → общий 409 без EMAIL_EXISTS (семейство T6.5/T7.1/T8.2); EmailStr отвергает *.local; UserOut.roles показывает неактивные роли — deferred; общий маппинг IntegrityError пересмотреть в final +O-9: EmailStr не принимает домен .local — dev-пользователи вида x@armka.local через API/UI создать нельзя (seed-dev создаёт напрямую); упомянуть в README (Task 15). +Task 12: complete (commits 83746e6..800f3a4, review clean) +Task 13: dispatched (sonnet, implementer a3c5011bf4b5363d7), BASE=6217e4e +Task 13: implementer DONE_WITH_CONCERNS (498450b). biome/tsc/build/web-check зелёные; UI в браузере не проверялся (смоук клиента из Node против API); NODE_ENV литералом из-за подмены process.env в rsbuild.config; AuthGate вокруг Layout; MeContext. Review dispatched (sonnet). +Task 13: review (sonnet) — spec OK; Needs fixes. + T13.1 IMPORTANT: apiFetch не оборачивает сетевую ошибку (TypeError: Failed to fetch) — пользователь видит английский текст, будущие экраны получат необработанный reject — fix round 1 + T13.2 IMPORTANT: apiFetch: response.json() на пустом/не-JSON 2xx теле бросает сырой SyntaxError — fix round 1 + T13.3 IMPORTANT: DevLogin в prod-ветке теряет `error` (тупик при ошибке /me со старым токеном) — fix round 1 (показать Alert + «Повторить») + T13.4 minor: `void reloadCount;` в useMe — deferred + T13.5 minor: на armka:unauthorized `error` не выставляется («Сессия истекла») — включено в round 1 (дёшево) + T13.6 minor: email не trim-ится, whitespace проходит required — включено в round 1 + T13.7 minor: height:'100%' формы входа не работает (#root min-height) — включено в round 1 + T13.8 minor: токен в памяти параллельно с sessionStorage — deferred (добавить комментарий о приоритете — включено) + T13.9 minor: Permission.object_type/action — string, а не union — включено в round 1 + T13.10 minor: API_BASE при Config.API_URL='/' даст `//api/v1` — включено в round 1 (trim trailing slash) +Task 13: fix round 1/5 dispatched (resume a3c5011bf4b5363d7); FIX_BASE=498450b +Task 13: fix round 1/5 (3 Important + 7 minors addressed, 0 open — T13.1, T13.2, T13.3, T13.5, T13.6, T13.7, T13.8, T13.9, T13.10 fixed d76ca57; commits 498450b..d76ca57); re-review (sonnet): all addressed, no new Critical/Important. T13.4 deferred. + O-10 (re-review): в prod-ветке «Повторить» без токена — no-op (useMe при отсутствии токена не сбрасывает error); сетевой сбой при чтении тела (response.text()) не оборачивается в NETWORK — deferred (редкие случаи) +Task 13: complete (commits 6217e4e..d76ca57, review clean after 1 fix round). Решения исполнителя: R-20 (design #31). +Task 14: dispatched (opus, implementer aee7f102e715b6674), BASE=a7502f6 +Task 14: implementer DONE (d3c2f8a). biome/tsc/build/web-check зелёные; UI прогнан в headless Chrome через DevTools (смена ролей, LAST_ADMIN, создание, матрица, partial failure, 403 без запросов /admin/*). Concerns: Redux scaffold 'Store does not have a valid reducer' в консоли (O-11), нет поиска/пагинации >200, чанк antd 887 кБ. Review dispatched (opus). +Task 14: review (opus) — spec OK; Needs fixes (1 Important). + T14.1 IMPORTANT (I-a): UsersTable.tsx:603 `disabled: !r.is_active` — пользователь с неактивной ролью не может её снять (rc-select: у disabled-опции нет крестика; сервер даст 422 UNKNOWN_ROLE на повторную отправку) — fix round 1 (`disabled: !r.is_active && !user.roles.includes(r.code)`) + T14.2 minor (M-a): PermissionMatrix — ответы PUT отбрасываются, при сбое reload после сохранения показывается старое состояние — включено в round 1 (merge RoleOut через setData до reload) + T14.3 minor (M-b): неверный doc-комментарий hooks.ts (data при неудачном reload сохраняется) — включено в round 1 + T14.4 minor (M-c): CreateUserModal без ref-lock от двойного клика — включено в round 1 + T14.5 minor (M-d): `in`/`draft[code]` по plain object цепляет Object.prototype — включено (Object.hasOwn) + T14.6 minor (M-e): memo(Section) бесполезен без useMemo(visibleGroups) — включено + T14.7 minor (M-f): множественный выбор ролей = N PUT и N аудит-событий (не оптимистично) — deferred, README (Task 15) +O-11: консольная ошибка Redux «Store does not have a valid reducer» — pre-existing каркас (store.ts reducer: {}), не вводилась; deferred (follow-up). +Task 14: fix round 1/5 dispatched (resume aee7f102e715b6674); FIX_BASE=d3c2f8a +Task 14: fix round 1/5 (1 Important + 5 minors addressed, 0 open — T14.1..T14.6 fixed 4d719ec; commits a7502f6..4d719ec); re-review (sonnet): all addressed, no new Critical/Important. T14.7 deferred (README в Task 15). +Task 14: complete (commits a7502f6..4d719ec, review clean after 1 fix round). Решения исполнителя: R-21 (design #32). +Task 15: dispatched (sonnet, implementer ad359cdca9bdba5a0), BASE=fda907b; commit A = cleanup T12.1/T12.3/T12.4 (tests), commit B = docs+OpenAPI test+check-full +Task 15: implementer DONE (d241c07 cleanup T12.1/3/4; 7b75981 docs+OpenAPI test). check-full зелёный, 0 skipped: unit 764 passed, integration 264 passed, fixtures 266 passed/0 skipped (integration or fixtures); docker-build, web-check OK. Расхождения брифа: (1) неактивная роль с USERS_ADMIN через API = 422 UNKNOWN_ROLE (не 409 LAST_ADMIN); (2) повторный seed ВОЗВРАЩАЕТ права, снятые админом (ON CONFLICT DO NOTHING на вставку) — README описывает реальность. Review dispatched (sonnet). +Task 15: review (sonnet) — Approved with notes (документация сверена с кодом, check-full полный и зелёный: unit 764, integration 264, fixtures 266/0 skipped); Critical: none. + T15.1 IMPORTANT: design.md строка «Повторный seed не отзывает права» противоречила коду/README (на самом деле возвращает) — fixed (commit chore design.md/tasks.md, controller) + T15.2 IMPORTANT: tasks.md 5.1 [x] включал пункт archive и несуществующий get_uow_session — fixed (5.1 переформулирован, archive вынесен в 5.2 [ ] «после merge, человек») + T15.3 minor: README импорт шаг 4 «все строки получают *_EXISTS» неточно (ранее пропущенные строки остаются ошибками) — fix в финальной волне + T15.4 minor: test_components_have_no_duplicated_models — count==1 по ключам словаря бессмыслен (реальную защиту даёт проверка "__"/"-") — fix в финальной волне + T15.5 minor: exact-set тест маршрутов хрупок по замыслу — rejected (намеренно для спек-ориентированного среза) +Расхождения брифа с кодом (приняты, факты в README): повторный seed возвращает права; неактивная роль с USERS_ADMIN через API = 422 UNKNOWN_ROLE. +Task 15: complete (commits fda907b..7b75981 + controller doc fix) +Pre-final: IntegrityError->coded 409 + T15.3/T15.4 dispatched (sonnet a1c561f3c0871b3bb), BASE=def3d72 (закрывает T6.5, T7.1, T8.2, T12.7 если пройдёт ревью) +Pre-final fix (commit 070a398): общий маппинг IntegrityError(23505) → coded 409 (7 ограничений, реестр constraint_conflicts.py), 11 новых тестов гонок; make check 773 unit, integration 275 passed. R-22 (design #33). T6.5 fixed 070a398, T7.1 fixed 070a398, T8.2 fixed 070a398 (для покрытых ограничений; ALIAS_TAKEN/email-в-другом-регистре/external_id остаются без кода — задокументировано в README), T12.7 (EMAIL_EXISTS-часть) fixed 070a398, T15.3 fixed 070a398, T15.4 fixed 070a398. +Final review: dispatched (opus a1d658a85175acb98), range 44806c8..811a1d2 + +## Финальное ревью (opus): Ready to merge: With fixes. Critical: none. +C-/I-/M- маркеры: + I-1 IMPORTANT: требование спеки users-admin «администратор MUST NOT снять с себя последнюю роль» vs код/план (запрет только для последнего активного обладателя в системе) — Ruling R-23 (design #34): трактовка системная, спека/D-8/proposal приведены к коду — fixed (commit chore R-23/R-24) + M-1: multipart импорта читается до аутентификации (Starlette сбрасывает на диск до 401/403) — fix wave: требование к ingress в README (client_max_body_size) — документировано + M-2: README обещает legal_entity_id в INN_EXISTS, код не отдаёт — fix wave (README) + M-3: смена имени+филиала ГК — одно событие ASSIGNMENT_CHANGE — Ruling R-24 (design #35): оставлено — deferred + M-4: /admin зависит от DIRECTORY_VIEW (список филиалов) — fix wave: документировано в README; GET /admin/regional-offices — deferred + M-5: /me устаревает после правки админом себя — deferred (follow-up: reload профиля после правки своей строки/матрицы) + M-6: design #22 неверно описывал последствие T8.1 (409 строкой, не 500) — fixed (commit chore R-23/R-24) + M-7: в design #28 не названа цена «S3 до commit» — fixed (commit chore R-23/R-24) + M-8: CompanyGroupCreate/LegalEntityCreate без extra="forbid" — fix wave + T11.3 (bidi/Cf в имени файла) — fix wave (исключить Cf) + T9.6 (реальная выгрузка, строка 4034: пустая «Организация» → ЮЛ с name="") — deferred; OQ человеку до боевого импорта +Deferred minors triage финального ревью: все прочие T-маркеры — follow-up (см. отчёт ревью); must-fix-before-merge: только I-1 (закрыт рулингом R-23). +Final fix wave: dispatched (sonnet a05e1583424ebbad0), BASE=bea295d (M-1, M-2, M-4 README, M-8, T11.3, final make check-full) +Final fix wave (commit 7f891dd): M-1 fixed 7f891dd (README: требование к ingress), M-2 fixed 7f891dd, M-4 fixed 7f891dd (README, документировано), M-8 fixed 7f891dd, T11.3 fixed 7f891dd. Scoped re-review (sonnet): all addressed, no new Critical/Important. Final make check-full (HEAD 7f891dd): EXIT 0 — unit 776 passed/0 skipped, integration 277 passed/0 skipped, `integration or fixtures` 279 passed/0 skipped, dbml-check/check-infra пусто, docker-build OK, web-check OK. +Final review: clean (no open Critical/Important). + +--- +## Вывод финального make check-full (сводка, HEAD 7f891dd) + +``` +cd api && /Users/andrey.epifancev/projects/awp-ca/.wt/slice-1/arm-core/api/.venv/bin/ruff check . +All checks passed! +cd api && /Users/andrey.epifancev/projects/awp-ca/.wt/slice-1/arm-core/api/.venv/bin/ruff format --check . +138 files already formatted +cd api && /Users/andrey.epifancev/projects/awp-ca/.wt/slice-1/arm-core/api/.venv/bin/mypy src scripts +Success: no issues found in 86 source files(B +cd api && /Users/andrey.epifancev/projects/awp-ca/.wt/slice-1/arm-core/api/.venv/bin/pytest -m "not integration" +platform darwin -- Python 3.11.15, pytest-9.1.1, pluggy-1.6.0 -- /Users/andrey.epifancev/projects/awp-ca/.wt/slice-1/arm-core/api/.venv/bin/python +cachedir: .pytest_cache +configfile: pytest.ini +===================== 776 passed, 277 deselected in 16.21s ===================== +cd api && /Users/andrey.epifancev/projects/awp-ca/.wt/slice-1/arm-core/api/.venv/bin/python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.14.dbml +cd api && /Users/andrey.epifancev/projects/awp-ca/.wt/slice-1/arm-core/api/.venv/bin/python scripts/check_infra.py +cd api && /Users/andrey.epifancev/projects/awp-ca/.wt/slice-1/arm-core/api/.venv/bin/pytest -m integration +platform darwin -- Python 3.11.15, pytest-9.1.1, pluggy-1.6.0 -- /Users/andrey.epifancev/projects/awp-ca/.wt/slice-1/arm-core/api/.venv/bin/python +cachedir: .pytest_cache +configfile: pytest.ini +================ 277 passed, 776 deselected in 67.60s (0:01:07) ================ +#10 exporting to image +#10 naming to docker.io/library/arm-core:latest done +cd web && npm install +cd web && npx biome check src +Checked 24 files in 21ms. No fixes applied. +cd web && npx tsc --noEmit +cd web && npm run build +ready built in 0.58 s +EXIT 0 +``` diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md new file mode 100644 index 0000000..a2764bf --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md @@ -0,0 +1,420 @@ +# Срез 1 — справочники arm-core — план исполнения, фаза 1 (модель v5.14, инфраструктура запросов, policy, API справочников) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Branch:** `feat/5-slice-1-directories` (worktree `.wt/slice-1/arm-core`). **Issue:** RSHB/arm-core#5. **PR:** RSHB/arm-core#6 (открыт, спека). +**Spec:** `openspec/changes/slice-1-directories/` — proposal.md, design.md (D-1…D-11), specs/{directories-api,directory-import,directory-policy,users-admin,data-model,audit-trail}/spec.md. Правила предметной области: `../docs/context/05-domain-rules.md` §3 (нормализация алиаса), §10/§10.1 (права и видимость), §13 (импорт и ручные операции). Модель: `../docs/model/data-model-v5.14.dbml`. Спека — единственный источник истины при рулингах; план сгенерирован из `tasks.md` change'а (номера в скобках — номера задач tasks.md; по завершении задачи отметить `- [x]` и там). + +**Goal:** после фазы 1 ядро умеет вести ГК, ЮЛ/ИП с историей и алиасами, ГСК/ГСЗ с составом через `/api/v1/directories/*` с правами, политикой видимости по филиалу и аудитом; схема БД — DBML v5.14 (миграция 0002). + +**Architecture:** FastAPI-эндпоинты → сервисы (одна транзакция на запрос: сессия коммитится при успехе) → репозитории с фильтром видимости → ORM. Policy — чистые функции `src/domain/policy.py` над `Select`. Все мутации пишут историю (SCD4/SCD2) и `audit_event` через `AuditService` в той же транзакции. Доменные ошибки → `{"detail": {"code", "message", …}}` с 404/409/422. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, Alembic, PostgreSQL 16, pydantic v2, pytest-asyncio, httpx. + +## Global Constraints + +- Имена таблиц/колонок/enum'ов — ровно как в `../docs/model/data-model-v5.14.dbml` (43 таблицы, 27 enum, схема `dkb`); полей вне DBML нет. `related_group_member.kind` NOT NULL и равен `related_group.kind` группы (проверяет сервис). Новые значения `audit_action`: `LEGAL_ENTITY_CHANGE` (после `GROUP_CHANGE`), `USER_CHANGE` (после `USER_ROLE_CHANGE`) — порядок как в DBML (сверка enum'ов сравнивает упорядоченный список). +- Права — данные: маршруты защищены `require("")`, код роли нигде не читается. Policy (§10.1): ГО (`regional_office.is_head_office`) видит всё; филиал — ГК с `base_regional_office_id` своего филиала, их ЮЛ/ГСК и монозаёмщиков своего филиала; `business_segment` не используется (ASSUMPTION(OQ-86)). Объект вне видимости → **404** с тем же телом, что и несуществующий. +- Все мутации: одна транзакция, `audit_event` (`user_id` инициатора, `before_value`/`after_value`, `ip_address`, `user_agent`) через `AuditService.record` — append-only (тест `test_audit_service.py` сканирует `src/` на UPDATE/DELETE по `AuditEvent`; не добавлять). +- Ошибки: `{"detail": {"code": "...", "message": "...по-русски"}}` для доменных 404/409/422; существующие ответы Среза 0 (401/403 строкой) не меняются. Списки — `{items, total, limit, offset}`, `limit` ≤ 200 (по умолчанию 50), `offset` ≥ 0, сортировка по имени. +- Коды ГК/ГСК/ГСЗ — `GK-NNNNNN` / `GSK-NNNNNN` / `GSZ-NNNNNN` (6 цифр с нулями) из последовательностей PostgreSQL `dkb.company_group_code_seq`, `dkb.related_group_code_seq` (D-5). +- История (D-6): при изменении ГК — снимок прежних значений в `company_group_history` (`valid_from` прежний, `valid_to = now`), у ГК `valid_from = now`; ЮЛ — то же в `legal_entity_history` с `event_type`; при создании история не пишется. Членство ГСК/ГСЗ — SCD2: исключение = `valid_to = now, is_current = false`; повторное включение — новая строка. +- Деньги не затрагиваются; `Decimal` в домене, никаких float. +- Линтеры: ruff (line-length 120, E,F,I,UP,B,PL — `PLR0913` > 5 аргументов решается `# noqa: PLR0913 - причина` или dataclass'ом параметров; `PLR2004` — именованные константы) + mypy (`src.models.*`, `src.domain.*` — strict-флаги). Тесты: pytest-asyncio auto, маркер `integration` для БД, `fixtures` для реальных данных; БД-тесты — на `db_session` (SAVEPOINT, откат после теста). +- Все команды — из `api/` (`make` — из корня репозитория); Python 3.11; venv `api/.venv` (`make venv` идемпотентен). PostgreSQL из compose `../docs/dev` уже поднят (`make up`), контейнеры не перезапускать. +- Язык комментариев/коммитов — русский, идентификаторы — английские. Коммит заканчивается строками `Co-Authored-By: Claude Sonnet 5.5 ` (или фактическая модель исполнителя) и `Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2`. Не пушить, не создавать PR, не запускать `openspec archive`. +- Допущения в коде — комментарием `# ASSUMPTION(OQ-nn)`: OQ-34 (удаление только пустой ГСК/ГСЗ), OQ-86 (ЗО не фильтрует), OQ-88 (ИНН — только длина), OQ-21 (`effective_date` ≥ сегодня). + +## Review Focus + +Случаи, которых спека не называет явно, но которые ударят по пользователю; тест на каждый добавлен в задачу-владельца: + +1. **`PATCH` без изменений** (тело с теми же значениями) — не должен плодить строку истории и аудит: Task 5 (ГК) и Task 6 (ЮЛ) — тест «no-op PATCH не пишет историю». +2. **Фильтр `inn` с пробелами / ИНН с ведущими нулями** — `inn` хранится строкой, сравнение точное после `strip()`; ИНН `0123456789` валиден: Task 6 — тест. +3. **Пользователь филиала переводит ЮЛ в ГК чужого филиала** — целевая ГК вне scope → 404 (не 422, не утечка): Task 6 — тест. +4. **Добавление в ГСК закрытого ЮЛ (`close_date` заполнен, `company_group_id = NULL`)** — 422 `DIFFERENT_COMPANY_GROUP` (ЮЛ без ГК не в той же ГК): Task 8 — тест. +5. **`offset` больше `total`** — пустой `items`, `total` настоящий, 200: Task 5 — тест списка ГК. + +--- + +## Задачи + +### Task 1: [1.1] ORM v5.14, миграция `0002_dbml_v5_14`, версия DBML + +**Files:** +- Modify: `api/src/models/directories.py` (RelatedGroupMember, последовательности), `api/src/models/enums.py` (AuditAction), `api/src/models/__init__.py:10` (`DBML_VERSION`), `api/src/models/base.py:9` (докстринг v5.14) +- Create: `api/alembic/versions/0002_dbml_v5_14.py` +- Modify: `Makefile:6` (`DBML ?= ../../docs/model/data-model-v5.14.dbml`), `api/scripts/dbml_parity.py:59` (`DBML_FILE_NAME = "data-model-v5.14.dbml"`), `api/tests/test_migrations.py:31` (`HEAD = "0002_dbml_v5_14"`), `api/tests/test_enums.py:18` (`"5.14"`), `api/tests/test_check_dbml_script.py:145-154` (фиктивная версия `v5.15`, сообщение `версия DBML 5.15 не совпадает с версией ORM 5.14`), `api/tests/test_models_part_01_03.py::_check_related_group_member_uniqueness` +- Test: `api/tests/test_models_part_01_03.py`, `api/tests/test_migrations.py` + +**Interfaces:** +- Produces: `RelatedGroupMember.kind: Mapped[RelatedGroupKind]` (колонка между `legal_entity_id` и `valid_from`, как в DBML); `COMPANY_GROUP_CODE_SEQ = Sequence("company_group_code_seq", schema=SCHEMA, metadata=Base.metadata)`, `RELATED_GROUP_CODE_SEQ = Sequence("related_group_code_seq", …)` в `src/models/directories.py`; `AuditAction.LEGAL_ENTITY_CHANGE`, `AuditAction.USER_CHANGE`. + +- [x] **Step 1: Обновить тест уникальности членства (падает на старой схеме)** + +В `_check_related_group_member_uniqueness` (`tests/test_models_part_01_03.py`): INSERT в `related_group_member` получает колонку `kind`; сценарий: `g1` (gsk) текущее — ок; `g3` (gsz, создать третью группу) текущее для того же ЮЛ — **ок** (v5.14); `g2` (gsk) текущее — `IntegrityError`; закрытая версия в `g2` — ок. Докстринг: «ЮЛ — в одной ГСК и одной ГСЗ одновременно (v5.14, решение владельца 04.10.2026)». + +- [x] **Step 2: Запустить — убедиться, что падает** + +Run: `cd api && .venv/bin/pytest -m integration tests/test_models_part_01_03.py -k related_group_member -v` +Expected: FAIL (`column "kind" does not exist` или IntegrityError на gsz). + +- [x] **Step 3: ORM** + +`src/models/directories.py`: +```python +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Sequence, String, text +from .base import SCHEMA, Base, UUIDPrimaryMixin + +#: Последовательности кодов ГК и ГСК/ГСЗ (design D-5): `GK-000001`, `GSK-000001`/`GSZ-000001`. В DBML не описываются +#: (не таблицы); создаются миграцией 0002 и `Base.metadata.create_all` (привязаны к metadata). +COMPANY_GROUP_CODE_SEQ = Sequence("company_group_code_seq", schema=SCHEMA, metadata=Base.metadata) +RELATED_GROUP_CODE_SEQ = Sequence("related_group_code_seq", schema=SCHEMA, metadata=Base.metadata) +``` +В `RelatedGroupMember.__table_args__` заменить второй индекс: +```python + # (legal_entity_id, kind): partial UNIQUE WHERE is_current — v5.14: ЮЛ в одной ГСК И одной ГСЗ одновременно + Index( + "uq_related_group_member_entity_kind_current", + "legal_entity_id", + "kind", + unique=True, + postgresql_where=text("is_current"), + ), +``` +и добавить колонку после `legal_entity_id`: +```python + kind: Mapped[RelatedGroupKind] = mapped_column(pg_enum("related_group_kind"), nullable=False) +``` +`src/models/enums.py`: `LEGAL_ENTITY_CHANGE = "LEGAL_ENTITY_CHANGE"` сразу после `GROUP_CHANGE`, `USER_CHANGE = "USER_CHANGE"` сразу после `USER_ROLE_CHANGE`. `src/models/__init__.py`: `DBML_VERSION = "5.14"`. Докстринги модулей `models/*.py`, `scripts/check_dbml_vs_orm.py`: «v5.13» → «v5.14» там, где речь о текущей версии. + +- [x] **Step 4: Миграция 0002** + +`api/alembic/versions/0002_dbml_v5_14.py`: +```python +"""DBML v5.14: related_group_member.kind + partial unique (legal_entity_id, kind), audit_action += LEGAL_ENTITY_CHANGE, +USER_CHANGE, последовательности кодов ГК/ГСК (design D-1, D-5). + +`ALTER TYPE … ADD VALUE` необратим — downgrade пересоздаёт тип audit_action со значениями v5.13 (строк с новыми +значениями в audit_event быть не должно; иначе downgrade падает на USING — это осознанно). + +Revision ID: 0002_dbml_v5_14 +Revises: 0001_dbml_v5_13 +Create Date: 2026-10-04 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from sqlalchemy.schema import CreateSequence, DropSequence + +from alembic import op + +revision: str = "0002_dbml_v5_14" +down_revision: str | Sequence[str] | None = "0001_dbml_v5_13" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +SCHEMA = "dkb" +TABLE = "related_group_member" +OLD_INDEX = "uq_related_group_member_entity_current" +NEW_INDEX = "uq_related_group_member_entity_kind_current" +SEQUENCES = ("company_group_code_seq", "related_group_code_seq") +AUDIT_ACTION_V5_13 = ( + "LOGIN", "LOGIN_FAILED", "LOGOUT", "CONSOLIDATION_CREATE", "CONSOLIDATION_STATUS_CHANGE", "CONSOLIDATION_APPROVE", + "CONSOLIDATION_VERIFY", "GROUP_CHANGE", "MEMBERSHIP_CHANGE", "ASSIGNMENT_CHANGE", "FILE_UPLOAD", "FILE_DELETE", + "FILE_REPLACE", "DOCUMENT_CONFIRM", "CONSOLIDATION_LOCK", "CONSOLIDATION_LOCK_TAKEOVER", "DISCLOSURE_EDIT", + "RECOGNIZED_DATA_EDIT", "OSV_COUNTERPARTY_MAP", "OSV_TYPE_OVERRIDE", "CORRECTION_CREATE", "CORRECTION_EDIT", + "CORRECTION_REVOKE", "GOODWILL_EDIT", "EXPORT", "DIRECTORY_IMPORT", "USER_ROLE_CHANGE", "ROLE_PERMISSION_CHANGE", +) + + +def upgrade() -> None: + kind = postgresql.ENUM(name="related_group_kind", schema=SCHEMA, create_type=False) + op.add_column(TABLE, sa.Column("kind", kind, nullable=True), schema=SCHEMA) + op.execute( + f"UPDATE {SCHEMA}.{TABLE} m SET kind = g.kind FROM {SCHEMA}.related_group g WHERE g.id = m.related_group_id" + ) + op.alter_column(TABLE, "kind", nullable=False, schema=SCHEMA) + op.drop_index(OLD_INDEX, table_name=TABLE, schema=SCHEMA) + op.create_index( + NEW_INDEX, TABLE, ["legal_entity_id", "kind"], unique=True, schema=SCHEMA, postgresql_where=sa.text("is_current") + ) + # порядок значений — как в DBML v5.14 (сверка enum'ов сравнивает упорядоченные списки) + op.execute(f"ALTER TYPE {SCHEMA}.audit_action ADD VALUE IF NOT EXISTS 'LEGAL_ENTITY_CHANGE' AFTER 'GROUP_CHANGE'") + op.execute(f"ALTER TYPE {SCHEMA}.audit_action ADD VALUE IF NOT EXISTS 'USER_CHANGE' AFTER 'USER_ROLE_CHANGE'") + for name in SEQUENCES: + op.execute(CreateSequence(sa.Sequence(name, schema=SCHEMA))) + + +def downgrade() -> None: + for name in reversed(SEQUENCES): + op.execute(DropSequence(sa.Sequence(name, schema=SCHEMA))) + op.drop_index(NEW_INDEX, table_name=TABLE, schema=SCHEMA) + op.create_index(OLD_INDEX, TABLE, ["legal_entity_id"], unique=True, schema=SCHEMA, postgresql_where=sa.text("is_current")) + op.drop_column(TABLE, "kind", schema=SCHEMA) + op.execute(f"ALTER TYPE {SCHEMA}.audit_action RENAME TO audit_action_old") + postgresql.ENUM(*AUDIT_ACTION_V5_13, name="audit_action", schema=SCHEMA).create(op.get_bind(), checkfirst=False) + op.execute( + f"ALTER TABLE {SCHEMA}.audit_event ALTER COLUMN action TYPE {SCHEMA}.audit_action " + f"USING action::text::{SCHEMA}.audit_action" + ) + op.execute(f"DROP TYPE {SCHEMA}.audit_action_old") +``` +`alembic/env.py` менять не нужно. В `tests/test_migrations.py`: `HEAD = "0002_dbml_v5_14"`; добавить тест `test_downgrade_one_step` (сценарий спеки «Откат на один шаг»): `upgrade head` → `downgrade 0001_dbml_v5_13` → в `information_schema.columns` нет `related_group_member.kind`, в `pg_indexes` есть `uq_related_group_member_entity_current`, `enum_range(NULL::dkb.audit_action)` без `LEGAL_ENTITY_CHANGE` → `upgrade head` снова. Если `test_migrated_schema_equals_orm_create_all` сравнивает последовательности — они теперь есть и в metadata, и в миграции. + +- [x] **Step 5: Пути и версии** + +`Makefile:6` → `data-model-v5.14.dbml`; `scripts/dbml_parity.py:59` → `"data-model-v5.14.dbml"`; `tests/test_enums.py` → `"5.14"`; `tests/test_check_dbml_script.py` — фиктивный файл `v5.15`, ожидаемое сообщение с `5.15`/`5.14`. + +- [x] **Step 6: Проверить** + +Run: `cd api && .venv/bin/ruff check . && .venv/bin/ruff format --check . && .venv/bin/mypy src scripts && .venv/bin/pytest -m "not integration"` → PASS. +Run: `make dbml-check` (из корня) → пустой вывод, код 0. +Run: `cd api && .venv/bin/pytest -m integration tests/test_migrations.py tests/test_models_part_01_03.py -v` → PASS (цикл 0001→0002→base→head, пустой autogenerate, снимок схемы = create_all, откат на шаг, уникальность gsk+gsz). + +- [x] **Step 7: Commit** + +`git add -A && git commit -m "feat(model): DBML v5.14 — related_group_member.kind, audit_action +2, миграция 0002, последовательности кодов"` (+ трейлеры). + +--- + +### Task 2: [1.2] Единица работы, аудит с контекстом запроса, доменные исключения, пагинация + +**Files:** +- Modify: `api/src/db/db.py` (`get_async_session` — commit при успехе, см. R-1 ниже) +- Create: `api/src/api/dependencies/audit.py`, `api/src/schemas/common.py` +- Modify: `api/src/common/exceptions.py`, `api/src/core/exception_handlers.py` +- Test: `api/tests/test_uow_and_errors.py` (unit + integration) + +**Interfaces:** +- Produces: + - `get_async_session()` — та же зависимость, что в Срезе 0, но тело `async with async_session_maker() as session: async with session.begin(): yield session` — commit при нормальном завершении запроса, rollback при любом исключении (в т.ч. `HTTPException`, доменных). **R-1 (design #12):** вместо отдельной `get_uow_session` из D-2 используется одна сессия на запрос — `current_user` и мутации работают в одной транзакции и одном соединении; цена ошибки — read-only маршруты коммитят пустую транзакцию (безвредно). + - `class DomainError(Exception)`: поля `status: int`, `code: str`, `message: str`, `extra: dict[str, Any]`; подклассы `NotFoundError(code="NOT_FOUND", message=…)` (404), `ConflictError(code, message, **extra)` (409), `DomainValidationError(code, message, **extra)` (422). Конструкторы: `NotFoundError(message: str = "объект не найден")`, `ConflictError(code: str, message: str, **extra: Any)`, `DomainValidationError(code: str, message: str, **extra: Any)`. Старый `CouldntCreateError` удалить (не используется). + - Обработчик `DomainError` → `JSONResponse(status, {"detail": {"code": code, "message": message, **extra}})` (значения `extra` через `jsonable_encoder`). + - `RequestAudit` (`src/api/dependencies/audit.py`): `@dataclass(frozen=True) class RequestAudit: service: AuditService; user_id: uuid.UUID; ip_address: str | None; user_agent: str | None` с методом `async def record(self, *, action, section, object_type, object_id=None, description=None, before_value=None, after_value=None, comment=None, result=None, changed_field=None) -> AuditEvent` (делегирует `AuditService.record`, подставляя `user_id`/`ip_address`/`user_agent`). Зависимость `async def get_request_audit(request: Request, session: Annotated[AsyncSession, Depends(get_async_session)], principal: Annotated[Principal, Depends(current_user)]) -> RequestAudit` (`ip_address = request.client.host if request.client else None`, `user_agent = request.headers.get("user-agent")`). + - `src/schemas/common.py`: `class Page(BaseModel, Generic[T]): items: list[T]; total: int; limit: int; offset: int`; `class PageParams(BaseModel): limit: int = Field(50, ge=1, le=200); offset: int = Field(0, ge=0)` — используется как `Annotated[PageParams, Query()]`; `class ErrorDetail(BaseModel): code: str; message: str` (для OpenAPI `responses`). + +- [x] **Step 1: Тесты** + +`tests/test_uow_and_errors.py`: +- unit: мини-`FastAPI()` с `register_exception_handlers`, маршруты бросают `NotFoundError("ГК не найдена")`, `ConflictError("INN_EXISTS", "ИНН уже есть", legal_entity_id=uuid)`, `DomainValidationError("INVALID_INN", "…")` → 404/409/422 и тело `{"detail": {"code", "message", ...}}`; `HTTPException(403, "x")` по-прежнему `{"detail": "x"}`. +- integration (`scratch_db_url` + `alembic upgrade head` через `tests.pg_helpers.alembic_async`, затем свой `async_sessionmaker` на этот URL с переопределением `src.db.db.async_session_maker` через `monkeypatch`): приложение с двумя маршрутами на `Depends(get_async_session)` — `POST /ok` вставляет `regional_office` и возвращает 201; `POST /fail` вставляет и бросает `ConflictError`. После `/ok` строка видна из **другой** сессии; после `/fail` — нет. Это единственный тест, который проверяет реальный commit (SAVEPOINT-сессия тестов его не ловит). +- `Page[RegionalOfficeOut]` сериализуется; `PageParams(limit=201)` → ValidationError. + +- [x] **Step 2: Запустить — падает (нет модулей/обработчика)** + +- [x] **Step 3: Реализация** по интерфейсам выше. В `exception_handlers.py` обработчик `DomainError` регистрируется **до** `Exception`. `src/db/db.py`: +```python +async def get_async_session() -> AsyncIterator[AsyncSession]: + """Сессия запроса = одна транзакция: commit при успешном ответе, rollback при любом исключении (R-1, design #12).""" + async with async_session_maker() as session, session.begin(): + yield session +``` +Проверить, что `src/api/endpoints/dev_auth.py` и обмен (`services/recognition_exchange.py`) не используют `get_async_session` с собственным `commit()` (они работают через `async_session_maker` напрямую — не трогать). + +- [x] **Step 4: Тесты зелёные**: `pytest tests/test_uow_and_errors.py -v`, затем `pytest -m "not integration"` и `pytest -m integration tests/test_me_and_dev_token.py tests/test_auth_deps.py` (регрессия Среза 0). + +- [x] **Step 5: Commit** `feat(api): транзакция на запрос, RequestAudit, доменные ошибки 404/409/422, Page`. + +--- + +### Task 3: [1.3] Фабрики справочников и `auth_client` в conftest + +**Files:** +- Modify: `api/tests/factories.py`, `api/tests/conftest.py`, `api/tests/test_me_and_dev_token.py` (перевести на общие фикстуры, поведение тестов не менять) + +**Interfaces:** +- Produces (`tests/factories.py`, все `flush`, без `commit`): + - `async def make_regional_office(session, *, name: str | None = None, is_head_office: bool = False) -> RegionalOffice` + - `async def make_company_group(session, *, name="ГК Тест", office: RegionalOffice | None = None, crm_id: str | None = None, code: str | None = None) -> CompanyGroup` (`code` по умолчанию `f"GK-T{uuid4().hex[:6]}"`, `valid_from=now`) + - `async def make_legal_entity(session, *, name="ООО Тест", inn: str | None = None, entity_type=EntityType.ЮЛ, group: CompanyGroup | None = None, office: RegionalOffice | None = None, close_date=None) -> LegalEntity` (`inn` по умолчанию 10 случайных цифр; `regional_office_id` = офис группы, если не задан) + - `async def make_related_group(session, *, group: CompanyGroup, kind=RelatedGroupKind.gsk, name="ГСК Тест") -> RelatedGroup` + - `async def make_membership(session, *, related_group: RelatedGroup, entity: LegalEntity, is_current=True) -> RelatedGroupMember` (`kind = related_group.kind`) + - `async def make_user(session, *, email: str, office: RegionalOffice, roles: Sequence[str] = (), full_name="Тест Тестов", is_active=True) -> AppUser` (роли — по `Role.code` из seed; `UserRole` строки) +- Produces (`tests/conftest.py`): + - `seeded(db_session)` — `await run_seed(db_session, dev=True); await db_session.flush()`. + - `app(db_session) -> FastAPI` — `build_api_router(settings)` с `DEBUG=True, AUTH_DEV_ISSUER=True`, `register_exception_handlers`, `app.state.token_verifier = build_verifier(settings)`, `app.state.storage = None`, override `get_async_session`: + ```python + async def override_session() -> AsyncIterator[AsyncSession]: + async with db_session.begin_nested(): # как транзакция запроса: ошибка → откат до SAVEPOINT + yield db_session + ``` + - `auth_client(app, seeded) -> AuthClientFactory` где `AuthClientFactory = Callable[[str], Awaitable[httpx.AsyncClient]]`: по email запрашивает `POST /api/v1/dev/token`, возвращает клиент с `headers={"Authorization": f"Bearer {token}"}` и `base_url="http://test/api/v1"`; клиенты закрываются в teardown (`AsyncExitStack`). Dev-пользователи seed: `ka_rf@armka.local` (Ростовский РФ), `rka@armka.local` (Ростовский РФ), `client_manager@armka.local` (Краснодарский РФ), `ka_go@armka.local`, `curator_go@armka.local`, `expert@armka.local`, `admin@armka.local` (ГО). + +- [x] **Step 1:** написать фабрики и фикстуры; `test_me_and_dev_token.py` перевести на `app`/`auth_client` там, где он строит приложение сам (тесты сочетаний флагов `DEBUG`/`AUTH_DEV_ISSUER` оставить на локальном `_build_app`). +- [x] **Step 2:** `pytest -m integration tests/test_me_and_dev_token.py -v` → PASS; `ruff`, `mypy` чисты. +- [x] **Step 3: Commit** `test: фабрики справочников, фикстуры app/auth_client/seeded`. + +--- + +### Task 4: [2.1] Policy видимости — `src/domain/policy.py` + +**Files:** +- Create: `api/src/domain/__init__.py` (докстринг: «чистые функции по §§ правил, без I/O»), `api/src/domain/policy.py` +- Test: `api/tests/test_domain_policy.py` (без БД) + +**Interfaces:** +- Produces (`src/domain/policy.py`, §10.1): +```python +@dataclass(frozen=True) +class VisibilityScope: + """Область видимости справочников: `office_id is None` — вся система (ГО).""" + office_id: uuid.UUID | None + + @property + def is_global(self) -> bool: ... + +def scope_for(*, is_head_office: bool, regional_office_id: uuid.UUID) -> VisibilityScope +def scope_of(principal: "UserWithAccess") -> VisibilityScope # обёртка: scope_for(is_head_office=principal.regional_office.is_head_office, regional_office_id=principal.user.regional_office_id) +def company_groups_visible(stmt: Select[Any], scope: VisibilityScope) -> Select[Any] # WHERE CompanyGroup.base_regional_office_id == office +def legal_entities_visible(stmt: Select[Any], scope: VisibilityScope) -> Select[Any] # outerjoin CompanyGroup; WHERE group.base_office == office OR (le.company_group_id IS NULL AND le.regional_office_id == office) +def company_group_is_visible(group: CompanyGroup, scope: VisibilityScope) -> bool +def legal_entity_is_visible(entity: LegalEntity, group_office_id: uuid.UUID | None, scope: VisibilityScope) -> bool +def office_for_create(scope: VisibilityScope, requested: uuid.UUID | None) -> uuid.UUID | None + # ГО: requested как есть; филиал: None → свой офис; чужой → DomainValidationError("OFFICE_NOT_ALLOWED", "ГК можно закрепить только за своим филиалом") +``` +`scope_of` импортирует `UserWithAccess` только под `TYPE_CHECKING`, чтобы `domain/` не тянул репозитории. + +- [x] **Step 1: Тесты** (`tests/test_domain_policy.py`, без БД): `scope_for(is_head_office=True, …).is_global`; филиал → `office_id`; `company_groups_visible` для global возвращает тот же `stmt` (compile без WHERE), для филиала — `str(stmt)` содержит `base_regional_office_id`; `legal_entities_visible` содержит `OR` и `IS NULL`; `company_group_is_visible` — 4 комбинации; `legal_entity_is_visible` — монозаёмщик своего/чужого филиала, ЮЛ в ГК своего/чужого; `office_for_create` — 4 ветви, `DomainValidationError.code == "OFFICE_NOT_ALLOWED"`. +- [x] **Step 2:** падает → **Step 3:** реализация → **Step 4:** `pytest tests/test_domain_policy.py -v`, `mypy src` (strict для `src.domain.*`) → PASS. +- [x] **Step 5: Commit** `feat(domain): policy видимости справочников (§10.1, ASSUMPTION(OQ-86))`. + +--- + +### Task 5: [2.2] Филиалы и ГК: репозиторий, сервис, схемы, эндпоинты + +**Files:** +- Create: `api/src/repositories/directories.py`, `api/src/services/company_groups.py`, `api/src/schemas/directories.py`, `api/src/api/endpoints/directories/__init__.py`, `api/src/api/endpoints/directories/regional_offices.py`, `api/src/api/endpoints/directories/company_groups.py`, `api/src/api/dependencies/directories.py` +- Modify: `api/src/api/router.py` (include `directories.router` — префикс `/directories`, теги) +- Test: `api/tests/test_api_company_groups.py`, `api/tests/test_api_policy.py` + +**Interfaces:** +- `src/api/dependencies/directories.py`: + - `def get_scope(principal: Annotated[Principal, Depends(current_user)]) -> VisibilityScope` (= `scope_of(principal)`) + - `def get_company_group_service(session, audit: Depends(get_request_audit), scope: Depends(get_scope)) -> CompanyGroupService` +- `src/repositories/directories.py` (функции, как `repositories/users.py`): + - `async def next_code(session, seq: Sequence, prefix: str) -> str` → `f"{prefix}-{n:06d}"` через `select(seq.next_value())` + - `async def list_company_groups(session, scope, *, q: str | None, is_archived: bool | None, limit, offset) -> tuple[list[tuple[CompanyGroup, int]], int]` (ГК + `legal_entities_count` через подзапрос `count(legal_entity.id)`, `total` отдельным `count()`; сортировка `CompanyGroup.name`) + - `async def get_company_group(session, scope, group_id) -> CompanyGroup | None` (None и для невидимой) + - `async def list_company_group_history(session, group_id) -> list[CompanyGroupHistory]` + - `async def get_regional_office(session, office_id) -> RegionalOffice | None`, `async def list_regional_offices(session) -> list[RegionalOffice]` +- `src/services/company_groups.py`: +```python +class CompanyGroupService: + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None + async def create(self, data: CompanyGroupCreate) -> CompanyGroup # code из COMPANY_GROUP_CODE_SEQ; office_for_create; crm_id pre-check → ConflictError("CRM_ID_EXISTS", …); valid_from=created_at=now; updated_by=user; audit GROUP_CHANGE after_value={code,name,crm_id,base_regional_office_id} + async def update(self, group_id: uuid.UUID, data: CompanyGroupUpdate) -> CompanyGroup # NotFoundError; diff полей; нет изменений → вернуть без истории/аудита; иначе снимок CompanyGroupHistory(prev values, valid_from=group.valid_from, valid_to=now, changed_by=user), group.valid_from=now, updated_*; action = ASSIGNMENT_CHANGE если менялся base_regional_office_id, иначе GROUP_CHANGE; changed_field — имя поля при одном изменении; before/after — только изменённые поля + async def set_archived(self, group_id: uuid.UUID, archived: bool) -> CompanyGroup # GROUP_CHANGE changed_field="is_archived" + async def get(self, group_id) -> CompanyGroup # NotFoundError("ГК не найдена") + async def history(self, group_id) -> list[CompanyGroupHistory] + async def page(self, params: CompanyGroupFilter, page: PageParams) -> tuple[list[tuple[CompanyGroup, int]], int] +``` + Константа сообщения `COMPANY_GROUP_NOT_FOUND = "ГК не найдена"` — одно и то же для чужой и несуществующей (spec directory-policy). +- `src/schemas/directories.py`: `RegionalOfficeOut(id, name, is_head_office, is_active)`; `CompanyGroupCreate(name: str = Field(min_length=1, max_length=500), crm_id: str | None = None, base_regional_office_id: uuid.UUID | None = None)`; `CompanyGroupUpdate` — те же поля, все `Optional`, `model_config = ConfigDict(extra="forbid")`, семантика «поле передано» через `model_fields_set`; `CompanyGroupOut(id, code, crm_id, name, base_regional_office_id, is_archived, valid_from, legal_entities_count: int = 0, created_at, updated_at)`; `CompanyGroupHistoryOut(id, crm_id, name, base_regional_office_id, valid_from, valid_to, changed_by)`; `CompanyGroupFilter(q: str | None = None, is_archived: bool | None = None)`. +- Эндпоинты (`/api/v1/directories/...`, все с `Depends(require("DIRECTORY_VIEW"|"DIRECTORY_EDIT"))`): + `GET /regional-offices` → `list[RegionalOfficeOut]`; `GET /company-groups` → `Page[CompanyGroupOut]`; `POST /company-groups` → 201; `GET /company-groups/{id}`; `PATCH /company-groups/{id}`; `POST /company-groups/{id}/archive`, `/unarchive`; `GET /company-groups/{id}/history`. + +- [x] **Step 1: Тесты** (`tests/test_api_company_groups.py`, `@pytest.mark.integration`, `auth_client`): + 1. `ka_go` создаёт две ГК → коды `GK-` + 6 цифр, различаются; в `audit_event` две `GROUP_CHANGE` с `after_value["code"]`. + 2. `PATCH base_regional_office_id` (на Краснодарский РФ по id из `/regional-offices`) → `company_group_history` 1 строка с прежним филиалом и `valid_to`; `valid_from` ГК изменился; `audit_event(ASSIGNMENT_CHANGE)` с `before_value`/`after_value`. + 3. Дубликат `crm_id` → 409 `{"detail": {"code": "CRM_ID_EXISTS", ...}}`. + 4. **no-op PATCH** (то же имя) → 200, история пуста, аудита нет (Review Focus 1). + 5. `expert` (`DIRECTORY_VIEW` только) → `POST` 403, аудита нет. + 6. Список: `q` по подстроке имени без регистра, `is_archived`, `offset > total` → `items == []`, `total` верный (Review Focus 5); `limit=201` → 422. + 7. `archive`/`unarchive` переключают `is_archived`, пишут `GROUP_CHANGE changed_field="is_archived"`. + `tests/test_api_policy.py` (сценарии spec directory-policy): + 1. ГК А (Ростовский РФ) и ГК Б (Краснодарский РФ) через фабрики; `ka_rf` видит только А (`total == 1`); `ka_go` — обе. + 2. `ka_rf` `GET /company-groups/{Б}` → 404, тело совпадает с `GET /company-groups/{uuid4()}`. + 3. `ka_rf` создаёт ГК без филиала → `base_regional_office_id` = Ростовский; с чужим → 422 `OFFICE_NOT_ALLOWED`. +- [x] **Step 2:** падает → **Step 3:** реализация → **Step 4:** `pytest -m integration tests/test_api_company_groups.py tests/test_api_policy.py -v`; `ruff`, `mypy` → PASS. +- [x] **Step 5: Commit** `feat(directories): филиалы и ГК — API, история, аудит, видимость`. + +--- + +### Task 6: [2.3] ЮЛ/ИП: CRUD, перевод, вывод из состава, история + +**Files:** +- Create: `api/src/domain/inn.py`, `api/src/services/legal_entities.py`, `api/src/api/endpoints/directories/legal_entities.py` +- Modify: `api/src/repositories/directories.py`, `api/src/schemas/directories.py`, `api/src/api/dependencies/directories.py`, `api/src/api/endpoints/directories/__init__.py` +- Test: `api/tests/test_domain_inn.py`, `api/tests/test_api_legal_entities.py` + +**Interfaces:** +- `src/domain/inn.py`: `INN_LENGTH = {EntityType.ЮЛ: 10, EntityType.ИП: 12}`; `def normalize_inn(raw: str) -> str` (убрать пробелы); `def validate_inn(inn: str, entity_type: EntityType) -> str` → нормализованный ИНН или `DomainValidationError("INVALID_INN", f"ИНН {entity_type.value} — ровно {n} цифр")`; `# ASSUMPTION(OQ-88): контрольные разряды не проверяются`. +- Репозиторий: `list_legal_entities(session, scope, *, company_group_id, outside_group: bool | None, inn: str | None, q: str | None, closed: bool | None, limit, offset) -> tuple[list[LegalEntity], int]`; `get_legal_entity(session, scope, entity_id) -> LegalEntity | None` (через `legal_entities_visible`); `get_legal_entity_by_inn(session, inn) -> LegalEntity | None` (без scope — для pre-check уникальности); `list_legal_entity_history(session, entity_id)`; `current_memberships(session, entity_id) -> list[RelatedGroupMember]`; `close_memberships(session, entity_id, now) -> list[RelatedGroupMember]` (SET `valid_to`, `is_current=false` на всех текущих; возвращает закрытые). +- `src/services/legal_entities.py`: +```python +class LegalEntityService: + def __init__(self, session, audit: RequestAudit, scope: VisibilityScope) -> None + async def create(self, data: LegalEntityCreate) -> LegalEntity # validate_inn; INN_EXISTS → ConflictError; crm_id → CRM_ID_EXISTS; company_group_id — через get_company_group(scope) иначе NotFoundError("ГК не найдена"); regional_office_id по умолчанию = офис ГК, для монозаёмщика — office_for_create; audit LEGAL_ENTITY_CHANGE + async def update(self, entity_id, data: LegalEntityUpdate) -> LegalEntity # name, entity_type (с повторной validate_inn), crm_id, regional_office_id; no-op без истории; снимок LegalEntityHistory(event_type=UPDATE, valid_from=entity.valid_from, valid_to=now, все поля-снимки), entity.valid_from=now; audit LEGAL_ENTITY_CHANGE + async def move(self, entity_id, data: LegalEntityMove) -> LegalEntity # effective_date >= today иначе DomainValidationError("EFFECTIVE_DATE_IN_PAST") ASSUMPTION(OQ-21); целевая ГК через scope → 404; та же ГК → ConflictError("SAME_COMPANY_GROUP"); снимок MOVE_TO_GROUP; close_memberships; audit LEGAL_ENTITY_CHANGE (before/after company_group_id) + MEMBERSHIP_CHANGE на каждое закрытое членство (object_type="related_group_member") + async def withdraw(self, entity_id, data: LegalEntityWithdraw) -> LegalEntity # event CLOSE | REMOVE_FROM_GROUP; company_group_id=None; CLOSE → close_date=effective_date; снимок с event_type; close_memberships; аудит как в move; comment → audit.comment + async def get(self, entity_id) -> LegalEntityDetail # entity + current memberships (related_group id/code/kind/name) + aliases + async def history(self, entity_id) -> list[LegalEntityHistory] + async def page(self, flt: LegalEntityFilter, page: PageParams) -> tuple[list[LegalEntity], int] +``` +- Схемы: `LegalEntityCreate(inn: str, entity_type: EntityType, name: str, company_group_id: uuid | None = None, regional_office_id: uuid | None = None, crm_id: str | None = None)`; `LegalEntityUpdate` (name, entity_type, crm_id, regional_office_id — optional, `extra="forbid"`); `LegalEntityMove(company_group_id: uuid.UUID, effective_date: datetime.date)`; `LegalEntityWithdraw(event: Literal["CLOSE", "REMOVE_FROM_GROUP"], effective_date: datetime.date, comment: str | None = None)`; `LegalEntityOut(id, inn, crm_id, entity_type, name, company_group_id, regional_office_id, close_date, valid_from, outside_group: bool, closed: bool, created_at, updated_at)`; `MembershipOut(related_group_id, code, kind, name, valid_from)`; `AliasOut(id, alias, normalized_alias, created_at)`; `LegalEntityDetail(LegalEntityOut + memberships: list[MembershipOut], aliases: list[AliasOut])`; `LegalEntityHistoryOut(все поля снимка + event_type, effective_date, valid_from, valid_to, changed_by)`; `LegalEntityFilter(company_group_id, outside_group, inn, q, closed)`. +- Эндпоинты: `GET/POST /legal-entities`, `GET/PATCH /legal-entities/{id}`, `POST /legal-entities/{id}/move`, `POST /legal-entities/{id}/withdraw`, `GET /legal-entities/{id}/history`. + +- [x] **Step 1: Тесты.** `tests/test_domain_inn.py` (unit): 10 цифр ЮЛ ок, 12 ИП ок, `0123456789` ок (Review Focus 2), 10 цифр для ИП → `INVALID_INN`, буквы → `INVALID_INN`, пробелы внутри убираются. `tests/test_api_legal_entities.py` (integration): создание ИП с 10 цифрами → 422 `INVALID_INN`; дубликат ИНН → 409 `INN_EXISTS`; монозаёмщик (`ka_rf`, без ГК) → `outside_group == true`, виден `ka_rf` и `ka_go`, не виден `client_manager`; `move` закрывает членство (фабрики `make_related_group` + `make_membership`), история `MOVE_TO_GROUP` с датой; `move` в ГК чужого филиала пользователем `ka_rf` → 404 (Review Focus 3); `effective_date` вчера → 422; `withdraw CLOSE` → `close_date`, `company_group_id is None`, `closed == true` в списке с фильтром `closed=true`; no-op PATCH без истории; фильтр `inn=" 3528052718 "` находит ЮЛ с `inn="3528052718"`; откат мутации — `monkeypatch` `close_memberships` на бросающую `RuntimeError` → 500, ЮЛ в прежней ГК, истории/аудита нет (SAVEPOINT откатился — проверяется в той же сессии). +- [x] **Step 2–4:** падает → реализация → `pytest tests/test_domain_inn.py && pytest -m integration tests/test_api_legal_entities.py -v`; `ruff`, `mypy`. +- [x] **Step 5: Commit** `feat(directories): ЮЛ/ИП — CRUD, перевод, вывод из состава, история, аудит`. + +--- + +### Task 7: [2.4] Алиасы ЮЛ + +**Files:** +- Create: `api/src/domain/aliases.py`, `api/src/services/aliases.py` +- Modify: `api/src/repositories/directories.py` (`find_alias_owner(session, normalized) -> LegalEntityAlias | None`, `list_aliases(session, entity_id)`), `api/src/api/endpoints/directories/legal_entities.py` (маршруты алиасов), `api/src/schemas/directories.py` (`AliasCreate(alias: str = Field(min_length=1, max_length=500))`) +- Test: `api/tests/test_domain_aliases.py`, `api/tests/test_api_aliases.py` + +**Interfaces:** +- `src/domain/aliases.py` (§3 п. 4 — «lower, trim, убрать пунктуацию/кавычки, убрать ОПФ (ООО, АО, ПАО, ИП…), схлопнуть пробелы»): +```python +LEGAL_FORMS: frozenset[str] = frozenset({"ооо", "ао", "пао", "зао", "оао", "нао", "ип", "гуп", "муп", "фгуп", "ано", "тсж", "кфх", "пк", "спк", "сх", "сха"}) # одиночные токены ОПФ (§3); убираются только целиком, «ооо» внутри слова не трогается +_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE) # кавычки «»"'„“ и прочая пунктуация → пробел +def normalize_alias(raw: str) -> str: + """§3: lower → ё→е → пунктуация и кавычки → пробел → убрать токены ОПФ → схлопнуть пробелы → strip.""" +``` +- `AliasService(session, audit, scope)`: `async def add(entity_id, data) -> LegalEntityAlias` (ЮЛ через scope → 404; `normalize_alias`; пустая нормализованная строка → `DomainValidationError("EMPTY_ALIAS")`; у того же ЮЛ → `ConflictError("ALIAS_EXISTS")`; у другого ЮЛ → `ConflictError("ALIAS_TAKEN", …, legal_entity_id=owner_id)`; `created_by`, `created_at`; audit `LEGAL_ENTITY_CHANGE object_type="legal_entity_alias"`), `async def remove(entity_id, alias_id) -> None` (404 если не у этого ЮЛ; audit с `before_value`). +- Маршруты: `POST /legal-entities/{id}/aliases` → 201 `AliasOut` (`DIRECTORY_EDIT`), `DELETE /legal-entities/{id}/aliases/{alias_id}` → 204 (`DIRECTORY_DELETE`). + +- [x] **Step 1: Тесты.** unit: `normalize_alias('ООО «Пекарня Счастливочная»') == "пекарня счастливочная"`; `'Пекарня Счастливочная, ООО'` → то же; `'АО "РУССКИЙ БИСКВИТ"'` → `"русский бисквит"`; `'ИП Иванов И.И.'` → `"иванов и и"`; ё/е; двойные пробелы; строка только из ОПФ → `""`. integration: сценарий спеки «Нормализация» (409 `ALIAS_EXISTS`), `ALIAS_TAKEN` с `legal_entity_id` владельца, `DELETE` → 204 и запись аудита, `expert` → 403 на POST. +- [x] **Step 2–4:** падает → реализация → тесты, `ruff`, `mypy` → PASS. +- [x] **Step 5: Commit** `feat(directories): алиасы ЮЛ с нормализацией §3`. + +--- + +### Task 8: [2.5] ГСК/ГСЗ и состав + +**Files:** +- Create: `api/src/services/related_groups.py`, `api/src/api/endpoints/directories/related_groups.py` +- Modify: `api/src/repositories/directories.py`, `api/src/schemas/directories.py`, `api/src/api/dependencies/directories.py`, `api/src/api/endpoints/directories/__init__.py` +- Test: `api/tests/test_api_related_groups.py` + +**Interfaces:** +- Репозиторий: `list_related_groups(session, company_group_id) -> list[tuple[RelatedGroup, int]]` (с числом текущих членов), `get_related_group(session, scope, group_id) -> RelatedGroup | None` (через ГК и `company_groups_visible`), `current_membership_of_kind(session, entity_id, kind) -> RelatedGroupMember | None`, `current_members(session, related_group_id) -> list[tuple[RelatedGroupMember, LegalEntity]]`, `has_current_members(session, related_group_id) -> bool`. +- `RelatedGroupService(session, audit, scope)`: + - `create(company_group_id, data: RelatedGroupCreate) -> RelatedGroup` — ГК через scope → 404; `code = next_code(RELATED_GROUP_CODE_SEQ, "GSK"|"GSZ")`; audit `MEMBERSHIP_CHANGE object_type="related_group"`. + - `update(group_id, data: RelatedGroupUpdate)` — name, created_date. + - `delete(group_id)` — `has_current_members` → `ConflictError("GROUP_NOT_EMPTY", "в группе есть текущие члены")` `# ASSUMPTION(OQ-34)`; удаляет и закрытые строки состава (история группы исчезает вместе с ней); audit с `before_value`. + - `add_member(group_id, data: MemberAdd) -> RelatedGroupMember` — ЮЛ через scope → 404; `entity.company_group_id != group.company_group_id` (в т.ч. `None`) → `DomainValidationError("DIFFERENT_COMPANY_GROUP")`; `current_membership_of_kind(entity, group.kind)`: та же группа → `ConflictError("MEMBER_EXISTS")`, другая → `ConflictError("MEMBER_OF_OTHER_GROUP", related_group_id=…)`; строка `RelatedGroupMember(kind=group.kind, valid_from=now, is_current=True, created_by=user)`; audit `MEMBERSHIP_CHANGE object_type="related_group_member"`. + - `remove_member(group_id, legal_entity_id)` — текущее членство → `valid_to=now, is_current=False`; нет → 404; audit. + - `get(group_id) -> RelatedGroupDetail`, `list_for(company_group_id) -> list[RelatedGroupOut]`. +- Схемы: `RelatedGroupCreate(kind: RelatedGroupKind, name: str, created_date: date | None = None)`, `RelatedGroupUpdate(name?, created_date?)`, `MemberAdd(legal_entity_id: uuid.UUID)`, `RelatedGroupOut(id, code, company_group_id, kind, name, created_date, members_count: int)`, `RelatedGroupMemberOut(legal_entity_id, inn, name, valid_from)`, `RelatedGroupDetail(RelatedGroupOut + members: list[RelatedGroupMemberOut])`. +- Маршруты: `GET/POST /company-groups/{gk_id}/related-groups`; `GET/PATCH /related-groups/{id}`; `DELETE /related-groups/{id}` (`DIRECTORY_DELETE`); `POST /related-groups/{id}/members` → 201; `DELETE /related-groups/{id}/members/{legal_entity_id}` → 204. + +- [x] **Step 1: Тесты** (сценарии спеки + Review Focus 4): коды `GSK-`/`GSZ-` + 6 цифр; ЮЛ в ГСК и затем в ГСЗ той же ГК — обе 201, два текущих членства с разными `kind`; вторая ГСК → 409 `MEMBER_OF_OTHER_GROUP` с id ГСК A; повтор в той же → 409 `MEMBER_EXISTS`; исключение → `is_current=false`, `valid_to`; повторное включение → новая строка, старая осталась; удаление непустой → 409 `GROUP_NOT_EMPTY`; удаление пустой → 204; ЮЛ другой ГК → 422 `DIFFERENT_COMPANY_GROUP`; **закрытый ЮЛ (без ГК) → 422** `DIFFERENT_COMPANY_GROUP`; `kind` строки = `kind` группы (проверить в БД); `ka_rf` для группы чужой ГК → 404. +- [x] **Step 2–4:** падает → реализация → `pytest -m integration tests/test_api_related_groups.py -v`, полный `make check` → PASS. +- [x] **Step 5: Commit** `feat(directories): ГСК/ГСЗ и состав (SCD2, одна группа каждого вида на ЮЛ)`. + +--- + +## Завершение фазы 1 + +После Task 8: `make check` (из корня) и `make dbml-check` зелёные; `pytest -m integration` зелёный; `tasks.md` change'а — группы 1–2 отмечены `[x]`. Фаза 2 (`2026-10-04-slice-1-directories-phase2.md`): импорт из ЕЦП, администрирование, фронт, документы, `make check-full`. + +- [x] Проверки фазы 1 (Task 8, 2026-10-04): `make check` — 667 passed, ruff/mypy чистые; `make dbml-check` — пустой вывод; `pytest -m integration` — 229 passed, 0 skipped, 0 failed; `tasks.md` — группы 1–2 `[x]`. diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md new file mode 100644 index 0000000..ffa1311 --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md @@ -0,0 +1,268 @@ +# Срез 1 — справочники arm-core — план исполнения, фаза 2 (импорт из ЕЦП CRM, администрирование, экран администратора, документы) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Branch:** `feat/5-slice-1-directories` (worktree `.wt/slice-1/arm-core`). **Issue:** RSHB/arm-core#5. **PR:** RSHB/arm-core#6. +**Spec:** `openspec/changes/slice-1-directories/` (design D-7…D-11; specs `directory-import`, `users-admin`, `audit-trail`). Правила: `../docs/context/05-domain-rules.md` §13 (импорт: формат 13.1, построчные правила и коды 13.2, транзакция/отчёт/след 13.3), §10 (роли, права). Фикстуры: `../fixtures/directories/ecp-export-russkiy-biskvit.xlsx` (21 строка ГК «Русский Бисквит»), полная выгрузка `../docs/source/biq/ecp-export-lagal-entity.xlsx` (8400 строк; читать только, не менять). +**Предусловие:** фаза 1 выполнена (сервисы `CompanyGroupService`, `LegalEntityService`, `RelatedGroupService`, репозиторий `repositories/directories.py`, `RequestAudit`, `DomainError`, фикстуры `app`/`auth_client`/`seeded`, фабрики). + +**Goal:** импорт реальной выгрузки ЕЦП CRM с отчётом, `dry_run` и следом в аудите/S3; администрирование пользователей, ролей и матрицы прав через API и минимальный экран `/admin`; документы и `make check-full`. + +**Architecture:** импорт = чистое ядро (`src/domain/directory_import.py`: чтение файла → нормализация → план операций и отчёт над снимком справочника в памяти) + сервис, применяющий план в транзакции запроса. Администрирование — сервис `users_admin.py` над `app_user`/`user_role`/`role_permission` с аудитом. Фронт — fetch-клиент с Bearer, dev-вход по email, одна страница на antd без Redux. + +**Tech Stack:** как в фазе 1 + `openpyxl` (чтение xlsx, read-only), `python-multipart` (уже есть); фронт React 18 + TS strict, antd 6, react-router 7, rsbuild, biome. + +## Global Constraints + +Все пункты «Global Constraints» фазы 1 действуют. Дополнительно: +- Коды отчёта импорта — ровно перечень §13.2 / spec directory-import: итоговые на строку `UNSUPPORTED_TYPE`, `INVALID_INN`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`, `GK_NAME_CONFLICT` (пропуск) | `LE_CREATED`, `LE_UPDATED`, `LE_EXISTS` (применена); сопутствующие события `RF_CREATED`, `GK_CREATED`, `GK_UPDATED`, `GK_EXISTS`, `GSK_CREATED`, `GSZ_CREATED`, `MEMBER_ADDED`, `UNKNOWN_FLAG`; ошибки файла `MISSING_COLUMNS`, `UNSUPPORTED_FILE`. Других кодов не вводить; каждое допущение — `# ASSUMPTION(OQ-87|88|89|90|91)`. +- Импорт: право `DIRECTORY_ADMIN`; `dry_run` по умолчанию `true`; `on_existing ∈ {skip, update}`, по умолчанию `skip`; одна транзакция запроса; при `dry_run` — явный `rollback` сессии до возврата ответа и **ни аудита, ни файла в S3**; лимит `IMPORT_MAX_BYTES = 20 * 1024 * 1024` (Settings) → 413. +- Администрирование: право `USERS_ADMIN`; пользователей не удалять; email обязателен и уникален без учёта регистра; новые коды прав не создаются; `LAST_ADMIN` защищает последнее право `USERS_ADMIN` у активных пользователей (и при снятии роли с пользователя, и при снятии права с роли). +- Seed не меняется (16 кодов прав, матрица §10); тесты `test_seed_roles.py`, `test_me_and_dev_token.py` не трогать. +- Фронт: `biome check src` (ширина 140, одинарные кавычки, табуляция как в репозитории), `tsc --noEmit` strict, `npm run build`; env только `REACT_APP_*` через `conf/config.ts`; никаких новых npm-зависимостей (antd, icons, react-router уже есть); `raisa-*` не импортировать. + +## Review Focus + +1. **xlsx с пустыми строками в конце листа и объединёнными ячейками заголовка** — пустые строки пропускаются без кодов, заголовок ищется в первой непустой строке: Task 9 — тест на синтетическом xlsx с 3 пустыми строками в хвосте. +2. **ИНН в xlsx как число** (`3528052718.0`, научная нотация) — openpyxl отдаёт `int`/`float`; нормализация переводит в строку без `.0`: Task 9 — тест. +3. **csv в cp1251** (типичный экспорт Excel) — `UnicodeDecodeError` → 422 `UNSUPPORTED_FILE` с сообщением «ожидается UTF-8», не 500: Task 10 — тест. +4. **Повторный `dry_run` одного файла** не должен расходовать последовательности кодов заметно для пользователя (коды с дырами допустимы, но не падение): Task 10 — тест, что второй `dry_run` возвращает тот же отчёт. +5. **Администратор деактивирует самого себя** — `PATCH is_active=false` на себе → 409 `LAST_ADMIN`, если он последний активный с `USERS_ADMIN`; иначе разрешено: Task 12 — тест. + +--- + +## Задачи + +### Task 9: [3.1] Чтение файла и ядро решений импорта (чистые функции) + +**Files:** +- Modify: `api/requirements.txt` (`openpyxl==3.1.5` в алфавитном порядке; `make venv` переустанавливает) +- Create: `api/src/domain/directory_import.py` +- Test: `api/tests/test_domain_directory_import.py`, `api/tests/data/import/` (синтетические csv/xlsx генерируются в тесте через `openpyxl`/`csv`, файлы в репозиторий не кладутся) + +**Interfaces (`src/domain/directory_import.py`, §13.1–13.2):** +```python +class ImportCode(enum.StrEnum): + UNSUPPORTED_TYPE = "UNSUPPORTED_TYPE"; INVALID_INN = "INVALID_INN"; DUPLICATE_IN_FILE = "DUPLICATE_IN_FILE" + LE_IN_OTHER_GROUP = "LE_IN_OTHER_GROUP"; GK_NAME_CONFLICT = "GK_NAME_CONFLICT" + LE_CREATED = "LE_CREATED"; LE_UPDATED = "LE_UPDATED"; LE_EXISTS = "LE_EXISTS" + RF_CREATED = "RF_CREATED"; GK_CREATED = "GK_CREATED"; GK_UPDATED = "GK_UPDATED"; GK_EXISTS = "GK_EXISTS" + GSK_CREATED = "GSK_CREATED"; GSZ_CREATED = "GSZ_CREATED"; MEMBER_ADDED = "MEMBER_ADDED"; UNKNOWN_FLAG = "UNKNOWN_FLAG" + +class OnExisting(enum.StrEnum): skip = "skip"; update = "update" + +REQUIRED_COLUMNS = ("инн", "idгк", "группакомпаний", "организация", "тип") +OPTIONAL_COLUMNS = ("базовыйрфгквcrm", "гск", "гсз") +def normalize_header(raw: object) -> str # lower, без пробелов, ё→е +def normalize_cell(raw: object) -> str # None→""; int→str; float целое→str без .0; str→strip +FLAG_GSK = "входитвгск"; FLAG_GSZ = "входитвгсз" # сравнение после normalize_header + +class FileFormatError(Exception): code: str ("MISSING_COLUMNS" | "UNSUPPORTED_FILE"); message: str; missing: list[str] + +@dataclass(frozen=True) +class ImportRow: + row: int; inn: str; crm_id: str; office_name: str; group_name: str; entity_name: str; gsk_flag: str; gsz_flag: str; type_raw: str + +def read_rows(filename: str, content: bytes) -> list[ImportRow] + # по расширению: .xlsx → openpyxl load_workbook(BytesIO, read_only=True, data_only=True), первый лист, первая непустая строка — заголовки; + # .csv → decode utf-8-sig (UnicodeDecodeError → FileFormatError UNSUPPORTED_FILE "ожидается UTF-8"), разделитель по строке заголовков (';' если в ней больше ';' чем ','), csv.reader; + # иное расширение / битый zip → FileFormatError UNSUPPORTED_FILE; нет обязательных колонок → FileFormatError MISSING_COLUMNS(missing=[исходные имена]); + # пустые строки (все ячейки "") пропускаются; row = номер строки в файле (1-based, как в Excel) + +# снимок справочника (строится сервисом из БД, в домене — только данные) +@dataclass +class OfficeSnap: id: uuid.UUID; name: str +@dataclass +class GroupSnap: id: uuid.UUID; crm_id: str; name: str; office_id: uuid.UUID | None; gsk_id: uuid.UUID | None; gsz_id: uuid.UUID | None +@dataclass +class EntitySnap: id: uuid.UUID; inn: str; name: str; entity_type: EntityType; group_id: uuid.UUID | None; gsk_group_id: uuid.UUID | None; gsz_group_id: uuid.UUID | None # текущие членства +@dataclass +class DirectorySnapshot: + offices_by_name: dict[str, OfficeSnap]; groups_by_crm: dict[str, GroupSnap]; entities_by_inn: dict[str, EntitySnap] + +# операции плана — только данные; применяет сервис (Task 10). id новых объектов генерирует домен (uuid4), чтобы ссылки внутри плана были готовы. +@dataclass(frozen=True) +class CreateOffice: id: uuid.UUID; name: str +@dataclass(frozen=True) +class CreateGroup: id: uuid.UUID; crm_id: str; name: str; office_id: uuid.UUID | None +@dataclass(frozen=True) +class UpdateGroup: id: uuid.UUID; name: str | None; office_id: uuid.UUID | None # None = без изменений; office — только если в файле непусто +@dataclass(frozen=True) +class CreateEntity: id: uuid.UUID; inn: str; name: str; entity_type: EntityType; group_id: uuid.UUID; office_id: uuid.UUID | None +@dataclass(frozen=True) +class UpdateEntity: id: uuid.UUID; name: str | None; entity_type: EntityType | None +@dataclass(frozen=True) +class CreateRelatedGroup: id: uuid.UUID; group_id: uuid.UUID; kind: RelatedGroupKind; name: str +@dataclass(frozen=True) +class AddMember: related_group_id: uuid.UUID; entity_id: uuid.UUID; kind: RelatedGroupKind +Operation = CreateOffice | CreateGroup | UpdateGroup | CreateEntity | UpdateEntity | CreateRelatedGroup | AddMember + +@dataclass +class RowResult: row: int; inn: str; code: ImportCode; message: str +@dataclass +class ImportReport: + rows: int = 0; groups_created: int = 0; groups_updated: int = 0; groups_skipped: int = 0 + entities_created: int = 0; entities_updated: int = 0; entities_skipped: int = 0 + memberships_created: int = 0; offices_created: int = 0; errors: int = 0 + lines: list[RowResult] = field(default_factory=list) + def to_dict(self) -> dict[str, Any] +@dataclass +class ImportPlan: operations: list[Operation]; report: ImportReport + +def plan_import(rows: Sequence[ImportRow], snapshot: DirectorySnapshot, on_existing: OnExisting) -> ImportPlan +``` +Алгоритм `plan_import` — §13.2 построчно, в порядке строк файла, с накопителями «создано в этом файле» (офисы по имени, ГК по crm_id, ЮЛ по ИНН, ГСК/ГСЗ по ГК), чтобы одна ГК из 20 строк создавалась один раз: +1. `type_raw.lower()` ∉ {«юл», «ип»} → `UNSUPPORTED_TYPE` (`errors += 1`; `# ASSUMPTION(OQ-87)`), строка завершена. +2. `inn = normalize_inn(...)`; длина ≠ 10 (ЮЛ) / 12 (ИП) или не цифры → `INVALID_INN` (`# ASSUMPTION(OQ-88)`). +3. ИНН уже встречался в файле → `DUPLICATE_IN_FILE`. +4. Офис: `office_name` пусто → `office_id=None`; есть в снимке/накопителе → id; иначе `CreateOffice` + событие `RF_CREATED` (`offices_created += 1`; строка события — `RowResult(row, inn, RF_CREATED, "создан филиал «…»")`; `# ASSUMPTION(OQ-89)`). +5. ГК по `crm_id`. Пустой `iD ГК` → итоговый код `GK_NAME_CONFLICT` с сообщением «пустой iD ГК», `errors += 1`, строка завершена (отдельного кода спека не предусматривает). Нет → `CreateGroup` (`GK_CREATED`, `groups_created += 1`); есть и имя/офис отличаются: `update` → `UpdateGroup` (`GK_UPDATED`, один раз на ГК), `skip` → `GK_EXISTS` (`groups_skipped += 1`, один раз на ГК); разные имена одной ГК внутри файла → `GK_NAME_CONFLICT` (строка пропущена, `errors += 1`). +6. ЮЛ по ИНН: нет → `CreateEntity` (`LE_CREATED`, `entities_created += 1`, `office_id` = офис ГК); есть в этой же ГК → `update` и отличия → `UpdateEntity` (`LE_UPDATED`), иначе `LE_EXISTS` (`entities_skipped += 1`); есть в другой ГК (или без ГК) → `LE_IN_OTHER_GROUP` (`errors += 1`, `# ASSUMPTION(OQ-90)`), строка завершена без членств. +7. Флаги: `normalize_header(flag)` == `FLAG_GSK`/`FLAG_GSZ` → членство; непустое иное → `UNKNOWN_FLAG` (событие, не ошибка). Для флага: ГСК/ГСЗ ГК из снимка/накопителя или `CreateRelatedGroup(kind, name=имя ГК)` (`GSK_CREATED`/`GSZ_CREATED`, `# ASSUMPTION(OQ-91)`); у ЮЛ нет текущего членства этого вида → `AddMember` (`MEMBER_ADDED`, `memberships_created += 1`); есть в этой группе → ничего; есть в другой группе того же вида → ничего (импорт не переводит; событие не пишется). +`rows` = число непустых строк данных. Итоговый код строки — ровно один из восьми; события — дополнительные `RowResult` с тем же `row`. + +- [x] **Step 1: Тесты** (`tests/test_domain_directory_import.py`, без БД): + - `normalize_header("Базовый РФ ГК в CRM") == "базовыйрфгквcrm"`, `normalize_cell(3528052718.0) == "3528052718"`, `normalize_cell(None) == ""`. + - `read_rows` csv `;` и `,`, utf-8-sig, перестановка колонок, лишняя колонка; без «Тип» → `FileFormatError.code == "MISSING_COLUMNS"`, `missing == ["Тип"]`; `.txt` → `UNSUPPORTED_FILE`; xlsx, собранный `openpyxl.Workbook()` с заголовком во 2-й строке, ИНН числом, 3 пустыми строками в хвосте (Review Focus 1–2) → 2 строки данных. + - `plan_import` на синтетических строках, по одному тесту на каждый код (параметризация: строка → ожидаемый итоговый код и события), пустой снимок и снимок с существующими; `update` vs `skip`; две строки одной ГК → один `CreateGroup`; флаг у двух ЮЛ одной ГК → один `CreateRelatedGroup` и два `AddMember`; ЮЛ в другой ГК → нет `AddMember`. + - `@pytest.mark.fixtures`: `read_rows` + `plan_import` на `fixtures_dir / "directories/ecp-export-russkiy-biskvit.xlsx"` с пустым снимком: `report.rows == 21`, `groups_created == 1`, `offices_created == 1`, `entities_created == 20` (18 ЮЛ + 2 ИП), ровно одна `CreateRelatedGroup(kind=gsk)`, два `AddMember`, нет `gsz`; коды строк ФЛ/дубля — зафиксировать по фактическому порядку в файле (посмотреть файл в тесте, не угадывать) и записать ожидание явно. +- [x] **Step 2:** падает → **Step 3:** реализация (+ `openpyxl` в requirements, `make venv`) → **Step 4:** `pytest tests/test_domain_directory_import.py -v`, `mypy src` (strict для `src.domain.*`), `ruff` → PASS. +- [x] **Step 5: Commit** `feat(domain): ядро импорта справочников из выгрузки ЕЦП (§13.1–13.2)`. + +--- + +### Task 10: [3.2] Сервис и эндпоинт импорта + +**Files:** +- Create: `api/src/services/directory_import.py`, `api/src/schemas/directory_import.py`, `api/src/api/endpoints/directories/import_.py` +- Modify: `api/src/core/config.py` (`IMPORT_MAX_BYTES: PositiveInt = 20 * 1024 * 1024`), `api/.env.example` (`IMPORT_MAX_BYTES=20971520` с комментарием), `api/src/repositories/directories.py` (`load_snapshot(session) -> DirectorySnapshot` — три запроса: офисы, ГК с их ГСК/ГСЗ (`related_group` по `company_group_id`, первая группа каждого вида), ЮЛ с текущими членствами), `api/src/api/dependencies/directories.py`, `api/src/api/endpoints/directories/__init__.py` +- Test: `api/tests/test_api_directory_import.py` + +**Interfaces:** +- `DirectoryImportService(session, audit: RequestAudit, storage: S3Storage | None, settings: Settings)`: +```python +async def run(self, *, filename: str, content: bytes, dry_run: bool, on_existing: OnExisting) -> ImportReport +``` + - `len(content) > settings.IMPORT_MAX_BYTES` → `HTTPException(413, "файл больше 20 МБ")` (проверяется в эндпоинте до чтения в память: `UploadFile.size` если известен, иначе чтение с лимитом). + - `read_rows` → `FileFormatError` → `DomainValidationError(code, message, missing=…)` (422). + - `snapshot = await load_snapshot(session)`; `plan = plan_import(rows, snapshot, on_existing)`. + - Применение операций по порядку: `CreateOffice` → `RegionalOffice(id, name, is_head_office=False, is_active=True)`; `CreateGroup` → `CompanyGroup(id, code=await next_code(COMPANY_GROUP_CODE_SEQ, "GK"), crm_id, name, base_regional_office_id, valid_from=now, created_at=now, updated_by=user)`; `UpdateGroup` → как `CompanyGroupService.update` (снимок истории, аудит `GROUP_CHANGE`/`ASSIGNMENT_CHANGE` с `description="импорт ЕЦП"`) — переиспользовать внутреннюю функцию сервиса ГК, вынесенную в `services/company_groups.py` как `apply_group_update(session, audit, group, *, name, office_id, now)`; `CreateEntity` → `LegalEntity(...)`, `UpdateEntity` → снимок `legal_entity_history(UPDATE)` через `apply_entity_update(...)` из `services/legal_entities.py`; `CreateRelatedGroup` → `RelatedGroup(id, code=await next_code(RELATED_GROUP_CODE_SEQ, "GSK"|"GSZ"), …)`; `AddMember` → `RelatedGroupMember(kind, valid_from=now, is_current=True, created_by=user)`. Один `flush` после пакета операций каждого типа; вставки через `session.add_all`. + - `dry_run=True`: после применения и сборки отчёта — `await session.rollback()` (транзакция запроса станет пустой; в тестах — откат SAVEPOINT), вернуть отчёт; аудит и S3 не вызываются. + - `dry_run=False`: `event = await audit.record(action=DIRECTORY_IMPORT, section=DIRECTORIES, object_type="directory_import", description=filename, after_value=report.to_dict())`; при `storage is not None`: ключ `f"uploads/imports/{event.id}/{filename}"`, `await storage.put_object(key, content)`, `S3Object(bucket_role=uploads, bucket=storage.bucket, key, content_type, size_bytes=len(content), created_by=f"user:{audit.user_id}", created_at=now)`; `storage is None` (dev без S3) — предупреждение в лог, `S3Object` не создаётся. +- Схема ответа `ImportReportOut` (= `ImportReport.to_dict()`; `lines: list[RowResultOut(row, inn, code, message)]`). +- Эндпоинт `POST /api/v1/directories/import` (`require("DIRECTORY_ADMIN")`): `file: UploadFile = File(...)`, `dry_run: bool = Form(True)`, `on_existing: OnExisting = Form(OnExisting.skip)`; `storage = request.app.state.storage`. +- `load_snapshot` строит `EntitySnap.gsk_group_id/gsz_group_id` из текущих членств (`is_current`), `GroupSnap.gsk_id/gsz_id` — первая по `code` группа каждого вида у ГК. + +- [x] **Step 1: Тесты** (`tests/test_api_directory_import.py`, integration; клиент `admin@armka.local`; `app.state.storage` — заглушка `FakeStorage(bucket="arm-ka")` с `put_object(key, data)` в словарь): + - срез РБ `dry_run=false, skip` на seed-базе → 200; счётчики как в Task 9; в БД 1 ГК с `crm_id="1-AZFL6BQ"`, `regional_office` «Санкт-Петербургский РФ», 20 ЮЛ/ИП, 1 ГСК (`name` = имя ГК, 2 члена с `kind=gsk`), `audit_event(DIRECTORY_IMPORT)` с `after_value["rows"] == 21`, `S3Object` с ключом `uploads/imports//ecp-export-russkiy-biskvit.xlsx`, байты в `FakeStorage`; + - повтор `skip` → все `LE_EXISTS`/`GK_EXISTS`, число строк не изменилось; + - csv с изменённым именем ЮЛ + `update` → имя обновлено, `legal_entity_history(UPDATE)`, код `LE_UPDATED`; + - ЮЛ заранее в другой ГК (фабрика) → `LE_IN_OTHER_GROUP`, ГК не изменилась; + - `dry_run=true` → 200, счётчики как боевые, число строк во всех таблицах и `audit_event`/`s3_object` не изменилось; второй `dry_run` — тот же отчёт (Review Focus 4); + - csv без «Тип» → 422 `MISSING_COLUMNS` с `missing`; csv в cp1251 → 422 `UNSUPPORTED_FILE` (Review Focus 3); файл 20 МБ + 1 байт → 413; + - `ka_go` (нет `DIRECTORY_ADMIN`) → 403. +- [x] **Step 2–4:** падает → реализация → `pytest -m integration tests/test_api_directory_import.py -v`, `make check` → PASS. +- [x] **Step 5: Commit** `feat(directories): импорт выгрузки ЕЦП — сервис, эндпоинт, аудит, файл в S3`. + +--- + +### Task 11: [3.3] Полная выгрузка: fixtures-тест производительности + +**Files:** +- Create: `api/tests/test_import_full_export.py` +- Modify (при необходимости): `api/src/services/directory_import.py` (пакетные вставки `insert(...).values([...])`, если > 60 с) + +- [x] **Step 1: Тест** `@pytest.mark.integration @pytest.mark.fixtures`: путь `Path(os.environ.get("ARM_KA_DOCS") or Path(__file__).resolve().parents[3] / "docs") / "source/biq/ecp-export-lagal-entity.xlsx"`; нет файла → `pytest.skip("нет выгрузки ЕЦП: …")`. `dry_run=true` под `admin@armka.local`; `time.perf_counter()` ≤ 60 с; `rows == 8400`; `offices_created == 53`; среди кодов есть `UNSUPPORTED_TYPE` (≥ 500 строк), `INVALID_INN`, `DUPLICATE_IN_FILE` (`LE_IN_OTHER_GROUP` на пустой БД невозможен — не проверять); `groups_created == 344` (R-15); таблицы после теста не выросли. +- [x] **Step 2:** запустить; при превышении 60 с — профилировать (`--durations`), перевести вставки на `session.execute(insert(Model), [dict, ...])` пакетами по 1000, повторить. +- [x] **Step 3: Commit** `test(import): dry_run полной выгрузки ЕЦП (8400 строк) укладывается в 60 с`. + +--- + +### Task 12: [4.1] Администрирование: пользователи, роли, матрица прав (API) + +**Files:** +- Create: `api/src/services/users_admin.py`, `api/src/schemas/admin.py`, `api/src/api/endpoints/admin/__init__.py`, `api/src/api/endpoints/admin/users.py`, `api/src/api/endpoints/admin/roles.py`, `api/src/api/dependencies/admin.py` +- Modify: `api/src/repositories/users.py` (запросы списка пользователей с ролями, ролей с правами, `count_active_users_with_permission(session, code, *, excluding_user: uuid | None = None, excluding_role: uuid | None = None) -> int`), `api/src/api/router.py` (include `admin.router`, префикс `/admin`) +- Test: `api/tests/test_api_admin.py` + +**Interfaces:** +- `UsersAdminService(session, audit: RequestAudit, actor_id: uuid.UUID)`: + - `list_users(q, is_active, page) -> tuple[list[UserWithRoles], int]` (`UserWithRoles = tuple[AppUser, RegionalOffice, list[str]]`) + - `create_user(data: UserCreate) -> AppUser` — `email.strip().lower()` для сравнения, хранится как введён; `lower(email)` занят → `ConflictError("EMAIL_EXISTS")`; офис → 404 если нет; роли по кодам (`Role.is_active`) иначе `DomainValidationError("UNKNOWN_ROLE", …, roles=[...])`; `created_at/updated_at=now`; `audit USER_CHANGE section=USERS object_type="app_user" after_value={email, full_name, regional_office_id, business_segment, roles}`. + - `update_user(user_id, data: UserUpdate) -> AppUser` — поля `full_name, employee_no, regional_office_id, business_segment, is_active`; `is_active=False` на пользователе, после которого `count_active_users_with_permission("USERS_ADMIN", excluding_user=user_id) == 0` → `ConflictError("LAST_ADMIN")` (Review Focus 5); no-op без аудита; `USER_CHANGE` с diff. + - `set_roles(user_id, codes: list[str]) -> list[str]` — diff `user_role`; неизвестный/неактивный код → 422 `UNKNOWN_ROLE`; если после замены у активных пользователей никто не имеет `USERS_ADMIN` → 409 `LAST_ADMIN` (проверка: `count_active_users_with_permission("USERS_ADMIN", excluding_user=user_id) == 0` и новый набор не содержит роли с `USERS_ADMIN`); `audit USER_ROLE_CHANGE before_value={"roles": old}, after_value={"roles": new}`. + - `list_roles() -> list[tuple[Role, list[str]]]`, `list_permissions() -> list[Permission]`. + - `set_role_permissions(code: str, permission_codes: list[str]) -> list[str]` — роль → 404; неизвестный код права → 422 `UNKNOWN_PERMISSION`; если убирается `USERS_ADMIN` и `count_active_users_with_permission("USERS_ADMIN", excluding_role=role.id) == 0` → 409 `LAST_ADMIN`; diff `role_permission` (`granted_by=actor_id`, `granted_at=now`); `audit ROLE_PERMISSION_CHANGE section=ACCESS object_type="role" object_id=role.id before/after {"permissions": [...]}`. +- Схемы (`schemas/admin.py`): `UserCreate(email: EmailStr, full_name: str, regional_office_id: uuid.UUID, employee_no: str | None = None, business_segment: BusinessSegment | None = None, roles: list[str] = [])`, `UserUpdate` (все optional, `extra="forbid"`), `UserOut(id, email, full_name, employee_no, regional_office: RegionalOfficeOut, business_segment, is_active, roles: list[str], created_at, updated_at)`, `RolesSet(roles: list[str])`, `RoleOut(code, name, description, is_system, is_active, permissions: list[str])`, `PermissionOut(code, object_type, action, name, description)`, `PermissionsSet(permissions: list[str])`. +- Маршруты (`require("USERS_ADMIN")`): `GET /admin/users` → `Page[UserOut]` (`q`, `is_active`), `POST /admin/users` → 201, `PATCH /admin/users/{id}`, `PUT /admin/users/{id}/roles` → `UserOut`, `GET /admin/roles` → `list[RoleOut]`, `GET /admin/permissions` → `list[PermissionOut]`, `PUT /admin/roles/{code}/permissions` → `RoleOut`. + +- [x] **Step 1: Тесты** (integration, `admin@armka.local`): создание с `roles=["KA_RF"]` → `app_user`+`user_role`; `GET /me` новым пользователем (через `auth_client(new_email)`) → права KA_RF; email в другом регистре → 409 `EMAIL_EXISTS`; `PUT roles` `[KA_GO, EXPERT]` → ровно две строки, аудит before/after; единственный админ снимает с себя `ADMIN` → 409 `LAST_ADMIN`; второй админ создан → снятие разрешено; `PATCH is_active=false` на себе как последнем → 409; матрица: `EXPERT` + `EXPORT` → `role_permission` строка, `/me` экспертом видит `EXPORT`, аудит `ROLE_PERMISSION_CHANGE`; убрать `USERS_ADMIN` у `ADMIN` → 409 `LAST_ADMIN`; `GET /admin/permissions` → 16 кодов; `ka_go` → 403 на всё. +- [x] **Step 2–4:** падает → реализация → тесты, `make check` → PASS. +- [x] **Step 5: Commit** `feat(admin): пользователи, роли, матрица прав — API с аудитом и защитой LAST_ADMIN`. + +--- + +### Task 13: [4.2] Фронт: API-клиент, dev-вход, `/me` + +**Files:** +- Create: `web/src/api/client.ts`, `web/src/api/types.ts`, `web/src/auth/token.ts`, `web/src/auth/DevLogin.tsx`, `web/src/auth/useMe.ts` +- Modify: `web/src/utils.ts` (удалить пустой файл или оставить — не трогать, если не используется) + +**Interfaces:** +- `web/src/auth/token.ts`: `const KEY = 'armka.token'`; `getToken(): string | null` (`sessionStorage`), `setToken(t: string)`, `clearToken()`. +- `web/src/api/client.ts`: +```ts +export class ApiError extends Error { constructor(public status: number, public code: string | undefined, message: string) { super(message); } } +export const API_BASE = `${Config.API_URL}/api/v1`; +export async function apiFetch(path: string, init: RequestInit = {}): Promise +// заголовки: Authorization: Bearer если есть; 'Content-Type: application/json' если body — строка; +// 401 → clearToken(), window.dispatchEvent(new Event('armka:unauthorized')), throw ApiError; +// !ok → тело {detail}: строка → message; объект {code,message} → code+message; массив (422 pydantic) → первая msg; throw ApiError; +// 204 → undefined as T; иначе response.json() +export const api = { get: (p: string) => apiFetch(p), post: (p: string, body?: unknown) => ..., patch, put, del }; +``` +- `web/src/api/types.ts`: `Me { id; email; full_name; regional_office: {id; name; is_head_office}; business_segment: string | null; roles: string[]; permissions: string[] }`, `Page { items: T[]; total: number; limit: number; offset: number }`, `RegionalOffice`, `AdminUser` (= `UserOut`), `Role` (= `RoleOut`), `Permission` (= `PermissionOut`), `UserCreate`. +- `web/src/auth/useMe.ts`: `useMe(): { me: Me | null; loading: boolean; error: string | null; reload: () => void }` — `GET /me` при наличии токена; слушает `armka:unauthorized` → `me = null`. +- `web/src/auth/DevLogin.tsx`: форма antd (`Input` email + `Button` «Войти (dev)»), `POST /dev/token {email}` → `setToken(token)` → `onLogin()`; рендерится только при `process.env.NODE_ENV !== 'production'` (rsbuild подставляет); в проде — текст «Вход выполняется через РАИСА» (TODO OQ: параметры JWT РАИСА). +- Ответ dev-токена: `DevTokenResponse.access_token` (`src/api/endpoints/dev_auth.py:40`). + +- [x] **Step 1:** написать модули; `App.tsx`: обернуть `Routes` в компонент `AuthGate` (`useMe`; нет токена/`me` → `DevLogin`, иначе дети). `Main.tsx` показывает `me.full_name` и роли. +- [x] **Step 2:** `cd web && npm install && npx biome check src && npx tsc --noEmit && npm run build` → PASS. Ручная проверка (README в Task 15): `make up`, `make migrate && make seed-dev`, `cd api && python run_server.py`, `cd web && npm run start`, вход `admin@armka.local`. +- [x] **Step 3: Commit** `feat(web): API-клиент с Bearer, dev-вход по email, загрузка /me`. + +--- + +### Task 14: [4.3] Фронт: страница администратора `/admin` + +**Files:** +- Create: `web/src/layouts/Admin.tsx`, `web/src/widgets/Admin/UsersTable.tsx`, `web/src/widgets/Admin/CreateUserModal.tsx`, `web/src/widgets/Admin/PermissionMatrix.tsx` +- Modify: `web/src/App.tsx` (маршрут `/admin`), `web/src/widgets/Sidebar/Sidebar.tsx` (группа «Система» → «Пользователи и роли», иконка `TeamOutlined`) + +**Interfaces / поведение (spec users-admin «Минимальный экран администратора»):** +- `Admin.tsx`: `useMe()`; нет `USERS_ADMIN` в `me.permissions` → `` без запросов к `/admin/*`; иначе `Tabs`: «Пользователи», «Матрица прав». +- `UsersTable.tsx`: загрузка `GET /admin/users?limit=200`, `GET /admin/roles`, `GET /directories/regional-offices`; antd `Table` (email, ФИО, филиал, роли — `Select mode='multiple'` c опциями из ролей; `onChange` → `PUT /admin/users/{id}/roles` → перезагрузка строки; `Switch` активности → `PATCH`), кнопка «Создать пользователя» → `CreateUserModal` (`Form`: email, ФИО, табельный номер, филиал `Select`, сегмент `Select` (DKB/DMSB/DM/—), роли) → `POST /admin/users` → закрыть, перезагрузить. Ошибки `ApiError` → `message.error(e.message)`. +- `PermissionMatrix.tsx`: `GET /admin/roles`, `GET /admin/permissions`; `Table` строки = права (код, имя), колонки = роли, ячейки `Checkbox`; локальный state изменений; кнопка «Сохранить» → для каждой изменённой роли `PUT /admin/roles/{code}/permissions`; после — перезагрузка; системные роли помечены тегом. +- Маршрут `/admin` в `App.tsx` внутри `AuthGate`; Sidebar: группа `{ title: 'Система', key: 'system', children: [{ link: '/admin', icon: , label: 'Пользователи и роли' }] }`. + +- [x] **Step 1:** реализовать; **Step 2:** `npx biome check src && npx tsc --noEmit && npm run build` → PASS; ручной сценарий: вход админом, создать пользователя с ролью, сменить роль, снять галочку в матрице и сохранить; убедиться, что ошибка 409 `LAST_ADMIN` показывается уведомлением. +- [x] **Step 3:** самостоятельное ревью TS/React (пользователь слаб во фронте): в описание коммита — 3–5 строк, почему без Redux, как устроен клиент, где появится РАИСА. +- [x] **Step 4: Commit** `feat(web): страница администратора — пользователи, роли, матрица прав`. + +--- + +### Task 15: [5.1] Документы, OpenAPI, `make check-full` + +**Files:** +- Modify: `README.md` (разделы: «API справочников» — таблица маршрутов и прав; «Импорт из ЕЦП CRM» — пошагово с `dry_run` сначала, формат файла, коды отчёта со ссылкой на §13.2, допущения OQ-87…91; «Администрирование и экран /admin» — dev-вход, ограничение «повторный seed не отзывает права, снятые администратором»; «Ошибки API» — формат `detail.code`), `CLAUDE.md` (DBML v5.14; `domain/policy.py`, `domain/directory_import.py`, `domain/aliases.py`, `domain/inn.py`; транзакция на запрос в `get_async_session`; формат доменных ошибок; `openpyxl`; фронт: `api/client.ts`, `auth/`, `/admin`), `openspec/changes/slice-1-directories/tasks.md` (все `[x]`), `openspec/changes/slice-1-directories/design.md` (раздел «Decisions made without discussion» — дописать рулинги контроллера R-n) +- Проверка OpenAPI: `GET /openapi.json` содержит все маршруты спек (скрипт-однострочник в README не нужен; проверить тестом `tests/test_openapi_routes.py`: список путей из спек присутствует в `app.openapi()["paths"]`). + +- [x] **Step 1:** тест `test_openapi_routes.py` (unit, `src.app.app` импортируется при `ENV_FILE=.env.test`): пути `/api/v1/directories/company-groups`, `/api/v1/directories/legal-entities/{entity_id}/move`, `/api/v1/directories/import`, `/api/v1/admin/users/{user_id}/roles`, `/api/v1/admin/roles/{code}/permissions` и остальные — присутствуют. +- [x] **Step 2:** документы по списку выше. +- [x] **Step 3:** `make check-full` (из корня; compose поднят): `check`, `dbml-check`, `check-infra`, `test-integration` (включая `fixtures`-тесты — `../fixtures` и `../docs` доступны), `docker-build`, `web-check` → всё зелёное; вывод сохранить для описания PR (в ledger). +- [x] **Step 4: Commit** `docs: README/CLAUDE.md Среза 1, тест маршрутов OpenAPI, tasks.md`. + +--- + +## Завершение + +После Task 15 — финальное ревью всей ветки (от первого коммита фазы 1 до HEAD) на модели opus: спека ↔ код, инварианты (`audit_event` append-only, поля вне DBML, код роли в логике, float), безопасность (утечка существования чужих ГК, `LAST_ADMIN`, лимит файла), качество тестов. Находки `C-n`/`I-n`/`M-n` со статусами; `deferred` — в описание PR. diff --git a/openspec/changes/slice-1-directories/.openspec.yaml b/openspec/changes/slice-1-directories/.openspec.yaml new file mode 100644 index 0000000..f98e139 --- /dev/null +++ b/openspec/changes/slice-1-directories/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-04 diff --git a/openspec/changes/slice-1-directories/design.md b/openspec/changes/slice-1-directories/design.md new file mode 100644 index 0000000..0594dfe --- /dev/null +++ b/openspec/changes/slice-1-directories/design.md @@ -0,0 +1,169 @@ +# Design: Срез 1 — справочники, импорт ЕЦП, видимость, администрирование + +## Context + +См. `proposal.md`. Что уже есть после Среза 0 и на что опирается дизайн (обследование кода 04.10.2026): + +- ORM 1:1 с DBML (43 таблицы), `models/directories.py` и `models/access.py` без `relationship()`; `valid_from` NOT NULL без + server default; `related_group_member.is_current` без default; `NAMING_CONVENTION` в `models/base.py`; `DBML_VERSION` в + `models/__init__.py`; путь DBML — `Makefile:6`, `scripts/dbml_parity.py:DBML_FILE_NAME`; миграция `0001_dbml_v5_13` — + явный DDL, `HEAD` зашит в `tests/test_migrations.py`. +- DI: `api/dependencies/auth.py` — `current_user` → `Principal(UserWithAccess)` с `permissions: frozenset`, `require(code)`; + `repositories/users.py` — функции, не классы; `AuditService(session).record(...)` без зависимости FastAPI; `get_async_session` + **не коммитит**; обработчики ошибок: `IntegrityError` → 409 с общим текстом, `NotFoundError`/`CouldntCreateError` без обработчиков. +- Seed: 16 кодов прав (`DIRECTORY_VIEW/EDIT/DELETE/ADMIN`, `USERS_ADMIN`, …), матрица §10; `regional_office` в core — только ГО. +- Тесты: `db_session` на SAVEPOINT (commit внутри теста невидим снаружи), клиент строится вручную в `test_me_and_dev_token.py`, + фабрик для ГК/ЮЛ/пользователей нет; `fixtures_dir` + маркер `fixtures`. +- Фронт — скелет без API-клиента и авторизации. +- Платформа: Python 3.11, PostgreSQL 16; Nexus недоступен; `openpyxl` в `requirements.txt` нет. + +## Goals / Non-Goals + +**Goals:** все мутации справочников через сервисы с аудитом и историей; импорт реальной выгрузки проходит `dry_run` на 8400 +строках за разумное время (< 60 с) с отчётом; policy — одна функция, применяемая везде; администратор может завести +пользователя и назначить роль без seed. **Non-Goals:** см. proposal. + +## Decisions + +### D-1. Миграция `0002_dbml_v5_14`, явный DDL, как 0001 +`kind related_group_kind` добавляется пересозданием `related_group_member` (R-5, решение №16: `ADD COLUMN` ставит колонку в конец, а DBML и тест «схема = create_all» требуют порядок; строки копируются с `kind` из `related_group`), `DROP INDEX +uq_related_group_member_entity_current`, `CREATE UNIQUE INDEX uq_related_group_member_entity_kind_current ON (legal_entity_id, kind) +WHERE is_current`; `ALTER TYPE audit_action ADD VALUE` ×2 (`LEGAL_ENTITY_CHANGE` после `GROUP_CHANGE`, `USER_CHANGE` после +`USER_ROLE_CHANGE` — порядок как в DBML, сверка enum'ов сравнивает упорядоченный список). `ADD VALUE` необратим в PostgreSQL → +`downgrade` пересоздаёт тип (`RENAME TYPE` → новый тип → `ALTER COLUMN … TYPE … USING` → `DROP TYPE`), как это уже умеет +тест цикла. `HEAD` в `tests/test_migrations.py` → `0002_dbml_v5_14`; `DBML_VERSION = "5.14"`; `DBML_FILE_NAME`, `Makefile`, +`test_enums`, `test_check_dbml_script` (фиктивная версия → 5.15), docstring'и. + +### D-2. Единица работы: `uow_session` + сервисы, аудит внутри +Новая зависимость `get_uow_session` (`api/dependencies/db.py`): `async with session.begin(): yield session` — commit при успешном +ответе, rollback при исключении; `get_async_session` остаётся для чтения. Сервисы (`services/directories.py`, +`services/related_groups.py`, `services/directory_import.py`, `services/users_admin.py`) получают сессию, `AuditService` +и `Principal`; каждая мутация: валидация → изменение → снимок истории → `audit.record(before/after)` в той же транзакции. +`get_audit_service(request, session, principal)` заполняет `ip_address`/`user_agent`/`user_id`. Тест на реальный commit — +через `scratch_db_url` и вторую сессию (SAVEPOINT-сессия тестов его не ловит, см. обследование). + +### D-3. Исключения и формат ответов +`common/exceptions.py`: `NotFoundError(entity, key)` → 404 `{"detail": "ГК … не найдена"}`; `ConflictError(code, detail)` → 409 +с человекочитаемым текстом (`INN_EXISTS`, `ALIAS_TAKEN`, `MEMBER_EXISTS`, `GROUP_NOT_EMPTY`, …) и полем `code` в `detail`-объекте +(`{"detail": {"code": …, "message": …}}`); `DomainValidationError` → 422. Pre-check уникальных ключей перед вставкой, чтобы не +полагаться на `IntegrityError`. Списки — `{items, total, limit, offset}`, `limit ≤ 200`, по умолчанию 50; сортировка фиксированная (имя). + +### D-4. Policy — чистые функции в `src/domain/policy.py` +`VisibilityScope = all | office(regional_office_id)` вычисляется из `Principal` (`regional_office.is_head_office`); репозитории +принимают `scope` и добавляют `WHERE company_group.base_regional_office_id = :office` (для ЮЛ — через ГК или +`legal_entity.regional_office_id` при `company_group_id IS NULL`). Чтение объекта вне scope → `NotFoundError` (404). Код роли нигде +не читается; `business_segment` не используется (ASSUMPTION(OQ-86)). Это первый модуль `src/domain/` — создаётся с strict mypy. + +### D-5. Коды ГК/ГСК — последовательности PostgreSQL +`company_group.code = 'GK-' || lpad(nextval('dkb.company_group_code_seq'), 6, '0')`, аналогично `GSK-`/`GSZ-` (одна +последовательность `related_group_code_seq`). Последовательности создаются в миграции 0002; в DBML они не описываются +(не таблицы), `check_dbml_vs_orm` их не сравнивает. *Альтернатива* — `max(code)+1` в транзакции: гонки. Макет показывает +`GSK-001` — ширина 6 вместо 3, чтобы не переполниться; формат — только отображение. + +### D-6. История SCD4/SCD2 и события +ГК: любое изменение `name`/`base_regional_office_id`/`crm_id` — строка в `company_group_history` со **старыми** значениями +(`valid_from` = прежний `valid_from`, `valid_to` = now) и обновление `valid_from = now` у живой записи; аудит `GROUP_CHANGE` +(имя/crm) или `ASSIGNMENT_CHANGE` (РФ). ЮЛ: аналогично в `legal_entity_history` с `event_type` (`UPDATE`, `MOVE_TO_GROUP`, `CLOSE`, +`REMOVE_FROM_GROUP`, при создании — `CREATE` с `valid_to = NULL`? — нет: при создании история не пишется, первый снимок появляется +при первом изменении; `CREATE` используется только импортом для фиксации источника? — нет, тоже не пишется; значение `CREATE` +остаётся для будущего восстановления). `effective_date` — из запроса (≥ сегодня, OQ-21). Членство ГСК/ГСЗ: исключение = +`valid_to = now, is_current = false`; повторное включение — новая строка. Перевод ЮЛ в другую ГК закрывает все текущие членства +(§13.4). Аудит: `LEGAL_ENTITY_CHANGE` (ЮЛ, алиасы), `MEMBERSHIP_CHANGE` (состав, создание/удаление ГСК/ГСЗ с `object_type`). + +### D-7. Импорт — чистое ядро + сервис +`src/domain/directory_import.py`: чтение файла в строки (xlsx — `openpyxl` read-only, csv — `csv` с определением разделителя), +нормализация заголовков и значений, построчные решения §13.2 как чистая функция над «снимком справочника» (словари +`inn → ЮЛ`, `crm_id → ГК`, `name → РФ`) → список операций и отчёт; `services/directory_import.py` применяет операции в одной +транзакции (или откатывает при `dry_run`), пишет `audit_event(DIRECTORY_IMPORT, after_value = отчёт)` и файл в S3. Снимок +справочника грузится одним запросом на таблицу (8400 строк — секунды). Коды отчёта — константы `ImportCode` (StrEnum), +список = §13.2. Лимит файла 20 МБ (`IMPORT_MAX_BYTES`). Допущения помечены `# ASSUMPTION(OQ-87..91)`. + +### D-8. Администрирование — без новых прав и без удаления +`USERS_ADMIN` на всё; пользователи только деактивируются (`is_active=false`); email обязателен и уникален без учёта регистра +(pre-check `lower(email)`); `external_id` не задаётся (появится при первом входе через РАИСА — Срез JWT). Матрица: `PUT +/admin/roles/{code}/permissions` заменяет набор целиком (diff → `ROLE_PERMISSION_CHANGE` с before/after); системные роли +(`is_system`) переименовывать/деактивировать нельзя; нельзя снять `USERS_ADMIN` у роли, если это единственная роль с этим правом +у активных пользователей, (защита системная: «у себя» запрещено лишь пока ты — последний активный обладатель права, решение №34). Новые коды прав не создаются (комментарий seed). Повторный seed +**возвращает** права, снятые администратором (вставка недостающих пар `ON CONFLICT DO NOTHING`; лишние права не отзывает) — +известное ограничение (OQ из Среза 0 M-8), задокументировано в README администратора. + +### D-9. Минимальный фронт администратора +`web/src/api/client.ts` (fetch с `Authorization: Bearer`, базовый URL `${Config.API_URL}/api/v1`, обработка 401/403 → +сообщение), `web/src/auth/` (dev-вход: поле email → `POST /dev/token`, токен в `sessionStorage`; в проде токен даёт РАИСА — заглушка +с TODO и ссылкой на OQ), страница `layouts/Admin.tsx` (antd Table пользователей + Select ролей + Modal создания; Table-матрица +`роль × право` с Checkbox; сохранение по кнопке), пункт «Система → Пользователи и роли» в Sidebar, маршрут `/admin`, +доступ по наличию `USERS_ADMIN` в `/me`. Без Redux-слайсов (локальный state + клиент) — минимализм по решению владельца. +Проверка: `make web-check` + ручной сценарий в README. + +### D-10. `openpyxl` в `requirements.txt` +Чтение xlsx без него невозможно; добавляется с пином (как `PyJWT` в Срезе 0, R-11). `xlrd`/`pandas` не нужны. Платформа ставит +из Nexus — пакет там стандартный; если его нет, импорт csv работает без него (ленивый импорт, ошибка `UNSUPPORTED_FILE`). + +### D-11. Тестовая инфраструктура +`tests/factories.py`: `make_regional_office`, `make_company_group`, `make_legal_entity`, `make_related_group`, `make_user(roles=…)`; +`conftest.py`: фикстура `auth_client(email)` (приложение с `dependency_overrides`, dev-токен) и `seeded`; импорт — unit на +синтетических строках (все коды §13.2) + `fixtures`-тест на `ecp-export-russkiy-biskvit.xlsx` (21 строка: 1 ГК, 1 РФ, 20 ЮЛ/ИП, +1 `UNSUPPORTED_TYPE` ФЛ, 2 члена ГСК, 0 ГСЗ) + `dry_run` полной выгрузки из `../docs/source/biq` +(`ARM_KA_DOCS`, маркер `fixtures`, время < 60 с). + +## Risks / Trade-offs + +- [`ALTER TYPE … ADD VALUE` нельзя выполнить в транзакции с использованием нового значения] → миграция добавляет значения + отдельными операциями (`op.execute` вне блока, Alembic на PG ≥ 12 выполняет `ADD VALUE` в транзакции, использования в той же + миграции нет); тест цикла проверяет. +- [Импорт 8400 строк создаст 53 филиала] → `dry_run` по умолчанию в README; `RF_CREATED` считается предупреждением. +- [Расхождение `kind` члена и группы] → проверка в сервисе + тест; индекс всё равно держит уникальность. +- [Фронт без RTK/роутинга прав станет «времянкой»] → страница изолирована (`layouts/Admin.tsx`, `api/client.ts`), остальное + появится со срезом UI справочника; клиент и auth переиспользуются. +- [Повторный seed возвращает права, снятые администратором] → задокументировано; решение — follow-up (M-8 Среза 0). + +## Migration Plan + +`alembic upgrade head` (0002) → данных для переноса нет (членств ещё нет; `UPDATE … FROM` защищает dev-БД с данными); +откат — `downgrade 0001` пересоздаёт enum и индекс. Seed не меняется. + +## Open Questions + +Не блокируют (допущения в коде): OQ-86 ЗО, OQ-87 типы `нерез.`/`ФЛ`, OQ-88 контрольные разряды ИНН, OQ-89 «ЦКБ»/«Головная +организация», OQ-90 ИНН в нескольких ГК, OQ-91 одна ГСК/ГСЗ на ГК, OQ-34 удаление ГСК. К владельцу: OQ-89 и OQ-87 — самые +влияющие на данные после первого реального импорта. + +## Decisions made without discussion + +| № | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 2026-10-04 | D-1: миграция 0002 явным DDL, `downgrade` пересоздаёт enum | стиль 0001; `ADD VALUE` необратим | нет | +| 2 | 2026-10-04 | D-2: `get_uow_session` (commit при успехе) + аудит внутри сервисов | в Срезе 0 коммит нигде не делался | перейти на явный `session.commit()` в сервисах | +| 3 | 2026-10-04 | D-3: доменные исключения → 404/409/422, `detail.code`; списки `{items,total,limit,offset}` | единый контракт для будущего UI | смена формата до появления UI бесплатна | +| 4 | 2026-10-04 | D-4: policy по `is_head_office`, 404 вне видимости, ЗО не фильтрует (ASSUMPTION(OQ-86)) | права — данные; у ГК нет сегмента | добавить фильтр при появлении поля | +| 5 | 2026-10-04 | D-5: коды `GK-/GSK-/GSZ-NNNNNN` из последовательностей PostgreSQL | без гонок; формат из макета, ширина 6 | формат отображения | +| 6 | 2026-10-04 | D-6: при создании ГК/ЮЛ история не пишется; снимок — при первом изменении; `effective_date` ≥ сегодня | SCD4 как в `entities-v5.13.md`; OQ-21 | дописать снимок `CREATE` | +| 7 | 2026-10-04 | D-7: импорт как чистое ядро + сервис, снимок справочника в памяти, лимит 20 МБ | тестируемость; 8400 строк — малый объём | стриминг при больших файлах | +| 8 | 2026-10-04 | D-8: пользователей не удалять; email обязателен; защита от снятия `USERS_ADMIN` с себя/последней роли | безопасность администрирования | снять защиту одной проверкой | +| 9 | 2026-10-04 | D-9: фронт администратора без Redux, dev-вход по email, токен в `sessionStorage` | решение владельца «минималистично» | переписать на слайсы в срезе UI | +| 10 | 2026-10-04 | D-10: `openpyxl` в `requirements.txt` с пином | без него xlsx не прочитать | убрать, если платформа против | +| 11 | 2026-10-04 | ASSUMPTION(OQ-34): удаляется только пустая ГСК/ГСЗ; ASSUMPTION(OQ-87…91) — допущения §13 | см. docs design D-4 | повторный импорт/миграция данных | +| 12 | 2026-10-04 | R-1 (план, фаза 1, Task 2): вместо отдельной `get_uow_session` (D-2) сама `get_async_session` оборачивает запрос в `session.begin()` — commit при успехе, rollback при исключении | одна сессия и одна транзакция на запрос: `current_user` и мутации не расходятся по соединениям | read-only маршруты коммитят пустую транзакцию (безвредно); при проблемах — вернуть отдельную зависимость | +| 13 | 2026-10-04 | R-2 (SDD фаза 1, pre-flight): `q` в списке ГК ищет по `name`/`crm_id`/`code` (подстрока без регистра), как в спеке, а не только по имени, как в плане | спека — источник истины; план её сужал | нет: надмножество поиска | +| 14 | 2026-10-04 | R-3 (SDD фаза 1, pre-flight): `GET …/history` для ГК и ЮЛ — по убыванию `valid_from` | так сказано в спеке; план порядок не задавал | порядок вывода, меняется одной строкой | +| 15 | 2026-10-04 | R-4 (SDD фаза 1, pre-flight): `q` в списке ЮЛ ищет по `name` и `inn` (подстрока) | спека `directories-api`; план не уточнял | нет: надмножество поиска | +| 16 | 2026-10-04 | R-5 (SDD фаза 1, Task 1): upgrade 0002 пересоздаёт `related_group_member` (копия → drop → create в порядке колонок DBML → insert), а не `ADD COLUMN`; downgrade — по плану | DBML v5.14 ставит `kind` после `legal_entity_id`, а `ADD COLUMN` добавляет в конец; тест Среза 0 `test_migrated_schema_equals_orm_create_all` сверяет порядок колонок | вернуть `ADD COLUMN` и ослабить сравнение колонок (без порядка) в тесте и миграции; пересоздание теряет GRANT/RLS/триггеры, добавленные платформой вне Alembic (T1.2) | +| 17 | 2026-10-04 | R-6 (SDD фаза 1, Task 2): сессия запроса объявляется как `SessionDep = Annotated[AsyncSession, Depends(get_async_session, scope="function")]` (`api/src/api/dependencies/db.py`), а не голым `Depends(get_async_session)`; `get_request_audit(session: SessionDep, …)`; guard-тест падает на объявлении без `scope="function"` | в FastAPI 0.135 yield-зависимость по умолчанию закрывается ПОСЛЕ отправки ответа — commit шёл бы после 201, и его ошибка не доходила бы до клиента; scope входит в ключ кэша зависимостей — смешение даёт две сессии (нарушает R-1) | заменить `SessionDep` на `Depends(get_async_session)` (1 строка) ценой потери гарантии «commit до ответа» | +| 18 | 2026-10-04 | R-7 (SDD фаза 1, Task 4): `legal_entities_visible` фильтрует подзапросом `company_group_id IN (SELECT id FROM company_group WHERE base_regional_office_id = :office) OR (company_group_id IS NULL AND regional_office_id = :office)`, а не outerjoin; ГК без филиала и монозаёмщики без филиала видны только ГО | подзапрос не конфликтует с join'ами вызывающего и с count-запросами, не размножает строки; NULL-семантика закреплена интеграционным тестом на PostgreSQL | при необходимости вернуть outerjoin; изменение видимости «бесхозных» объектов — одна правка фильтра и тестов | +| 19 | 2026-10-04 | R-8 (SDD фаза 1, Task 5, приняты решения исполнителя): `crm_id` и `name` нормализуются (strip; пустой `crm_id` → NULL; пустое/`null` имя → 422); `office_for_create` применяется и к PATCH (филиал не может перепривязать ГК на чужой филиал, `null` у филиала → свой филиал; у ГО `null` очищает); новый 422 `OFFICE_NOT_FOUND` на несуществующий филиал (без проверки `is_active`); pre-check `crm_id` глобальный (409 раскрывает лишь факт существования строки `crm_id`, не данные ГК); повторный archive/unarchive — no-op без аудита; список ГК — одна Query-модель `CompanyGroupListQuery` (FastAPI не принимает две); сортировка `name, id` | единая семантика для остальных справочников; спека не уточняла | пересмотр нормализации/слабого oracle `crm_id` — правка сервиса; снятие `office_for_create` с PATCH — одна строка | +| 20 | 2026-10-04 | R-9 (SDD фаза 1, Task 6, приняты решения исполнителя): филиал ЮЛ идёт через `office_for_create` только при фактической смене значения (совпадающий с текущим пропускается — no-op PATCH без последствий); новые 409 `ALREADY_CLOSED` (повторный CLOSE), `NOT_IN_COMPANY_GROUP` (REMOVE_FROM_GROUP у ЮЛ без ГК), `LEGAL_ENTITY_CLOSED` (перевод закрытого ЮЛ); «сегодня» для `effective_date` — дата UTC; `move` не меняет `regional_office_id`; `move`/`withdraw` применяются сразу, `effective_date` пишется только в историю; PATCH не принимает `inn`/`company_group_id`; ИНН — только ASCII-цифры, внутренние пробелы убираются; `crm_id` ЮЛ нормализуется и проверяется глобально (как у ГК) | спека не называла; защита инвариантов (закрытый ЮЛ без ГК) и единообразие с ГК | пересмотр кодов/семантики даты — правка сервиса; UTC vs MSK — сдвиг границы на 3 часа | +| 21 | 2026-10-04 | R-10 (SDD фаза 1, Task 7, приняты решения исполнителя): `ALIAS_TAKEN` раскрывает только `legal_entity_id` владельца (даже вне видимости пользователя; имя и ИНН не отдаются); `alias` хранится дословно, строка из пробелов → 422 `EMPTY_ALIAS`; чужой/несуществующий `alias_id` → одинаковый 404 «Алиас не найден» после проверки видимости ЮЛ; `DELETE` алиаса требует `DIRECTORY_DELETE` (по seed — только ADMIN) | спека требует `legal_entity_id` владельца; единообразие 404 | при желании скрыть владельца — убрать поле из `extra`; расширить `DIRECTORY_DELETE` — отдельное решение по матрице прав | +| 22 | 2026-10-04 | R-11 (SDD фаза 1, Task 8, приняты решения исполнителя): `add_member` возвращает пару (членство, ЮЛ) и проверяет сначала видимость ГСК/ГСЗ через ГК (явный join `CompanyGroup`, T4.1), затем ЮЛ через scope; список ГСК/ГСЗ ГК — простой массив (не `Page`; групп у ГК единицы); `DELETE …/members/{id}` требует `DIRECTORY_EDIT` (спека: `DIRECTORY_DELETE` — только удаление ГСК/ГСЗ и алиасов); при удалении группы весь её состав сохраняется в `before_value` аудита; общий `src/common/clock.py:utc_now` вместо локальных `_now` | спека и бриф; единообразие аудита («для списков — весь набор») | смена формы списка до фронта бесплатна; удаление группы, на которую ссылаются consolidation/ex_snapshot, даст нарушение FK (23503) — общий 409 со строкой «нарушение целостности данных в БД», без кода `GROUP_IN_USE` (T8.1, deferred к OQ-34; в первоначальной формулировке ошибочно значилось «500») | +| 23 | 2026-10-04 | R-12 (SDD фаза 1→2, контроллер, T8.3): список ГСК/ГСЗ одной ГК остаётся простым массивом; требование «списки `{items,total,limit,offset}`» относится к спискам верхнего уровня (ГК, ЮЛ, пользователи) | размер ограничен составом ГК (единицы групп); спека `directories-api` пагинацию для него не требует; фронт Среза 1 его не использует | обернуть в `Page` до появления UI справочника; смена формата до фронта бесплатна | +| 24 | 2026-10-04 | R-13 (SDD фаза 1→2, контроллер, O-5): дубль `RegionalOfficeOut` в `schemas/me.py` и `schemas/directories.py` устраняется — одна модель в `schemas/directories.py`, `schemas/me.py` импортирует её (Task 12); ответ `/me` не меняется | две одноимённые Pydantic-модели порождают OpenAPI-компоненты с длинными именами и расхождение схем | вернуть локальную модель в `me.py` | +| 25 | 2026-10-04 | R-14 (SDD фаза 2, pre-flight): Task 10 выносит логику update ГК и ЮЛ в модульные функции `apply_group_update` / `apply_entity_update` (`services/company_groups.py`, `services/legal_entities.py`), `*Service.update` вызывает их; план называл их существующими, но в коде фазы 1 были только методы | импорт обязан писать историю/аудит так же, как ручная правка; дублирование — расхождение поведения | вернуть дублирование; регрессию фазы 1 ловят её тесты | +| 26 | 2026-10-04 | R-15 (SDD фаза 2, Task 9, находка исполнителя): на фикстуре «Русский Бисквит» ожидается 20 ЮЛ/ИП (18 ЮЛ + 2 ИП), `DUPLICATE_IN_FILE` в этом файле не возникает (ФЛ с тем же ИНН идёт после ИП и отсекается типом); на полной выгрузке `groups_created = 344` (ГК `1-5AL0CDM` целиком из дублей), `LE_IN_OTHER_GROUP` на пустой БД недостижим и в ожиданиях не проверяется; текст спеки `directory-import`, tasks.md, design.md и план приведены к факту | данные важнее ориентировочных цифр спеки; ожидания фиксируются тестами по реальным файлам | поправить числа в тестах и спеке | +| 27 | 2026-10-04 | R-16 (SDD фаза 2, Task 9, приняты решения исполнителя): `errors` считает итоговые коды-пропуски; `GK_EXISTS` и для неизменной ГК (идемпотентность); РФ разрешается один раз на ГК после проверок пустого iD ГК и конфликта имени (нет «осиротевших» филиалов, `RF_CREATED = 53`); ИНН считается «встречавшимся» после проверок типа и формата; офис нового ЮЛ = действующий РФ ГК; ГСК/ГСЗ создаётся по флагу даже если у ЮЛ уже есть членство того же вида в другой группе (буквально §13.2, возможна пустая группа — T9.5); флаги обрабатываются и при `skip` (добавляется отсутствующее членство); значение колонки ГСК «входит в ГСЗ» → `UNKNOWN_FLAG`; xlsx читается с `reset_dimensions()`; пины `openpyxl==3.1.5`, `et_xmlfile==2.0.0`, `types-openpyxl` (dev) | единообразие со спекой и идемпотентность повторного импорта | каждое — локальная правка `plan_import` и теста | +| 28 | 2026-10-04 | R-17 (SDD фаза 2, Task 10, приняты решения исполнителя): общие `apply_group_update`/`apply_entity_update(session, audit, obj, changes: dict, *, now, description=None)` принимают словарь изменений (PATCH и импорт делят одну реализацию), `snapshot_entity`/`touch_entity` — функции модуля; обновления ГК/ЮЛ импортом пишут собственные события `GROUP_CHANGE`/`ASSIGNMENT_CHANGE`/`LEGAL_ENTITY_CHANGE` с `description="импорт ЕЦП"` помимо одного `DIRECTORY_IMPORT` (создания — только сводным событием); тест `dry_run` — на отдельной БД с зафиксированным seed, т.к. `session.rollback()` под SAVEPOINT-фикстурой `app` сносит seed теста; `IMPORT_MAX_BYTES` также в `.env.test`; имя файла сводится к basename (путь отбрасывается, пустое/`.`/`..` → `import`) для формата, `description` и ключа S3; `s3_object.sha256` заполняется, `content_type` — по расширению; сначала строка `s3_object` + flush, затем `put_object` (ошибка S3 откатывает импорт); разбор файла в `asyncio.to_thread`; `created_date` ГСК/ГСЗ не заполняется; 413 проверяется в эндпоинте (до чтения) и в сервисе (`HTTPException` из сервиса, как в брифе); архивные ГК входят в снимок и при `update` не разархивируются; `put_object` выполняется до commit — при сбое commit в бакете остаётся объект без строки `s3_object` (зачистка — follow-up, M-7); сообщение `LE_IN_OTHER_GROUP` называет ЮЛ из другой ГК независимо от филиала пользователя (импорт — только `DIRECTORY_ADMIN`, права — данные) | единообразие истории/аудита ручной правки и импорта; честная проверка rollback; безопасность ключа S3 | пересмотр — правка сервиса/теста; ограничить раскрытие имён при выдаче `DIRECTORY_ADMIN` филиальному пользователю — убрать имя из сообщения | +| 29 | 2026-10-04 | R-18 (SDD фаза 2, Task 11, приняты решения исполнителя): сборка приложения в `tests/conftest.py` вынесена в приватные `_build_application`/`_client_factory`, добавлены фикстуры `committed_sessionmaker`/`committed_client`/`committed_app`/`committed_storage` (отдельная БД с зафиксированным seed — для тестов с явным `rollback`); `safe_filename`: убираются управляющие символы (Cc), имя ≤ 200 символов, суффикс ≤ 16 (иначе отбрасывается); замер ≤ 60 с — только HTTP-запрос полной выгрузки (факт 1,14 с) | не дублировать фабрику приложения; ключ S3 ≤ 1024 байт | косметика тестов; смена лимита имени — константа | +| 30 | 2026-10-04 | R-19 (SDD фаза 2, Task 12, приняты решения исполнителя): R-13 реализован иерархией — `RegionalOfficeBrief` (`/me`, поля `id,name,is_head_office`) и `RegionalOfficeOut(RegionalOfficeBrief)` (+`is_active`; справочник и `UserOut`), одно определение в `schemas/directories.py` (буквальный импорт добавил бы `is_active` в `/me`); несуществующий филиал в теле — 422 `OFFICE_NOT_FOUND` (как R-8), не 404; email хранится в форме `EmailStr` (домен в нижнем регистре), сравнение `strip().lower()`; `UserCreate` тоже `extra=forbid` (явный 422 на `external_id`); аудит создания содержит и `employee_no`; `USER_ROLE_CHANGE`: before — коды по алфавиту, after — в порядке запроса без дублей, повтор набора — без аудита; `UNKNOWN_ROLE`/`UNKNOWN_PERMISSION` несут список кодов; LAST_ADMIN сериализуется блокировкой `FOR NO KEY UPDATE` строки права `USERS_ADMIN` (+ строка пользователя/роли), порядок «объект → право» без дедлоков (`FOR UPDATE` дедлочит с FK-проверкой вставки аудита); LAST_ADMIN считает активных пользователей с активными ролями, несущими право (код роли не читается) | безопасность администрирования; `/me` не меняется | вернуть локальную модель в `me.py`; сменить код 422↔404 — одна проверка; снять блокировку — риск остаться без администратора при гонке | +| 31 | 2026-10-04 | R-20 (SDD фаза 2, Task 13, приняты решения исполнителя): условие dev-входа — литерал `process.env.NODE_ENV !== 'production'` прямо в `DevLogin` (rsbuild подменяет `process.env` объектом `REACT_APP_*`, через `Config` `NODE_ENV` недоступен; в prod-бандле формы и `dev/token` нет — проверено grep); `AuthGate` оборачивает весь `Layout` (а не только `Routes`), чтобы форма входа не рисовалась рядом с сайдбаром; профиль детям — `MeContext`/`useCurrentUser`; Redux Provider каркаса не удалён; токен: `sessionStorage` с запасным токеном в памяти при недоступном хранилище; клиент: сетевые ошибки → `ApiError(0,'NETWORK')`, не-JSON 2xx → `BAD_RESPONSE`; в prod ошибка `/me` показывается в `Alert` с кнопкой «Повторить»; типы `Permission.object_type/action` — union по enum бэкенда | D-9, безопасность prod-сборки, устойчивость клиента | вернуть `AuthGate` вокруг `Routes`; при смене сборщика пересмотреть условие `NODE_ENV` | +| 32 | 2026-10-04 | R-21 (SDD фаза 2, Task 14, приняты решения исполнителя): `/admin` берёт профиль из `useCurrentUser()` (контекст `AuthGate`), а не `useMe()` (второго `/me` нет); пункт меню «Пользователи и роли» скрыт без `USERS_ADMIN` (страница по URL всё равно закрыта 403 без запросов к `/admin/*`); корень приложения обёрнут в antd `` (`index.tsx`, вне списка файлов брифа) — уведомления через `App.useApp().message`; общие хуки (`useNotifyError`/`useMountedRef`/`useAsyncData`) — `widgets/Admin/hooks.ts`, константа `USERS_ADMIN` — `auth/permissions.ts`; строки таблицы показывают серверное состояние (без оптимистичных обновлений), при ошибке список перечитывается, на время сохранения блокируются контролы всех строк; матрица: PUT по ролям строго последовательно (защита от гонки снятий `USERS_ADMIN`), после сохранения — слияние ответов, сброс черновика, перечитывание; неактивную роль нельзя назначить, но можно снять; черновик матрицы — `Map` (lib ES2020 без `Object.hasOwn`); `EMAIL_EXISTS` показывается под полем email; страница показывает первые 200 пользователей с пометкой (поиска/пагинации нет) | D-9 минимализм; безопасность и устойчивость экрана | добавить поиск/пагинацию при росте числа пользователей; расширить lib и заменить `Map` | +| 33 | 2026-10-04 | R-22 (SDD фаза 2, пост-Task 15): общий маппинг нарушений уникальности PostgreSQL (SQLSTATE 23505) в коды 409 — реестр `src/common/constraint_conflicts.py` (7 ограничений: crm_id ГК и ЮЛ, inn, алиас, email (точный дубликат), два индекса членства); имя ограничения берётся из `orig.__cause__.constraint_name` (asyncpg) / `.diag` (psycopg), текст ошибки не разбирается; ответ — тот же `{"detail": {"code","message"}}`, что у pre-check, без значений ключей; прочие ошибки целостности — прежняя строка; в лог добавлен `resolved_code`; гонка повторного добавления ЮЛ в ту же группу даёт `MEMBER_OF_OTHER_GROUP` (срабатывает индекс «ЮЛ + вид»), текст нейтральный; без кода остаются `ALIAS_TAKEN`, email в другом регистре, `external_id` | закрывает T6.5, T7.1, T8.2, T12.7: гонка не отличается от последовательного дубля | расширить реестр; убрать — вернуть общий 409 | +| 34 | 2026-10-04 | R-23 (SDD, финальное ревью I-1): правило «администратор не снимает с себя USERS_ADMIN» трактуется как системное — запрещено (409 `LAST_ADMIN`), пока он единственный активный обладатель права; при втором активном администраторе снятие роли у себя, самодеактивация и снятие права с роли разрешены; текст требования спеки users-admin, D-8 и proposal приведены к этому; код и тест `test_second_admin_allows_dropping_own_admin_role` не менялись | сценарии спеки и Review Focus 5 плана описывают именно системную защиту; план сузил буквальный текст требования, расхождение не было записано | реализовать строгий вариант: при `user_id == actor_id` и потере всех источников `USERS_ADMIN` у себя — 409 `LAST_ADMIN` в `set_roles`/`set_role_permissions`/`update_user(is_active=false)` и поправить тест | +| 35 | 2026-10-04 | R-24 (SDD, финальное ревью M-3): смена имени и филиала ГК одним PATCH (и импортом `update`) пишет одно событие `ASSIGNMENT_CHANGE` с обоими полями в before/after — оставлено как есть; выборка журнала по `GROUP_CHANGE` пропустит такие переименования | спека не требует разнесения; полный состав изменения сохранён в одном событии | разнести на два события (филиал → `ASSIGNMENT_CHANGE`, прочее → `GROUP_CHANGE`) — правка `apply_group_update` и тестов | diff --git a/openspec/changes/slice-1-directories/proposal.md b/openspec/changes/slice-1-directories/proposal.md new file mode 100644 index 0000000..907a78b --- /dev/null +++ b/openspec/changes/slice-1-directories/proposal.md @@ -0,0 +1,82 @@ +# Proposal: Срез 1 — справочники (API), импорт из ЕЦП CRM, политика видимости, администрирование пользователей + +**Срез:** 1 «Справочники» (`../docs/context/09-delivery-plan.md`), в сокращённом объёме по решению владельца 04.10.2026: +**без UI справочника**, экран администратора — минимальный. Цель — быстрее дойти до Среза 2 (`tabular-parser`). +**Репозиторий:** arm-core. **Issue:** RSHB/arm-core#5. **Родительский change:** `../docs/openspec/changes/slice-1-directories` +(DBML v5.14, §13 импорт, §10.1 видимость, OQ-86…91). **Фикстура:** `../fixtures/directories/ecp-export-russkiy-biskvit.xlsx` (RSHB/arm-ka-fixtures#2). + +## Why + +Срез 2 (загрузка файлов и парсер) требует, чтобы в ядре уже были ЮЛ-владельцы файлов, их ГК и состав ГСК, а также +пользователи с ролями, под которыми загружают. В Срезе 0 есть только схема, seed ролей и `/api/v1/me`: ни одной записи +справочника создать нельзя, пользователей заводит только seed. Срез 1 даёт API справочников, импорт реальной выгрузки +ЕЦП CRM (8400 строк, 345 ГК — включая группу «Русский Бисквит» для тестов), правило видимости по филиалу и +администрирование пользователей. + +## What Changes + +- **DBML v5.14** (`../docs/model/data-model-v5.14.dbml`): `related_group_member.kind` + частичный уникальный индекс + `(legal_entity_id, kind) WHERE is_current` (ЮЛ одновременно в одной ГСК и одной ГСЗ), `audit_action` + `LEGAL_ENTITY_CHANGE`, + `USER_CHANGE`. ORM, миграция `0002_dbml_v5_14`, `DBML_VERSION`, `Makefile`, `dbml_parity.DBML_FILE_NAME`, тесты + миграций и паритета — на v5.14. Таблиц 43, enum'ов 27 — без изменений. +- **Инфраструктура запросов** (общая для всех мутаций): зависимость сессии с commit при успехе и rollback при ошибке; + зависимость `AuditService` с `ip_address`/`user_agent` из запроса и `user_id` из `Principal`; доменные исключения + `NotFoundError` → 404, `ConflictError` → 409 (с осмысленным текстом вместо шаблонного «нарушение целостности»), + `DomainValidationError` → 422; единый формат списков `{items, total, limit, offset}`. +- **Policy-слой** `src/domain/policy.py` (чистые функции, без ролей): видимость ГК/ЮЛ по филиалу — ГО видит всё, филиал — + ГК своего РФ и своих монозаёмщиков (§10.1); применение — фильтр в репозиториях и проверка на объекте (404 вне видимости). +- **API справочников** (`/api/v1/directories/…`, права `DIRECTORY_VIEW` / `DIRECTORY_EDIT` / `DIRECTORY_DELETE`): + - филиалы: список; + - ГК: список (поиск по имени/`crm_id`, архив), карточка, создание (`code` генерируется `GK-NNNNNN`), изменение + (имя, РФ — снимок в `company_group_history`, `GROUP_CHANGE`/`ASSIGNMENT_CHANGE`), архив/разархив, история; + - ЮЛ/ИП: список (фильтры: ГК, «вне ГК», ИНН, имя), карточка, создание (ИНН 10/12 по типу), изменение, перевод в + другую ГК (`MOVE_TO_GROUP`, `effective_date`), вывод из состава (`CLOSE` / `REMOVE_FROM_GROUP`), история, алиасы + (добавить/удалить, `normalized_alias` по §3, конфликт с чужим алиасом → 409); + - ГСК/ГСЗ: список по ГК, создание (`code` `GSK-`/`GSZ-NNNNNN`), изменение, удаление пустой (ASSUMPTION(OQ-34)), + состав: добавить/исключить (SCD2; одна группа каждого вида на ЮЛ; ЮЛ только из той же ГК). +- **Импорт из ЕЦП CRM** (`POST /api/v1/directories/import`, право `DIRECTORY_ADMIN`): xlsx/csv по §13 (`openpyxl` + добавляется в `requirements.txt`), `dry_run`, `on_existing = skip | update`, одна транзакция, построчные коды §13.2, + отчёт в ответе и в `audit_event(DIRECTORY_IMPORT)`, файл в S3 `uploads/imports/{audit_event_id}/{name}` + `s3_object`. + Допущения `ASSUMPTION(OQ-87…OQ-91)` в коде. Тесты: срез по «Русскому Бисквиту» из fixtures и `dry_run` всей выгрузки + (маркер `fixtures`, `ARM_KA_DOCS`). +- **Администрирование** (`/api/v1/admin/…`, право `USERS_ADMIN`): пользователи (список, создание с обязательным email, + изменение, деактивация; `USER_CHANGE`), роли (список), назначение ролей пользователю (`USER_ROLE_CHANGE`), матрица + `role_permission` (чтение, замена набора прав роли; `ROLE_PERMISSION_CHANGE`); нельзя оставить систему без активного обладателя `USERS_ADMIN` (в т.ч. снять его с себя, будучи последним). +- **Минимальный экран администратора** (`web/`): API-клиент с `Authorization: Bearer` (dev-токен по email в режиме + разработки), страница `/admin`: таблица пользователей с назначением ролей и форма создания, матрица «роль × право» + с чекбоксами. Без справочника ГК/ЮЛ (решение владельца). +- **Фикстуры тестов**: фабрики `make_company_group`, `make_legal_entity`, `make_user`; общая фикстура `auth_client`. +- **Документы:** `README.md` (API, импорт, права), `CLAUDE.md`, спеки. + +## Capabilities + +### New Capabilities +- `directories-api`: ГК, ЮЛ/ИП, алиасы, ГСК/ГСЗ и состав, филиалы — поведение API, истории и аудита. +- `directory-import`: импорт выгрузки ЕЦП CRM по §13 с отчётом, `dry_run`, стратегией дублей и следом в аудите/S3. +- `directory-policy`: видимость справочников по филиалу без проверки кода роли. +- `users-admin`: администрирование пользователей, назначения ролей и матрицы прав; минимальный экран администратора. + +### Modified Capabilities +- `data-model`: схема равна DBML **v5.14** (требования «Схема БД равна DBML», «Версия DBML зафиксирована в коде»); + миграции 0001 → 0002 применяются с нуля и откатываются. +- `audit-trail`: добавляется требование, какие события пишут справочники, импорт и администрирование (новые значения + `LEGAL_ENTITY_CHANGE`, `USER_CHANGE`). + +## Impact + +- **Таблицы DBML v5.14:** `regional_office` (чтение, создание при импорте), `company_group`, `company_group_history`, + `legal_entity`, `legal_entity_history`, `legal_entity_alias`, `related_group`, `related_group_member` (новая колонка + `kind`), `app_user`, `user_role`, `role_permission`, `audit_event`, `s3_object` (файл импорта). Полей вне DBML нет. +- **Правила:** §3 (нормализация алиаса), §10/§10.1 (права и видимость), §13 (импорт, ручные операции). +- **Зависимости:** `openpyxl` (чтение xlsx, в `requirements.txt`); фронт — без новых пакетов (antd, RTK уже есть). +- **Seed:** права и матрица не меняются (16 кодов); `test_me_and_dev_token`/`test_seed_roles` не трогаются. +- **Внешние системы:** S3 (файл импорта), PostgreSQL 16 (миграция 0002). + +## Non-goals + +- UI справочника ГК/ЮЛ/ГСК по макету (экраны 50, 51, 61, 77–80) — отдельный срез. +- Нерезиденты и физлица в справочнике, контрольные разряды ИНН, маппинг «ЦКБ»/«Головная организация» на ГО, + несколько ГСК в одной ГК из выгрузки — после ответов OQ-87…OQ-91. +- Фильтр видимости по ЗО (`business_segment`) — поля у ГК нет (OQ-86). +- Онлайн-интеграция с ЕЦП CRM; удаление ГК и ЮЛ (только архив/закрытие); восстановление истории назад. +- Проверка JWT РАИСА, аудит входа (`LOGIN`) — остаются как в Срезе 0. diff --git a/openspec/changes/slice-1-directories/specs/audit-trail/spec.md b/openspec/changes/slice-1-directories/specs/audit-trail/spec.md new file mode 100644 index 0000000..deb6d10 --- /dev/null +++ b/openspec/changes/slice-1-directories/specs/audit-trail/spec.md @@ -0,0 +1,29 @@ +# Spec Delta: audit-trail + +## ADDED Requirements + +### Requirement: События справочников, импорта и администрирования +Каждая мутация справочников, импорт и каждое действие администратора SHALL писать `audit_event` через сервис аудита в +той же транзакции с `user_id` инициатора, `ip_address` и `user_agent` запроса: + +| Действие | `action` | `section` | `object_type` | +|---|---|---|---| +| создание/изменение/архив ГК | `GROUP_CHANGE` | `DIRECTORIES` | `company_group` | +| смена филиала ГК | `ASSIGNMENT_CHANGE` | `DIRECTORIES` | `company_group` | +| создание/изменение/перевод/закрытие ЮЛ, алиасы | `LEGAL_ENTITY_CHANGE` | `DIRECTORIES` | `legal_entity` / `legal_entity_alias` | +| ГСК/ГСЗ и их состав | `MEMBERSHIP_CHANGE` | `DIRECTORIES` | `related_group` / `related_group_member` | +| импорт выгрузки | `DIRECTORY_IMPORT` | `DIRECTORIES` | `directory_import` | +| пользователь создан/изменён/деактивирован | `USER_CHANGE` | `USERS` | `app_user` | +| роли пользователя | `USER_ROLE_CHANGE` | `USERS` | `app_user` | +| права роли | `ROLE_PERMISSION_CHANGE` | `ACCESS` | `role` | + +`before_value`/`after_value` SHALL содержать изменённые поля (для списков — весь набор до и после); `changed_field` — +имя поля при изменении одного поля. + +#### Scenario: Изменение имени ГК +- **WHEN** имя ГК меняется с «А» на «Б» +- **THEN** в `audit_event` строка `GROUP_CHANGE` с `changed_field = "name"`, `before_value = {"name": "А"}`, `after_value = {"name": "Б"}`, `user_id` инициатора + +#### Scenario: Откат мутации справочника +- **WHEN** транзакция перевода ЮЛ откатывается +- **THEN** строки `LEGAL_ENTITY_CHANGE` и `MEMBERSHIP_CHANGE` в `audit_event` отсутствуют diff --git a/openspec/changes/slice-1-directories/specs/data-model/spec.md b/openspec/changes/slice-1-directories/specs/data-model/spec.md new file mode 100644 index 0000000..76e015b --- /dev/null +++ b/openspec/changes/slice-1-directories/specs/data-model/spec.md @@ -0,0 +1,60 @@ +# Spec Delta: data-model + +## RENAMED Requirements +- FROM: `### Requirement: Схема БД равна DBML v5.13` +- TO: `### Requirement: Схема БД равна DBML v5.14` + +## MODIFIED Requirements + +### Requirement: Схема БД равна DBML v5.14 +ORM-метаданные SHALL содержать ровно 43 таблицы и 27 enum'ов из `../docs/model/data-model-v5.14.dbml` в схеме `dkb`; +имена таблиц, колонок, enum'ов и их значений SHALL совпадать с DBML буквально; nullability, PK, UNIQUE, FK, значения +по умолчанию и индексы SHALL соответствовать DBML, включая частичные уникальные индексы (`related_group_member` +`(related_group_id, legal_entity_id)` и `(legal_entity_id, kind)` WHERE `is_current`, `consolidation_lock` WHERE +`released_at IS NULL`) и `NULLS NOT DISTINCT` на `document (stored_file_id, document_type, osv_account_code)`. +`related_group_member.kind` SHALL быть NOT NULL и равняться `related_group.kind` группы (проверяет ядро при вставке). +`audit_action` SHALL содержать `LEGAL_ENTITY_CHANGE` и `USER_CHANGE`. Поля, отсутствующие в DBML, MUST NOT существовать. + +#### Scenario: Сверка ORM с DBML +- **WHEN** выполняется `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.14.dbml` +- **THEN** вывод пуст и код возврата 0 + +#### Scenario: Лишняя колонка в ORM +- **WHEN** в ORM добавлена колонка, которой нет в DBML (или удалена существующая) +- **THEN** скрипт печатает `таблица.колонка: отсутствует в DBML` (или `отсутствует в ORM`) и возвращает код 1 + +#### Scenario: Расхождение типа или nullability +- **WHEN** колонка `osv_entry.opening_debit` объявлена в ORM как `Numeric(18, 4)` или `NOT NULL` +- **THEN** скрипт сообщает `ожидалось numeric(18,2) NULL / фактически numeric(18,4) NOT NULL` и возвращает код 1 + +#### Scenario: ЮЛ одновременно в ГСК и ГСЗ +- **WHEN** для одного `legal_entity_id` вставлены две текущие строки `related_group_member` с `kind = gsk` и `kind = gsz` +- **THEN** обе вставки проходят; третья текущая строка с `kind = gsk` нарушает уникальный индекс + +### Requirement: Миграции применяются с нуля и откатываются +Цепочка ревизий `0001_dbml_v5_13` → `0002_dbml_v5_14` SHALL создавать схему `dkb` (если нет), все enum'ы, таблицы, +ограничения, индексы и последовательности кодов (`company_group_code_seq`, `related_group_code_seq`) на пустой базе +PostgreSQL 16 (требуется ≥ 15); `downgrade` до `base` SHALL удалять всё созданное (включая новые значения `audit_action` +через пересоздание типа), оставляя пустую схему; повторный `upgrade` после `downgrade` SHALL проходить без ошибок. + +#### Scenario: Полный цикл +- **WHEN** на пустой БД выполняются `alembic upgrade head`, `alembic downgrade base`, `alembic upgrade head` +- **THEN** все три команды завершаются успешно, после последней в `information_schema.tables` схемы `dkb` 43 таблицы модели (+ служебная `alembic_version`) + и в `pg_type` 27 enum-типов + +#### Scenario: Откат на один шаг +- **WHEN** после `upgrade head` выполняется `alembic downgrade 0001_dbml_v5_13` +- **THEN** колонка `related_group_member.kind` отсутствует, индекс `(legal_entity_id) WHERE is_current` восстановлен, + `audit_action` не содержит `LEGAL_ENTITY_CHANGE`/`USER_CHANGE` + +#### Scenario: Автогенерация ничего не находит +- **WHEN** после `upgrade head` выполняется `alembic revision --autogenerate` на той же БД +- **THEN** сгенерированная ревизия не содержит операций (пустые `upgrade`/`downgrade`) + +### Requirement: Версия DBML зафиксирована в коде +Пакет моделей SHALL объявлять константу версии DBML (`5.14`); скрипт сверки SHALL сравнивать её с версией в шапке файла +DBML и падать при расхождении — смена модели без осознанного обновления ORM невозможна. + +#### Scenario: Новая версия DBML без обновления ORM +- **WHEN** в `docs/model/` появляется `data-model-v5.15.dbml`, а константа в ORM — `5.14` +- **THEN** сверка против нового файла падает с сообщением о версии до сравнения таблиц diff --git a/openspec/changes/slice-1-directories/specs/directories-api/spec.md b/openspec/changes/slice-1-directories/specs/directories-api/spec.md new file mode 100644 index 0000000..01dd8ba --- /dev/null +++ b/openspec/changes/slice-1-directories/specs/directories-api/spec.md @@ -0,0 +1,125 @@ +# Spec Delta: directories-api + +## Purpose + +API справочников ядра: группы компаний (ГК), юридические лица и ИП с алиасами и историей, группы связанных компаний и +заёмщиков (ГСК/ГСЗ) с составом, филиалы. Все изменения идут через сервисы с историей (SCD4/SCD2) и записью в `audit_event`; +имена полей — как в DBML v5.14. + +## ADDED Requirements + +### Requirement: Филиалы доступны для чтения +`GET /api/v1/directories/regional-offices` SHALL возвращать список `regional_office` (`id`, `name`, `is_head_office`, `is_active`) +любому пользователю с правом `DIRECTORY_VIEW`; создание и изменение филиалов через API в Срезе 1 не предоставляется +(seed, импорт). + +#### Scenario: Список филиалов +- **WHEN** пользователь с `DIRECTORY_VIEW` запрашивает список после `seed --dev` +- **THEN** ответ 200 содержит Головной офис с `is_head_office = true` и два dev-филиала + +### Requirement: Группа компаний — создание, чтение, изменение, архив, история +`POST /api/v1/directories/company-groups` (право `DIRECTORY_EDIT`) SHALL создавать ГК с полями `name` (обязательно), +`crm_id` (необязательно, уникально), `base_regional_office_id` (необязательно), генерируя `code` вида `GK-NNNNNN` и +`valid_from = now`, с записью `audit_event(GROUP_CHANGE)`. `PATCH …/{id}` SHALL изменять `name`, `crm_id`, +`base_regional_office_id`, сохраняя снимок прежних значений в `company_group_history` (`valid_from` прежний, +`valid_to = now`) и обновляя `valid_from`; смена филиала пишет `ASSIGNMENT_CHANGE`, остальное — `GROUP_CHANGE`. +`POST …/{id}/archive` и `…/unarchive` SHALL переключать `is_archived`. `GET …` SHALL отдавать список с поиском по +`name`/`crm_id`/`code` (подстрока без регистра), фильтром `is_archived`, пагинацией `{items, total, limit, offset}` +(`limit` ≤ 200) и счётчиком `legal_entities_count`; `GET …/{id}` — карточку; `GET …/{id}/history` — снимки по убыванию `valid_from`. +Удаление ГК API NOT предоставляет. + +#### Scenario: Создание и код +- **WHEN** создаются две ГК подряд +- **THEN** их `code` различаются, имеют вид `GK-` и шесть цифр, `valid_from` заполнен, в `audit_event` две записи `GROUP_CHANGE` с `after_value` + +#### Scenario: Перезакрепление за филиалом +- **WHEN** у ГК меняется `base_regional_office_id` +- **THEN** в `company_group_history` появляется строка с прежним филиалом и `valid_to`, у ГК новый `valid_from`, записан `audit_event(ASSIGNMENT_CHANGE)` с `before_value`/`after_value` + +#### Scenario: Дубликат crm_id +- **WHEN** создаётся ГК с `crm_id`, который уже есть +- **THEN** ответ 409 `{"detail": {"code": "CRM_ID_EXISTS", "message": …}}`, записи нет + +### Requirement: Юридическое лицо / ИП — создание, чтение, изменение +`POST /api/v1/directories/legal-entities` (`DIRECTORY_EDIT`) SHALL создавать запись с `inn` (ЮЛ — ровно 10 цифр, ИП — 12; +иначе 422 `INVALID_INN`), `entity_type` (`ЮЛ` | `ИП`), `name`, `company_group_id` (NULL = монозаёмщик), `regional_office_id` +(по умолчанию — филиал ГК), `crm_id`; `inn` уникален (409 `INN_EXISTS`). `PATCH …/{id}` SHALL менять `name`, `entity_type`, +`crm_id`, `regional_office_id` со снимком в `legal_entity_history(event_type = UPDATE)` и `audit_event(LEGAL_ENTITY_CHANGE)`. +`GET …` SHALL давать список с фильтрами `company_group_id`, `outside_group` (`company_group_id IS NULL`), `inn` +(точно), `q` (подстрока имени/ИНН), `closed` (по `close_date`), пагинацией; `GET …/{id}` — карточку с текущими +членствами ГСК/ГСЗ и алиасами; `GET …/{id}/history` — снимки. + +#### Scenario: Неверная длина ИНН +- **WHEN** создаётся ИП с ИНН из 10 цифр +- **THEN** ответ 422 с кодом `INVALID_INN`, запись не создана + +#### Scenario: Монозаёмщик +- **WHEN** ЮЛ создано без `company_group_id` +- **THEN** оно отдаётся в списке с `outside_group = true` и видно пользователям филиала `regional_office_id` + +### Requirement: Перевод ЮЛ в другую ГК и вывод из состава +`POST /api/v1/directories/legal-entities/{id}/move` (`DIRECTORY_EDIT`; тело `company_group_id`, `effective_date` ≥ сегодня) +SHALL сменить ГК, записать `legal_entity_history(event_type = MOVE_TO_GROUP, effective_date)`, закрыть все текущие +членства ЮЛ в ГСК/ГСЗ прежней ГК (`valid_to = now`, `is_current = false`) и записать `LEGAL_ENTITY_CHANGE` и +`MEMBERSHIP_CHANGE`. `POST …/{id}/withdraw` (тело `event`: `CLOSE` | `REMOVE_FROM_GROUP`, `effective_date`, `comment`) +SHALL установить `company_group_id = NULL`, при `CLOSE` — ещё `close_date = effective_date`, закрыть членства, записать +историю и аудит. `effective_date` раньше сегодняшнего дня → 422. + +#### Scenario: Перевод закрывает членства +- **WHEN** ЮЛ состоит в ГСК старой ГК и переводится в новую ГК +- **THEN** членство получает `is_current = false` и `valid_to`, ЮЛ имеет новую `company_group_id`, в истории строка `MOVE_TO_GROUP` с датой перевода + +#### Scenario: Закрытие +- **WHEN** выполнен `withdraw` с `event = CLOSE` +- **THEN** `close_date` заполнен, `company_group_id = NULL`, ЮЛ отдаётся в списке с `closed = true`, в истории `CLOSE` + +### Requirement: Алиасы ЮЛ +`POST /api/v1/directories/legal-entities/{id}/aliases` (`DIRECTORY_EDIT`; тело `alias`) SHALL сохранять `alias` как есть и +`normalized_alias` по §3 (нижний регистр, trim, без пунктуации и кавычек, без ОПФ ООО/АО/ПАО/ЗАО/ИП, схлопнутые пробелы); +повтор для того же ЮЛ — 409 `ALIAS_EXISTS`; такой же `normalized_alias` у другого ЮЛ — 409 `ALIAS_TAKEN` с указанием +`legal_entity_id` владельца. `DELETE …/aliases/{alias_id}` SHALL удалять алиас. Оба пишут `LEGAL_ENTITY_CHANGE`. + +#### Scenario: Нормализация +- **WHEN** добавляется алиас `ООО «Пекарня Счастливочная»` +- **THEN** `normalized_alias = "пекарня счастливочная"`, повторное добавление `Пекарня Счастливочная, ООО` → 409 `ALIAS_EXISTS` + +### Requirement: ГСК и ГСЗ — группы и состав +`POST /api/v1/directories/company-groups/{gk_id}/related-groups` (`DIRECTORY_EDIT`; тело `kind` ∈ {gsk, gsz}, `name`, +`created_date`) SHALL создавать `related_group` с `code` `GSK-NNNNNN`/`GSZ-NNNNNN`; `PATCH …/related-groups/{id}` — менять +`name`, `created_date`; `DELETE …/related-groups/{id}` (`DIRECTORY_DELETE`) SHALL удалять только группу без текущих +членов (иначе 409 `GROUP_NOT_EMPTY`). `POST …/related-groups/{id}/members` (тело `legal_entity_id`) SHALL добавлять текущее +членство с `kind` группы, `valid_from = now`, `is_current = true`; ЮЛ MUST принадлежать той же ГК (иначе 422 +`DIFFERENT_COMPANY_GROUP`); если у ЮЛ уже есть текущее членство в группе того же вида — 409 `MEMBER_OF_OTHER_GROUP` +(с `related_group_id`); повтор в той же группе — 409 `MEMBER_EXISTS`. `DELETE …/members/{legal_entity_id}` SHALL закрывать +членство (`valid_to`, `is_current = false`). Все операции пишут `MEMBERSHIP_CHANGE`. `GET …/related-groups` и +`GET …/related-groups/{id}` SHALL отдавать группы с числом текущих членов и списком членов. + +#### Scenario: Одновременно в ГСК и ГСЗ +- **WHEN** ЮЛ добавлено в ГСК своей ГК, затем в ГСЗ той же ГК +- **THEN** обе операции успешны, у ЮЛ два текущих членства с `kind = gsk` и `kind = gsz` + +#### Scenario: Вторая ГСК запрещена +- **WHEN** ЮЛ с текущим членством в ГСК A добавляют в ГСК B той же ГК +- **THEN** ответ 409 `MEMBER_OF_OTHER_GROUP` с идентификатором ГСК A; данные не меняются + +#### Scenario: Повторное включение после исключения +- **WHEN** членство закрыто, затем ЮЛ снова добавляют в ту же группу +- **THEN** появляется новая строка `related_group_member` с `is_current = true`, старая остаётся с `valid_to` + +#### Scenario: Удаление непустой группы +- **WHEN** удаляется ГСК с текущими членами +- **THEN** ответ 409 `GROUP_NOT_EMPTY` + +### Requirement: Права и аудит на всех маршрутах справочников +Каждый маршрут SHALL требовать соответствующее право (`DIRECTORY_VIEW` для чтения, `DIRECTORY_EDIT` для изменений, +`DIRECTORY_DELETE` для удаления ГСК/ГСЗ и алиасов) и применять политику видимости (`directory-policy`); каждая мутация +SHALL выполняться в одной транзакции с записью `audit_event` (`user_id` инициатора, `before_value`/`after_value`, +`ip_address`, `user_agent`), откат транзакции отменяет и аудит. + +#### Scenario: Нет права +- **WHEN** пользователь с ролью `EXPERT` (только `DIRECTORY_VIEW`) создаёт ГК +- **THEN** ответ 403, записи и аудита нет + +#### Scenario: Ошибка внутри мутации +- **WHEN** при переводе ЮЛ закрытие членства завершается ошибкой БД +- **THEN** ЮЛ остаётся в прежней ГК, строк истории и аудита нет diff --git a/openspec/changes/slice-1-directories/specs/directory-import/spec.md b/openspec/changes/slice-1-directories/specs/directory-import/spec.md new file mode 100644 index 0000000..bbc7dec --- /dev/null +++ b/openspec/changes/slice-1-directories/specs/directory-import/spec.md @@ -0,0 +1,70 @@ +# Spec Delta: directory-import + +## Purpose + +Импорт справочников ГК / ЮЛ / ГСК / ГСЗ / филиалов из выгрузки ЕЦП CRM (xlsx или csv) по правилам §13 +`05-domain-rules.md`: построчные решения с кодами, одна транзакция, `dry_run`, стратегия дублей, отчёт в ответе, +в `audit_event(DIRECTORY_IMPORT)` и файл в S3. + +## ADDED Requirements + +### Requirement: Приём файла и формат +`POST /api/v1/directories/import` (multipart: `file`; поля `dry_run` (по умолчанию `true`), `on_existing` ∈ {`skip`, `update`}, +по умолчанию `skip`; право `DIRECTORY_ADMIN`) SHALL принимать xlsx (первый лист) или csv (UTF-8, разделитель `;` или `,`) +размером до 20 МБ, сопоставлять заголовки по нормализованному тексту (`ИНН`, `iD ГК`, `Базовый РФ ГК в CRM`, `Группа +компаний`, `Организация`, `ГСК`, `ГСЗ`, `Тип`; порядок и лишние колонки не важны). Отсутствие любой из обязательных колонок +(`ИНН`, `iD ГК`, `Группа компаний`, `Организация`, `Тип`) → 422 `MISSING_COLUMNS` с перечнем; нечитаемый файл → 422 +`UNSUPPORTED_FILE`; превышение размера → 413. + +#### Scenario: Реальная выгрузка принята +- **WHEN** загружается `fixtures/directories/ecp-export-russkiy-biskvit.xlsx` с `dry_run = true` +- **THEN** ответ 200 с отчётом: `rows = 21`, ошибок файла нет + +#### Scenario: Нет колонки «Тип» +- **WHEN** в csv отсутствует колонка `Тип` +- **THEN** ответ 422 `MISSING_COLUMNS`, в отчёте перечислена недостающая колонка, в БД ничего не изменено + +### Requirement: Построчные правила и коды отчёта +Для каждой строки импорт SHALL применять §13.2 и присваивать ровно один итоговый код из перечня: `UNSUPPORTED_TYPE`, +`INVALID_INN`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`, `GK_NAME_CONFLICT` (строка пропущена) или `LE_CREATED`, `LE_UPDATED`, +`LE_EXISTS` (строка применена), плюс сопутствующие события `RF_CREATED`, `GK_CREATED`, `GK_UPDATED`, `GK_EXISTS`, +`GSK_CREATED`, `GSZ_CREATED`, `MEMBER_ADDED`, `UNKNOWN_FLAG`. Тип вне {ЮЛ, ИП} (ASSUMPTION(OQ-87)), ИНН не 10/12 цифр +(ASSUMPTION(OQ-88)) и ИНН, уже встречавшийся в файле, пропускаются. Филиал по имени создаётся при отсутствии +(ASSUMPTION(OQ-89)). ЮЛ, существующее в другой ГК, не переносится (ASSUMPTION(OQ-90)). Флаги «входит в ГСК»/«входит в ГСЗ» +создают не более одной ГСК и одной ГСЗ на ГК с именем ГК (ASSUMPTION(OQ-91)). Отсутствие флага членство NOT закрывает. + +#### Scenario: Срез «Русский Бисквит», пустая база +- **WHEN** импорт `ecp-export-russkiy-biskvit.xlsx` с `dry_run = false`, `on_existing = skip` на базе после `seed` +- **THEN** создана 1 ГК (`crm_id = 1-AZFL6BQ`, имя «Русский Бисквит»), 1 филиал «Санкт-Петербургский РФ» (`RF_CREATED`), + 20 ЮЛ/ИП (`LE_CREATED`: 18 ЮЛ и 2 ИП), 1 ГСК с именем ГК и двумя членами (АО «Русский бисквит», ООО «Пекарня Счастливочная»), 0 ГСЗ; + строка типа `ФЛ` (идёт после ИП с тем же ИНН) → `UNSUPPORTED_TYPE` (тип проверяется первым, `DUPLICATE_IN_FILE` в этом файле не возникает) + +#### Scenario: Повторный импорт идемпотентен +- **WHEN** тот же файл импортируется второй раз с `on_existing = skip` +- **THEN** все применённые строки получают `LE_EXISTS`/`GK_EXISTS`, число строк в таблицах не меняется + +#### Scenario: Обновление имени +- **WHEN** в файле изменено имя существующего ЮЛ и импорт идёт с `on_existing = update` +- **THEN** имя обновлено, в `legal_entity_history` снимок `UPDATE`, код строки `LE_UPDATED` + +#### Scenario: ЮЛ в другой ГК +- **WHEN** ИНН уже состоит в другой ГК +- **THEN** строка пропущена с `LE_IN_OTHER_GROUP`, ЮЛ не переведено, импорт продолжается + +### Requirement: Транзакция, dry_run, след +Импорт SHALL выполняться в одной транзакции: ошибки строк не прерывают его, при `dry_run = true` транзакция откатывается, +а отчёт идентичен боевому. При `dry_run = false` SHALL быть записан `audit_event(action = DIRECTORY_IMPORT, +section = DIRECTORIES, object_type = 'directory_import', after_value = отчёт, description = имя файла)` и файл сохранён в S3 +`uploads/imports/{audit_event_id}/{original_name}` с регистрацией в `s3_object` (`bucket_role = uploads`, +`created_by = user:{id}`); при `dry_run` ни аудита, ни файла. Отчёт SHALL содержать счётчики (`rows`, `groups_created`, +`groups_updated`, `groups_skipped`, `entities_created`, `entities_updated`, `entities_skipped`, `memberships_created`, +`offices_created`, `errors`) и построчный список `{row, inn, code, message}`. + +#### Scenario: dry_run не оставляет следов +- **WHEN** импорт выполнен с `dry_run = true` +- **THEN** число строк в `company_group`, `legal_entity`, `regional_office`, `audit_event`, `s3_object` не изменилось, отчёт возвращён + +#### Scenario: Полная выгрузка +- **WHEN** `docs/source/biq/ecp-export-lagal-entity.xlsx` импортируется с `dry_run = true` (маркер `fixtures`, `ARM_KA_DOCS`) +- **THEN** ответ 200 за ≤ 60 с, `rows = 8400`, в отчёте есть коды `UNSUPPORTED_TYPE` (≥ 500), `INVALID_INN`, `DUPLICATE_IN_FILE`, + `RF_CREATED` = 53, `groups_created` = 344 (из 345 ГК файла одна целиком состоит из строк-дублей и не создаётся) diff --git a/openspec/changes/slice-1-directories/specs/directory-policy/spec.md b/openspec/changes/slice-1-directories/specs/directory-policy/spec.md new file mode 100644 index 0000000..f9f302c --- /dev/null +++ b/openspec/changes/slice-1-directories/specs/directory-policy/spec.md @@ -0,0 +1,39 @@ +# Spec Delta: directory-policy + +## Purpose + +Политика видимости справочников поверх прав: что именно из ГК/ЮЛ/ГСК видит пользователь, определяется данными о его +филиале, а не кодом роли (§10.1 `05-domain-rules.md`). + +## ADDED Requirements + +### Requirement: Область видимости вычисляется из филиала пользователя +Для каждого запроса ядро SHALL вычислять область видимости из `Principal`: если `regional_office.is_head_office = true` — +вся система; иначе — ГК с `company_group.base_regional_office_id = app_user.regional_office_id`, их ЮЛ и ГСК/ГСЗ, а также +монозаёмщики с `legal_entity.regional_office_id = app_user.regional_office_id`. Код роли (`role.code`) MUST NOT участвовать +в вычислении; `business_segment` в Срезе 1 не используется (ASSUMPTION(OQ-86)). + +#### Scenario: Пользователь филиала видит только своё +- **WHEN** есть ГК А (филиал X) и ГК Б (филиал Y), пользователь `KA_RF` филиала X запрашивает список ГК +- **THEN** в ответе только ГК А, `total = 1` + +#### Scenario: Пользователь ГО видит всё +- **WHEN** пользователь с `is_head_office = true` запрашивает список ГК +- **THEN** в ответе обе ГК + +### Requirement: Объект вне видимости неотличим от несуществующего +Чтение, изменение и любые операции над ГК/ЮЛ/ГСК вне области видимости SHALL возвращать 404 с тем же телом, что и для +несуществующего идентификатора; 403 для этого случая MUST NOT использоваться. + +#### Scenario: Чужая ГК по id +- **WHEN** пользователь филиала X запрашивает `GET /directories/company-groups/{id}` ГК Б +- **THEN** ответ 404, текст совпадает с ответом на случайный `uuid` + +### Requirement: Политика применяется до прав на изменение +Проверка права (`require`) и проверка видимости SHALL выполняться обе; порядок: право → видимость → операция. Создание ГК +пользователем филиала SHALL привязывать её к его филиалу, если `base_regional_office_id` не указан; указание чужого филиала +пользователем филиала → 422 `OFFICE_NOT_ALLOWED`. + +#### Scenario: Создание ГК из филиала +- **WHEN** пользователь филиала X с `DIRECTORY_EDIT` создаёт ГК без филиала +- **THEN** у ГК `base_regional_office_id` = X, и она видна создателю diff --git a/openspec/changes/slice-1-directories/specs/users-admin/spec.md b/openspec/changes/slice-1-directories/specs/users-admin/spec.md new file mode 100644 index 0000000..42cc394 --- /dev/null +++ b/openspec/changes/slice-1-directories/specs/users-admin/spec.md @@ -0,0 +1,70 @@ +# Spec Delta: users-admin + +## Purpose + +Администрирование доступа: пользователи, назначение ролей и матрица «роль × право» как данные (`role_permission`), с +аудитом; минимальный экран администратора во фронте. + +## ADDED Requirements + +### Requirement: Пользователи — список, создание, изменение, деактивация +Маршруты `/api/v1/admin/users` (право `USERS_ADMIN`): `GET` — список с ролями и филиалом (поиск по email/ФИО, фильтр +`is_active`, пагинация); `POST` — создание с обязательными `email` (уникален без учёта регистра → 409 `EMAIL_EXISTS`), +`full_name`, `regional_office_id`, необязательными `employee_no`, `business_segment`, `roles` (коды); `PATCH …/{id}` — +`full_name`, `employee_no`, `regional_office_id`, `business_segment`, `is_active`. Удаление MUST NOT предоставляться. +`external_id` через API не задаётся. Каждая мутация пишет `audit_event(USER_CHANGE, section = USERS)`. + +#### Scenario: Создание с ролью +- **WHEN** администратор создаёт пользователя с `roles = ["KA_RF"]` +- **THEN** появляются `app_user` и `user_role`, `GET /me` от его имени (dev-токен) возвращает права `KA_RF` + +#### Scenario: Email в другом регистре +- **WHEN** создаётся пользователь с email, отличающимся от существующего только регистром +- **THEN** ответ 409 `EMAIL_EXISTS` + +### Requirement: Назначение ролей +`PUT /api/v1/admin/users/{id}/roles` (тело: список кодов) SHALL заменять набор ролей пользователя: отсутствующие в наборе +назначения удаляются, новые добавляются; неизвестный или неактивный код роли → 422; пишется `audit_event(USER_ROLE_CHANGE, +before_value = прежний набор, after_value = новый)`. Замена набора ролей MUST NOT оставить систему без активного пользователя с +`USERS_ADMIN` (409 `LAST_ADMIN`): администратор не может снять с себя последнюю роль, дающую `USERS_ADMIN`, если он — +единственный активный обладатель этого права; при наличии другого активного администратора снятие у себя разрешено +(решение №34). + +#### Scenario: Замена набора ролей +- **WHEN** у пользователя роли `[KA_RF]`, администратор ставит `[KA_GO, EXPERT]` +- **THEN** `user_role` содержит ровно две новые строки, в аудите `before_value = ["KA_RF"]`, `after_value = ["KA_GO","EXPERT"]` + +#### Scenario: Последний администратор +- **WHEN** единственный активный пользователь с `USERS_ADMIN` снимает с себя роль `ADMIN` +- **THEN** ответ 409 `LAST_ADMIN`, роли не изменены + +### Requirement: Матрица прав +`GET /api/v1/admin/roles` SHALL отдавать роли с их правами; `GET /api/v1/admin/permissions` — справочник прав (16 кодов, только +чтение; создание прав MUST NOT предоставляться); `PUT /api/v1/admin/roles/{code}/permissions` (тело: список кодов прав) SHALL +заменять набор прав роли и писать `audit_event(ROLE_PERMISSION_CHANGE)` с before/after. Системные роли (`is_system`) +нельзя деактивировать или переименовать; нельзя снять `USERS_ADMIN` с роли, если после этого ни у одного активного +пользователя не останется этого права (409 `LAST_ADMIN`). + +#### Scenario: Изменение матрицы +- **WHEN** у роли `EXPERT` добавляется право `EXPORT` +- **THEN** `role_permission` содержит новую строку, пользователь с ролью `EXPERT` при следующем запросе `/me` видит `EXPORT`, + в аудите `ROLE_PERMISSION_CHANGE` + +#### Scenario: Снятие USERS_ADMIN с единственной роли +- **WHEN** у роли `ADMIN` (единственной с `USERS_ADMIN` у активных пользователей) убирают `USERS_ADMIN` +- **THEN** ответ 409 `LAST_ADMIN` + +### Requirement: Минимальный экран администратора +SPA SHALL содержать маршрут `/admin` (пункт «Система → Пользователи и роли» в боковом меню), доступный при наличии +`USERS_ADMIN` в ответе `/api/v1/me`: таблица пользователей с выбором ролей и формой создания, таблица «роль × право» с +чекбоксами и кнопкой сохранения; запросы идут через общий API-клиент с заголовком `Authorization: Bearer`. В режиме +разработки SHALL быть доступен ввод email для получения dev-токена (`POST /api/v1/dev/token`); в сборке для прода этот +ввод отсутствует. + +#### Scenario: Назначение роли через экран +- **WHEN** администратор на `/admin` выбирает пользователю роль `KA_GO` и сохраняет +- **THEN** вызывается `PUT /admin/users/{id}/roles`, таблица показывает новую роль, ошибка API показывается уведомлением + +#### Scenario: Нет права +- **WHEN** пользователь без `USERS_ADMIN` открывает `/admin` +- **THEN** страница показывает сообщение об отсутствии доступа и не делает запросов к `/admin/*` diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/slice-1-directories/tasks.md new file mode 100644 index 0000000..4134358 --- /dev/null +++ b/openspec/changes/slice-1-directories/tasks.md @@ -0,0 +1,87 @@ +# Tasks: Срез 1 — arm-core + +Каждая задача ≤ половины дня, начинается с тестов/фикстур, заканчивается зелёным `make check` (интеграционные — `make +test-integration`). Пути — от корня репозитория. Допущения в коде помечаются `# ASSUMPTION(OQ-nn)`. + +## 1. Модель v5.14 и инфраструктура запросов + +- [x] 1.1 **ORM и миграция 0002.** `api/src/models/directories.py`: `RelatedGroupMember.kind` (enum `related_group_kind`, NOT NULL), + индекс `uq_related_group_member_entity_kind_current (legal_entity_id, kind) WHERE is_current` вместо `…_entity_current`; + `enums.py`: `AuditAction.LEGAL_ENTITY_CHANGE` (после `GROUP_CHANGE`), `USER_CHANGE` (после `USER_ROLE_CHANGE`); + `alembic/versions/0002_dbml_v5_14.py` (явный DDL: колонка с заполнением из `related_group`, индексы, `ALTER TYPE … ADD VALUE`, + последовательности `company_group_code_seq`, `related_group_code_seq`; downgrade пересоздаёт enum); + `DBML_VERSION = "5.14"`, `Makefile DBML`, `dbml_parity.DBML_FILE_NAME`, `tests/test_migrations.HEAD`, `test_enums`, + `test_check_dbml_script` (фиктивная версия 5.15), `_check_related_group_member_uniqueness`. + Проверка: `make dbml-check` пуст; `make migration-cycle` зелёный; тест «ЮЛ в ГСК и ГСЗ одновременно, вторая ГСК — IntegrityError». +- [x] 1.2 **Единица работы, аудит, исключения, пагинация.** *(Фактически: R-1/R-6, design #12/#17 — `get_async_session` коммитит, зависимость `SessionDep` (`dependencies/db.py`), `get_request_audit`; `get_uow_session`/`get_audit_service` не создавались.)* `api/src/api/dependencies/db.py:get_uow_session` (commit при успехе, + rollback при исключении); `dependencies/audit.py:get_audit_service` (ip/user-agent/user_id); `common/exceptions.py`: + `NotFoundError`, `ConflictError(code, message, extra)`, `DomainValidationError(code, message)` + обработчики 404/409/422 + в `core/exception_handlers.py` (формат `{"detail": {"code", "message", …}}`); `schemas/common.py`: `Page[T]` + `{items,total,limit,offset}`, параметры `limit ≤ 200`. Проверка: unit-тесты обработчиков; интеграционный тест через + `scratch_db_url`: эндпоинт-заглушка с `get_uow_session` коммитит при 2xx и откатывает при исключении. +- [x] 1.3 **Фабрики и клиент тестов.** `tests/factories.py`: `make_regional_office`, `make_company_group`, `make_legal_entity`, + `make_related_group`, `make_membership`, `make_user(email, office, roles)`; `tests/conftest.py`: `seeded`, `app`, + `auth_client(email) -> httpx.AsyncClient` с dev-токеном. Переписать `test_me_and_dev_token.py` на новые фикстуры. + Проверка: существующие тесты зелёные. + +## 2. Policy и справочники + +- [x] 2.1 **Policy.** *(Фактические имена: `company_groups_visible`, `legal_entities_visible`, `office_for_create`, `scope_for`/`scope_of`; R-7, design #18.)* `api/src/domain/policy.py` (strict mypy): `VisibilityScope`, `scope_for(principal)`, `apply_scope_company_groups(stmt, scope)`, + `apply_scope_legal_entities(stmt, scope)`, `assert_office_allowed(scope, office_id)`; unit-тесты без БД на все ветки + (ГО, филиал, монозаёмщик, чужой филиал → `OFFICE_NOT_ALLOWED`). +- [x] 2.2 **Филиалы и ГК.** `repositories/directories.py` (запросы с scope), `services/company_groups.py` (создание с `code` из + последовательности, изменение со снимком `company_group_history`, архив, аудит `GROUP_CHANGE`/`ASSIGNMENT_CHANGE`), + `schemas/directories.py`, `api/endpoints/directories/regional_offices.py`, `company_groups.py`. Интеграционные тесты: + сценарии спеки `directories-api` (ГК) и `directory-policy` (видимость филиала/ГО, 404 вне scope, создание из филиала). +- [x] 2.3 **ЮЛ/ИП: CRUD, перевод, вывод, история.** `domain/inn.py` (длина 10/12 по типу; `ASSUMPTION(OQ-88)`), + `services/legal_entities.py` (создание, изменение со снимком `legal_entity_history(UPDATE)`, `move` (MOVE_TO_GROUP, закрытие + членств), `withdraw` (CLOSE/REMOVE_FROM_GROUP), `effective_date ≥ today`), эндпоинты `legal_entities.py`, аудит + `LEGAL_ENTITY_CHANGE` + `MEMBERSHIP_CHANGE`. Тесты: сценарии спеки (ИНН, монозаёмщик, перевод закрывает членства, закрытие, откат). +- [x] 2.4 **Алиасы.** `domain/aliases.py:normalize_alias` по §3 (unit-тесты на кавычки, ОПФ, пробелы, ё), сервис + эндпоинты + `POST/DELETE …/aliases`, конфликты `ALIAS_EXISTS` / `ALIAS_TAKEN` (с владельцем). Тесты: сценарий «Нормализация». +- [x] 2.5 **ГСК/ГСЗ и состав.** `services/related_groups.py` (создание с `code` `GSK-/GSZ-`, изменение, удаление пустой + `ASSUMPTION(OQ-34)`, добавление члена: та же ГК, `kind` группы, одна группа каждого вида → `MEMBER_OF_OTHER_GROUP`, повтор → + `MEMBER_EXISTS`; исключение — SCD2), эндпоинты `related_groups.py`, аудит `MEMBERSHIP_CHANGE`. Тесты: сценарии спеки + (ГСК+ГСЗ одновременно, вторая ГСК, повторное включение, непустая группа). + +## 3. Импорт из ЕЦП CRM + +- [x] 3.1 **Чтение файла и ядро решений.** `openpyxl` в `api/requirements.txt` (пин); `domain/directory_import.py`: + `read_rows(file) -> (headers, rows)` для xlsx/csv, нормализация заголовков (`MISSING_COLUMNS`), `ImportCode` (StrEnum = §13.2), + `plan_import(rows, snapshot, on_existing) -> ImportPlan(operations, report)` — чистая функция; `ASSUMPTION(OQ-87..91)` в коде. + Unit-тесты без БД: синтетический csv, покрывающий каждый код §13.2 ровно один раз; xlsx-фикстура + `../fixtures/directories/ecp-export-russkiy-biskvit.xlsx` → ожидаемый план (1 ГК, 1 РФ, 20 ЮЛ/ИП, 1 ГСК с 2 членами, + коды ФЛ/дубля по фактическому порядку строк). +- [x] 3.2 **Сервис и эндпоинт.** `services/directory_import.py` (снимок справочника одним запросом на таблицу, применение плана, + `dry_run` → rollback, `audit_event(DIRECTORY_IMPORT, after_value = отчёт)`, файл в S3 `uploads/imports/{audit_event_id}/{name}` + + `s3_object`), `POST /api/v1/directories/import` (multipart, `IMPORT_MAX_BYTES` = 20 МБ → 413, `DIRECTORY_ADMIN`), + `schemas/directory_import.py`. Интеграционные тесты: сценарии спеки `directory-import` (срез РБ, идемпотентность, `update`, + `LE_IN_OTHER_GROUP`, dry_run без следов, `MISSING_COLUMNS`); S3 — через `app.state.storage` с заглушкой в тестах. +- [x] 3.3 **Полная выгрузка (fixtures-тест).** Тест с маркером `fixtures`: `ARM_KA_DOCS/source/biq/ecp-export-lagal-entity.xlsx`, + `dry_run`, `rows = 8400`, `RF_CREATED = 53`, `groups_created = 344`, присутствие кодов, время ≤ 60 с; пропуск с причиной, если файла нет. + Профилирование: при превышении — пакетные вставки (`insert().values([...])`). + +## 4. Администрирование + +- [x] 4.1 **Пользователи и роли (API).** `services/users_admin.py` (создание с проверкой `lower(email)`, изменение, деактивация, + `PUT roles` с diff и `LAST_ADMIN`), `repositories/users.py` (+ запросы списка), эндпоинты `api/endpoints/admin/users.py`, + `roles.py` (`GET roles`, `GET permissions`, `PUT roles/{code}/permissions` с `LAST_ADMIN` и защитой `is_system`), + аудит `USER_CHANGE` / `USER_ROLE_CHANGE` / `ROLE_PERMISSION_CHANGE`. Тесты: сценарии спеки `users-admin` (создание с ролью → + `/me`, email в другом регистре, замена набора ролей, последний администратор, матрица → `/me` видит новое право). +- [x] 4.2 **Фронт: API-клиент и dev-вход.** `web/src/api/client.ts` (`apiFetch(path, init)`: базовый URL, Bearer из + `sessionStorage`, 401 → очистка токена и переход на вход, ошибка `{detail}` → `Error` с текстом), `web/src/api/types.ts` + (DTO `/me`, `/admin/*`), `web/src/auth/DevLogin.tsx` (поле email → `POST /dev/token`, только при `process.env.NODE_ENV !== + 'production'`), `web/src/auth/useMe.ts` (загрузка `/me`, права). Проверка: `make web-check`; ручной сценарий в README. +- [x] 4.3 **Фронт: страница администратора.** `web/src/layouts/Admin.tsx`: antd `Table` пользователей (email, ФИО, филиал, роли — + `Select mode="multiple"` с сохранением по `PUT roles`, переключатель `is_active`), `Modal` создания (email, ФИО, филиал из + `/directories/regional-offices`, роли), таблица «роль × право» с `Checkbox` и кнопкой «Сохранить» (`PUT roles/{code}/permissions`), + `message.error` на ошибки API, экран «нет доступа» без `USERS_ADMIN`; маршрут `/admin` в `App.tsx`, пункт «Система → + Пользователи и роли» в `Sidebar`. Проверка: `make web-check`; самостоятельное ревью TS/React с объяснением решений в PR. + +## 5. Завершение + +- [x] 5.1 **Документы и проверка.** `README.md` (разделы: API справочников, импорт из ЕЦП — пошагово с `dry_run`, администрирование и + экран `/admin`, ограничение: повторный seed возвращает права, снятые администратором), `CLAUDE.md` (DBML v5.14, `domain/policy.py`, `SessionDep`/`get_async_session`, + формат ошибок, `openpyxl`), `api/.env.example` (`IMPORT_MAX_BYTES`); `make check-full` зелёный, вывод — в описание PR. + Проверка: OpenAPI (`/docs`) содержит все маршруты спек (тест `tests/test_openapi_routes.py`). +- [ ] 5.2 **После merge (человек):** `openspec archive slice-1-directories` — синхронизация спек `openspec/specs/*` (в этом PR не выполняется). diff --git a/web/src/App.tsx b/web/src/App.tsx index 9e13510..26cb0a0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,9 @@ import './App.css'; import { Layout } from 'antd'; import { Provider } from 'react-redux'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { AuthGate } from './auth/AuthGate'; import { Config } from './conf/config'; +import { Admin } from './layouts/Admin'; import { Main } from './layouts/Main'; import { store } from './store/store'; import { Sidebar } from './widgets/Sidebar/Sidebar'; @@ -12,14 +14,17 @@ function App() { return ( - - - - - } /> - - - + + + + + + } /> + } /> + + + + ); diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..29aa352 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,119 @@ +import { clearToken, getToken } from '../auth/token'; +import { Config } from '../conf/config'; + +// Хвостовые слэши срезаются: API_URL='/' иначе дал бы `//api/v1` (адрес без хоста) +export const API_BASE = `${Config.API_URL.replace(/\/+$/, '')}/api/v1`; + +/** Событие `window`: сервер ответил 401, токен сброшен — интерфейс должен вернуться к входу. */ +export const UNAUTHORIZED_EVENT = 'armka:unauthorized'; + +const FALLBACK_MESSAGE = 'Не удалось выполнить запрос'; + +export class ApiError extends Error { + constructor( + public status: number, + public code: string | undefined, + message: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +type ErrorInfo = { code: string | undefined; message: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Тело ошибки бэкенда — `{"detail": …}` в одной из трёх форм: + * строка (HTTPException) → message; объект `{code, message}` (доменная ошибка) → code + message; + * массив (422 валидации pydantic) → `msg` первого элемента. + */ +function parseErrorBody(body: unknown): ErrorInfo { + const detail = isRecord(body) ? body.detail : undefined; + if (typeof detail === 'string') { + return { code: undefined, message: detail }; + } + if (isRecord(detail)) { + const code = typeof detail.code === 'string' ? detail.code : undefined; + const message = typeof detail.message === 'string' ? detail.message : FALLBACK_MESSAGE; + return { code, message }; + } + if (Array.isArray(detail)) { + const first: unknown = detail[0]; + if (isRecord(first) && typeof first.msg === 'string') { + return { code: undefined, message: first.msg }; + } + } + return { code: undefined, message: FALLBACK_MESSAGE }; +} + +async function readErrorInfo(response: Response): Promise { + try { + return parseErrorBody(await response.json()); + } catch { + // тело не JSON (например, ответ прокси) — показываем общий текст + return { code: undefined, message: FALLBACK_MESSAGE }; + } +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + const token = getToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + // JSON-заголовок только для строкового тела; FormData и др. отдаём браузеру как есть (он сам задаёт boundary) + if (typeof init.body === 'string' && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + let response: Response; + try { + response = await fetch(`${API_BASE}${path}`, { ...init, headers }); + } catch (e: unknown) { + // отмену запроса (AbortController) вызывающий код обрабатывает сам; остальное — сеть/CORS/DNS: текст браузера не показываем + if (e instanceof DOMException && e.name === 'AbortError') { + throw e; + } + throw new ApiError(0, 'NETWORK', 'Нет связи с сервером'); + } + + if (response.status === 401) { + clearToken(); + window.dispatchEvent(new Event(UNAUTHORIZED_EVENT)); + const info = await readErrorInfo(response); + throw new ApiError(401, info.code, info.message); + } + if (!response.ok) { + const info = await readErrorInfo(response); + throw new ApiError(response.status, info.code, info.message); + } + if (response.status === 204) { + return undefined as T; + } + // 2xx с пустым или не-JSON телом (страница прокси, 200 без содержимого) не должен давать «сырой» SyntaxError + const text = await response.text(); + if (text === '') { + return undefined as T; + } + try { + return JSON.parse(text) as T; + } catch { + throw new ApiError(response.status, 'BAD_RESPONSE', 'Некорректный ответ сервера'); + } +} + +function withJsonBody(method: string, body: unknown): RequestInit { + return body === undefined ? { method } : { method, body: JSON.stringify(body) }; +} + +export const api = { + get: (path: string) => apiFetch(path), + post: (path: string, body?: unknown) => apiFetch(path, withJsonBody('POST', body)), + patch: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PATCH', body)), + put: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PUT', body)), + del: (path: string) => apiFetch(path, { method: 'DELETE' }), +}; diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..d153092 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,106 @@ +// Типы ответов API. Имена полей — как в Pydantic-схемах бэкенда (api/src/schemas/*.py), это источник истины. + +export type BusinessSegment = 'DKB' | 'DMSB' | 'DM'; + +/** Как `PermissionObject` / `PermissionAction` в api/src/models/enums.py. */ +export type PermissionObject = 'DIRECTORY' | 'DOCUMENT' | 'CONSOLIDATION' | 'KFO' | 'EX' | 'EXPORT' | 'USERS' | 'AUDIT'; +export type PermissionAction = 'VIEW' | 'EDIT' | 'APPROVE' | 'VERIFY' | 'DELETE' | 'EXPORT' | 'ADMIN'; + +/** Страница списка верхнего уровня (`schemas/common.py:Page`). */ +export interface Page { + items: T[]; + total: number; + limit: number; + offset: number; +} + +/** Филиал кратко: так он приходит в `GET /me` (без `is_active`). */ +export interface RegionalOfficeBrief { + id: string; + name: string; + is_head_office: boolean; +} + +/** Филиал целиком: `GET /directories/regional-offices` (простой массив, не `Page`) и пользователи администратора. */ +export interface RegionalOffice extends RegionalOfficeBrief { + is_active: boolean; +} + +/** `GET /me`: профиль, коды ролей и эффективные права (оба списка отсортированы по алфавиту). */ +export interface Me { + id: string; + email: string; + full_name: string; + regional_office: RegionalOfficeBrief; + business_segment: BusinessSegment | null; + roles: string[]; + permissions: string[]; +} + +/** `POST /dev/token`: JWT для заголовка `Authorization: Bearer` (только при AUTH_DEV_ISSUER и DEBUG). */ +export interface DevTokenResponse { + access_token: string; + token_type: string; +} + +/** `UserOut`: пользователь для администратора. */ +export interface AdminUser { + id: string; + email: string | null; + full_name: string; + employee_no: string | null; + regional_office: RegionalOffice; + business_segment: BusinessSegment | null; + is_active: boolean; + roles: string[]; + created_at: string | null; + updated_at: string | null; +} + +/** `UserCreate`: тело `POST /admin/users`. */ +export interface UserCreate { + email: string; + full_name: string; + regional_office_id: string; + employee_no?: string | null; + business_segment?: BusinessSegment | null; + roles?: string[]; +} + +/** `UserUpdate`: тело `PATCH /admin/users/{id}` (передаются только изменяемые поля). */ +export interface UserUpdate { + full_name?: string; + employee_no?: string | null; + regional_office_id?: string; + business_segment?: BusinessSegment | null; + is_active?: boolean; +} + +/** `RoleOut`: роль с кодами её прав. */ +export interface Role { + code: string; + name: string; + description: string | null; + is_system: boolean; + is_active: boolean; + permissions: string[]; +} + +/** `PermissionOut`: право из справочника (только чтение). */ +export interface Permission { + code: string; + object_type: PermissionObject; + action: PermissionAction; + name: string; + description: string | null; +} + +/** `RolesSet`: новый набор ролей пользователя. */ +export interface RolesSet { + roles: string[]; +} + +/** `PermissionsSet`: новый набор прав роли. */ +export interface PermissionsSet { + permissions: string[]; +} diff --git a/web/src/auth/AuthGate.tsx b/web/src/auth/AuthGate.tsx new file mode 100644 index 0000000..79e2faa --- /dev/null +++ b/web/src/auth/AuthGate.tsx @@ -0,0 +1,18 @@ +import { Spin } from 'antd'; +import type { ReactNode } from 'react'; +import { DevLogin } from './DevLogin'; +import { MeContext } from './MeContext'; +import { useMe } from './useMe'; + +// Пока профиль не загружен — вход (или индикатор загрузки); после входа дети получают профиль через `useCurrentUser`. +export function AuthGate({ children }: { children: ReactNode }) { + const { me, loading, error, reload } = useMe(); + + if (loading) { + return ; + } + if (!me) { + return ; + } + return {children}; +} diff --git a/web/src/auth/DevLogin.tsx b/web/src/auth/DevLogin.tsx new file mode 100644 index 0000000..ef5a76d --- /dev/null +++ b/web/src/auth/DevLogin.tsx @@ -0,0 +1,69 @@ +import { Alert, Button, Form, Input } from 'antd'; +import { useState } from 'react'; +import { api } from '../api/client'; +import type { DevTokenResponse } from '../api/types'; +import { setToken } from './token'; + +type Props = { onLogin: () => void; error?: string | null }; + +// Вход по email для разработки: бэкенд выпускает токен только при AUTH_DEV_ISSUER=true и DEBUG=true. +function DevLoginForm({ onLogin, error }: Props) { + const [submitting, setSubmitting] = useState(false); + const [failure, setFailure] = useState(null); + + const submit = async ({ email }: { email: string }) => { + setSubmitting(true); + setFailure(null); + try { + const { access_token } = await api.post('/dev/token', { email: email.trim() }); + setToken(access_token); + onLogin(); + } catch (e: unknown) { + setFailure(e instanceof Error ? e.message : 'Не удалось выполнить вход'); + } finally { + setSubmitting(false); + } + }; + + const shownError = failure ?? error ?? null; + + return ( + layout='vertical' onFinish={submit} style={{ width: 360 }}> +

Вход для разработки

+ {shownError && } + + + + + + ); +} + +// Production: вход выполняет внешний SSO; если загрузка профиля не удалась, показываем причину и даём повторить запрос +function ProdNotice({ onLogin, error }: Props) { + return ( +
+

Вход выполняется через РАИСА

+ {error && } + +
+ ); +} + +export function DevLogin(props: Props) { + return ( +
+ {/* Литерал process.env.NODE_ENV rsbuild подставляет при сборке: в production форма вырезается из бандла целиком */} + {process.env.NODE_ENV !== 'production' ? ( + + ) : ( + // TODO(OQ): параметры JWT РАИСА (издатель, claim с email) + + )} +
+ ); +} diff --git a/web/src/auth/MeContext.ts b/web/src/auth/MeContext.ts new file mode 100644 index 0000000..2bddad6 --- /dev/null +++ b/web/src/auth/MeContext.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; +import type { Me } from '../api/types'; + +export const MeContext = createContext(null); + +/** Текущий пользователь; вызывать только внутри `AuthGate` (вне него профиля нет — это ошибка программиста). */ +export function useCurrentUser(): Me { + const me = useContext(MeContext); + if (!me) { + throw new Error('useCurrentUser вызван вне AuthGate'); + } + return me; +} diff --git a/web/src/auth/permissions.ts b/web/src/auth/permissions.ts new file mode 100644 index 0000000..f6a49b8 --- /dev/null +++ b/web/src/auth/permissions.ts @@ -0,0 +1,3 @@ +// Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер: +// проверки на клиенте только прячут то, что пользователю всё равно не разрешат. +export const USERS_ADMIN = 'USERS_ADMIN'; diff --git a/web/src/auth/token.ts b/web/src/auth/token.ts new file mode 100644 index 0000000..70937aa --- /dev/null +++ b/web/src/auth/token.ts @@ -0,0 +1,33 @@ +// Токен доступа живёт в sessionStorage: пропадает при закрытии вкладки и не доступен другим вкладкам (design D-9). +// Любой доступ к хранилищу обёрнут в try/catch: в приватном режиме или при заблокированных данных сайта оно может +// бросать исключение — тогда токен хранится только в памяти страницы, а приложение продолжает работать. +const KEY = 'armka.token'; + +let memoryToken: string | null = null; + +// Приоритет у sessionStorage (общий для вкладки); токен в памяти — только запасной вариант, когда хранилище недоступно +export function getToken(): string | null { + try { + return sessionStorage.getItem(KEY) ?? memoryToken; + } catch { + return memoryToken; + } +} + +export function setToken(token: string): void { + memoryToken = token; + try { + sessionStorage.setItem(KEY, token); + } catch { + // хранилище недоступно: остаётся токен в памяти + } +} + +export function clearToken(): void { + memoryToken = null; + try { + sessionStorage.removeItem(KEY); + } catch { + // хранилище недоступно: токен в памяти уже сброшен + } +} diff --git a/web/src/auth/useMe.ts b/web/src/auth/useMe.ts new file mode 100644 index 0000000..c949367 --- /dev/null +++ b/web/src/auth/useMe.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from 'react'; +import { UNAUTHORIZED_EVENT, api } from '../api/client'; +import type { Me } from '../api/types'; +import { getToken } from './token'; + +type MeState = { me: Me | null; loading: boolean; error: string | null; reload: () => void }; + +/** + * Профиль текущего пользователя (`GET /me`). Запрос уходит только при наличии токена; ответ после размонтирования + * или после повторного `reload` (устаревший) отбрасывается. На событие `armka:unauthorized` профиль сбрасывается. + */ +export function useMe(): MeState { + const [me, setMe] = useState(null); + const [loading, setLoading] = useState(() => getToken() !== null); + const [error, setError] = useState(null); + const [reloadCount, setReloadCount] = useState(0); + + useEffect(() => { + // reloadCount в зависимостях: его смена перезапускает загрузку + void reloadCount; + if (!getToken()) { + setMe(null); + setLoading(false); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + api + .get('/me') + .then((data) => { + if (!cancelled) { + setMe(data); + } + }) + .catch((e: unknown) => { + if (!cancelled) { + setMe(null); + setError(e instanceof Error ? e.message : 'Не удалось загрузить профиль'); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [reloadCount]); + + useEffect(() => { + const onUnauthorized = () => { + setMe(null); + setLoading(false); + setError('Сессия истекла'); + }; + window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized); + return () => window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized); + }, []); + + const reload = useCallback(() => setReloadCount((n) => n + 1), []); + + return { me, loading, error, reload }; +} diff --git a/web/src/index.tsx b/web/src/index.tsx index ae87a8b..ef98385 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -1,6 +1,6 @@ import { createRoot } from 'react-dom/client'; import './index.css'; -import { ConfigProvider } from 'antd'; +import { App as AntApp, ConfigProvider } from 'antd'; import App from './App'; const rootElement = document.getElementById('root'); @@ -35,7 +35,10 @@ createRoot(rootElement).render( }, }} > - + {/* AntApp даёт message/notification через App.useApp() с темой ConfigProvider; component={false} — без лишнего div */} + + + , // , ); diff --git a/web/src/layouts/Admin.tsx b/web/src/layouts/Admin.tsx new file mode 100644 index 0000000..22499f6 --- /dev/null +++ b/web/src/layouts/Admin.tsx @@ -0,0 +1,32 @@ +import { Result, Tabs } from 'antd'; +import { useCurrentUser } from '../auth/MeContext'; +import { USERS_ADMIN } from '../auth/permissions'; +import { PermissionMatrix } from '../widgets/Admin/PermissionMatrix'; +import { UsersTable } from '../widgets/Admin/UsersTable'; + +/** + * Экран администратора `/admin`: пользователи и роли, матрица прав. + * + * Профиль берём из `AuthGate` (`useCurrentUser`), а не новым запросом `/me`. Без права `USERS_ADMIN` таблицы даже не + * создаются — значит, их эффекты не запускаются и запросов к `/admin/*` нет. Проверка здесь — только удобство + * интерфейса: доступ на самом деле решает сервер (403 на `/admin/*` без права). + */ +export function Admin() { + const me = useCurrentUser(); + + if (!me.permissions.includes(USERS_ADMIN)) { + return ; + } + + return ( +
+ {/* вкладка «Матрица прав» создаётся (и грузит данные) только при первом открытии */} + }, + { key: 'matrix', label: 'Матрица прав', children: }, + ]} + /> +
+ ); +} diff --git a/web/src/layouts/Main.tsx b/web/src/layouts/Main.tsx index 95571bb..0ea7656 100644 --- a/web/src/layouts/Main.tsx +++ b/web/src/layouts/Main.tsx @@ -1,5 +1,9 @@ +import { useCurrentUser } from '../auth/MeContext'; + // Каркас: стартовый экран. Содержимое — по docs/context/10-user-journey.md. export function Main() { + const me = useCurrentUser(); + return (

АРМ КА

+

{me.full_name}

+

Роли: {me.roles.length > 0 ? me.roles.join(', ') : 'нет'}

); } diff --git a/web/src/widgets/Admin/CreateUserModal.tsx b/web/src/widgets/Admin/CreateUserModal.tsx new file mode 100644 index 0000000..6db7af2 --- /dev/null +++ b/web/src/widgets/Admin/CreateUserModal.tsx @@ -0,0 +1,141 @@ +import { Form, Input, Modal, Select } from 'antd'; +import { useRef, useState } from 'react'; +import { ApiError, api } from '../../api/client'; +import type { AdminUser, BusinessSegment, RegionalOffice, Role, UserCreate } from '../../api/types'; +import { useMountedRef, useNotifyError } from './hooks'; + +type Props = { + open: boolean; + roles: Role[]; + offices: RegionalOffice[]; + onClose: () => void; + onCreated: (user: AdminUser) => void; +}; + +/** Значения формы; пустой сегмент (`undefined`) уходит на сервер как `null` — «не задан». */ +type FormValues = { + email: string; + full_name: string; + employee_no?: string; + regional_office_id: string; + business_segment?: BusinessSegment; + roles?: string[]; +}; + +// Коды сегмента — как enum `BusinessSegment` бэкенда; «не задан» — очищенное поле +const SEGMENT_OPTIONS: { value: BusinessSegment; label: string }[] = [ + { value: 'DKB', label: 'DKB' }, + { value: 'DMSB', label: 'DMSB' }, + { value: 'DM', label: 'DM' }, +]; + +/** + * Окно создания пользователя (`POST /admin/users`). Проверки формы — на клиенте (формат email, обязательные поля), + * окончательная — на сервере: занятый email (409 `EMAIL_EXISTS`) показывается под полем email, прочие ошибки — + * уведомлением. После закрытия поля очищаются. + */ +export function CreateUserModal({ open, roles, offices, onClose, onCreated }: Props) { + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const notifyError = useNotifyError(); + const mounted = useMountedRef(); + // Замок от двойного клика: onFinish вызывается после асинхронной проверки полей, и второй клик успел бы отправить + // второй POST до того, как `submitting` отключит кнопку; ref меняется сразу + const submitLock = useRef(false); + + const submit = async (values: FormValues) => { + if (submitLock.current) { + return; + } + submitLock.current = true; + setSubmitting(true); + const body: UserCreate = { + email: values.email.trim(), + full_name: values.full_name.trim(), + employee_no: values.employee_no?.trim() || null, + regional_office_id: values.regional_office_id, + business_segment: values.business_segment ?? null, + roles: values.roles ?? [], + }; + try { + const user = await api.post('/admin/users', body); + if (mounted.current) { + onCreated(user); + } + } catch (e: unknown) { + if (!mounted.current) { + return; + } + if (e instanceof ApiError && e.code === 'EMAIL_EXISTS') { + form.setFields([{ name: 'email', errors: [e.message] }]); + } else { + notifyError(e); + } + } finally { + submitLock.current = false; + if (mounted.current) { + setSubmitting(false); + } + } + }; + + // Пока запрос идёт, окно не закрывается: иначе ответ придёт в уже закрытую форму + const cancel = () => { + if (!submitting) { + onClose(); + } + }; + + return ( + form.submit()} + onCancel={cancel} + confirmLoading={submitting} + cancelButtonProps={{ disabled: submitting }} + mask={{ closable: !submitting }} + // форма смонтирована всегда (forceRender), поэтому её можно очистить после закрытия окна + forceRender + afterClose={() => form.resetFields()}> + form={form} layout='vertical' onFinish={submit} disabled={submitting}> + + + + + + + + + + + + + + ({ + value: r.code, + label: r.code, + title: r.name, + disabled: !r.is_active && !user.roles.includes(r.code), + }))} + optionRender={(option) => `${option.data.label} — ${option.data.title}`} + onChange={(codes: string[]) => void changeRoles(user, codes)} + /> + ), + }, + { + title: 'Активен', + key: 'is_active', + width: 100, + render: (_, user) => ( + void changeActive(user, checked)} + /> + ), + }, + ]; + + return ( + <> + + + {page && `Всего пользователей: ${page.total}`} + {page && page.total > page.items.length && ` (показаны первые ${page.items.length})`} + + + + + rowKey='id' + size='small' + columns={columns} + dataSource={page?.items ?? []} + loading={users.loading} + pagination={false} + /> + setCreateOpen(false)} + onCreated={onCreated} + /> + + ); +} diff --git a/web/src/widgets/Admin/hooks.ts b/web/src/widgets/Admin/hooks.ts new file mode 100644 index 0000000..6cdce77 --- /dev/null +++ b/web/src/widgets/Admin/hooks.ts @@ -0,0 +1,94 @@ +import { App } from 'antd'; +import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; +import { ApiError } from '../../api/client'; + +/** + * Показ ошибки запроса уведомлением antd. `ApiError` уже несёт текст с сервера (например, «Нельзя снять последнего + * администратора» для 409 `LAST_ADMIN`); прочие исключения — общий текст. `message` берём из `App.useApp()`, а не + * статический `message.error`: так уведомление получает тему из `ConfigProvider` (статический вызов её не видит). + */ +export function useNotifyError(): (e: unknown) => void { + const { message } = App.useApp(); + return useCallback( + (e: unknown) => { + // отмена запроса (AbortController) — не ошибка для пользователя + if (e instanceof DOMException && e.name === 'AbortError') { + return; + } + message.error(e instanceof ApiError ? e.message : 'Не удалось выполнить запрос'); + }, + [message], + ); +} + +/** + * Флаг «компонент ещё на экране». Проверяется после `await` в обработчиках кнопок, чтобы не менять состояние + * компонента, который пользователь уже закрыл (ушёл со страницы, пока шёл запрос). + */ +export function useMountedRef(): { readonly current: boolean } { + const mounted = useRef(false); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + return mounted; +} + +type AsyncData = { + /** + * Последние успешно полученные данные. `null` — первая загрузка ещё идёт или не удалась; неудачный `reload()` + * оставляет прежние данные (ошибка показывается уведомлением). + */ + data: T | null; + /** Замена данных без запроса (например, строка из ответа `PUT`). */ + setData: Dispatch>; + loading: boolean; + /** Повторная загрузка; промис не отклоняется — ошибка уже показана уведомлением. */ + reload: () => Promise; +}; + +/** + * Загрузка данных при появлении компонента и по `reload()`. + * + * `loader` должен быть стабильным (функция уровня модуля или `useCallback`), иначе загрузка пойдёт на каждой отрисовке. + * Защита от устаревших ответов: у каждого запуска свой номер; применяется только ответ последнего запуска. Ответ, + * пришедший после более нового `reload()` или после ухода со страницы, отбрасывается. + */ +export function useAsyncData(loader: () => Promise): AsyncData { + const notifyError = useNotifyError(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const latestRun = useRef(0); + + const reload = useCallback(async () => { + latestRun.current += 1; + const run = latestRun.current; + setLoading(true); + try { + const result = await loader(); + if (run === latestRun.current) { + setData(result); + } + } catch (e: unknown) { + if (run === latestRun.current) { + notifyError(e); + } + } finally { + if (run === latestRun.current) { + setLoading(false); + } + } + }, [loader, notifyError]); + + useEffect(() => { + void reload(); + return () => { + // уход со страницы: ответы запросов «в полёте» станут устаревшими и будут отброшены + latestRun.current += 1; + }; + }, [reload]); + + return { data, setData, loading, reload }; +} diff --git a/web/src/widgets/Sidebar/Sidebar.tsx b/web/src/widgets/Sidebar/Sidebar.tsx index fd32ea5..95e6afa 100644 --- a/web/src/widgets/Sidebar/Sidebar.tsx +++ b/web/src/widgets/Sidebar/Sidebar.tsx @@ -1,13 +1,17 @@ -import { HomeOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; +import { HomeOutlined, LeftOutlined, RightOutlined, TeamOutlined } from '@ant-design/icons'; import { Divider, Layout, Tooltip } from 'antd'; -import { type ReactNode, memo, useState } from 'react'; +import { type ReactNode, memo, useMemo, useState } from 'react'; import { NavLink } from 'react-router-dom'; +import { useCurrentUser } from '../../auth/MeContext'; +import { USERS_ADMIN } from '../../auth/permissions'; import styles from './styles.module.css'; type NavItem = { link: string; label: string; icon: ReactNode; + /** Право из `/me`, без которого пункт не показывается; нет — пункт виден всем. */ + permission?: string; }; type Group = { @@ -23,8 +27,20 @@ const config: Group[] = [ key: 'home', children: [{ link: '/', icon: , label: 'Главная' }], }, + { + title: 'Система', + key: 'system', + children: [{ link: '/admin', icon: , label: 'Пользователи и роли', permission: USERS_ADMIN }], + }, ]; +/** Меню для пользователя: пункты без нужного права убираются, опустевшие группы — тоже. */ +function visibleGroups(permissions: string[]): Group[] { + return config + .map((group) => ({ ...group, children: group.children.filter((i) => !i.permission || permissions.includes(i.permission)) })) + .filter((group) => group.children.length > 0); +} + function CollapsedIcon({ icon, title, to }: { icon: ReactNode; title: string; to: string }) { return ( @@ -66,6 +82,9 @@ const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boo export function Sidebar() { const [isCollapsed, setIsCollapsed] = useState(false); + const me = useCurrentUser(); + // useMemo: группы пересчитываются только при смене прав, иначе memo(Section) получал бы новый объект на каждой отрисовке + const groups = useMemo(() => visibleGroups(me.permissions), [me.permissions]); return ( : } - {config.map((g) => ( + {groups.map((g) => (
))}