From 39a7affc4844951c2a0ca2c4bf1941f1fdc8279f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 20:23:03 +0400 Subject: [PATCH] =?UTF-8?q?OpenSpec:=20change=20slice-1-directories=20(?= =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8,=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=20=D0=95=D0=A6?= =?UTF-8?q?=D0=9F,=20policy,=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .../slice-1-directories/.openspec.yaml | 2 + .../changes/slice-1-directories/design.md | 144 ++++++++++++++++++ .../changes/slice-1-directories/proposal.md | 82 ++++++++++ .../specs/audit-trail/spec.md | 29 ++++ .../specs/data-model/spec.md | 60 ++++++++ .../specs/directories-api/spec.md | 125 +++++++++++++++ .../specs/directory-import/spec.md | 71 +++++++++ .../specs/directory-policy/spec.md | 39 +++++ .../specs/users-admin/spec.md | 68 +++++++++ openspec/changes/slice-1-directories/tasks.md | 86 +++++++++++ 10 files changed, 706 insertions(+) create mode 100644 openspec/changes/slice-1-directories/.openspec.yaml create mode 100644 openspec/changes/slice-1-directories/design.md create mode 100644 openspec/changes/slice-1-directories/proposal.md create mode 100644 openspec/changes/slice-1-directories/specs/audit-trail/spec.md create mode 100644 openspec/changes/slice-1-directories/specs/data-model/spec.md create mode 100644 openspec/changes/slice-1-directories/specs/directories-api/spec.md create mode 100644 openspec/changes/slice-1-directories/specs/directory-import/spec.md create mode 100644 openspec/changes/slice-1-directories/specs/directory-policy/spec.md create mode 100644 openspec/changes/slice-1-directories/specs/users-admin/spec.md create mode 100644 openspec/changes/slice-1-directories/tasks.md 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..936b0e4 --- /dev/null +++ b/openspec/changes/slice-1-directories/design.md @@ -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 | повторный импорт/миграция данных | diff --git a/openspec/changes/slice-1-directories/proposal.md b/openspec/changes/slice-1-directories/proposal.md new file mode 100644 index 0000000..cc82b7c --- /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..48bc0f7 --- /dev/null +++ b/openspec/changes/slice-1-directories/specs/directory-import/spec.md @@ -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 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..5e0212f --- /dev/null +++ b/openspec/changes/slice-1-directories/specs/users-admin/spec.md @@ -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/*` diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/slice-1-directories/tasks.md new file mode 100644 index 0000000..6fe1ec9 --- /dev/null +++ b/openspec/changes/slice-1-directories/tasks.md @@ -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(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`) содержит все маршруты спек.