docs/superpowers/plans/2026-10-04-slice-1-directories-phase{1,2}.md — 15 задач из tasks.md change'а
slice-1-directories: модель v5.14 и инфраструктура запросов, policy, API справочников (фаза 1); импорт ЕЦП,
администрирование, экран /admin, документы и check-full (фаза 2).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
38 KiB
Срез 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 --noEmitstrict,npm run build; env толькоREACT_APP_*черезconf/config.ts; никаких новых npm-зависимостей (antd, icons, react-router уже есть);raisa-*не импортировать.
Review Focus
- xlsx с пустыми строками в конце листа и объединёнными ячейками заголовка — пустые строки пропускаются без кодов, заголовок ищется в первой непустой строке: Task 9 — тест на синтетическом xlsx с 3 пустыми строками в хвосте.
- ИНН в xlsx как число (
3528052718.0, научная нотация) — openpyxl отдаётint/float; нормализация переводит в строку без.0: Task 9 — тест. - csv в cp1251 (типичный экспорт Excel) —
UnicodeDecodeError→ 422UNSUPPORTED_FILEс сообщением «ожидается UTF-8», не 500: Task 10 — тест. - Повторный
dry_runодного файла не должен расходовать последовательности кодов заметно для пользователя (коды с дырами допустимы, но не падение): Task 10 — тест, что второйdry_runвозвращает тот же отчёт. - Администратор деактивирует самого себя —
PATCH is_active=falseна себе → 409LAST_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 строк создавалась один раз:
type_raw.lower()∉ {«юл», «ип»} →UNSUPPORTED_TYPE(errors += 1;# ASSUMPTION(OQ-87)), строка завершена.inn = normalize_inn(...); длина ≠ 10 (ЮЛ) / 12 (ИП) или не цифры →INVALID_INN(# ASSUMPTION(OQ-88)).- ИНН уже встречался в файле →
DUPLICATE_IN_FILE. - Офис:
office_nameпусто →office_id=None; есть в снимке/накопителе → id; иначеCreateOffice+ событиеRF_CREATED(offices_created += 1; строка события —RowResult(row, inn, RF_CREATED, "создан филиал «…»");# ASSUMPTION(OQ-89)). - ГК по
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). - ЮЛ по ИНН: нет →
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)), строка завершена без членств. - Флаги:
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_rowscsv;и,, utf-8-sig, перестановка колонок, лишняя колонка; без «Тип» →FileFormatError.code == "MISSING_COLUMNS",missing == ["Тип"];.txt→UNSUPPORTED_FILE; xlsx, собранныйopenpyxl.Workbook()с заголовком во 2-й строке, ИНН числом, 3 пустыми строками в хвосте (Review Focus 1–2) → 2 строки данных.plan_importна синтетических строках, по одному тесту на каждый код (параметризация: строка → ожидаемый итоговый код и события), пустой снимок и снимок с существующими;updatevsskip; две строки одной ГК → один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 == 18, ровно одна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«Санкт-Петербургский РФ», 18 ЮЛ, 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 → 422UNSUPPORTED_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 == 345; таблицы после теста не выросли. -
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(includeadmin.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]— diffuser_role; неизвестный/неактивный код → 422UNKNOWN_ROLE; если после замены у активных пользователей никто не имеетUSERS_ADMIN→ 409LAST_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; неизвестный код права → 422UNKNOWN_PERMISSION; если убираетсяUSERS_ADMINиcount_active_users_with_permission("USERS_ADMIN", excluding_role=role.id) == 0→ 409LAST_ADMIN; diffrole_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 в другом регистре → 409EMAIL_EXISTS;PUT roles[KA_GO, EXPERT]→ ровно две строки, аудит before/after; единственный админ снимает с себяADMIN→ 409LAST_ADMIN; второй админ создан → снятие разрешено;PATCH is_active=falseна себе как последнем → 409; матрица:EXPERT+EXPORT→role_permissionстрока,/meэкспертом видитEXPORT, аудитROLE_PERMISSION_CHANGE; убратьUSERS_ADMINуADMIN→ 409LAST_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 (Inputemail +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; antdTable(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; ручной сценарий: вход админом, создать пользователя с ролью, сменить роль, снять галочку в матрице и сохранить; убедиться, что ошибка 409LAST_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.