2026-10-05 02:02:43 +04:00

40 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 возвращает права, снятые администратором (вставка недостающих пар 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