# Срез 1 — справочники arm-core — план исполнения, фаза 2 (импорт из ЕЦП CRM, администрирование, экран администратора, документы) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Branch:** `feat/5-slice-1-directories` (worktree `.wt/slice-1/arm-core`). **Issue:** RSHB/arm-core#5. **PR:** RSHB/arm-core#6. **Spec:** `openspec/changes/slice-1-directories/` (design D-7…D-11; specs `directory-import`, `users-admin`, `audit-trail`). Правила: `../docs/context/05-domain-rules.md` §13 (импорт: формат 13.1, построчные правила и коды 13.2, транзакция/отчёт/след 13.3), §10 (роли, права). Фикстуры: `../fixtures/directories/ecp-export-russkiy-biskvit.xlsx` (21 строка ГК «Русский Бисквит»), полная выгрузка `../docs/source/biq/ecp-export-lagal-entity.xlsx` (8400 строк; читать только, не менять). **Предусловие:** фаза 1 выполнена (сервисы `CompanyGroupService`, `LegalEntityService`, `RelatedGroupService`, репозиторий `repositories/directories.py`, `RequestAudit`, `DomainError`, фикстуры `app`/`auth_client`/`seeded`, фабрики). **Goal:** импорт реальной выгрузки ЕЦП CRM с отчётом, `dry_run` и следом в аудите/S3; администрирование пользователей, ролей и матрицы прав через API и минимальный экран `/admin`; документы и `make check-full`. **Architecture:** импорт = чистое ядро (`src/domain/directory_import.py`: чтение файла → нормализация → план операций и отчёт над снимком справочника в памяти) + сервис, применяющий план в транзакции запроса. Администрирование — сервис `users_admin.py` над `app_user`/`user_role`/`role_permission` с аудитом. Фронт — fetch-клиент с Bearer, dev-вход по email, одна страница на antd без Redux. **Tech Stack:** как в фазе 1 + `openpyxl` (чтение xlsx, read-only), `python-multipart` (уже есть); фронт React 18 + TS strict, antd 6, react-router 7, rsbuild, biome. ## Global Constraints Все пункты «Global Constraints» фазы 1 действуют. Дополнительно: - Коды отчёта импорта — ровно перечень §13.2 / spec directory-import: итоговые на строку `UNSUPPORTED_TYPE`, `INVALID_INN`, `DUPLICATE_IN_FILE`, `LE_IN_OTHER_GROUP`, `GK_NAME_CONFLICT` (пропуск) | `LE_CREATED`, `LE_UPDATED`, `LE_EXISTS` (применена); сопутствующие события `RF_CREATED`, `GK_CREATED`, `GK_UPDATED`, `GK_EXISTS`, `GSK_CREATED`, `GSZ_CREATED`, `MEMBER_ADDED`, `UNKNOWN_FLAG`; ошибки файла `MISSING_COLUMNS`, `UNSUPPORTED_FILE`. Других кодов не вводить; каждое допущение — `# ASSUMPTION(OQ-87|88|89|90|91)`. - Импорт: право `DIRECTORY_ADMIN`; `dry_run` по умолчанию `true`; `on_existing ∈ {skip, update}`, по умолчанию `skip`; одна транзакция запроса; при `dry_run` — явный `rollback` сессии до возврата ответа и **ни аудита, ни файла в S3**; лимит `IMPORT_MAX_BYTES = 20 * 1024 * 1024` (Settings) → 413. - Администрирование: право `USERS_ADMIN`; пользователей не удалять; email обязателен и уникален без учёта регистра; новые коды прав не создаются; `LAST_ADMIN` защищает последнее право `USERS_ADMIN` у активных пользователей (и при снятии роли с пользователя, и при снятии права с роли). - Seed не меняется (16 кодов прав, матрица §10); тесты `test_seed_roles.py`, `test_me_and_dev_token.py` не трогать. - Фронт: `biome check src` (ширина 140, одинарные кавычки, табуляция как в репозитории), `tsc --noEmit` strict, `npm run build`; env только `REACT_APP_*` через `conf/config.ts`; никаких новых npm-зависимостей (antd, icons, react-router уже есть); `raisa-*` не импортировать. ## Review Focus 1. **xlsx с пустыми строками в конце листа и объединёнными ячейками заголовка** — пустые строки пропускаются без кодов, заголовок ищется в первой непустой строке: Task 9 — тест на синтетическом xlsx с 3 пустыми строками в хвосте. 2. **ИНН в xlsx как число** (`3528052718.0`, научная нотация) — openpyxl отдаёт `int`/`float`; нормализация переводит в строку без `.0`: Task 9 — тест. 3. **csv в cp1251** (типичный экспорт Excel) — `UnicodeDecodeError` → 422 `UNSUPPORTED_FILE` с сообщением «ожидается UTF-8», не 500: Task 10 — тест. 4. **Повторный `dry_run` одного файла** не должен расходовать последовательности кодов заметно для пользователя (коды с дырами допустимы, но не падение): Task 10 — тест, что второй `dry_run` возвращает тот же отчёт. 5. **Администратор деактивирует самого себя** — `PATCH is_active=false` на себе → 409 `LAST_ADMIN`, если он последний активный с `USERS_ADMIN`; иначе разрешено: Task 12 — тест. --- ## Задачи ### Task 9: [3.1] Чтение файла и ядро решений импорта (чистые функции) **Files:** - Modify: `api/requirements.txt` (`openpyxl==3.1.5` в алфавитном порядке; `make venv` переустанавливает) - Create: `api/src/domain/directory_import.py` - Test: `api/tests/test_domain_directory_import.py`, `api/tests/data/import/` (синтетические csv/xlsx генерируются в тесте через `openpyxl`/`csv`, файлы в репозиторий не кладутся) **Interfaces (`src/domain/directory_import.py`, §13.1–13.2):** ```python class ImportCode(enum.StrEnum): UNSUPPORTED_TYPE = "UNSUPPORTED_TYPE"; INVALID_INN = "INVALID_INN"; DUPLICATE_IN_FILE = "DUPLICATE_IN_FILE" LE_IN_OTHER_GROUP = "LE_IN_OTHER_GROUP"; GK_NAME_CONFLICT = "GK_NAME_CONFLICT" LE_CREATED = "LE_CREATED"; LE_UPDATED = "LE_UPDATED"; LE_EXISTS = "LE_EXISTS" RF_CREATED = "RF_CREATED"; GK_CREATED = "GK_CREATED"; GK_UPDATED = "GK_UPDATED"; GK_EXISTS = "GK_EXISTS" GSK_CREATED = "GSK_CREATED"; GSZ_CREATED = "GSZ_CREATED"; MEMBER_ADDED = "MEMBER_ADDED"; UNKNOWN_FLAG = "UNKNOWN_FLAG" class OnExisting(enum.StrEnum): skip = "skip"; update = "update" REQUIRED_COLUMNS = ("инн", "idгк", "группакомпаний", "организация", "тип") OPTIONAL_COLUMNS = ("базовыйрфгквcrm", "гск", "гсз") def normalize_header(raw: object) -> str # lower, без пробелов, ё→е def normalize_cell(raw: object) -> str # None→""; int→str; float целое→str без .0; str→strip FLAG_GSK = "входитвгск"; FLAG_GSZ = "входитвгсз" # сравнение после normalize_header class FileFormatError(Exception): code: str ("MISSING_COLUMNS" | "UNSUPPORTED_FILE"); message: str; missing: list[str] @dataclass(frozen=True) class ImportRow: row: int; inn: str; crm_id: str; office_name: str; group_name: str; entity_name: str; gsk_flag: str; gsz_flag: str; type_raw: str def read_rows(filename: str, content: bytes) -> list[ImportRow] # по расширению: .xlsx → openpyxl load_workbook(BytesIO, read_only=True, data_only=True), первый лист, первая непустая строка — заголовки; # .csv → decode utf-8-sig (UnicodeDecodeError → FileFormatError UNSUPPORTED_FILE "ожидается UTF-8"), разделитель по строке заголовков (';' если в ней больше ';' чем ','), csv.reader; # иное расширение / битый zip → FileFormatError UNSUPPORTED_FILE; нет обязательных колонок → FileFormatError MISSING_COLUMNS(missing=[исходные имена]); # пустые строки (все ячейки "") пропускаются; row = номер строки в файле (1-based, как в Excel) # снимок справочника (строится сервисом из БД, в домене — только данные) @dataclass class OfficeSnap: id: uuid.UUID; name: str @dataclass class GroupSnap: id: uuid.UUID; crm_id: str; name: str; office_id: uuid.UUID | None; gsk_id: uuid.UUID | None; gsz_id: uuid.UUID | None @dataclass class EntitySnap: id: uuid.UUID; inn: str; name: str; entity_type: EntityType; group_id: uuid.UUID | None; gsk_group_id: uuid.UUID | None; gsz_group_id: uuid.UUID | None # текущие членства @dataclass class DirectorySnapshot: offices_by_name: dict[str, OfficeSnap]; groups_by_crm: dict[str, GroupSnap]; entities_by_inn: dict[str, EntitySnap] # операции плана — только данные; применяет сервис (Task 10). id новых объектов генерирует домен (uuid4), чтобы ссылки внутри плана были готовы. @dataclass(frozen=True) class CreateOffice: id: uuid.UUID; name: str @dataclass(frozen=True) class CreateGroup: id: uuid.UUID; crm_id: str; name: str; office_id: uuid.UUID | None @dataclass(frozen=True) class UpdateGroup: id: uuid.UUID; name: str | None; office_id: uuid.UUID | None # None = без изменений; office — только если в файле непусто @dataclass(frozen=True) class CreateEntity: id: uuid.UUID; inn: str; name: str; entity_type: EntityType; group_id: uuid.UUID; office_id: uuid.UUID | None @dataclass(frozen=True) class UpdateEntity: id: uuid.UUID; name: str | None; entity_type: EntityType | None @dataclass(frozen=True) class CreateRelatedGroup: id: uuid.UUID; group_id: uuid.UUID; kind: RelatedGroupKind; name: str @dataclass(frozen=True) class AddMember: related_group_id: uuid.UUID; entity_id: uuid.UUID; kind: RelatedGroupKind Operation = CreateOffice | CreateGroup | UpdateGroup | CreateEntity | UpdateEntity | CreateRelatedGroup | AddMember @dataclass class RowResult: row: int; inn: str; code: ImportCode; message: str @dataclass class ImportReport: rows: int = 0; groups_created: int = 0; groups_updated: int = 0; groups_skipped: int = 0 entities_created: int = 0; entities_updated: int = 0; entities_skipped: int = 0 memberships_created: int = 0; offices_created: int = 0; errors: int = 0 lines: list[RowResult] = field(default_factory=list) def to_dict(self) -> dict[str, Any] @dataclass class ImportPlan: operations: list[Operation]; report: ImportReport def plan_import(rows: Sequence[ImportRow], snapshot: DirectorySnapshot, on_existing: OnExisting) -> ImportPlan ``` Алгоритм `plan_import` — §13.2 построчно, в порядке строк файла, с накопителями «создано в этом файле» (офисы по имени, ГК по crm_id, ЮЛ по ИНН, ГСК/ГСЗ по ГК), чтобы одна ГК из 20 строк создавалась один раз: 1. `type_raw.lower()` ∉ {«юл», «ип»} → `UNSUPPORTED_TYPE` (`errors += 1`; `# ASSUMPTION(OQ-87)`), строка завершена. 2. `inn = normalize_inn(...)`; длина ≠ 10 (ЮЛ) / 12 (ИП) или не цифры → `INVALID_INN` (`# ASSUMPTION(OQ-88)`). 3. ИНН уже встречался в файле → `DUPLICATE_IN_FILE`. 4. Офис: `office_name` пусто → `office_id=None`; есть в снимке/накопителе → id; иначе `CreateOffice` + событие `RF_CREATED` (`offices_created += 1`; строка события — `RowResult(row, inn, RF_CREATED, "создан филиал «…»")`; `# ASSUMPTION(OQ-89)`). 5. ГК по `crm_id`. Пустой `iD ГК` → итоговый код `GK_NAME_CONFLICT` с сообщением «пустой iD ГК», `errors += 1`, строка завершена (отдельного кода спека не предусматривает). Нет → `CreateGroup` (`GK_CREATED`, `groups_created += 1`); есть и имя/офис отличаются: `update` → `UpdateGroup` (`GK_UPDATED`, один раз на ГК), `skip` → `GK_EXISTS` (`groups_skipped += 1`, один раз на ГК); разные имена одной ГК внутри файла → `GK_NAME_CONFLICT` (строка пропущена, `errors += 1`). 6. ЮЛ по ИНН: нет → `CreateEntity` (`LE_CREATED`, `entities_created += 1`, `office_id` = офис ГК); есть в этой же ГК → `update` и отличия → `UpdateEntity` (`LE_UPDATED`), иначе `LE_EXISTS` (`entities_skipped += 1`); есть в другой ГК (или без ГК) → `LE_IN_OTHER_GROUP` (`errors += 1`, `# ASSUMPTION(OQ-90)`), строка завершена без членств. 7. Флаги: `normalize_header(flag)` == `FLAG_GSK`/`FLAG_GSZ` → членство; непустое иное → `UNKNOWN_FLAG` (событие, не ошибка). Для флага: ГСК/ГСЗ ГК из снимка/накопителя или `CreateRelatedGroup(kind, name=имя ГК)` (`GSK_CREATED`/`GSZ_CREATED`, `# ASSUMPTION(OQ-91)`); у ЮЛ нет текущего членства этого вида → `AddMember` (`MEMBER_ADDED`, `memberships_created += 1`); есть в этой группе → ничего; есть в другой группе того же вида → ничего (импорт не переводит; событие не пишется). `rows` = число непустых строк данных. Итоговый код строки — ровно один из восьми; события — дополнительные `RowResult` с тем же `row`. - [x] **Step 1: Тесты** (`tests/test_domain_directory_import.py`, без БД): - `normalize_header("Базовый РФ ГК в CRM") == "базовыйрфгквcrm"`, `normalize_cell(3528052718.0) == "3528052718"`, `normalize_cell(None) == ""`. - `read_rows` csv `;` и `,`, utf-8-sig, перестановка колонок, лишняя колонка; без «Тип» → `FileFormatError.code == "MISSING_COLUMNS"`, `missing == ["Тип"]`; `.txt` → `UNSUPPORTED_FILE`; xlsx, собранный `openpyxl.Workbook()` с заголовком во 2-й строке, ИНН числом, 3 пустыми строками в хвосте (Review Focus 1–2) → 2 строки данных. - `plan_import` на синтетических строках, по одному тесту на каждый код (параметризация: строка → ожидаемый итоговый код и события), пустой снимок и снимок с существующими; `update` vs `skip`; две строки одной ГК → один `CreateGroup`; флаг у двух ЮЛ одной ГК → один `CreateRelatedGroup` и два `AddMember`; ЮЛ в другой ГК → нет `AddMember`. - `@pytest.mark.fixtures`: `read_rows` + `plan_import` на `fixtures_dir / "directories/ecp-export-russkiy-biskvit.xlsx"` с пустым снимком: `report.rows == 21`, `groups_created == 1`, `offices_created == 1`, `entities_created == 20` (18 ЮЛ + 2 ИП), ровно одна `CreateRelatedGroup(kind=gsk)`, два `AddMember`, нет `gsz`; коды строк ФЛ/дубля — зафиксировать по фактическому порядку в файле (посмотреть файл в тесте, не угадывать) и записать ожидание явно. - [x] **Step 2:** падает → **Step 3:** реализация (+ `openpyxl` в requirements, `make venv`) → **Step 4:** `pytest tests/test_domain_directory_import.py -v`, `mypy src` (strict для `src.domain.*`), `ruff` → PASS. - [x] **Step 5: Commit** `feat(domain): ядро импорта справочников из выгрузки ЕЦП (§13.1–13.2)`. --- ### Task 10: [3.2] Сервис и эндпоинт импорта **Files:** - Create: `api/src/services/directory_import.py`, `api/src/schemas/directory_import.py`, `api/src/api/endpoints/directories/import_.py` - Modify: `api/src/core/config.py` (`IMPORT_MAX_BYTES: PositiveInt = 20 * 1024 * 1024`), `api/.env.example` (`IMPORT_MAX_BYTES=20971520` с комментарием), `api/src/repositories/directories.py` (`load_snapshot(session) -> DirectorySnapshot` — три запроса: офисы, ГК с их ГСК/ГСЗ (`related_group` по `company_group_id`, первая группа каждого вида), ЮЛ с текущими членствами), `api/src/api/dependencies/directories.py`, `api/src/api/endpoints/directories/__init__.py` - Test: `api/tests/test_api_directory_import.py` **Interfaces:** - `DirectoryImportService(session, audit: RequestAudit, storage: S3Storage | None, settings: Settings)`: ```python async def run(self, *, filename: str, content: bytes, dry_run: bool, on_existing: OnExisting) -> ImportReport ``` - `len(content) > settings.IMPORT_MAX_BYTES` → `HTTPException(413, "файл больше 20 МБ")` (проверяется в эндпоинте до чтения в память: `UploadFile.size` если известен, иначе чтение с лимитом). - `read_rows` → `FileFormatError` → `DomainValidationError(code, message, missing=…)` (422). - `snapshot = await load_snapshot(session)`; `plan = plan_import(rows, snapshot, on_existing)`. - Применение операций по порядку: `CreateOffice` → `RegionalOffice(id, name, is_head_office=False, is_active=True)`; `CreateGroup` → `CompanyGroup(id, code=await next_code(COMPANY_GROUP_CODE_SEQ, "GK"), crm_id, name, base_regional_office_id, valid_from=now, created_at=now, updated_by=user)`; `UpdateGroup` → как `CompanyGroupService.update` (снимок истории, аудит `GROUP_CHANGE`/`ASSIGNMENT_CHANGE` с `description="импорт ЕЦП"`) — переиспользовать внутреннюю функцию сервиса ГК, вынесенную в `services/company_groups.py` как `apply_group_update(session, audit, group, *, name, office_id, now)`; `CreateEntity` → `LegalEntity(...)`, `UpdateEntity` → снимок `legal_entity_history(UPDATE)` через `apply_entity_update(...)` из `services/legal_entities.py`; `CreateRelatedGroup` → `RelatedGroup(id, code=await next_code(RELATED_GROUP_CODE_SEQ, "GSK"|"GSZ"), …)`; `AddMember` → `RelatedGroupMember(kind, valid_from=now, is_current=True, created_by=user)`. Один `flush` после пакета операций каждого типа; вставки через `session.add_all`. - `dry_run=True`: после применения и сборки отчёта — `await session.rollback()` (транзакция запроса станет пустой; в тестах — откат SAVEPOINT), вернуть отчёт; аудит и S3 не вызываются. - `dry_run=False`: `event = await audit.record(action=DIRECTORY_IMPORT, section=DIRECTORIES, object_type="directory_import", description=filename, after_value=report.to_dict())`; при `storage is not None`: ключ `f"uploads/imports/{event.id}/{filename}"`, `await storage.put_object(key, content)`, `S3Object(bucket_role=uploads, bucket=storage.bucket, key, content_type, size_bytes=len(content), created_by=f"user:{audit.user_id}", created_at=now)`; `storage is None` (dev без S3) — предупреждение в лог, `S3Object` не создаётся. - Схема ответа `ImportReportOut` (= `ImportReport.to_dict()`; `lines: list[RowResultOut(row, inn, code, message)]`). - Эндпоинт `POST /api/v1/directories/import` (`require("DIRECTORY_ADMIN")`): `file: UploadFile = File(...)`, `dry_run: bool = Form(True)`, `on_existing: OnExisting = Form(OnExisting.skip)`; `storage = request.app.state.storage`. - `load_snapshot` строит `EntitySnap.gsk_group_id/gsz_group_id` из текущих членств (`is_current`), `GroupSnap.gsk_id/gsz_id` — первая по `code` группа каждого вида у ГК. - [x] **Step 1: Тесты** (`tests/test_api_directory_import.py`, integration; клиент `admin@armka.local`; `app.state.storage` — заглушка `FakeStorage(bucket="arm-ka")` с `put_object(key, data)` в словарь): - срез РБ `dry_run=false, skip` на seed-базе → 200; счётчики как в Task 9; в БД 1 ГК с `crm_id="1-AZFL6BQ"`, `regional_office` «Санкт-Петербургский РФ», 20 ЮЛ/ИП, 1 ГСК (`name` = имя ГК, 2 члена с `kind=gsk`), `audit_event(DIRECTORY_IMPORT)` с `after_value["rows"] == 21`, `S3Object` с ключом `uploads/imports//ecp-export-russkiy-biskvit.xlsx`, байты в `FakeStorage`; - повтор `skip` → все `LE_EXISTS`/`GK_EXISTS`, число строк не изменилось; - csv с изменённым именем ЮЛ + `update` → имя обновлено, `legal_entity_history(UPDATE)`, код `LE_UPDATED`; - ЮЛ заранее в другой ГК (фабрика) → `LE_IN_OTHER_GROUP`, ГК не изменилась; - `dry_run=true` → 200, счётчики как боевые, число строк во всех таблицах и `audit_event`/`s3_object` не изменилось; второй `dry_run` — тот же отчёт (Review Focus 4); - csv без «Тип» → 422 `MISSING_COLUMNS` с `missing`; csv в cp1251 → 422 `UNSUPPORTED_FILE` (Review Focus 3); файл 20 МБ + 1 байт → 413; - `ka_go` (нет `DIRECTORY_ADMIN`) → 403. - [x] **Step 2–4:** падает → реализация → `pytest -m integration tests/test_api_directory_import.py -v`, `make check` → PASS. - [x] **Step 5: Commit** `feat(directories): импорт выгрузки ЕЦП — сервис, эндпоинт, аудит, файл в S3`. --- ### Task 11: [3.3] Полная выгрузка: fixtures-тест производительности **Files:** - Create: `api/tests/test_import_full_export.py` - Modify (при необходимости): `api/src/services/directory_import.py` (пакетные вставки `insert(...).values([...])`, если > 60 с) - [x] **Step 1: Тест** `@pytest.mark.integration @pytest.mark.fixtures`: путь `Path(os.environ.get("ARM_KA_DOCS") or Path(__file__).resolve().parents[3] / "docs") / "source/biq/ecp-export-lagal-entity.xlsx"`; нет файла → `pytest.skip("нет выгрузки ЕЦП: …")`. `dry_run=true` под `admin@armka.local`; `time.perf_counter()` ≤ 60 с; `rows == 8400`; `offices_created == 53`; среди кодов есть `UNSUPPORTED_TYPE` (≥ 500 строк), `INVALID_INN`, `DUPLICATE_IN_FILE` (`LE_IN_OTHER_GROUP` на пустой БД невозможен — не проверять); `groups_created == 344` (R-15); таблицы после теста не выросли. - [x] **Step 2:** запустить; при превышении 60 с — профилировать (`--durations`), перевести вставки на `session.execute(insert(Model), [dict, ...])` пакетами по 1000, повторить. - [x] **Step 3: Commit** `test(import): dry_run полной выгрузки ЕЦП (8400 строк) укладывается в 60 с`. --- ### Task 12: [4.1] Администрирование: пользователи, роли, матрица прав (API) **Files:** - Create: `api/src/services/users_admin.py`, `api/src/schemas/admin.py`, `api/src/api/endpoints/admin/__init__.py`, `api/src/api/endpoints/admin/users.py`, `api/src/api/endpoints/admin/roles.py`, `api/src/api/dependencies/admin.py` - Modify: `api/src/repositories/users.py` (запросы списка пользователей с ролями, ролей с правами, `count_active_users_with_permission(session, code, *, excluding_user: uuid | None = None, excluding_role: uuid | None = None) -> int`), `api/src/api/router.py` (include `admin.router`, префикс `/admin`) - Test: `api/tests/test_api_admin.py` **Interfaces:** - `UsersAdminService(session, audit: RequestAudit, actor_id: uuid.UUID)`: - `list_users(q, is_active, page) -> tuple[list[UserWithRoles], int]` (`UserWithRoles = tuple[AppUser, RegionalOffice, list[str]]`) - `create_user(data: UserCreate) -> AppUser` — `email.strip().lower()` для сравнения, хранится как введён; `lower(email)` занят → `ConflictError("EMAIL_EXISTS")`; офис → 404 если нет; роли по кодам (`Role.is_active`) иначе `DomainValidationError("UNKNOWN_ROLE", …, roles=[...])`; `created_at/updated_at=now`; `audit USER_CHANGE section=USERS object_type="app_user" after_value={email, full_name, regional_office_id, business_segment, roles}`. - `update_user(user_id, data: UserUpdate) -> AppUser` — поля `full_name, employee_no, regional_office_id, business_segment, is_active`; `is_active=False` на пользователе, после которого `count_active_users_with_permission("USERS_ADMIN", excluding_user=user_id) == 0` → `ConflictError("LAST_ADMIN")` (Review Focus 5); no-op без аудита; `USER_CHANGE` с diff. - `set_roles(user_id, codes: list[str]) -> list[str]` — diff `user_role`; неизвестный/неактивный код → 422 `UNKNOWN_ROLE`; если после замены у активных пользователей никто не имеет `USERS_ADMIN` → 409 `LAST_ADMIN` (проверка: `count_active_users_with_permission("USERS_ADMIN", excluding_user=user_id) == 0` и новый набор не содержит роли с `USERS_ADMIN`); `audit USER_ROLE_CHANGE before_value={"roles": old}, after_value={"roles": new}`. - `list_roles() -> list[tuple[Role, list[str]]]`, `list_permissions() -> list[Permission]`. - `set_role_permissions(code: str, permission_codes: list[str]) -> list[str]` — роль → 404; неизвестный код права → 422 `UNKNOWN_PERMISSION`; если убирается `USERS_ADMIN` и `count_active_users_with_permission("USERS_ADMIN", excluding_role=role.id) == 0` → 409 `LAST_ADMIN`; diff `role_permission` (`granted_by=actor_id`, `granted_at=now`); `audit ROLE_PERMISSION_CHANGE section=ACCESS object_type="role" object_id=role.id before/after {"permissions": [...]}`. - Схемы (`schemas/admin.py`): `UserCreate(email: EmailStr, full_name: str, regional_office_id: uuid.UUID, employee_no: str | None = None, business_segment: BusinessSegment | None = None, roles: list[str] = [])`, `UserUpdate` (все optional, `extra="forbid"`), `UserOut(id, email, full_name, employee_no, regional_office: RegionalOfficeOut, business_segment, is_active, roles: list[str], created_at, updated_at)`, `RolesSet(roles: list[str])`, `RoleOut(code, name, description, is_system, is_active, permissions: list[str])`, `PermissionOut(code, object_type, action, name, description)`, `PermissionsSet(permissions: list[str])`. - Маршруты (`require("USERS_ADMIN")`): `GET /admin/users` → `Page[UserOut]` (`q`, `is_active`), `POST /admin/users` → 201, `PATCH /admin/users/{id}`, `PUT /admin/users/{id}/roles` → `UserOut`, `GET /admin/roles` → `list[RoleOut]`, `GET /admin/permissions` → `list[PermissionOut]`, `PUT /admin/roles/{code}/permissions` → `RoleOut`. - [x] **Step 1: Тесты** (integration, `admin@armka.local`): создание с `roles=["KA_RF"]` → `app_user`+`user_role`; `GET /me` новым пользователем (через `auth_client(new_email)`) → права KA_RF; email в другом регистре → 409 `EMAIL_EXISTS`; `PUT roles` `[KA_GO, EXPERT]` → ровно две строки, аудит before/after; единственный админ снимает с себя `ADMIN` → 409 `LAST_ADMIN`; второй админ создан → снятие разрешено; `PATCH is_active=false` на себе как последнем → 409; матрица: `EXPERT` + `EXPORT` → `role_permission` строка, `/me` экспертом видит `EXPORT`, аудит `ROLE_PERMISSION_CHANGE`; убрать `USERS_ADMIN` у `ADMIN` → 409 `LAST_ADMIN`; `GET /admin/permissions` → 16 кодов; `ka_go` → 403 на всё. - [x] **Step 2–4:** падает → реализация → тесты, `make check` → PASS. - [x] **Step 5: Commit** `feat(admin): пользователи, роли, матрица прав — API с аудитом и защитой LAST_ADMIN`. --- ### Task 13: [4.2] Фронт: API-клиент, dev-вход, `/me` **Files:** - Create: `web/src/api/client.ts`, `web/src/api/types.ts`, `web/src/auth/token.ts`, `web/src/auth/DevLogin.tsx`, `web/src/auth/useMe.ts` - Modify: `web/src/utils.ts` (удалить пустой файл или оставить — не трогать, если не используется) **Interfaces:** - `web/src/auth/token.ts`: `const KEY = 'armka.token'`; `getToken(): string | null` (`sessionStorage`), `setToken(t: string)`, `clearToken()`. - `web/src/api/client.ts`: ```ts export class ApiError extends Error { constructor(public status: number, public code: string | undefined, message: string) { super(message); } } export const API_BASE = `${Config.API_URL}/api/v1`; export async function apiFetch(path: string, init: RequestInit = {}): Promise // заголовки: Authorization: Bearer если есть; 'Content-Type: application/json' если body — строка; // 401 → clearToken(), window.dispatchEvent(new Event('armka:unauthorized')), throw ApiError; // !ok → тело {detail}: строка → message; объект {code,message} → code+message; массив (422 pydantic) → первая msg; throw ApiError; // 204 → undefined as T; иначе response.json() export const api = { get: (p: string) => apiFetch(p), post: (p: string, body?: unknown) => ..., patch, put, del }; ``` - `web/src/api/types.ts`: `Me { id; email; full_name; regional_office: {id; name; is_head_office}; business_segment: string | null; roles: string[]; permissions: string[] }`, `Page { items: T[]; total: number; limit: number; offset: number }`, `RegionalOffice`, `AdminUser` (= `UserOut`), `Role` (= `RoleOut`), `Permission` (= `PermissionOut`), `UserCreate`. - `web/src/auth/useMe.ts`: `useMe(): { me: Me | null; loading: boolean; error: string | null; reload: () => void }` — `GET /me` при наличии токена; слушает `armka:unauthorized` → `me = null`. - `web/src/auth/DevLogin.tsx`: форма antd (`Input` email + `Button` «Войти (dev)»), `POST /dev/token {email}` → `setToken(token)` → `onLogin()`; рендерится только при `process.env.NODE_ENV !== 'production'` (rsbuild подставляет); в проде — текст «Вход выполняется через РАИСА» (TODO OQ: параметры JWT РАИСА). - Ответ dev-токена: `DevTokenResponse.access_token` (`src/api/endpoints/dev_auth.py:40`). - [x] **Step 1:** написать модули; `App.tsx`: обернуть `Routes` в компонент `AuthGate` (`useMe`; нет токена/`me` → `DevLogin`, иначе дети). `Main.tsx` показывает `me.full_name` и роли. - [x] **Step 2:** `cd web && npm install && npx biome check src && npx tsc --noEmit && npm run build` → PASS. Ручная проверка (README в Task 15): `make up`, `make migrate && make seed-dev`, `cd api && python run_server.py`, `cd web && npm run start`, вход `admin@armka.local`. - [x] **Step 3: Commit** `feat(web): API-клиент с Bearer, dev-вход по email, загрузка /me`. --- ### Task 14: [4.3] Фронт: страница администратора `/admin` **Files:** - Create: `web/src/layouts/Admin.tsx`, `web/src/widgets/Admin/UsersTable.tsx`, `web/src/widgets/Admin/CreateUserModal.tsx`, `web/src/widgets/Admin/PermissionMatrix.tsx` - Modify: `web/src/App.tsx` (маршрут `/admin`), `web/src/widgets/Sidebar/Sidebar.tsx` (группа «Система» → «Пользователи и роли», иконка `TeamOutlined`) **Interfaces / поведение (spec users-admin «Минимальный экран администратора»):** - `Admin.tsx`: `useMe()`; нет `USERS_ADMIN` в `me.permissions` → `` без запросов к `/admin/*`; иначе `Tabs`: «Пользователи», «Матрица прав». - `UsersTable.tsx`: загрузка `GET /admin/users?limit=200`, `GET /admin/roles`, `GET /directories/regional-offices`; antd `Table` (email, ФИО, филиал, роли — `Select mode='multiple'` c опциями из ролей; `onChange` → `PUT /admin/users/{id}/roles` → перезагрузка строки; `Switch` активности → `PATCH`), кнопка «Создать пользователя» → `CreateUserModal` (`Form`: email, ФИО, табельный номер, филиал `Select`, сегмент `Select` (DKB/DMSB/DM/—), роли) → `POST /admin/users` → закрыть, перезагрузить. Ошибки `ApiError` → `message.error(e.message)`. - `PermissionMatrix.tsx`: `GET /admin/roles`, `GET /admin/permissions`; `Table` строки = права (код, имя), колонки = роли, ячейки `Checkbox`; локальный state изменений; кнопка «Сохранить» → для каждой изменённой роли `PUT /admin/roles/{code}/permissions`; после — перезагрузка; системные роли помечены тегом. - Маршрут `/admin` в `App.tsx` внутри `AuthGate`; Sidebar: группа `{ title: 'Система', key: 'system', children: [{ link: '/admin', icon: , label: 'Пользователи и роли' }] }`. - [x] **Step 1:** реализовать; **Step 2:** `npx biome check src && npx tsc --noEmit && npm run build` → PASS; ручной сценарий: вход админом, создать пользователя с ролью, сменить роль, снять галочку в матрице и сохранить; убедиться, что ошибка 409 `LAST_ADMIN` показывается уведомлением. - [x] **Step 3:** самостоятельное ревью TS/React (пользователь слаб во фронте): в описание коммита — 3–5 строк, почему без Redux, как устроен клиент, где появится РАИСА. - [x] **Step 4: Commit** `feat(web): страница администратора — пользователи, роли, матрица прав`. --- ### Task 15: [5.1] Документы, OpenAPI, `make check-full` **Files:** - Modify: `README.md` (разделы: «API справочников» — таблица маршрутов и прав; «Импорт из ЕЦП CRM» — пошагово с `dry_run` сначала, формат файла, коды отчёта со ссылкой на §13.2, допущения OQ-87…91; «Администрирование и экран /admin» — dev-вход, ограничение «повторный seed не отзывает права, снятые администратором»; «Ошибки API» — формат `detail.code`), `CLAUDE.md` (DBML v5.14; `domain/policy.py`, `domain/directory_import.py`, `domain/aliases.py`, `domain/inn.py`; транзакция на запрос в `get_async_session`; формат доменных ошибок; `openpyxl`; фронт: `api/client.ts`, `auth/`, `/admin`), `openspec/changes/slice-1-directories/tasks.md` (все `[x]`), `openspec/changes/slice-1-directories/design.md` (раздел «Decisions made without discussion» — дописать рулинги контроллера R-n) - Проверка OpenAPI: `GET /openapi.json` содержит все маршруты спек (скрипт-однострочник в README не нужен; проверить тестом `tests/test_openapi_routes.py`: список путей из спек присутствует в `app.openapi()["paths"]`). - [x] **Step 1:** тест `test_openapi_routes.py` (unit, `src.app.app` импортируется при `ENV_FILE=.env.test`): пути `/api/v1/directories/company-groups`, `/api/v1/directories/legal-entities/{entity_id}/move`, `/api/v1/directories/import`, `/api/v1/admin/users/{user_id}/roles`, `/api/v1/admin/roles/{code}/permissions` и остальные — присутствуют. - [x] **Step 2:** документы по списку выше. - [x] **Step 3:** `make check-full` (из корня; compose поднят): `check`, `dbml-check`, `check-infra`, `test-integration` (включая `fixtures`-тесты — `../fixtures` и `../docs` доступны), `docker-build`, `web-check` → всё зелёное; вывод сохранить для описания PR (в ledger). - [x] **Step 4: Commit** `docs: README/CLAUDE.md Среза 1, тест маршрутов OpenAPI, tasks.md`. --- ## Завершение После Task 15 — финальное ревью всей ветки (от первого коммита фазы 1 до HEAD) на модели opus: спека ↔ код, инварианты (`audit_event` append-only, поля вне DBML, код роли в логике, float), безопасность (утечка существования чужих ГК, `LAST_ADMIN`, лимит файла), качество тестов. Находки `C-n`/`I-n`/`M-n` со статусами; `deferred` — в описание PR.