# Design: Срез 1 — справочники, импорт ЕЦП, видимость, администрирование ## Context См. `proposal.md`. Что уже есть после Среза 0 и на что опирается дизайн (обследование кода 04.10.2026): - ORM 1:1 с DBML (43 таблицы), `models/directories.py` и `models/access.py` без `relationship()`; `valid_from` NOT NULL без server default; `related_group_member.is_current` без default; `NAMING_CONVENTION` в `models/base.py`; `DBML_VERSION` в `models/__init__.py`; путь DBML — `Makefile:6`, `scripts/dbml_parity.py:DBML_FILE_NAME`; миграция `0001_dbml_v5_13` — явный DDL, `HEAD` зашит в `tests/test_migrations.py`. - DI: `api/dependencies/auth.py` — `current_user` → `Principal(UserWithAccess)` с `permissions: frozenset`, `require(code)`; `repositories/users.py` — функции, не классы; `AuditService(session).record(...)` без зависимости FastAPI; `get_async_session` **не коммитит**; обработчики ошибок: `IntegrityError` → 409 с общим текстом, `NotFoundError`/`CouldntCreateError` без обработчиков. - Seed: 16 кодов прав (`DIRECTORY_VIEW/EDIT/DELETE/ADMIN`, `USERS_ADMIN`, …), матрица §10; `regional_office` в core — только ГО. - Тесты: `db_session` на SAVEPOINT (commit внутри теста невидим снаружи), клиент строится вручную в `test_me_and_dev_token.py`, фабрик для ГК/ЮЛ/пользователей нет; `fixtures_dir` + маркер `fixtures`. - Фронт — скелет без API-клиента и авторизации. - Платформа: Python 3.11, PostgreSQL 16; Nexus недоступен; `openpyxl` в `requirements.txt` нет. ## Goals / Non-Goals **Goals:** все мутации справочников через сервисы с аудитом и историей; импорт реальной выгрузки проходит `dry_run` на 8400 строках за разумное время (< 60 с) с отчётом; policy — одна функция, применяемая везде; администратор может завести пользователя и назначить роль без seed. **Non-Goals:** см. proposal. ## Decisions ### D-1. Миграция `0002_dbml_v5_14`, явный DDL, как 0001 `kind related_group_kind` добавляется пересозданием `related_group_member` (R-5, решение №16: `ADD COLUMN` ставит колонку в конец, а DBML и тест «схема = create_all» требуют порядок; строки копируются с `kind` из `related_group`), `DROP INDEX uq_related_group_member_entity_current`, `CREATE UNIQUE INDEX uq_related_group_member_entity_kind_current ON (legal_entity_id, kind) WHERE is_current`; `ALTER TYPE audit_action ADD VALUE` ×2 (`LEGAL_ENTITY_CHANGE` после `GROUP_CHANGE`, `USER_CHANGE` после `USER_ROLE_CHANGE` — порядок как в DBML, сверка enum'ов сравнивает упорядоченный список). `ADD VALUE` необратим в PostgreSQL → `downgrade` пересоздаёт тип (`RENAME TYPE` → новый тип → `ALTER COLUMN … TYPE … USING` → `DROP TYPE`), как это уже умеет тест цикла. `HEAD` в `tests/test_migrations.py` → `0002_dbml_v5_14`; `DBML_VERSION = "5.14"`; `DBML_FILE_NAME`, `Makefile`, `test_enums`, `test_check_dbml_script` (фиктивная версия → 5.15), docstring'и. ### D-2. Единица работы: `uow_session` + сервисы, аудит внутри Новая зависимость `get_uow_session` (`api/dependencies/db.py`): `async with session.begin(): yield session` — commit при успешном ответе, rollback при исключении; `get_async_session` остаётся для чтения. Сервисы (`services/directories.py`, `services/related_groups.py`, `services/directory_import.py`, `services/users_admin.py`) получают сессию, `AuditService` и `Principal`; каждая мутация: валидация → изменение → снимок истории → `audit.record(before/after)` в той же транзакции. `get_audit_service(request, session, principal)` заполняет `ip_address`/`user_agent`/`user_id`. Тест на реальный commit — через `scratch_db_url` и вторую сессию (SAVEPOINT-сессия тестов его не ловит, см. обследование). ### D-3. Исключения и формат ответов `common/exceptions.py`: `NotFoundError(entity, key)` → 404 `{"detail": "ГК … не найдена"}`; `ConflictError(code, detail)` → 409 с человекочитаемым текстом (`INN_EXISTS`, `ALIAS_TAKEN`, `MEMBER_EXISTS`, `GROUP_NOT_EMPTY`, …) и полем `code` в `detail`-объекте (`{"detail": {"code": …, "message": …}}`); `DomainValidationError` → 422. Pre-check уникальных ключей перед вставкой, чтобы не полагаться на `IntegrityError`. Списки — `{items, total, limit, offset}`, `limit ≤ 200`, по умолчанию 50; сортировка фиксированная (имя). ### D-4. Policy — чистые функции в `src/domain/policy.py` `VisibilityScope = all | office(regional_office_id)` вычисляется из `Principal` (`regional_office.is_head_office`); репозитории принимают `scope` и добавляют `WHERE company_group.base_regional_office_id = :office` (для ЮЛ — через ГК или `legal_entity.regional_office_id` при `company_group_id IS NULL`). Чтение объекта вне scope → `NotFoundError` (404). Код роли нигде не читается; `business_segment` не используется (ASSUMPTION(OQ-86)). Это первый модуль `src/domain/` — создаётся с strict mypy. ### D-5. Коды ГК/ГСК — последовательности PostgreSQL `company_group.code = 'GK-' || lpad(nextval('dkb.company_group_code_seq'), 6, '0')`, аналогично `GSK-`/`GSZ-` (одна последовательность `related_group_code_seq`). Последовательности создаются в миграции 0002; в DBML они не описываются (не таблицы), `check_dbml_vs_orm` их не сравнивает. *Альтернатива* — `max(code)+1` в транзакции: гонки. Макет показывает `GSK-001` — ширина 6 вместо 3, чтобы не переполниться; формат — только отображение. ### D-6. История SCD4/SCD2 и события ГК: любое изменение `name`/`base_regional_office_id`/`crm_id` — строка в `company_group_history` со **старыми** значениями (`valid_from` = прежний `valid_from`, `valid_to` = now) и обновление `valid_from = now` у живой записи; аудит `GROUP_CHANGE` (имя/crm) или `ASSIGNMENT_CHANGE` (РФ). ЮЛ: аналогично в `legal_entity_history` с `event_type` (`UPDATE`, `MOVE_TO_GROUP`, `CLOSE`, `REMOVE_FROM_GROUP`, при создании — `CREATE` с `valid_to = NULL`? — нет: при создании история не пишется, первый снимок появляется при первом изменении; `CREATE` используется только импортом для фиксации источника? — нет, тоже не пишется; значение `CREATE` остаётся для будущего восстановления). `effective_date` — из запроса (≥ сегодня, OQ-21). Членство ГСК/ГСЗ: исключение = `valid_to = now, is_current = false`; повторное включение — новая строка. Перевод ЮЛ в другую ГК закрывает все текущие членства (§13.4). Аудит: `LEGAL_ENTITY_CHANGE` (ЮЛ, алиасы), `MEMBERSHIP_CHANGE` (состав, создание/удаление ГСК/ГСЗ с `object_type`). ### D-7. Импорт — чистое ядро + сервис `src/domain/directory_import.py`: чтение файла в строки (xlsx — `openpyxl` read-only, csv — `csv` с определением разделителя), нормализация заголовков и значений, построчные решения §13.2 как чистая функция над «снимком справочника» (словари `inn → ЮЛ`, `crm_id → ГК`, `name → РФ`) → список операций и отчёт; `services/directory_import.py` применяет операции в одной транзакции (или откатывает при `dry_run`), пишет `audit_event(DIRECTORY_IMPORT, after_value = отчёт)` и файл в S3. Снимок справочника грузится одним запросом на таблицу (8400 строк — секунды). Коды отчёта — константы `ImportCode` (StrEnum), список = §13.2. Лимит файла 20 МБ (`IMPORT_MAX_BYTES`). Допущения помечены `# ASSUMPTION(OQ-87..91)`. ### D-8. Администрирование — без новых прав и без удаления `USERS_ADMIN` на всё; пользователи только деактивируются (`is_active=false`); email обязателен и уникален без учёта регистра (pre-check `lower(email)`); `external_id` не задаётся (появится при первом входе через РАИСА — Срез JWT). Матрица: `PUT /admin/roles/{code}/permissions` заменяет набор целиком (diff → `ROLE_PERMISSION_CHANGE` с before/after); системные роли (`is_system`) переименовывать/деактивировать нельзя; нельзя снять `USERS_ADMIN` у роли, если это единственная роль с этим правом у активных пользователей, и нельзя снять её у себя. Новые коды прав не создаются (комментарий 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 | повторный импорт/миграция данных | | 12 | 2026-10-04 | R-1 (план, фаза 1, Task 2): вместо отдельной `get_uow_session` (D-2) сама `get_async_session` оборачивает запрос в `session.begin()` — commit при успехе, rollback при исключении | одна сессия и одна транзакция на запрос: `current_user` и мутации не расходятся по соединениям | read-only маршруты коммитят пустую транзакцию (безвредно); при проблемах — вернуть отдельную зависимость | | 13 | 2026-10-04 | R-2 (SDD фаза 1, pre-flight): `q` в списке ГК ищет по `name`/`crm_id`/`code` (подстрока без регистра), как в спеке, а не только по имени, как в плане | спека — источник истины; план её сужал | нет: надмножество поиска | | 14 | 2026-10-04 | R-3 (SDD фаза 1, pre-flight): `GET …/history` для ГК и ЮЛ — по убыванию `valid_from` | так сказано в спеке; план порядок не задавал | порядок вывода, меняется одной строкой | | 15 | 2026-10-04 | R-4 (SDD фаза 1, pre-flight): `q` в списке ЮЛ ищет по `name` и `inn` (подстрока) | спека `directories-api`; план не уточнял | нет: надмножество поиска | | 16 | 2026-10-04 | R-5 (SDD фаза 1, Task 1): upgrade 0002 пересоздаёт `related_group_member` (копия → drop → create в порядке колонок DBML → insert), а не `ADD COLUMN`; downgrade — по плану | DBML v5.14 ставит `kind` после `legal_entity_id`, а `ADD COLUMN` добавляет в конец; тест Среза 0 `test_migrated_schema_equals_orm_create_all` сверяет порядок колонок | вернуть `ADD COLUMN` и ослабить сравнение колонок (без порядка) в тесте и миграции; пересоздание теряет GRANT/RLS/триггеры, добавленные платформой вне Alembic (T1.2) | | 17 | 2026-10-04 | R-6 (SDD фаза 1, Task 2): сессия запроса объявляется как `SessionDep = Annotated[AsyncSession, Depends(get_async_session, scope="function")]` (`api/src/api/dependencies/db.py`), а не голым `Depends(get_async_session)`; `get_request_audit(session: SessionDep, …)`; guard-тест падает на объявлении без `scope="function"` | в FastAPI 0.135 yield-зависимость по умолчанию закрывается ПОСЛЕ отправки ответа — commit шёл бы после 201, и его ошибка не доходила бы до клиента; scope входит в ключ кэша зависимостей — смешение даёт две сессии (нарушает R-1) | заменить `SessionDep` на `Depends(get_async_session)` (1 строка) ценой потери гарантии «commit до ответа» | | 18 | 2026-10-04 | R-7 (SDD фаза 1, Task 4): `legal_entities_visible` фильтрует подзапросом `company_group_id IN (SELECT id FROM company_group WHERE base_regional_office_id = :office) OR (company_group_id IS NULL AND regional_office_id = :office)`, а не outerjoin; ГК без филиала и монозаёмщики без филиала видны только ГО | подзапрос не конфликтует с join'ами вызывающего и с count-запросами, не размножает строки; NULL-семантика закреплена интеграционным тестом на PostgreSQL | при необходимости вернуть outerjoin; изменение видимости «бесхозных» объектов — одна правка фильтра и тестов | | 19 | 2026-10-04 | R-8 (SDD фаза 1, Task 5, приняты решения исполнителя): `crm_id` и `name` нормализуются (strip; пустой `crm_id` → NULL; пустое/`null` имя → 422); `office_for_create` применяется и к PATCH (филиал не может перепривязать ГК на чужой филиал, `null` у филиала → свой филиал; у ГО `null` очищает); новый 422 `OFFICE_NOT_FOUND` на несуществующий филиал (без проверки `is_active`); pre-check `crm_id` глобальный (409 раскрывает лишь факт существования строки `crm_id`, не данные ГК); повторный archive/unarchive — no-op без аудита; список ГК — одна Query-модель `CompanyGroupListQuery` (FastAPI не принимает две); сортировка `name, id` | единая семантика для остальных справочников; спека не уточняла | пересмотр нормализации/слабого oracle `crm_id` — правка сервиса; снятие `office_for_create` с PATCH — одна строка | | 20 | 2026-10-04 | R-9 (SDD фаза 1, Task 6, приняты решения исполнителя): филиал ЮЛ идёт через `office_for_create` только при фактической смене значения (совпадающий с текущим пропускается — no-op PATCH без последствий); новые 409 `ALREADY_CLOSED` (повторный CLOSE), `NOT_IN_COMPANY_GROUP` (REMOVE_FROM_GROUP у ЮЛ без ГК), `LEGAL_ENTITY_CLOSED` (перевод закрытого ЮЛ); «сегодня» для `effective_date` — дата UTC; `move` не меняет `regional_office_id`; `move`/`withdraw` применяются сразу, `effective_date` пишется только в историю; PATCH не принимает `inn`/`company_group_id`; ИНН — только ASCII-цифры, внутренние пробелы убираются; `crm_id` ЮЛ нормализуется и проверяется глобально (как у ГК) | спека не называла; защита инвариантов (закрытый ЮЛ без ГК) и единообразие с ГК | пересмотр кодов/семантики даты — правка сервиса; UTC vs MSK — сдвиг границы на 3 часа | | 21 | 2026-10-04 | R-10 (SDD фаза 1, Task 7, приняты решения исполнителя): `ALIAS_TAKEN` раскрывает только `legal_entity_id` владельца (даже вне видимости пользователя; имя и ИНН не отдаются); `alias` хранится дословно, строка из пробелов → 422 `EMPTY_ALIAS`; чужой/несуществующий `alias_id` → одинаковый 404 «Алиас не найден» после проверки видимости ЮЛ; `DELETE` алиаса требует `DIRECTORY_DELETE` (по seed — только ADMIN) | спека требует `legal_entity_id` владельца; единообразие 404 | при желании скрыть владельца — убрать поле из `extra`; расширить `DIRECTORY_DELETE` — отдельное решение по матрице прав | | 22 | 2026-10-04 | R-11 (SDD фаза 1, Task 8, приняты решения исполнителя): `add_member` возвращает пару (членство, ЮЛ) и проверяет сначала видимость ГСК/ГСЗ через ГК (явный join `CompanyGroup`, T4.1), затем ЮЛ через scope; список ГСК/ГСЗ ГК — простой массив (не `Page`; групп у ГК единицы); `DELETE …/members/{id}` требует `DIRECTORY_EDIT` (спека: `DIRECTORY_DELETE` — только удаление ГСК/ГСЗ и алиасов); при удалении группы весь её состав сохраняется в `before_value` аудита; общий `src/common/clock.py:utc_now` вместо локальных `_now` | спека и бриф; единообразие аудита («для списков — весь набор») | смена формы списка до фронта бесплатна; удаление группы, на которую ссылаются consolidation/ex_snapshot, даст 500 вместо 409 (T8.1, deferred к OQ-34) | | 23 | 2026-10-04 | R-12 (SDD фаза 1→2, контроллер, T8.3): список ГСК/ГСЗ одной ГК остаётся простым массивом; требование «списки `{items,total,limit,offset}`» относится к спискам верхнего уровня (ГК, ЮЛ, пользователи) | размер ограничен составом ГК (единицы групп); спека `directories-api` пагинацию для него не требует; фронт Среза 1 его не использует | обернуть в `Page` до появления UI справочника; смена формата до фронта бесплатна | | 24 | 2026-10-04 | R-13 (SDD фаза 1→2, контроллер, O-5): дубль `RegionalOfficeOut` в `schemas/me.py` и `schemas/directories.py` устраняется — одна модель в `schemas/directories.py`, `schemas/me.py` импортирует её (Task 12); ответ `/me` не меняется | две одноимённые Pydantic-модели порождают OpenAPI-компоненты с длинными именами и расхождение схем | вернуть локальную модель в `me.py` |