arm-core/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md
2026-10-05 01:45:13 +04:00

38 KiB
Raw Blame History

Срез 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):

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.
  • 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; коды строк ФЛ/дубля — зафиксировать по фактическому порядку в файле (посмотреть файл в тесте, не угадывать) и записать ожидание явно.
  • 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.
  • 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):
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 группа каждого вида у ГК.

  • 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/<event.id>/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.
  • Step 2–4: падает → реализация → pytest -m integration tests/test_api_directory_import.py -v, make check → PASS.

  • 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 с)

  • 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); таблицы после теста не выросли.

  • Step 2: запустить; при превышении 60 с — профилировать (--durations), перевести вставки на session.execute(insert(Model), [dict, ...]) пакетами по 1000, повторить.

  • 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.

  • 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 на всё.

  • Step 2–4: падает → реализация → тесты, make check → PASS.

  • 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:
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<T>(path: string, init: RequestInit = {}): Promise<T>
// заголовки: Authorization: Bearer <token> если есть; '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: <T>(p: string) => apiFetch<T>(p), post: <T>(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<T> { 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).

  • Step 1: написать модули; App.tsx: обернуть Routes в компонент AuthGate (useMe; нет токена/me → DevLogin, иначе дети). Main.tsx показывает me.full_name и роли.

  • 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.

  • 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 → <Result status='403' title='Нет доступа' /> без запросов к /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: <TeamOutlined />, label: 'Пользователи и роли' }] }.

  • Step 1: реализовать; Step 2: npx biome check src && npx tsc --noEmit && npm run build → PASS; ручной сценарий: вход админом, создать пользователя с ролью, сменить роль, снять галочку в матрице и сохранить; убедиться, что ошибка 409 LAST_ADMIN показывается уведомлением.

  • Step 3: самостоятельное ревью TS/React (пользователь слаб во фронте): в описание коммита — 3–5 строк, почему без Redux, как устроен клиент, где появится РАИСА.

  • 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"]).

  • 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 и остальные — присутствуют.

  • Step 2: документы по списку выше.

  • Step 3: make check-full (из корня; compose поднят): check, dbml-check, check-infra, test-integration (включая fixtures-тесты — ../fixtures и ../docs доступны), docker-build, web-check → всё зелёное; вывод сохранить для описания PR (в ledger).

  • 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.