# 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 | повторный импорт/миграция данных |