Andrey Epifancev cd6cca32f2 chore(openspec): рулинг R-6 (SessionDep, scope=function) в design.md
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-04 21:04:26 +04:00

21 KiB
Raw Blame History

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 до ответа»