Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
20 KiB
Design: Срез 1 — справочники, импорт ЕЦП, видимость, администрирование
Context
См. proposal.md. Что уже есть после Среза 0 и на что опирается дизайн (обследование кода 04.10.2026):
- ORM 1:1 с DBML (43 таблицы),
models/directories.pyиmodels/access.pyбезrelationship();valid_fromNOT 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 | повторный импорт/миграция данных |
| 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 и ослабить сравнение колонок (без порядка) в тесте и миграции |