Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
50 KiB
Срез 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.kindNOT 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 цифр с нулями) из последовательностей PostgreSQLdkb.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; venvapi/.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
Случаи, которых спека не называет явно, но которые ударят по пользователю; тест на каждый добавлен в задачу-владельца:
PATCHбез изменений (тело с теми же значениями) — не должен плодить строку истории и аудит: Task 5 (ГК) и Task 6 (ЮЛ) — тест «no-op PATCH не пишет историю».- Фильтр
innс пробелами / ИНН с ведущими нулями —innхранится строкой, сравнение точное послеstrip(); ИНН0123456789валиден: Task 6 — тест. - Пользователь филиала переводит ЮЛ в ГК чужого филиала — целевая ГК вне scope → 404 (не 422, не утечка): Task 6 — тест.
- Добавление в ГСК закрытого ЮЛ (
close_dateзаполнен,company_group_id = NULL) — 422DIFFERENT_COMPANY_GROUP(ЮЛ без ГК не в той же ГК): Task 8 — тест. 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:
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__ заменить второй индекс:
# (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:
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:
"""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(для OpenAPIresponses).
-
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:
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) -> RegionalOfficeasync 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="ГСК Тест") -> RelatedGroupasync 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, overrideget_async_session:async def override_session() -> AsyncIterator[AsyncSession]: async with db_session.begin_nested(): # как транзакция запроса: ошибка → откат до SAVEPOINT yield db_sessionauth_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):
@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(includedirectories.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:
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):ka_goсоздаёт две ГК → кодыGK-+ 6 цифр, различаются; вaudit_eventдвеGROUP_CHANGEсafter_value["code"].PATCH base_regional_office_id(на Краснодарский РФ по id из/regional-offices) →company_group_history1 строка с прежним филиалом иvalid_to;valid_fromГК изменился;audit_event(ASSIGNMENT_CHANGE)сbefore_value/after_value.- Дубликат
crm_id→ 409{"detail": {"code": "CRM_ID_EXISTS", ...}}. - no-op PATCH (то же имя) → 200, история пуста, аудита нет (Review Focus 1).
expert(DIRECTORY_VIEWтолько) →POST403, аудита нет.- Список:
qпо подстроке имени без регистра,is_archived,offset > total→items == [],totalверный (Review Focus 5);limit=201→ 422. archive/unarchiveпереключаютis_archived, пишутGROUP_CHANGE changed_field="is_archived".tests/test_api_policy.py(сценарии spec directory-policy):- ГК А (Ростовский РФ) и ГК Б (Краснодарский РФ) через фабрики;
ka_rfвидит только А (total == 1);ka_go— обе. ka_rfGET /company-groups/{Б}→ 404, тело совпадает сGET /company-groups/{uuid4()}.ka_rfсоздаёт ГК без филиала →base_regional_office_id= Ростовский; с чужим → 422OFFICE_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](SETvalid_to,is_current=falseна всех текущих; возвращает закрытые). src/services/legal_entities.py:
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 цифрами → 422INVALID_INN; дубликат ИНН → 409INN_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"; откат мутации —monkeypatchclose_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, убрать пунктуацию/кавычки, убрать ОПФ (ООО, АО, ПАО, ИП…), схлопнуть пробелы»):
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; auditLEGAL_ENTITY_CHANGE object_type="legal_entity_alias"),async def remove(entity_id, alias_id) -> None(404 если не у этого ЮЛ; audit сbefore_value). -
Маршруты:
POST /legal-entities/{id}/aliases→ 201AliasOut(DIRECTORY_EDIT),DELETE /legal-entities/{id}/aliases/{alias_id}→ 204 (DIRECTORY_DELETE). -
Step 1: Тесты. unit:
normalize_alias('ООО «Пекарня Счастливочная»') == "пекарня счастливочная";'Пекарня Счастливочная, ООО'→ то же;'АО "РУССКИЙ БИСКВИТ"'→"русский бисквит";'ИП Иванов И.И.'→"иванов и и"; ё/е; двойные пробелы; строка только из ОПФ →"". integration: сценарий спеки «Нормализация» (409ALIAS_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"); auditMEMBERSHIP_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); auditMEMBERSHIP_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; вторая ГСК → 409MEMBER_OF_OTHER_GROUPс id ГСК A; повтор в той же → 409MEMBER_EXISTS; исключение →is_current=false,valid_to; повторное включение → новая строка, старая осталась; удаление непустой → 409GROUP_NOT_EMPTY; удаление пустой → 204; ЮЛ другой ГК → 422DIFFERENT_COMPANY_GROUP; закрытый ЮЛ (без ГК) → 422DIFFERENT_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.
- Проверки фазы 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].