# Срез 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("")`, код роли нигде не читается. 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 ` (или фактическая модель исполнителя) и `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`. - [x] **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)». - [x] **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). - [x] **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» там, где речь о текущей версии. - [x] **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, и в миграции. - [x] **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`. - [x] **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). - [x] **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`). - [x] **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. - [x] **Step 2: Запустить — падает (нет модулей/обработчика)** - [x] **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` напрямую — не трогать). - [x] **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). - [x] **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` (ГО). - [x] **Step 1:** написать фабрики и фикстуры; `test_me_and_dev_token.py` перевести на `app`/`auth_client` там, где он строит приложение сам (тесты сочетаний флагов `DEBUG`/`AUTH_DEV_ISSUER` оставить на локальном `_build_app`). - [x] **Step 2:** `pytest -m integration tests/test_me_and_dev_token.py -v` → PASS; `ruff`, `mypy` чисты. - [x] **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/` не тянул репозитории. - [x] **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"`. - [x] **Step 2:** падает → **Step 3:** реализация → **Step 4:** `pytest tests/test_domain_policy.py -v`, `mypy src` (strict для `src.domain.*`) → PASS. - [x] **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`. - [x] **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`. - [x] **Step 2:** падает → **Step 3:** реализация → **Step 4:** `pytest -m integration tests/test_api_company_groups.py tests/test_api_policy.py -v`; `ruff`, `mypy` → PASS. - [x] **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`. - [x] **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 откатился — проверяется в той же сессии). - [x] **Step 2–4:** падает → реализация → `pytest tests/test_domain_inn.py && pytest -m integration tests/test_api_legal_entities.py -v`; `ruff`, `mypy`. - [x] **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`). - [x] **Step 1: Тесты.** unit: `normalize_alias('ООО «Пекарня Счастливочная»') == "пекарня счастливочная"`; `'Пекарня Счастливочная, ООО'` → то же; `'АО "РУССКИЙ БИСКВИТ"'` → `"русский бисквит"`; `'ИП Иванов И.И.'` → `"иванов и и"`; ё/е; двойные пробелы; строка только из ОПФ → `""`. integration: сценарий спеки «Нормализация» (409 `ALIAS_EXISTS`), `ALIAS_TAKEN` с `legal_entity_id` владельца, `DELETE` → 204 и запись аудита, `expert` → 403 на POST. - [x] **Step 2–4:** падает → реализация → тесты, `ruff`, `mypy` → PASS. - [x] **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. - [x] **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. - [x] **Step 2–4:** падает → реализация → `pytest -m integration tests/test_api_related_groups.py -v`, полный `make check` → PASS. - [x] **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`. - [x] Проверки фазы 1 (Task 8, 2026-10-04): `make check` — 667 passed, ruff/mypy чистые; `make dbml-check` — пустой вывод; `pytest -m integration` — 229 passed, 0 skipped, 0 failed; `tasks.md` — группы 1–2 `[x]`.