Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
40 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
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
возвращает права, снятые администратором (вставка недостающих пар ON CONFLICT DO NOTHING; лишние права не отзывает) —
известное ограничение (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 РФ, 20 ЮЛ/ИП,
1 UNSUPPORTED_TYPE ФЛ, 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 |
| 25 | 2026-10-04 | R-14 (SDD фаза 2, pre-flight): Task 10 выносит логику update ГК и ЮЛ в модульные функции apply_group_update / apply_entity_update (services/company_groups.py, services/legal_entities.py), *Service.update вызывает их; план называл их существующими, но в коде фазы 1 были только методы |
импорт обязан писать историю/аудит так же, как ручная правка; дублирование — расхождение поведения | вернуть дублирование; регрессию фазы 1 ловят её тесты |
| 26 | 2026-10-04 | R-15 (SDD фаза 2, Task 9, находка исполнителя): на фикстуре «Русский Бисквит» ожидается 20 ЮЛ/ИП (18 ЮЛ + 2 ИП), DUPLICATE_IN_FILE в этом файле не возникает (ФЛ с тем же ИНН идёт после ИП и отсекается типом); на полной выгрузке groups_created = 344 (ГК 1-5AL0CDM целиком из дублей), LE_IN_OTHER_GROUP на пустой БД недостижим и в ожиданиях не проверяется; текст спеки directory-import, tasks.md, design.md и план приведены к факту |
данные важнее ориентировочных цифр спеки; ожидания фиксируются тестами по реальным файлам | поправить числа в тестах и спеке |
| 27 | 2026-10-04 | R-16 (SDD фаза 2, Task 9, приняты решения исполнителя): errors считает итоговые коды-пропуски; GK_EXISTS и для неизменной ГК (идемпотентность); РФ разрешается один раз на ГК после проверок пустого iD ГК и конфликта имени (нет «осиротевших» филиалов, RF_CREATED = 53); ИНН считается «встречавшимся» после проверок типа и формата; офис нового ЮЛ = действующий РФ ГК; ГСК/ГСЗ создаётся по флагу даже если у ЮЛ уже есть членство того же вида в другой группе (буквально §13.2, возможна пустая группа — T9.5); флаги обрабатываются и при skip (добавляется отсутствующее членство); значение колонки ГСК «входит в ГСЗ» → UNKNOWN_FLAG; xlsx читается с reset_dimensions(); пины openpyxl==3.1.5, et_xmlfile==2.0.0, types-openpyxl (dev) |
единообразие со спекой и идемпотентность повторного импорта | каждое — локальная правка plan_import и теста |
| 28 | 2026-10-04 | R-17 (SDD фаза 2, Task 10, приняты решения исполнителя): общие apply_group_update/apply_entity_update(session, audit, obj, changes: dict, *, now, description=None) принимают словарь изменений (PATCH и импорт делят одну реализацию), snapshot_entity/touch_entity — функции модуля; обновления ГК/ЮЛ импортом пишут собственные события GROUP_CHANGE/ASSIGNMENT_CHANGE/LEGAL_ENTITY_CHANGE с description="импорт ЕЦП" помимо одного DIRECTORY_IMPORT (создания — только сводным событием); тест dry_run — на отдельной БД с зафиксированным seed, т.к. session.rollback() под SAVEPOINT-фикстурой app сносит seed теста; IMPORT_MAX_BYTES также в .env.test; имя файла сводится к basename (путь отбрасывается, пустое/./.. → import) для формата, description и ключа S3; s3_object.sha256 заполняется, content_type — по расширению; сначала строка s3_object + flush, затем put_object (ошибка S3 откатывает импорт); разбор файла в asyncio.to_thread; created_date ГСК/ГСЗ не заполняется; 413 проверяется в эндпоинте (до чтения) и в сервисе (HTTPException из сервиса, как в брифе); архивные ГК входят в снимок и при update не разархивируются; сообщение LE_IN_OTHER_GROUP называет ЮЛ из другой ГК независимо от филиала пользователя (импорт — только DIRECTORY_ADMIN, права — данные) |
единообразие истории/аудита ручной правки и импорта; честная проверка rollback; безопасность ключа S3 | пересмотр — правка сервиса/теста; ограничить раскрытие имён при выдаче DIRECTORY_ADMIN филиальному пользователю — убрать имя из сообщения |
| 29 | 2026-10-04 | R-18 (SDD фаза 2, Task 11, приняты решения исполнителя): сборка приложения в tests/conftest.py вынесена в приватные _build_application/_client_factory, добавлены фикстуры committed_sessionmaker/committed_client/committed_app/committed_storage (отдельная БД с зафиксированным seed — для тестов с явным rollback); safe_filename: убираются управляющие символы (Cc), имя ≤ 200 символов, суффикс ≤ 16 (иначе отбрасывается); замер ≤ 60 с — только HTTP-запрос полной выгрузки (факт 1,14 с) |
не дублировать фабрику приложения; ключ S3 ≤ 1024 байт | косметика тестов; смена лимита имени — константа |
| 30 | 2026-10-04 | R-19 (SDD фаза 2, Task 12, приняты решения исполнителя): R-13 реализован иерархией — RegionalOfficeBrief (/me, поля id,name,is_head_office) и RegionalOfficeOut(RegionalOfficeBrief) (+is_active; справочник и UserOut), одно определение в schemas/directories.py (буквальный импорт добавил бы is_active в /me); несуществующий филиал в теле — 422 OFFICE_NOT_FOUND (как R-8), не 404; email хранится в форме EmailStr (домен в нижнем регистре), сравнение strip().lower(); UserCreate тоже extra=forbid (явный 422 на external_id); аудит создания содержит и employee_no; USER_ROLE_CHANGE: before — коды по алфавиту, after — в порядке запроса без дублей, повтор набора — без аудита; UNKNOWN_ROLE/UNKNOWN_PERMISSION несут список кодов; LAST_ADMIN сериализуется блокировкой FOR NO KEY UPDATE строки права USERS_ADMIN (+ строка пользователя/роли), порядок «объект → право» без дедлоков (FOR UPDATE дедлочит с FK-проверкой вставки аудита); LAST_ADMIN считает активных пользователей с активными ролями, несущими право (код роли не читается) |
безопасность администрирования; /me не меняется |
вернуть локальную модель в me.py; сменить код 422↔404 — одна проверка; снять блокировку — риск остаться без администратора при гонке |
| 31 | 2026-10-04 | R-20 (SDD фаза 2, Task 13, приняты решения исполнителя): условие dev-входа — литерал process.env.NODE_ENV !== 'production' прямо в DevLogin (rsbuild подменяет process.env объектом REACT_APP_*, через Config NODE_ENV недоступен; в prod-бандле формы и dev/token нет — проверено grep); AuthGate оборачивает весь Layout (а не только Routes), чтобы форма входа не рисовалась рядом с сайдбаром; профиль детям — MeContext/useCurrentUser; Redux Provider каркаса не удалён; токен: sessionStorage с запасным токеном в памяти при недоступном хранилище; клиент: сетевые ошибки → ApiError(0,'NETWORK'), не-JSON 2xx → BAD_RESPONSE; в prod ошибка /me показывается в Alert с кнопкой «Повторить»; типы Permission.object_type/action — union по enum бэкенда |
D-9, безопасность prod-сборки, устойчивость клиента | вернуть AuthGate вокруг Routes; при смене сборщика пересмотреть условие NODE_ENV |
| 32 | 2026-10-04 | R-21 (SDD фаза 2, Task 14, приняты решения исполнителя): /admin берёт профиль из useCurrentUser() (контекст AuthGate), а не useMe() (второго /me нет); пункт меню «Пользователи и роли» скрыт без USERS_ADMIN (страница по URL всё равно закрыта 403 без запросов к /admin/*); корень приложения обёрнут в antd <App component={false}> (index.tsx, вне списка файлов брифа) — уведомления через App.useApp().message; общие хуки (useNotifyError/useMountedRef/useAsyncData) — widgets/Admin/hooks.ts, константа USERS_ADMIN — auth/permissions.ts; строки таблицы показывают серверное состояние (без оптимистичных обновлений), при ошибке список перечитывается, на время сохранения блокируются контролы всех строк; матрица: PUT по ролям строго последовательно (защита от гонки снятий USERS_ADMIN), после сохранения — слияние ответов, сброс черновика, перечитывание; неактивную роль нельзя назначить, но можно снять; черновик матрицы — Map (lib ES2020 без Object.hasOwn); EMAIL_EXISTS показывается под полем email; страница показывает первые 200 пользователей с пометкой (поиска/пагинации нет) |
D-9 минимализм; безопасность и устойчивость экрана | добавить поиск/пагинацию при росте числа пользователей; расширить lib и заменить Map |
| 33 | 2026-10-04 | R-22 (SDD фаза 2, пост-Task 15): общий маппинг нарушений уникальности PostgreSQL (SQLSTATE 23505) в коды 409 — реестр src/common/constraint_conflicts.py (7 ограничений: crm_id ГК и ЮЛ, inn, алиас, email (точный дубликат), два индекса членства); имя ограничения берётся из orig.__cause__.constraint_name (asyncpg) / .diag (psycopg), текст ошибки не разбирается; ответ — тот же {"detail": {"code","message"}}, что у pre-check, без значений ключей; прочие ошибки целостности — прежняя строка; в лог добавлен resolved_code; гонка повторного добавления ЮЛ в ту же группу даёт MEMBER_OF_OTHER_GROUP (срабатывает индекс «ЮЛ + вид»), текст нейтральный; без кода остаются ALIAS_TAKEN, email в другом регистре, external_id |
закрывает T6.5, T7.1, T8.2, T12.7: гонка не отличается от последовательного дубля | расширить реестр; убрать — вернуть общий 409 |