docs: README/CLAUDE.md Среза 1, тест маршрутов OpenAPI, tasks.md
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
d241c07f54
commit
7b75981d0f
40
CLAUDE.md
40
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_<hex>` с миграциями,
|
||||
удаляется в конце; `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.
|
||||
|
||||
233
README.md
233
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,216 @@ 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` |
|
||||
|
||||
Остальные причины `UNSUPPORTED_FILE`: расширение не `xlsx`/`csv`, повреждённый xlsx, csv не в UTF-8.
|
||||
|
||||
## Администрирование и экран /admin
|
||||
|
||||
Бэкенд — `/api/v1/admin/*`, все маршруты требуют `USERS_ADMIN` (без права — 403 до любых проверок тела и существования
|
||||
объектов). Фронтенд — страница `/admin` («Пользователи и роли» в боковом меню; видна только с `USERS_ADMIN`): таблица
|
||||
пользователей (создание, смена ролей и активности) и вкладка «Матрица прав» (роль × право). Без права экран показывает
|
||||
«Нет доступа» и запросов к `/admin/*` не делает (настоящую защиту обеспечивает сервер).
|
||||
|
||||
**Как запустить.**
|
||||
|
||||
```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).
|
||||
- **Один выбор роли — один запрос.** Мульти-выбор ролей в строке таблицы отправляет по одному `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": "INN_EXISTS", "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` с тем же телом, что у несуществующего.
|
||||
|
||||
| Код | 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_id`, если видно) |
|
||||
| `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 +433,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 +462,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 +485,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 +508,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 +523,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 +566,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) — план срезов;
|
||||
|
||||
79
api/tests/test_openapi_routes.py
Normal file
79
api/tests/test_openapi_routes.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""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`."""
|
||||
names = list(schema["components"]["schemas"])
|
||||
for model in SINGLE_MODELS:
|
||||
assert names.count(model) == 1, model
|
||||
assert not [name for name in names if "__" in name or "-" in name], names
|
||||
@ -256,10 +256,10 @@ export const api = { get: <T>(p: string) => apiFetch<T>(p), post: <T>(p: string,
|
||||
- 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"]`).
|
||||
|
||||
- [ ] **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` и остальные — присутствуют.
|
||||
- [ ] **Step 2:** документы по списку выше.
|
||||
- [ ] **Step 3:** `make check-full` (из корня; compose поднят): `check`, `dbml-check`, `check-infra`, `test-integration` (включая `fixtures`-тесты — `../fixtures` и `../docs` доступны), `docker-build`, `web-check` → всё зелёное; вывод сохранить для описания PR (в ledger).
|
||||
- [ ] **Step 4: Commit** `docs: README/CLAUDE.md Среза 1, тест маршрутов OpenAPI, tasks.md`.
|
||||
- [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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -80,7 +80,7 @@ test-integration`). Пути — от корня репозитория. Доп
|
||||
|
||||
## 5. Завершение
|
||||
|
||||
- [ ] 5.1 **Документы и проверка.** `README.md` (разделы: API справочников, импорт из ЕЦП — пошагово с `dry_run`, администрирование и
|
||||
экран `/admin`, ограничение «повторный seed не отзывает права»), `CLAUDE.md` (DBML v5.14, `domain/policy.py`, `get_uow_session`,
|
||||
- [x] 5.1 **Документы и проверка.** `README.md` (разделы: API справочников, импорт из ЕЦП — пошагово с `dry_run`, администрирование и
|
||||
экран `/admin`, ограничение: повторный seed возвращает права, снятые администратором), `CLAUDE.md` (DBML v5.14, `domain/policy.py`, `get_uow_session`,
|
||||
формат ошибок, `openpyxl`), `api/.env.example` (`IMPORT_MAX_BYTES`), спеки `openspec/specs/*` после merge (archive);
|
||||
`make check-full` зелёный, вывод — в описание PR. Проверка: OpenAPI (`/docs`) содержит все маршруты спек.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user