OpenSpec: change slice-1-directories (справочники, импорт ЕЦП, policy, администрирование)

proposal, design, tasks и дельты спек: directories-api, directory-import, directory-policy, users-admin (новые),
data-model (v5.14: related_group_member.kind, audit_action), audit-trail (события справочников и администрирования).

Issue: RSHB/arm-core#5

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-04 20:23:03 +04:00
parent 73a208a57a
commit 39a7affc48
10 changed files with 706 additions and 0 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-04

View File

@ -0,0 +1,144 @@
# 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
`ADD COLUMN kind related_group_kind` (для существующих строк — `UPDATE … FROM related_group`, затем `SET NOT NULL`), `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` у роли, если это единственная роль с этим правом
у активных пользователей, и нельзя снять её у себя. Новые коды прав не создаются (комментарий seed). Повторный seed не
отзывает права (известное ограничение, 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 РФ, 18 ЮЛ/ИП,
1 `UNSUPPORTED_TYPE` ФЛ, 1 `DUPLICATE_IN_FILE`, 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 | повторный импорт/миграция данных |

View File

@ -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.

View File

@ -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` отсутствуют

View File

@ -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** сверка против нового файла падает с сообщением о версии до сравнения таблиц

View File

@ -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** ЮЛ остаётся в прежней ГК, строк истории и аудита нет

View File

@ -0,0 +1,71 @@
# 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`),
18 ЮЛ/ИП (`LE_CREATED`), 1 ГСК с именем ГК и двумя членами (АО «Русский бисквит», ООО «Пекарня Счастливочная»), 0 ГСЗ;
строка типа `ФЛ` → `UNSUPPORTED_TYPE`, строка ИП с тем же ИНН после неё → не затронута (ФЛ идёт второй → `DUPLICATE_IN_FILE`
у ФЛ или `UNSUPPORTED_TYPE` — зависит от порядка: ожидаемые коды фиксируются тестом по фактическому порядку строк)
#### 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`,
`LE_IN_OTHER_GROUP`, `RF_CREATED` = 53

View File

@ -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, и она видна создателю

View File

@ -0,0 +1,68 @@
# 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`).
#### 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/*`

View File

@ -0,0 +1,86 @@
# Tasks: Срез 1 — arm-core
Каждая задача ≤ половины дня, начинается с тестов/фикстур, заканчивается зелёным `make check` (интеграционные — `make
test-integration`). Пути — от корня репозитория. Допущения в коде помечаются `# ASSUMPTION(OQ-nn)`.
## 1. Модель v5.14 и инфраструктура запросов
- [ ] 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».
- [ ] 1.2 **Единица работы, аудит, исключения, пагинация.** `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 и откатывает при исключении.
- [ ] 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 и справочники
- [ ] 2.1 **Policy.** `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`).
- [ ] 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, создание из филиала).
- [ ] 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`. Тесты: сценарии спеки (ИНН, монозаёмщик, перевод закрывает членства, закрытие, откат).
- [ ] 2.4 **Алиасы.** `domain/aliases.py:normalize_alias` по §3 (unit-тесты на кавычки, ОПФ, пробелы, ё), сервис + эндпоинты
`POST/DELETE …/aliases`, конфликты `ALIAS_EXISTS` / `ALIAS_TAKEN` (с владельцем). Тесты: сценарий «Нормализация».
- [ ] 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
- [ ] 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 РФ, 18 ЮЛ/ИП, 1 ГСК с 2 членами,
коды ФЛ/дубля по фактическому порядку строк).
- [ ] 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` с заглушкой в тестах.
- [ ] 3.3 **Полная выгрузка (fixtures-тест).** Тест с маркером `fixtures`: `ARM_KA_DOCS/source/biq/ecp-export-lagal-entity.xlsx`,
`dry_run`, `rows = 8400`, `RF_CREATED = 53`, присутствие кодов, время ≤ 60 с; пропуск с причиной, если файла нет.
Профилирование: при превышении — пакетные вставки (`insert().values([...])`).
## 4. Администрирование
- [ ] 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` видит новое право).
- [ ] 4.2 **Фронт: API-клиент и dev-вход.** `web/src/api/client.ts` (`apiFetch<T>(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.
- [ ] 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. Завершение
- [ ] 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`) содержит все маршруты спек.