chore(openspec): рулинги R-23, R-24 (design #34–35), спека users-admin уточнена (I-1), поправки design #22/#28

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 02:23:31 +04:00
parent 811a1d2e90
commit bea295d222
3 changed files with 10 additions and 6 deletions

View File

@ -83,7 +83,7 @@ WHERE is_current`; `ALTER TYPE audit_action ADD VALUE` ×2 (`LEGAL_ENTITY_CHANGE
(pre-check `lower(email)`); `external_id` не задаётся (появится при первом входе через РАИСА — Срез JWT). Матрица: `PUT
/admin/roles/{code}/permissions` заменяет набор целиком (diff → `ROLE_PERMISSION_CHANGE` с before/after); системные роли
(`is_system`) переименовывать/деактивировать нельзя; нельзя снять `USERS_ADMIN` у роли, если это единственная роль с этим правом
у активных пользователей, и нельзя снять её у себя. Новые коды прав не создаются (комментарий seed). Повторный seed
у активных пользователей, (защита системная: «у себя» запрещено лишь пока ты — последний активный обладатель права, решение №34). Новые коды прав не создаются (комментарий seed). Повторный seed
**возвращает** права, снятые администратором (вставка недостающих пар `ON CONFLICT DO NOTHING`; лишние права не отзывает) —
известное ограничение (OQ из Среза 0 M-8), задокументировано в README администратора.
@ -153,15 +153,17 @@ WHERE is_current`; `ALTER TYPE audit_action ADD VALUE` ×2 (`LEGAL_ENTITY_CHANGE
| 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) |
| 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, даст нарушение FK (23503) — общий 409 со строкой «нарушение целостности данных в БД», без кода `GROUP_IN_USE` (T8.1, deferred к OQ-34; в первоначальной формулировке ошибочно значилось «500») |
| 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` филиальному пользователю — убрать имя из сообщения |
| 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` не разархивируются; `put_object` выполняется до commit — при сбое commit в бакете остаётся объект без строки `s3_object` (зачистка — follow-up, M-7); сообщение `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 |
| 34 | 2026-10-04 | R-23 (SDD, финальное ревью I-1): правило «администратор не снимает с себя USERS_ADMIN» трактуется как системное — запрещено (409 `LAST_ADMIN`), пока он единственный активный обладатель права; при втором активном администраторе снятие роли у себя, самодеактивация и снятие права с роли разрешены; текст требования спеки users-admin, D-8 и proposal приведены к этому; код и тест `test_second_admin_allows_dropping_own_admin_role` не менялись | сценарии спеки и Review Focus 5 плана описывают именно системную защиту; план сузил буквальный текст требования, расхождение не было записано | реализовать строгий вариант: при `user_id == actor_id` и потере всех источников `USERS_ADMIN` у себя — 409 `LAST_ADMIN` в `set_roles`/`set_role_permissions`/`update_user(is_active=false)` и поправить тест |
| 35 | 2026-10-04 | R-24 (SDD, финальное ревью M-3): смена имени и филиала ГК одним PATCH (и импортом `update`) пишет одно событие `ASSIGNMENT_CHANGE` с обоими полями в before/after — оставлено как есть; выборка журнала по `GROUP_CHANGE` пропустит такие переименования | спека не требует разнесения; полный состав изменения сохранён в одном событии | разнести на два события (филиал → `ASSIGNMENT_CHANGE`, прочее → `GROUP_CHANGE`) — правка `apply_group_update` и тестов |

View File

@ -41,7 +41,7 @@
(маркер `fixtures`, `ARM_KA_DOCS`).
- **Администрирование** (`/api/v1/admin/…`, право `USERS_ADMIN`): пользователи (список, создание с обязательным email,
изменение, деактивация; `USER_CHANGE`), роли (список), назначение ролей пользователю (`USER_ROLE_CHANGE`), матрица
`role_permission` (чтение, замена набора прав роли; `ROLE_PERMISSION_CHANGE`); нельзя снять `USERS_ADMIN` с себя.
`role_permission` (чтение, замена набора прав роли; `ROLE_PERMISSION_CHANGE`); нельзя оставить систему без активного обладателя `USERS_ADMIN` (в т.ч. снять его с себя, будучи последним).
- **Минимальный экран администратора** (`web/`): API-клиент с `Authorization: Bearer` (dev-токен по email в режиме
разработки), страница `/admin`: таблица пользователей с назначением ролей и форма создания, матрица «роль × право»
с чекбоксами. Без справочника ГК/ЮЛ (решение владельца).

View File

@ -25,8 +25,10 @@
### Requirement: Назначение ролей
`PUT /api/v1/admin/users/{id}/roles` (тело: список кодов) SHALL заменять набор ролей пользователя: отсутствующие в наборе
назначения удаляются, новые добавляются; неизвестный или неактивный код роли → 422; пишется `audit_event(USER_ROLE_CHANGE,
before_value = прежний набор, after_value = новый)`. Администратор MUST NOT снять с себя последнюю роль, дающую `USERS_ADMIN`
(409 `LAST_ADMIN`).
before_value = прежний набор, after_value = новый)`. Замена набора ролей MUST NOT оставить систему без активного пользователя с
`USERS_ADMIN` (409 `LAST_ADMIN`): администратор не может снять с себя последнюю роль, дающую `USERS_ADMIN`, если он —
единственный активный обладатель этого права; при наличии другого активного администратора снятие у себя разрешено
(решение №34).
#### Scenario: Замена набора ролей
- **WHEN** у пользователя роли `[KA_RF]`, администратор ставит `[KA_GO, EXPERT]`