chore: план исполнения Среза 1 (две фазы) и рулинг R-1 в design.md

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
This commit is contained in:
Andrey Epifancev 2026-10-04 20:37:47 +04:00
parent 39a7affc48
commit 44806c8403
3 changed files with 687 additions and 0 deletions

View File

@ -0,0 +1,418 @@
# Срез 1 — справочники arm-core — план исполнения, фаза 1 (модель v5.14, инфраструктура запросов, policy, API справочников)
> **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/` — proposal.md, design.md (D-1…D-11), specs/{directories-api,directory-import,directory-policy,users-admin,data-model,audit-trail}/spec.md. Правила предметной области: `../docs/context/05-domain-rules.md` §3 (нормализация алиаса), §10/§10.1 (права и видимость), §13 (импорт и ручные операции). Модель: `../docs/model/data-model-v5.14.dbml`. Спека — единственный источник истины при рулингах; план сгенерирован из `tasks.md` change'а (номера в скобках — номера задач tasks.md; по завершении задачи отметить `- [x]` и там).
**Goal:** после фазы 1 ядро умеет вести ГК, ЮЛ/ИП с историей и алиасами, ГСК/ГСЗ с составом через `/api/v1/directories/*` с правами, политикой видимости по филиалу и аудитом; схема БД — DBML v5.14 (миграция 0002).
**Architecture:** FastAPI-эндпоинты → сервисы (одна транзакция на запрос: сессия коммитится при успехе) → репозитории с фильтром видимости → ORM. Policy — чистые функции `src/domain/policy.py` над `Select`. Все мутации пишут историю (SCD4/SCD2) и `audit_event` через `AuditService` в той же транзакции. Доменные ошибки → `{"detail": {"code", "message", …}}` с 404/409/422.
**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 async, Alembic, PostgreSQL 16, pydantic v2, pytest-asyncio, httpx.
## Global Constraints
- Имена таблиц/колонок/enum'ов — ровно как в `../docs/model/data-model-v5.14.dbml` (43 таблицы, 27 enum, схема `dkb`); полей вне DBML нет. `related_group_member.kind` NOT NULL и равен `related_group.kind` группы (проверяет сервис). Новые значения `audit_action`: `LEGAL_ENTITY_CHANGE` (после `GROUP_CHANGE`), `USER_CHANGE` (после `USER_ROLE_CHANGE`) — порядок как в DBML (сверка enum'ов сравнивает упорядоченный список).
- Права — данные: маршруты защищены `require("<CODE>")`, код роли нигде не читается. Policy (§10.1): ГО (`regional_office.is_head_office`) видит всё; филиал — ГК с `base_regional_office_id` своего филиала, их ЮЛ/ГСК и монозаёмщиков своего филиала; `business_segment` не используется (ASSUMPTION(OQ-86)). Объект вне видимости → **404** с тем же телом, что и несуществующий.
- Все мутации: одна транзакция, `audit_event` (`user_id` инициатора, `before_value`/`after_value`, `ip_address`, `user_agent`) через `AuditService.record` — append-only (тест `test_audit_service.py` сканирует `src/` на UPDATE/DELETE по `AuditEvent`; не добавлять).
- Ошибки: `{"detail": {"code": "...", "message": "...по-русски"}}` для доменных 404/409/422; существующие ответы Среза 0 (401/403 строкой) не меняются. Списки — `{items, total, limit, offset}`, `limit` ≤ 200 (по умолчанию 50), `offset` ≥ 0, сортировка по имени.
- Коды ГК/ГСК/ГСЗ — `GK-NNNNNN` / `GSK-NNNNNN` / `GSZ-NNNNNN` (6 цифр с нулями) из последовательностей PostgreSQL `dkb.company_group_code_seq`, `dkb.related_group_code_seq` (D-5).
- История (D-6): при изменении ГК — снимок прежних значений в `company_group_history` (`valid_from` прежний, `valid_to = now`), у ГК `valid_from = now`; ЮЛ — то же в `legal_entity_history` с `event_type`; при создании история не пишется. Членство ГСК/ГСЗ — SCD2: исключение = `valid_to = now, is_current = false`; повторное включение — новая строка.
- Деньги не затрагиваются; `Decimal` в домене, никаких float.
- Линтеры: ruff (line-length 120, E,F,I,UP,B,PL — `PLR0913` > 5 аргументов решается `# noqa: PLR0913 - причина` или dataclass'ом параметров; `PLR2004` — именованные константы) + mypy (`src.models.*`, `src.domain.*` — strict-флаги). Тесты: pytest-asyncio auto, маркер `integration` для БД, `fixtures` для реальных данных; БД-тесты — на `db_session` (SAVEPOINT, откат после теста).
- Все команды — из `api/` (`make` — из корня репозитория); Python 3.11; venv `api/.venv` (`make venv` идемпотентен). PostgreSQL из compose `../docs/dev` уже поднят (`make up`), контейнеры не перезапускать.
- Язык комментариев/коммитов — русский, идентификаторы — английские. Коммит заканчивается строками `Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>` (или фактическая модель исполнителя) и `Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2`. Не пушить, не создавать PR, не запускать `openspec archive`.
- Допущения в коде — комментарием `# ASSUMPTION(OQ-nn)`: OQ-34 (удаление только пустой ГСК/ГСЗ), OQ-86 (ЗО не фильтрует), OQ-88 (ИНН — только длина), OQ-21 (`effective_date` ≥ сегодня).
## Review Focus
Случаи, которых спека не называет явно, но которые ударят по пользователю; тест на каждый добавлен в задачу-владельца:
1. **`PATCH` без изменений** (тело с теми же значениями) — не должен плодить строку истории и аудит: Task 5 (ГК) и Task 6 (ЮЛ) — тест «no-op PATCH не пишет историю».
2. **Фильтр `inn` с пробелами / ИНН с ведущими нулями** — `inn` хранится строкой, сравнение точное после `strip()`; ИНН `0123456789` валиден: Task 6 — тест.
3. **Пользователь филиала переводит ЮЛ в ГК чужого филиала** — целевая ГК вне scope → 404 (не 422, не утечка): Task 6 — тест.
4. **Добавление в ГСК закрытого ЮЛ (`close_date` заполнен, `company_group_id = NULL`)** — 422 `DIFFERENT_COMPANY_GROUP` (ЮЛ без ГК не в той же ГК): Task 8 — тест.
5. **`offset` больше `total`** — пустой `items`, `total` настоящий, 200: Task 5 — тест списка ГК.
---
## Задачи
### Task 1: [1.1] ORM v5.14, миграция `0002_dbml_v5_14`, версия DBML
**Files:**
- Modify: `api/src/models/directories.py` (RelatedGroupMember, последовательности), `api/src/models/enums.py` (AuditAction), `api/src/models/__init__.py:10` (`DBML_VERSION`), `api/src/models/base.py:9` (докстринг v5.14)
- Create: `api/alembic/versions/0002_dbml_v5_14.py`
- Modify: `Makefile:6` (`DBML ?= ../../docs/model/data-model-v5.14.dbml`), `api/scripts/dbml_parity.py:59` (`DBML_FILE_NAME = "data-model-v5.14.dbml"`), `api/tests/test_migrations.py:31` (`HEAD = "0002_dbml_v5_14"`), `api/tests/test_enums.py:18` (`"5.14"`), `api/tests/test_check_dbml_script.py:145-154` (фиктивная версия `v5.15`, сообщение `версия DBML 5.15 не совпадает с версией ORM 5.14`), `api/tests/test_models_part_01_03.py::_check_related_group_member_uniqueness`
- Test: `api/tests/test_models_part_01_03.py`, `api/tests/test_migrations.py`
**Interfaces:**
- Produces: `RelatedGroupMember.kind: Mapped[RelatedGroupKind]` (колонка между `legal_entity_id` и `valid_from`, как в DBML); `COMPANY_GROUP_CODE_SEQ = Sequence("company_group_code_seq", schema=SCHEMA, metadata=Base.metadata)`, `RELATED_GROUP_CODE_SEQ = Sequence("related_group_code_seq", …)` в `src/models/directories.py`; `AuditAction.LEGAL_ENTITY_CHANGE`, `AuditAction.USER_CHANGE`.
- [ ] **Step 1: Обновить тест уникальности членства (падает на старой схеме)**
В `_check_related_group_member_uniqueness` (`tests/test_models_part_01_03.py`): INSERT в `related_group_member` получает колонку `kind`; сценарий: `g1` (gsk) текущее — ок; `g3` (gsz, создать третью группу) текущее для того же ЮЛ — **ок** (v5.14); `g2` (gsk) текущее — `IntegrityError`; закрытая версия в `g2` — ок. Докстринг: «ЮЛ — в одной ГСК и одной ГСЗ одновременно (v5.14, решение владельца 04.10.2026)».
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `cd api && .venv/bin/pytest -m integration tests/test_models_part_01_03.py -k related_group_member -v`
Expected: FAIL (`column "kind" does not exist` или IntegrityError на gsz).
- [ ] **Step 3: ORM**
`src/models/directories.py`:
```python
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Sequence, String, text
from .base import SCHEMA, Base, UUIDPrimaryMixin
#: Последовательности кодов ГК и ГСК/ГСЗ (design D-5): `GK-000001`, `GSK-000001`/`GSZ-000001`. В DBML не описываются
#: (не таблицы); создаются миграцией 0002 и `Base.metadata.create_all` (привязаны к metadata).
COMPANY_GROUP_CODE_SEQ = Sequence("company_group_code_seq", schema=SCHEMA, metadata=Base.metadata)
RELATED_GROUP_CODE_SEQ = Sequence("related_group_code_seq", schema=SCHEMA, metadata=Base.metadata)
```
В `RelatedGroupMember.__table_args__` заменить второй индекс:
```python
# (legal_entity_id, kind): partial UNIQUE WHERE is_current — v5.14: ЮЛ в одной ГСК И одной ГСЗ одновременно
Index(
"uq_related_group_member_entity_kind_current",
"legal_entity_id",
"kind",
unique=True,
postgresql_where=text("is_current"),
),
```
и добавить колонку после `legal_entity_id`:
```python
kind: Mapped[RelatedGroupKind] = mapped_column(pg_enum("related_group_kind"), nullable=False)
```
`src/models/enums.py`: `LEGAL_ENTITY_CHANGE = "LEGAL_ENTITY_CHANGE"` сразу после `GROUP_CHANGE`, `USER_CHANGE = "USER_CHANGE"` сразу после `USER_ROLE_CHANGE`. `src/models/__init__.py`: `DBML_VERSION = "5.14"`. Докстринги модулей `models/*.py`, `scripts/check_dbml_vs_orm.py`: «v5.13» → «v5.14» там, где речь о текущей версии.
- [ ] **Step 4: Миграция 0002**
`api/alembic/versions/0002_dbml_v5_14.py`:
```python
"""DBML v5.14: related_group_member.kind + partial unique (legal_entity_id, kind), audit_action += LEGAL_ENTITY_CHANGE,
USER_CHANGE, последовательности кодов ГК/ГСК (design D-1, D-5).
`ALTER TYPE … ADD VALUE` необратим — downgrade пересоздаёт тип audit_action со значениями v5.13 (строк с новыми
значениями в audit_event быть не должно; иначе downgrade падает на USING — это осознанно).
Revision ID: 0002_dbml_v5_14
Revises: 0001_dbml_v5_13
Create Date: 2026-10-04
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.schema import CreateSequence, DropSequence
from alembic import op
revision: str = "0002_dbml_v5_14"
down_revision: str | Sequence[str] | None = "0001_dbml_v5_13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
SCHEMA = "dkb"
TABLE = "related_group_member"
OLD_INDEX = "uq_related_group_member_entity_current"
NEW_INDEX = "uq_related_group_member_entity_kind_current"
SEQUENCES = ("company_group_code_seq", "related_group_code_seq")
AUDIT_ACTION_V5_13 = (
"LOGIN", "LOGIN_FAILED", "LOGOUT", "CONSOLIDATION_CREATE", "CONSOLIDATION_STATUS_CHANGE", "CONSOLIDATION_APPROVE",
"CONSOLIDATION_VERIFY", "GROUP_CHANGE", "MEMBERSHIP_CHANGE", "ASSIGNMENT_CHANGE", "FILE_UPLOAD", "FILE_DELETE",
"FILE_REPLACE", "DOCUMENT_CONFIRM", "CONSOLIDATION_LOCK", "CONSOLIDATION_LOCK_TAKEOVER", "DISCLOSURE_EDIT",
"RECOGNIZED_DATA_EDIT", "OSV_COUNTERPARTY_MAP", "OSV_TYPE_OVERRIDE", "CORRECTION_CREATE", "CORRECTION_EDIT",
"CORRECTION_REVOKE", "GOODWILL_EDIT", "EXPORT", "DIRECTORY_IMPORT", "USER_ROLE_CHANGE", "ROLE_PERMISSION_CHANGE",
)
def upgrade() -> None:
kind = postgresql.ENUM(name="related_group_kind", schema=SCHEMA, create_type=False)
op.add_column(TABLE, sa.Column("kind", kind, nullable=True), schema=SCHEMA)
op.execute(
f"UPDATE {SCHEMA}.{TABLE} m SET kind = g.kind FROM {SCHEMA}.related_group g WHERE g.id = m.related_group_id"
)
op.alter_column(TABLE, "kind", nullable=False, schema=SCHEMA)
op.drop_index(OLD_INDEX, table_name=TABLE, schema=SCHEMA)
op.create_index(
NEW_INDEX, TABLE, ["legal_entity_id", "kind"], unique=True, schema=SCHEMA, postgresql_where=sa.text("is_current")
)
# порядок значений — как в DBML v5.14 (сверка enum'ов сравнивает упорядоченные списки)
op.execute(f"ALTER TYPE {SCHEMA}.audit_action ADD VALUE IF NOT EXISTS 'LEGAL_ENTITY_CHANGE' AFTER 'GROUP_CHANGE'")
op.execute(f"ALTER TYPE {SCHEMA}.audit_action ADD VALUE IF NOT EXISTS 'USER_CHANGE' AFTER 'USER_ROLE_CHANGE'")
for name in SEQUENCES:
op.execute(CreateSequence(sa.Sequence(name, schema=SCHEMA)))
def downgrade() -> None:
for name in reversed(SEQUENCES):
op.execute(DropSequence(sa.Sequence(name, schema=SCHEMA)))
op.drop_index(NEW_INDEX, table_name=TABLE, schema=SCHEMA)
op.create_index(OLD_INDEX, TABLE, ["legal_entity_id"], unique=True, schema=SCHEMA, postgresql_where=sa.text("is_current"))
op.drop_column(TABLE, "kind", schema=SCHEMA)
op.execute(f"ALTER TYPE {SCHEMA}.audit_action RENAME TO audit_action_old")
postgresql.ENUM(*AUDIT_ACTION_V5_13, name="audit_action", schema=SCHEMA).create(op.get_bind(), checkfirst=False)
op.execute(
f"ALTER TABLE {SCHEMA}.audit_event ALTER COLUMN action TYPE {SCHEMA}.audit_action "
f"USING action::text::{SCHEMA}.audit_action"
)
op.execute(f"DROP TYPE {SCHEMA}.audit_action_old")
```
`alembic/env.py` менять не нужно. В `tests/test_migrations.py`: `HEAD = "0002_dbml_v5_14"`; добавить тест `test_downgrade_one_step` (сценарий спеки «Откат на один шаг»): `upgrade head` → `downgrade 0001_dbml_v5_13` → в `information_schema.columns` нет `related_group_member.kind`, в `pg_indexes` есть `uq_related_group_member_entity_current`, `enum_range(NULL::dkb.audit_action)` без `LEGAL_ENTITY_CHANGE` → `upgrade head` снова. Если `test_migrated_schema_equals_orm_create_all` сравнивает последовательности — они теперь есть и в metadata, и в миграции.
- [ ] **Step 5: Пути и версии**
`Makefile:6` → `data-model-v5.14.dbml`; `scripts/dbml_parity.py:59` → `"data-model-v5.14.dbml"`; `tests/test_enums.py` → `"5.14"`; `tests/test_check_dbml_script.py` — фиктивный файл `v5.15`, ожидаемое сообщение с `5.15`/`5.14`.
- [ ] **Step 6: Проверить**
Run: `cd api && .venv/bin/ruff check . && .venv/bin/ruff format --check . && .venv/bin/mypy src scripts && .venv/bin/pytest -m "not integration"` → PASS.
Run: `make dbml-check` (из корня) → пустой вывод, код 0.
Run: `cd api && .venv/bin/pytest -m integration tests/test_migrations.py tests/test_models_part_01_03.py -v` → PASS (цикл 0001→0002→base→head, пустой autogenerate, снимок схемы = create_all, откат на шаг, уникальность gsk+gsz).
- [ ] **Step 7: Commit**
`git add -A && git commit -m "feat(model): DBML v5.14 — related_group_member.kind, audit_action +2, миграция 0002, последовательности кодов"` (+ трейлеры).
---
### Task 2: [1.2] Единица работы, аудит с контекстом запроса, доменные исключения, пагинация
**Files:**
- Modify: `api/src/db/db.py` (`get_async_session` — commit при успехе, см. R-1 ниже)
- Create: `api/src/api/dependencies/audit.py`, `api/src/schemas/common.py`
- Modify: `api/src/common/exceptions.py`, `api/src/core/exception_handlers.py`
- Test: `api/tests/test_uow_and_errors.py` (unit + integration)
**Interfaces:**
- Produces:
- `get_async_session()` — та же зависимость, что в Срезе 0, но тело `async with async_session_maker() as session: async with session.begin(): yield session` — commit при нормальном завершении запроса, rollback при любом исключении (в т.ч. `HTTPException`, доменных). **R-1 (design #12):** вместо отдельной `get_uow_session` из D-2 используется одна сессия на запрос — `current_user` и мутации работают в одной транзакции и одном соединении; цена ошибки — read-only маршруты коммитят пустую транзакцию (безвредно).
- `class DomainError(Exception)`: поля `status: int`, `code: str`, `message: str`, `extra: dict[str, Any]`; подклассы `NotFoundError(code="NOT_FOUND", message=…)` (404), `ConflictError(code, message, **extra)` (409), `DomainValidationError(code, message, **extra)` (422). Конструкторы: `NotFoundError(message: str = "объект не найден")`, `ConflictError(code: str, message: str, **extra: Any)`, `DomainValidationError(code: str, message: str, **extra: Any)`. Старый `CouldntCreateError` удалить (не используется).
- Обработчик `DomainError` → `JSONResponse(status, {"detail": {"code": code, "message": message, **extra}})` (значения `extra` через `jsonable_encoder`).
- `RequestAudit` (`src/api/dependencies/audit.py`): `@dataclass(frozen=True) class RequestAudit: service: AuditService; user_id: uuid.UUID; ip_address: str | None; user_agent: str | None` с методом `async def record(self, *, action, section, object_type, object_id=None, description=None, before_value=None, after_value=None, comment=None, result=None, changed_field=None) -> AuditEvent` (делегирует `AuditService.record`, подставляя `user_id`/`ip_address`/`user_agent`). Зависимость `async def get_request_audit(request: Request, session: Annotated[AsyncSession, Depends(get_async_session)], principal: Annotated[Principal, Depends(current_user)]) -> RequestAudit` (`ip_address = request.client.host if request.client else None`, `user_agent = request.headers.get("user-agent")`).
- `src/schemas/common.py`: `class Page(BaseModel, Generic[T]): items: list[T]; total: int; limit: int; offset: int`; `class PageParams(BaseModel): limit: int = Field(50, ge=1, le=200); offset: int = Field(0, ge=0)` — используется как `Annotated[PageParams, Query()]`; `class ErrorDetail(BaseModel): code: str; message: str` (для OpenAPI `responses`).
- [ ] **Step 1: Тесты**
`tests/test_uow_and_errors.py`:
- unit: мини-`FastAPI()` с `register_exception_handlers`, маршруты бросают `NotFoundError("ГК не найдена")`, `ConflictError("INN_EXISTS", "ИНН уже есть", legal_entity_id=uuid)`, `DomainValidationError("INVALID_INN", "…")` → 404/409/422 и тело `{"detail": {"code", "message", ...}}`; `HTTPException(403, "x")` по-прежнему `{"detail": "x"}`.
- integration (`scratch_db_url` + `alembic upgrade head` через `tests.pg_helpers.alembic_async`, затем свой `async_sessionmaker` на этот URL с переопределением `src.db.db.async_session_maker` через `monkeypatch`): приложение с двумя маршрутами на `Depends(get_async_session)` — `POST /ok` вставляет `regional_office` и возвращает 201; `POST /fail` вставляет и бросает `ConflictError`. После `/ok` строка видна из **другой** сессии; после `/fail` — нет. Это единственный тест, который проверяет реальный commit (SAVEPOINT-сессия тестов его не ловит).
- `Page[RegionalOfficeOut]` сериализуется; `PageParams(limit=201)` → ValidationError.
- [ ] **Step 2: Запустить — падает (нет модулей/обработчика)**
- [ ] **Step 3: Реализация** по интерфейсам выше. В `exception_handlers.py` обработчик `DomainError` регистрируется **до** `Exception`. `src/db/db.py`:
```python
async def get_async_session() -> AsyncIterator[AsyncSession]:
"""Сессия запроса = одна транзакция: commit при успешном ответе, rollback при любом исключении (R-1, design #12)."""
async with async_session_maker() as session, session.begin():
yield session
```
Проверить, что `src/api/endpoints/dev_auth.py` и обмен (`services/recognition_exchange.py`) не используют `get_async_session` с собственным `commit()` (они работают через `async_session_maker` напрямую — не трогать).
- [ ] **Step 4: Тесты зелёные**: `pytest tests/test_uow_and_errors.py -v`, затем `pytest -m "not integration"` и `pytest -m integration tests/test_me_and_dev_token.py tests/test_auth_deps.py` (регрессия Среза 0).
- [ ] **Step 5: Commit** `feat(api): транзакция на запрос, RequestAudit, доменные ошибки 404/409/422, Page`.
---
### Task 3: [1.3] Фабрики справочников и `auth_client` в conftest
**Files:**
- Modify: `api/tests/factories.py`, `api/tests/conftest.py`, `api/tests/test_me_and_dev_token.py` (перевести на общие фикстуры, поведение тестов не менять)
**Interfaces:**
- Produces (`tests/factories.py`, все `flush`, без `commit`):
- `async def make_regional_office(session, *, name: str | None = None, is_head_office: bool = False) -> RegionalOffice`
- `async def make_company_group(session, *, name="ГК Тест", office: RegionalOffice | None = None, crm_id: str | None = None, code: str | None = None) -> CompanyGroup` (`code` по умолчанию `f"GK-T{uuid4().hex[:6]}"`, `valid_from=now`)
- `async def make_legal_entity(session, *, name="ООО Тест", inn: str | None = None, entity_type=EntityType.ЮЛ, group: CompanyGroup | None = None, office: RegionalOffice | None = None, close_date=None) -> LegalEntity` (`inn` по умолчанию 10 случайных цифр; `regional_office_id` = офис группы, если не задан)
- `async def make_related_group(session, *, group: CompanyGroup, kind=RelatedGroupKind.gsk, name="ГСК Тест") -> RelatedGroup`
- `async def make_membership(session, *, related_group: RelatedGroup, entity: LegalEntity, is_current=True) -> RelatedGroupMember` (`kind = related_group.kind`)
- `async def make_user(session, *, email: str, office: RegionalOffice, roles: Sequence[str] = (), full_name="Тест Тестов", is_active=True) -> AppUser` (роли — по `Role.code` из seed; `UserRole` строки)
- Produces (`tests/conftest.py`):
- `seeded(db_session)` — `await run_seed(db_session, dev=True); await db_session.flush()`.
- `app(db_session) -> FastAPI` — `build_api_router(settings)` с `DEBUG=True, AUTH_DEV_ISSUER=True`, `register_exception_handlers`, `app.state.token_verifier = build_verifier(settings)`, `app.state.storage = None`, override `get_async_session`:
```python
async def override_session() -> AsyncIterator[AsyncSession]:
async with db_session.begin_nested(): # как транзакция запроса: ошибка → откат до SAVEPOINT
yield db_session
```
- `auth_client(app, seeded) -> AuthClientFactory` где `AuthClientFactory = Callable[[str], Awaitable[httpx.AsyncClient]]`: по email запрашивает `POST /api/v1/dev/token`, возвращает клиент с `headers={"Authorization": f"Bearer {token}"}` и `base_url="http://test/api/v1"`; клиенты закрываются в teardown (`AsyncExitStack`). Dev-пользователи seed: `ka_rf@armka.local` (Ростовский РФ), `rka@armka.local` (Ростовский РФ), `client_manager@armka.local` (Краснодарский РФ), `ka_go@armka.local`, `curator_go@armka.local`, `expert@armka.local`, `admin@armka.local` (ГО).
- [ ] **Step 1:** написать фабрики и фикстуры; `test_me_and_dev_token.py` перевести на `app`/`auth_client` там, где он строит приложение сам (тесты сочетаний флагов `DEBUG`/`AUTH_DEV_ISSUER` оставить на локальном `_build_app`).
- [ ] **Step 2:** `pytest -m integration tests/test_me_and_dev_token.py -v` → PASS; `ruff`, `mypy` чисты.
- [ ] **Step 3: Commit** `test: фабрики справочников, фикстуры app/auth_client/seeded`.
---
### Task 4: [2.1] Policy видимости — `src/domain/policy.py`
**Files:**
- Create: `api/src/domain/__init__.py` (докстринг: «чистые функции по §§ правил, без I/O»), `api/src/domain/policy.py`
- Test: `api/tests/test_domain_policy.py` (без БД)
**Interfaces:**
- Produces (`src/domain/policy.py`, §10.1):
```python
@dataclass(frozen=True)
class VisibilityScope:
"""Область видимости справочников: `office_id is None` — вся система (ГО)."""
office_id: uuid.UUID | None
@property
def is_global(self) -> bool: ...
def scope_for(*, is_head_office: bool, regional_office_id: uuid.UUID) -> VisibilityScope
def scope_of(principal: "UserWithAccess") -> VisibilityScope # обёртка: scope_for(is_head_office=principal.regional_office.is_head_office, regional_office_id=principal.user.regional_office_id)
def company_groups_visible(stmt: Select[Any], scope: VisibilityScope) -> Select[Any] # WHERE CompanyGroup.base_regional_office_id == office
def legal_entities_visible(stmt: Select[Any], scope: VisibilityScope) -> Select[Any] # outerjoin CompanyGroup; WHERE group.base_office == office OR (le.company_group_id IS NULL AND le.regional_office_id == office)
def company_group_is_visible(group: CompanyGroup, scope: VisibilityScope) -> bool
def legal_entity_is_visible(entity: LegalEntity, group_office_id: uuid.UUID | None, scope: VisibilityScope) -> bool
def office_for_create(scope: VisibilityScope, requested: uuid.UUID | None) -> uuid.UUID | None
# ГО: requested как есть; филиал: None → свой офис; чужой → DomainValidationError("OFFICE_NOT_ALLOWED", "ГК можно закрепить только за своим филиалом")
```
`scope_of` импортирует `UserWithAccess` только под `TYPE_CHECKING`, чтобы `domain/` не тянул репозитории.
- [ ] **Step 1: Тесты** (`tests/test_domain_policy.py`, без БД): `scope_for(is_head_office=True, …).is_global`; филиал → `office_id`; `company_groups_visible` для global возвращает тот же `stmt` (compile без WHERE), для филиала — `str(stmt)` содержит `base_regional_office_id`; `legal_entities_visible` содержит `OR` и `IS NULL`; `company_group_is_visible` — 4 комбинации; `legal_entity_is_visible` — монозаёмщик своего/чужого филиала, ЮЛ в ГК своего/чужого; `office_for_create` — 4 ветви, `DomainValidationError.code == "OFFICE_NOT_ALLOWED"`.
- [ ] **Step 2:** падает → **Step 3:** реализация → **Step 4:** `pytest tests/test_domain_policy.py -v`, `mypy src` (strict для `src.domain.*`) → PASS.
- [ ] **Step 5: Commit** `feat(domain): policy видимости справочников (§10.1, ASSUMPTION(OQ-86))`.
---
### Task 5: [2.2] Филиалы и ГК: репозиторий, сервис, схемы, эндпоинты
**Files:**
- Create: `api/src/repositories/directories.py`, `api/src/services/company_groups.py`, `api/src/schemas/directories.py`, `api/src/api/endpoints/directories/__init__.py`, `api/src/api/endpoints/directories/regional_offices.py`, `api/src/api/endpoints/directories/company_groups.py`, `api/src/api/dependencies/directories.py`
- Modify: `api/src/api/router.py` (include `directories.router` — префикс `/directories`, теги)
- Test: `api/tests/test_api_company_groups.py`, `api/tests/test_api_policy.py`
**Interfaces:**
- `src/api/dependencies/directories.py`:
- `def get_scope(principal: Annotated[Principal, Depends(current_user)]) -> VisibilityScope` (= `scope_of(principal)`)
- `def get_company_group_service(session, audit: Depends(get_request_audit), scope: Depends(get_scope)) -> CompanyGroupService`
- `src/repositories/directories.py` (функции, как `repositories/users.py`):
- `async def next_code(session, seq: Sequence, prefix: str) -> str` → `f"{prefix}-{n:06d}"` через `select(seq.next_value())`
- `async def list_company_groups(session, scope, *, q: str | None, is_archived: bool | None, limit, offset) -> tuple[list[tuple[CompanyGroup, int]], int]` (ГК + `legal_entities_count` через подзапрос `count(legal_entity.id)`, `total` отдельным `count()`; сортировка `CompanyGroup.name`)
- `async def get_company_group(session, scope, group_id) -> CompanyGroup | None` (None и для невидимой)
- `async def list_company_group_history(session, group_id) -> list[CompanyGroupHistory]`
- `async def get_regional_office(session, office_id) -> RegionalOffice | None`, `async def list_regional_offices(session) -> list[RegionalOffice]`
- `src/services/company_groups.py`:
```python
class CompanyGroupService:
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None
async def create(self, data: CompanyGroupCreate) -> CompanyGroup # code из COMPANY_GROUP_CODE_SEQ; office_for_create; crm_id pre-check → ConflictError("CRM_ID_EXISTS", …); valid_from=created_at=now; updated_by=user; audit GROUP_CHANGE after_value={code,name,crm_id,base_regional_office_id}
async def update(self, group_id: uuid.UUID, data: CompanyGroupUpdate) -> CompanyGroup # NotFoundError; diff полей; нет изменений → вернуть без истории/аудита; иначе снимок CompanyGroupHistory(prev values, valid_from=group.valid_from, valid_to=now, changed_by=user), group.valid_from=now, updated_*; action = ASSIGNMENT_CHANGE если менялся base_regional_office_id, иначе GROUP_CHANGE; changed_field — имя поля при одном изменении; before/after — только изменённые поля
async def set_archived(self, group_id: uuid.UUID, archived: bool) -> CompanyGroup # GROUP_CHANGE changed_field="is_archived"
async def get(self, group_id) -> CompanyGroup # NotFoundError("ГК не найдена")
async def history(self, group_id) -> list[CompanyGroupHistory]
async def page(self, params: CompanyGroupFilter, page: PageParams) -> tuple[list[tuple[CompanyGroup, int]], int]
```
Константа сообщения `COMPANY_GROUP_NOT_FOUND = "ГК не найдена"` — одно и то же для чужой и несуществующей (spec directory-policy).
- `src/schemas/directories.py`: `RegionalOfficeOut(id, name, is_head_office, is_active)`; `CompanyGroupCreate(name: str = Field(min_length=1, max_length=500), crm_id: str | None = None, base_regional_office_id: uuid.UUID | None = None)`; `CompanyGroupUpdate` — те же поля, все `Optional`, `model_config = ConfigDict(extra="forbid")`, семантика «поле передано» через `model_fields_set`; `CompanyGroupOut(id, code, crm_id, name, base_regional_office_id, is_archived, valid_from, legal_entities_count: int = 0, created_at, updated_at)`; `CompanyGroupHistoryOut(id, crm_id, name, base_regional_office_id, valid_from, valid_to, changed_by)`; `CompanyGroupFilter(q: str | None = None, is_archived: bool | None = None)`.
- Эндпоинты (`/api/v1/directories/...`, все с `Depends(require("DIRECTORY_VIEW"|"DIRECTORY_EDIT"))`):
`GET /regional-offices` → `list[RegionalOfficeOut]`; `GET /company-groups` → `Page[CompanyGroupOut]`; `POST /company-groups` → 201; `GET /company-groups/{id}`; `PATCH /company-groups/{id}`; `POST /company-groups/{id}/archive`, `/unarchive`; `GET /company-groups/{id}/history`.
- [ ] **Step 1: Тесты** (`tests/test_api_company_groups.py`, `@pytest.mark.integration`, `auth_client`):
1. `ka_go` создаёт две ГК → коды `GK-` + 6 цифр, различаются; в `audit_event` две `GROUP_CHANGE` с `after_value["code"]`.
2. `PATCH base_regional_office_id` (на Краснодарский РФ по id из `/regional-offices`) → `company_group_history` 1 строка с прежним филиалом и `valid_to`; `valid_from` ГК изменился; `audit_event(ASSIGNMENT_CHANGE)` с `before_value`/`after_value`.
3. Дубликат `crm_id` → 409 `{"detail": {"code": "CRM_ID_EXISTS", ...}}`.
4. **no-op PATCH** (то же имя) → 200, история пуста, аудита нет (Review Focus 1).
5. `expert` (`DIRECTORY_VIEW` только) → `POST` 403, аудита нет.
6. Список: `q` по подстроке имени без регистра, `is_archived`, `offset > total` → `items == []`, `total` верный (Review Focus 5); `limit=201` → 422.
7. `archive`/`unarchive` переключают `is_archived`, пишут `GROUP_CHANGE changed_field="is_archived"`.
`tests/test_api_policy.py` (сценарии spec directory-policy):
1. ГК А (Ростовский РФ) и ГК Б (Краснодарский РФ) через фабрики; `ka_rf` видит только А (`total == 1`); `ka_go` — обе.
2. `ka_rf` `GET /company-groups/{Б}` → 404, тело совпадает с `GET /company-groups/{uuid4()}`.
3. `ka_rf` создаёт ГК без филиала → `base_regional_office_id` = Ростовский; с чужим → 422 `OFFICE_NOT_ALLOWED`.
- [ ] **Step 2:** падает → **Step 3:** реализация → **Step 4:** `pytest -m integration tests/test_api_company_groups.py tests/test_api_policy.py -v`; `ruff`, `mypy` → PASS.
- [ ] **Step 5: Commit** `feat(directories): филиалы и ГК — API, история, аудит, видимость`.
---
### Task 6: [2.3] ЮЛ/ИП: CRUD, перевод, вывод из состава, история
**Files:**
- Create: `api/src/domain/inn.py`, `api/src/services/legal_entities.py`, `api/src/api/endpoints/directories/legal_entities.py`
- Modify: `api/src/repositories/directories.py`, `api/src/schemas/directories.py`, `api/src/api/dependencies/directories.py`, `api/src/api/endpoints/directories/__init__.py`
- Test: `api/tests/test_domain_inn.py`, `api/tests/test_api_legal_entities.py`
**Interfaces:**
- `src/domain/inn.py`: `INN_LENGTH = {EntityType.ЮЛ: 10, EntityType.ИП: 12}`; `def normalize_inn(raw: str) -> str` (убрать пробелы); `def validate_inn(inn: str, entity_type: EntityType) -> str` → нормализованный ИНН или `DomainValidationError("INVALID_INN", f"ИНН {entity_type.value} — ровно {n} цифр")`; `# ASSUMPTION(OQ-88): контрольные разряды не проверяются`.
- Репозиторий: `list_legal_entities(session, scope, *, company_group_id, outside_group: bool | None, inn: str | None, q: str | None, closed: bool | None, limit, offset) -> tuple[list[LegalEntity], int]`; `get_legal_entity(session, scope, entity_id) -> LegalEntity | None` (через `legal_entities_visible`); `get_legal_entity_by_inn(session, inn) -> LegalEntity | None` (без scope — для pre-check уникальности); `list_legal_entity_history(session, entity_id)`; `current_memberships(session, entity_id) -> list[RelatedGroupMember]`; `close_memberships(session, entity_id, now) -> list[RelatedGroupMember]` (SET `valid_to`, `is_current=false` на всех текущих; возвращает закрытые).
- `src/services/legal_entities.py`:
```python
class LegalEntityService:
def __init__(self, session, audit: RequestAudit, scope: VisibilityScope) -> None
async def create(self, data: LegalEntityCreate) -> LegalEntity # validate_inn; INN_EXISTS → ConflictError; crm_id → CRM_ID_EXISTS; company_group_id — через get_company_group(scope) иначе NotFoundError("ГК не найдена"); regional_office_id по умолчанию = офис ГК, для монозаёмщика — office_for_create; audit LEGAL_ENTITY_CHANGE
async def update(self, entity_id, data: LegalEntityUpdate) -> LegalEntity # name, entity_type (с повторной validate_inn), crm_id, regional_office_id; no-op без истории; снимок LegalEntityHistory(event_type=UPDATE, valid_from=entity.valid_from, valid_to=now, все поля-снимки), entity.valid_from=now; audit LEGAL_ENTITY_CHANGE
async def move(self, entity_id, data: LegalEntityMove) -> LegalEntity # effective_date >= today иначе DomainValidationError("EFFECTIVE_DATE_IN_PAST") ASSUMPTION(OQ-21); целевая ГК через scope → 404; та же ГК → ConflictError("SAME_COMPANY_GROUP"); снимок MOVE_TO_GROUP; close_memberships; audit LEGAL_ENTITY_CHANGE (before/after company_group_id) + MEMBERSHIP_CHANGE на каждое закрытое членство (object_type="related_group_member")
async def withdraw(self, entity_id, data: LegalEntityWithdraw) -> LegalEntity # event CLOSE | REMOVE_FROM_GROUP; company_group_id=None; CLOSE → close_date=effective_date; снимок с event_type; close_memberships; аудит как в move; comment → audit.comment
async def get(self, entity_id) -> LegalEntityDetail # entity + current memberships (related_group id/code/kind/name) + aliases
async def history(self, entity_id) -> list[LegalEntityHistory]
async def page(self, flt: LegalEntityFilter, page: PageParams) -> tuple[list[LegalEntity], int]
```
- Схемы: `LegalEntityCreate(inn: str, entity_type: EntityType, name: str, company_group_id: uuid | None = None, regional_office_id: uuid | None = None, crm_id: str | None = None)`; `LegalEntityUpdate` (name, entity_type, crm_id, regional_office_id — optional, `extra="forbid"`); `LegalEntityMove(company_group_id: uuid.UUID, effective_date: datetime.date)`; `LegalEntityWithdraw(event: Literal["CLOSE", "REMOVE_FROM_GROUP"], effective_date: datetime.date, comment: str | None = None)`; `LegalEntityOut(id, inn, crm_id, entity_type, name, company_group_id, regional_office_id, close_date, valid_from, outside_group: bool, closed: bool, created_at, updated_at)`; `MembershipOut(related_group_id, code, kind, name, valid_from)`; `AliasOut(id, alias, normalized_alias, created_at)`; `LegalEntityDetail(LegalEntityOut + memberships: list[MembershipOut], aliases: list[AliasOut])`; `LegalEntityHistoryOut(все поля снимка + event_type, effective_date, valid_from, valid_to, changed_by)`; `LegalEntityFilter(company_group_id, outside_group, inn, q, closed)`.
- Эндпоинты: `GET/POST /legal-entities`, `GET/PATCH /legal-entities/{id}`, `POST /legal-entities/{id}/move`, `POST /legal-entities/{id}/withdraw`, `GET /legal-entities/{id}/history`.
- [ ] **Step 1: Тесты.** `tests/test_domain_inn.py` (unit): 10 цифр ЮЛ ок, 12 ИП ок, `0123456789` ок (Review Focus 2), 10 цифр для ИП → `INVALID_INN`, буквы → `INVALID_INN`, пробелы внутри убираются. `tests/test_api_legal_entities.py` (integration): создание ИП с 10 цифрами → 422 `INVALID_INN`; дубликат ИНН → 409 `INN_EXISTS`; монозаёмщик (`ka_rf`, без ГК) → `outside_group == true`, виден `ka_rf` и `ka_go`, не виден `client_manager`; `move` закрывает членство (фабрики `make_related_group` + `make_membership`), история `MOVE_TO_GROUP` с датой; `move` в ГК чужого филиала пользователем `ka_rf` → 404 (Review Focus 3); `effective_date` вчера → 422; `withdraw CLOSE` → `close_date`, `company_group_id is None`, `closed == true` в списке с фильтром `closed=true`; no-op PATCH без истории; фильтр `inn=" 3528052718 "` находит ЮЛ с `inn="3528052718"`; откат мутации — `monkeypatch` `close_memberships` на бросающую `RuntimeError` → 500, ЮЛ в прежней ГК, истории/аудита нет (SAVEPOINT откатился — проверяется в той же сессии).
- [ ] **Step 2–4:** падает → реализация → `pytest tests/test_domain_inn.py && pytest -m integration tests/test_api_legal_entities.py -v`; `ruff`, `mypy`.
- [ ] **Step 5: Commit** `feat(directories): ЮЛ/ИП — CRUD, перевод, вывод из состава, история, аудит`.
---
### Task 7: [2.4] Алиасы ЮЛ
**Files:**
- Create: `api/src/domain/aliases.py`, `api/src/services/aliases.py`
- Modify: `api/src/repositories/directories.py` (`find_alias_owner(session, normalized) -> LegalEntityAlias | None`, `list_aliases(session, entity_id)`), `api/src/api/endpoints/directories/legal_entities.py` (маршруты алиасов), `api/src/schemas/directories.py` (`AliasCreate(alias: str = Field(min_length=1, max_length=500))`)
- Test: `api/tests/test_domain_aliases.py`, `api/tests/test_api_aliases.py`
**Interfaces:**
- `src/domain/aliases.py` (§3 п. 4 — «lower, trim, убрать пунктуацию/кавычки, убрать ОПФ (ООО, АО, ПАО, ИП…), схлопнуть пробелы»):
```python
LEGAL_FORMS: frozenset[str] = frozenset({"ооо", "ао", "пао", "зао", "оао", "нао", "ип", "гуп", "муп", "фгуп", "ано", "тсж", "кфх", "пк", "спк", "сх", "сха"}) # одиночные токены ОПФ (§3); убираются только целиком, «ооо» внутри слова не трогается
_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE) # кавычки «»"'„“ и прочая пунктуация → пробел
def normalize_alias(raw: str) -> str:
"""§3: lower → ё→е → пунктуация и кавычки → пробел → убрать токены ОПФ → схлопнуть пробелы → strip."""
```
- `AliasService(session, audit, scope)`: `async def add(entity_id, data) -> LegalEntityAlias` (ЮЛ через scope → 404; `normalize_alias`; пустая нормализованная строка → `DomainValidationError("EMPTY_ALIAS")`; у того же ЮЛ → `ConflictError("ALIAS_EXISTS")`; у другого ЮЛ → `ConflictError("ALIAS_TAKEN", …, legal_entity_id=owner_id)`; `created_by`, `created_at`; audit `LEGAL_ENTITY_CHANGE object_type="legal_entity_alias"`), `async def remove(entity_id, alias_id) -> None` (404 если не у этого ЮЛ; audit с `before_value`).
- Маршруты: `POST /legal-entities/{id}/aliases` → 201 `AliasOut` (`DIRECTORY_EDIT`), `DELETE /legal-entities/{id}/aliases/{alias_id}` → 204 (`DIRECTORY_DELETE`).
- [ ] **Step 1: Тесты.** unit: `normalize_alias('ООО «Пекарня Счастливочная»') == "пекарня счастливочная"`; `'Пекарня Счастливочная, ООО'` → то же; `'АО "РУССКИЙ БИСКВИТ"'` → `"русский бисквит"`; `'ИП Иванов И.И.'` → `"иванов и и"`; ё/е; двойные пробелы; строка только из ОПФ → `""`. integration: сценарий спеки «Нормализация» (409 `ALIAS_EXISTS`), `ALIAS_TAKEN` с `legal_entity_id` владельца, `DELETE` → 204 и запись аудита, `expert` → 403 на POST.
- [ ] **Step 2–4:** падает → реализация → тесты, `ruff`, `mypy` → PASS.
- [ ] **Step 5: Commit** `feat(directories): алиасы ЮЛ с нормализацией §3`.
---
### Task 8: [2.5] ГСК/ГСЗ и состав
**Files:**
- Create: `api/src/services/related_groups.py`, `api/src/api/endpoints/directories/related_groups.py`
- Modify: `api/src/repositories/directories.py`, `api/src/schemas/directories.py`, `api/src/api/dependencies/directories.py`, `api/src/api/endpoints/directories/__init__.py`
- Test: `api/tests/test_api_related_groups.py`
**Interfaces:**
- Репозиторий: `list_related_groups(session, company_group_id) -> list[tuple[RelatedGroup, int]]` (с числом текущих членов), `get_related_group(session, scope, group_id) -> RelatedGroup | None` (через ГК и `company_groups_visible`), `current_membership_of_kind(session, entity_id, kind) -> RelatedGroupMember | None`, `current_members(session, related_group_id) -> list[tuple[RelatedGroupMember, LegalEntity]]`, `has_current_members(session, related_group_id) -> bool`.
- `RelatedGroupService(session, audit, scope)`:
- `create(company_group_id, data: RelatedGroupCreate) -> RelatedGroup` — ГК через scope → 404; `code = next_code(RELATED_GROUP_CODE_SEQ, "GSK"|"GSZ")`; audit `MEMBERSHIP_CHANGE object_type="related_group"`.
- `update(group_id, data: RelatedGroupUpdate)` — name, created_date.
- `delete(group_id)` — `has_current_members` → `ConflictError("GROUP_NOT_EMPTY", "в группе есть текущие члены")` `# ASSUMPTION(OQ-34)`; удаляет и закрытые строки состава (история группы исчезает вместе с ней); audit с `before_value`.
- `add_member(group_id, data: MemberAdd) -> RelatedGroupMember` — ЮЛ через scope → 404; `entity.company_group_id != group.company_group_id` (в т.ч. `None`) → `DomainValidationError("DIFFERENT_COMPANY_GROUP")`; `current_membership_of_kind(entity, group.kind)`: та же группа → `ConflictError("MEMBER_EXISTS")`, другая → `ConflictError("MEMBER_OF_OTHER_GROUP", related_group_id=…)`; строка `RelatedGroupMember(kind=group.kind, valid_from=now, is_current=True, created_by=user)`; audit `MEMBERSHIP_CHANGE object_type="related_group_member"`.
- `remove_member(group_id, legal_entity_id)` — текущее членство → `valid_to=now, is_current=False`; нет → 404; audit.
- `get(group_id) -> RelatedGroupDetail`, `list_for(company_group_id) -> list[RelatedGroupOut]`.
- Схемы: `RelatedGroupCreate(kind: RelatedGroupKind, name: str, created_date: date | None = None)`, `RelatedGroupUpdate(name?, created_date?)`, `MemberAdd(legal_entity_id: uuid.UUID)`, `RelatedGroupOut(id, code, company_group_id, kind, name, created_date, members_count: int)`, `RelatedGroupMemberOut(legal_entity_id, inn, name, valid_from)`, `RelatedGroupDetail(RelatedGroupOut + members: list[RelatedGroupMemberOut])`.
- Маршруты: `GET/POST /company-groups/{gk_id}/related-groups`; `GET/PATCH /related-groups/{id}`; `DELETE /related-groups/{id}` (`DIRECTORY_DELETE`); `POST /related-groups/{id}/members` → 201; `DELETE /related-groups/{id}/members/{legal_entity_id}` → 204.
- [ ] **Step 1: Тесты** (сценарии спеки + Review Focus 4): коды `GSK-`/`GSZ-` + 6 цифр; ЮЛ в ГСК и затем в ГСЗ той же ГК — обе 201, два текущих членства с разными `kind`; вторая ГСК → 409 `MEMBER_OF_OTHER_GROUP` с id ГСК A; повтор в той же → 409 `MEMBER_EXISTS`; исключение → `is_current=false`, `valid_to`; повторное включение → новая строка, старая осталась; удаление непустой → 409 `GROUP_NOT_EMPTY`; удаление пустой → 204; ЮЛ другой ГК → 422 `DIFFERENT_COMPANY_GROUP`; **закрытый ЮЛ (без ГК) → 422** `DIFFERENT_COMPANY_GROUP`; `kind` строки = `kind` группы (проверить в БД); `ka_rf` для группы чужой ГК → 404.
- [ ] **Step 2–4:** падает → реализация → `pytest -m integration tests/test_api_related_groups.py -v`, полный `make check` → PASS.
- [ ] **Step 5: Commit** `feat(directories): ГСК/ГСЗ и состав (SCD2, одна группа каждого вида на ЮЛ)`.
---
## Завершение фазы 1
После Task 8: `make check` (из корня) и `make dbml-check` зелёные; `pytest -m integration` зелёный; `tasks.md` change'а — группы 1–2 отмечены `[x]`. Фаза 2 (`2026-10-04-slice-1-directories-phase2.md`): импорт из ЕЦП, администрирование, фронт, документы, `make check-full`.

View File

@ -0,0 +1,268 @@
# Срез 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`.
- [ ] **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 == 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)`:
```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` группа каждого вида у ГК.
- [ ] **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 → 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 == 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` (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`:
```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.

View File

@ -142,3 +142,4 @@ WHERE is_current`; `ALTER TYPE audit_action ADD VALUE` ×2 (`LEGAL_ENTITY_CHANGE
| 9 | 2026-10-04 | D-9: фронт администратора без Redux, dev-вход по email, токен в `sessionStorage` | решение владельца «минималистично» | переписать на слайсы в срезе UI |
| 10 | 2026-10-04 | D-10: `openpyxl` в `requirements.txt` с пином | без него xlsx не прочитать | убрать, если платформа против |
| 11 | 2026-10-04 | ASSUMPTION(OQ-34): удаляется только пустая ГСК/ГСЗ; ASSUMPTION(OQ-87…91) — допущения §13 | см. docs design D-4 | повторный импорт/миграция данных |
| 12 | 2026-10-04 | R-1 (план, фаза 1, Task 2): вместо отдельной `get_uow_session` (D-2) сама `get_async_session` оборачивает запрос в `session.begin()` — commit при успехе, rollback при исключении | одна сессия и одна транзакция на запрос: `current_user` и мутации не расходятся по соединениям | read-only маршруты коммитят пустую транзакцию (безвредно); при проблемах — вернуть отдельную зависимость |