arm-core/docs/superpowers/plans/2026-10-07-front-api-gaps.md
2026-10-07 17:04:04 +04:00

60 KiB
Raw Blame History

API до сводной ОСВ: пробелы по макету и комментариям фронтенда — Implementation Plan

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.

Goal: экран [42] создаёт консолидацию одним запросом с выбранным периметром, экран [34] строится из одной карточки версии (подписанные ячейки чек-листа, тип ЮЛ, счета ОСВ), контракт для фронта описывает всё, что фронт использует.

Architecture: только расширение существующих схем и сервиса консолидаций arm-core (ConsolidationCreate, ChecklistCellOut, MemberOut, services/consolidations.py) и один новый запрос репозитория; миграций и DBML нет. Документы — отдельный bounded-PR в arm-ka-docs.

Tech Stack: Python 3.11, FastAPI, pydantic v2, SQLAlchemy 2 async, PostgreSQL 16, pytest-asyncio; make из корня arm-core.

Spec: openspec/changes/front-api-gaps/ (proposal, design D-1…D-4 и «Decisions made without discussion» №1–8, specs/consolidations-api/spec.md). Issue — RSHB/arm-core#42.

Global Constraints

  • Рабочая копия: /Users/andrey.epifancev/projects/awp-ca/.wt/front-gaps/arm-core, ветка feat/42-front-api-gaps. Никаких cd в основные checkout'ы (/Users/andrey.epifancev/projects/awp-ca/{arm-core,docs}): хук guard-main блокирует запись, когда cwd — репозиторий на main.
  • Команды — из корня arm-core: make check (ruff + mypy + unit), make test-integration (нужен compose: PostgreSQL/NATS уже подняты — docker ps показывает arm-ka-postgres-1, arm-ka-nats-1, arm-ka-minio-1). Точечный прогон — cd api && .venv/bin/pytest <путь>::<тест> -v (venv создаётся make venv, если api/.venv нет).
  • DBML, миграции, контракт распознавания (api/contracts/) — не менять.
  • Ответы API только расширяются; запрос POST /consolidations без legal_entity_ids ведёт себя как прежде.
  • Значения entity_type — "ЮЛ" / "ИП" (enum EntityType, как в DBML).
  • Сортировка счетов — только src.domain.osv_mapping.account_sort_key (одно определение порядка счетов).
  • Язык комментариев, docstring и коммитов — русский; идентификаторы — английские. Docstring ссылается на спеку/design.
  • Каждый коммит заканчивается строками:
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
    
  • Сообщения коммитов — feat(consolidations): …, test(consolidations): …, docs: … по-русски.

Review Focus

  • Пустой список legal_entity_ids: [] — это явный периметр из нуля ЮЛ, а не «поле не передано»: ожидается 422 PERIMETER_TOO_SMALL, а не периметр из ГСК. Тест — Task 1, test_create_with_empty_selected_perimeter_is_too_small.
  • Несуществующий uuid в legal_entity_ids — ожидается 422 ENTITY_NOT_IN_GROUP с этим uuid в перечне (не 404 и не 500). Тест — Task 1, test_create_with_unknown_entity_is_not_in_group.
  • Неверная ГСК при явном списке — проверка ГСК раньше периметра: 422 RELATED_GROUP_MISMATCH, даже если список тоже неверный. Тест — Task 1, test_create_gsk_mismatch_reported_before_perimeter.
  • Один счёт в нескольких кварталах — в osv_accounts один раз. Тест — Task 3, в test_card_member_osv_accounts_loaded_to_version_period (ОСВ 60 за Q1 и Q2).
  • Версия за Q1 — документы Q2+ того же года в osv_accounts не попадают. Тест — Task 3, test_card_member_osv_accounts_q1_version_ignores_later_quarters.

File Structure

Файл Что меняется
api/src/schemas/consolidations.py ConsolidationCreate.legal_entity_ids; ChecklistCellOut.document_type/account; MemberOut.entity_type/osv_accounts
api/src/services/consolidations.py _validate_perimeter (общая для create и set_members); create/_insert по списку uuid; _cell_out(rule, cell); _card — новые поля участника
api/src/repositories/consolidations.py новая osv_accounts(...)
api/tests/test_api_consolidations.py тесты создания с периметром, карточки (тип ЮЛ, счета ОСВ)
api/tests/test_api_consolidation_checklist.py ячейки с document_type/account
api/tests/test_schemas_consolidations.py, api/tests/test_domain_checklist_csv.py конструкторы ячеек, граница legal_entity_ids
api/tests/test_openapi_routes.py новые поля в схеме OpenAPI
README.md, CLAUDE.md, openspec/changes/front-api-gaps/tasks.md описание (Task 5)
docs: dev/api-contract-files-directories.md (новый), dev/api-contract-slice-4.md, context/07-open-questions.md, context/10-user-journey.md, mockup/INDEX.md Task 4, отдельный worktree и PR

Task 1: Создание консолидации с выбранным периметром

Files:

  • Modify: api/src/schemas/consolidations.py (класс ConsolidationCreate, ~стр. 44)
  • Modify: api/src/services/consolidations.py (create ~стр. 89–131, set_members ~стр. 237–258, _insert ~стр. 294–341)
  • Test: api/tests/test_api_consolidations.py, api/tests/test_schemas_consolidations.py, api/tests/test_openapi_routes.py

Interfaces:

  • Consumes: repo.entities_in_group(session, group_id, entity_ids) -> set[uuid.UUID] (есть), directories_repo.current_members(session, related_group_id) (есть), MIN_PERIMETER = 2, MAX_MEMBERS = 500.

  • Produces: ConsolidationCreate.legal_entity_ids: list[uuid.UUID] | None; ConsolidationService._validate_perimeter(self, company_group_id: uuid.UUID, entity_ids: Sequence[uuid.UUID]) -> list[uuid.UUID]; ConsolidationService._insert(self, group_id: uuid.UUID, data: ConsolidationCreate, entity_ids: list[uuid.UUID]) -> tuple[Consolidation, ConsolidationVersion]. Класс сервиса — ConsolidationService.

  • Step 1: Тест схемы — граница и значение по умолчанию

В api/tests/test_schemas_consolidations.py после test_consolidation_create_year_bounds добавить:

def test_consolidation_create_selected_perimeter_bounds() -> None:
    """D-1: `legal_entity_ids` необязателен (`None` — периметр из ГСК), не больше `MAX_MEMBERS`."""
    ids = {"company_group_id": uuid.uuid4(), "related_group_id": uuid.uuid4(), "year": 2024, "quarter": "Q3"}
    assert ConsolidationCreate(**ids).legal_entity_ids is None
    selected = [uuid.uuid4() for _ in range(MAX_MEMBERS)]
    assert len(ConsolidationCreate(**ids, legal_entity_ids=selected).legal_entity_ids or []) == MAX_MEMBERS
    with pytest.raises(ValidationError):
        ConsolidationCreate(**ids, legal_entity_ids=[*selected, uuid.uuid4()])
  • Step 2: Интеграционные тесты API

В api/tests/test_api_consolidations.py: в импорт from src.models.directories import … добавить RelatedGroupMember; from collections.abc import Sequence — в стандартные импорты. После _body добавить помощник и после test_create_ignores_closed_memberships — тесты:

def _with_ids(body: dict[str, Any], ids: Sequence[uuid.UUID | str]) -> dict[str, Any]:
    """Тело создания с явным периметром (модалка [42], design D-1)."""
    return {**body, "legal_entity_ids": [str(entity_id) for entity_id in ids]}


async def _gsk_members(session: AsyncSession, gsk: RelatedGroup) -> set[uuid.UUID]:
    stmt = select(RelatedGroupMember.legal_entity_id).where(
        RelatedGroupMember.related_group_id == gsk.id, RelatedGroupMember.is_current.is_(True)
    )
    return set((await session.execute(stmt)).scalars())


@pytest.mark.integration
async def test_create_with_selected_perimeter_wider_than_gsk(
    auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
    """Выбранный периметр шире ГСК: ровно эти ЮЛ, одно событие создания, состав ГСК не меняется."""
    group, gsk, entities = await _scenario(seeded)
    outsider = await make_legal_entity(seeded, name="ООО Вне ГСК", group=group)
    selected = [*entities, outsider]
    client = await auth_client(KA_RF)

    response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [e.id for e in selected]))

    assert response.status_code == HTTP_CREATED, response.text
    assert {member["id"] for member in response.json()["members"]} == {str(e.id) for e in selected}
    event = (
        await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_CREATE))
    ).scalar_one()
    assert event.after_value["members"] == sorted(str(e.id) for e in selected)
    membership = select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE)
    assert (await seeded.execute(membership)).scalar_one() == 0
    assert await _gsk_members(seeded, gsk) == {e.id for e in entities}
    assert await _count(seeded, ConsolidationLock) == 0


@pytest.mark.integration
async def test_create_gsk_of_one_with_second_entity_selected(
    auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
    """ГСК из одного ЮЛ, второе отмечено вручную — периметр из двух (проверка размера — по выбранным)."""
    group, gsk, (single,) = await _scenario(seeded, members=1)
    second = await make_legal_entity(seeded, name="ООО Второе", group=group)
    client = await auth_client(KA_RF)

    response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [single.id, second.id]))

    assert response.status_code == HTTP_CREATED, response.text
    assert {member["id"] for member in response.json()["members"]} == {str(single.id), str(second.id)}


@pytest.mark.integration
async def test_create_with_entity_of_other_group_is_rejected(
    auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
    group, gsk, entities = await _scenario(seeded)
    other_group = await make_company_group(seeded, name="ГК Соседняя", office=await _office(seeded, ROSTOV))
    stranger = await make_legal_entity(seeded, name="ООО Чужое", group=other_group)
    client = await auth_client(KA_RF)

    response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [*[e.id for e in entities], stranger.id]))

    assert response.status_code == HTTP_UNPROCESSABLE, response.text
    detail = response.json()["detail"]
    assert detail["code"] == "ENTITY_NOT_IN_GROUP"
    assert detail["legal_entity_ids"] == [str(stranger.id)]
    assert await _count(seeded, Consolidation) == 0


@pytest.mark.integration
async def test_create_with_unknown_entity_is_not_in_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
    group, gsk, entities = await _scenario(seeded)
    ghost = uuid.uuid4()
    client = await auth_client(KA_RF)

    response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [*[e.id for e in entities], ghost]))

    assert response.status_code == HTTP_UNPROCESSABLE, response.text
    assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_GROUP"
    assert response.json()["detail"]["legal_entity_ids"] == [str(ghost)]


@pytest.mark.integration
async def test_create_with_duplicates_collapsing_to_one_is_too_small(
    auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
    group, gsk, entities = await _scenario(seeded)
    client = await auth_client(KA_RF)

    response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [entities[0].id, entities[0].id]))

    assert response.status_code == HTTP_UNPROCESSABLE, response.text
    assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
    assert await _count(seeded, Consolidation) == 0


@pytest.mark.integration
async def test_create_with_empty_selected_perimeter_is_too_small(
    auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
    """Review Focus: `[]` — явный пустой периметр, а не «поле не передано» (ГСК из двух не подставляется)."""
    group, gsk, _ = await _scenario(seeded)
    client = await auth_client(KA_RF)

    response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), []))

    assert response.status_code == HTTP_UNPROCESSABLE, response.text
    assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"


@pytest.mark.integration
async def test_create_with_null_selected_perimeter_takes_gsk(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
    """Решение №3: `legal_entity_ids: null` равнозначно отсутствию поля."""
    group, gsk, entities = await _scenario(seeded)
    client = await auth_client(KA_RF)

    response = await client.post(CONSOLIDATIONS, json={**_body(group, gsk), "legal_entity_ids": None})

    assert response.status_code == HTTP_CREATED, response.text
    assert {member["id"] for member in response.json()["members"]} == {str(e.id) for e in entities}


@pytest.mark.integration
async def test_create_gsk_mismatch_reported_before_perimeter(
    auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
    """Порядок проверок (решение №5): ГСК раньше периметра."""
    group, _, _ = await _scenario(seeded)
    gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ")
    client = await auth_client(KA_RF)

    response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsz), [uuid.uuid4()]))

    assert response.status_code == HTTP_UNPROCESSABLE, response.text
    assert response.json()["detail"]["code"] == "RELATED_GROUP_MISMATCH"


@pytest.mark.integration
async def test_create_selected_perimeter_longer_than_limit(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
    group, gsk, _ = await _scenario(seeded)
    client = await auth_client(KA_RF)

    response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [uuid.uuid4() for _ in range(501)]))

    assert response.status_code == HTTP_UNPROCESSABLE, response.text
    assert response.json()["detail"][0]["loc"] == ["body", "legal_entity_ids"]
    assert await _count(seeded, Consolidation) == 0

Если make_related_group не принимает kind= — посмотреть test_create_gsk_must_belong_to_group_and_be_gsk (там тот же вызов make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ")). Если поле членства ГСК в модели называется не is_current — взять фактическое имя из api/src/models/directories.py (class RelatedGroupMember). Строки длиннее 120 символов разбить, как требует ruff (make lint подскажет).

  • Step 3: Тест OpenAPI

В api/tests/test_openapi_routes.py в конец файла:

def test_consolidation_create_has_optional_selected_perimeter(schema: dict[str, Any]) -> None:
    """front-api-gaps D-1: `legal_entity_ids` в теле создания — необязательный массив."""
    create = schema["components"]["schemas"]["ConsolidationCreate"]
    assert "legal_entity_ids" in create["properties"]
    assert "legal_entity_ids" not in create.get("required", [])
  • Step 4: Запустить — тесты падают

Run: cd api && .venv/bin/pytest tests/test_schemas_consolidations.py::test_consolidation_create_selected_perimeter_bounds tests/test_openapi_routes.py::test_consolidation_create_has_optional_selected_perimeter -v && .venv/bin/pytest -m integration tests/test_api_consolidations.py -k "selected or other_group or unknown_entity or duplicates_collapsing or gsk_of_one or mismatch_reported" -v Expected: FAIL — ConsolidationCreate не знает legal_entity_ids (pydantic игнорирует лишнее поле: legal_entity_ids is None в теле с 500 id → AttributeError/assert; API создаёт периметр из ГСК).

  • Step 5: Схема

api/src/schemas/consolidations.py, класс ConsolidationCreate:

class ConsolidationCreate(BaseModel):
    """Тело `POST /consolidations`: ГК, ГСК, период и необязательный выбранный периметр (модалка [42], design D-1).

    `legal_entity_ids` нет или `null` — периметр из текущих членов ГСК; список — ровно эти ЮЛ ГК (дубли схлопывает сервис).
    """

    company_group_id: uuid.UUID
    related_group_id: uuid.UUID
    year: int = Field(ge=YEAR_MIN, le=YEAR_MAX)
    quarter: PeriodQuarter
    legal_entity_ids: list[uuid.UUID] | None = Field(default=None, max_length=MAX_MEMBERS)

MAX_MEMBERS объявлен выше в том же модуле (стр. ~31) — проверить, что объявление стоит до класса.

  • Step 6: Сервис — общая проверка периметра

В api/src/services/consolidations.py: в импорты from collections.abc import Sequence (если нет). В класс сервиса (рядом с _insert) добавить:

    async def _validate_perimeter(
        self, company_group_id: uuid.UUID, entity_ids: Sequence[uuid.UUID]
    ) -> list[uuid.UUID]:
        """Периметр консолидации — одно правило для создания и `PUT …/members` (design D-1): дубли схлопываются в порядке
        первого вхождения → все ЮЛ существуют и принадлежат ГК (422 `ENTITY_NOT_IN_GROUP`, `legal_entity_ids` — чужие в
        порядке запроса) → не меньше двух (422 `PERIMETER_TOO_SMALL`)."""
        requested = list(dict.fromkeys(entity_ids))
        valid = await repo.entities_in_group(self._session, company_group_id, requested)
        foreign = [entity_id for entity_id in requested if entity_id not in valid]
        if foreign:
            raise DomainValidationError(
                "ENTITY_NOT_IN_GROUP", "ЮЛ не принадлежат ГК консолидации", legal_entity_ids=foreign
            )
        if len(requested) < MIN_PERIMETER:
            raise DomainValidationError(
                "PERIMETER_TOO_SMALL", f"В периметре должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(requested)}"
            )
        return requested

В set_members заменить блок от requested = list(dict.fromkeys(data.legal_entity_ids)) до конца проверки PERIMETER_TOO_SMALL одной строкой:

        requested = await self._validate_perimeter(consolidation.company_group_id, data.legal_entity_ids)
  • Step 7: Сервис — создание по списку

В create заменить строки от entities = [entity for _, entity in await directories_repo.current_members(...)] до конца проверки PERIMETER_TOO_SMALL (по ГСК):

        if data.legal_entity_ids is None:
            entity_ids = [entity.id for _, entity in await directories_repo.current_members(self._session, related.id)]
            if len(entity_ids) < MIN_PERIMETER:
                raise DomainValidationError(
                    "PERIMETER_TOO_SMALL", f"В ГСК должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(entity_ids)}"
                )
        else:
            entity_ids = await self._validate_perimeter(group.id, data.legal_entity_ids)

Далее в create: consolidation, version = await self._insert(group.id, data, entity_ids) и в аудите "members": sorted(entity_ids),. Обновить docstring create: «Проверки по порядку: ГК видима (404) → ГСК видима (404), принадлежит ГК и kind = gsk (422 RELATED_GROUP_MISMATCH) → периметр: без legal_entity_ids — члены ГСК, не меньше двух; со списком — _validate_perimeter (422 ENTITY_NOT_IN_GROUP / PERIMETER_TOO_SMALL) → периода ещё нет (409 …)». Модульный docstring: «Периметр версии при создании — текущие члены ГСК или выбранные ЮЛ ГК (legal_entity_ids, front-api-gaps D-1), is_head_entity = false; …».

В _insert: параметр entities: list[LegalEntity] → entity_ids: list[uuid.UUID]; ConsolidationMember(..., legal_entity_id=entity_id, ...) for entity_id in entity_ids; bind_confirmed(..., entity_ids=entity_ids). Если LegalEntity больше нигде в модуле не используется — убрать импорт (ruff F401 подскажет; он нужен _checklist_cells — тогда оставить).

  • Step 8: Запустить — тесты проходят, прежние не сломаны

Run: cd api && .venv/bin/pytest tests/test_schemas_consolidations.py tests/test_openapi_routes.py -v && .venv/bin/pytest -m integration tests/test_api_consolidations.py tests/test_api_consolidation_members.py -v Expected: PASS (все, включая test_create_with_single_member_is_perimeter_too_small, test_foreign_entity_is_reported_before_size, test_duplicates_are_collapsed).

  • Step 9: Линтеры

Run: make lint Expected: без ошибок.

  • Step 10: Коммит
git add api/src/schemas/consolidations.py api/src/services/consolidations.py api/tests/test_api_consolidations.py api/tests/test_schemas_consolidations.py api/tests/test_openapi_routes.py
git commit -m "feat(consolidations): создание с выбранным периметром legal_entity_ids, общая проверка периметра (#42)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2"

Task 2: Самоописывающие ячейки чек-листа

Files:

  • Modify: api/src/schemas/consolidations.py (ChecklistCellOut, ~стр. 95)
  • Modify: api/src/services/consolidations.py (checklist ~стр. 169, _cell_out ~стр. 231, _card ~стр. 358)
  • Test: api/tests/test_api_consolidation_checklist.py, api/tests/test_schemas_consolidations.py, api/tests/test_domain_checklist_csv.py, api/tests/test_openapi_routes.py

Interfaces:

  • Consumes: checklist_domain.Rule(document_type, account, is_mandatory), checklist_domain.Cell(status, missing_quarters, document_ids), _checklist_cells(...) -> tuple[list[Rule], list[tuple[LegalEntity, list[Cell]]]] (есть).

  • Produces: ChecklistCellOut(document_type: DocumentType, account: str | None, status, missing_quarters, document_ids); ConsolidationService._cell_out(rule: checklist_domain.Rule, cell: checklist_domain.Cell) -> ChecklistCellOut. Task 3 меняет _card рядом — после Task 2 в _card есть переменная rules.

  • Step 1: Обновить существующие ожидания и добавить тест карточки

api/tests/test_api_consolidation_checklist.py:

    assert cells[OSV_60] == {
        "document_type": "OSV",
        "account": "60",
        "status": "CONFIRMED",
        "missing_quarters": [],
        "document_ids": [str(i) for i in documents],
    }
    assert cells[OSV_90] == {
        "document_type": "OSV",
        "account": "90",
        "status": "MISSING",
        "missing_quarters": [],
        "document_ids": [],
    }

(в test_checklist_osv_60_confirmed_osv_90_missing), и в test_checklist_gap_needs_check_and_processing:

    assert first_cells[OSV_60] == {
        "document_type": "OSV",
        "account": "60",
        "status": "MISSING",
        "missing_quarters": ["Q2"],
        "document_ids": [str(first_q1), str(third_q3)],
    }

После test_card_members_have_same_cells_as_checklist добавить:

@pytest.mark.integration
async def test_card_cells_are_self_describing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
    """front-api-gaps D-2: ячейка несёт правило — фронт не сопоставляет ячейки с `rules` по индексу."""
    version, _ = await _bound_world(seeded)
    client = await auth_client(KA_RF)

    checklist = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json()
    card = (await client.get(f"/consolidations/versions/{version.id}")).json()

    expected = [(rule["document_type"], rule["account"]) for rule in checklist["rules"]]
    assert expected == EXPECTED_RULES
    for member in card["members"]:
        assert [(cell["document_type"], cell["account"]) for cell in member["checklist"]] == expected
    for row in checklist["rows"]:
        assert [(cell["document_type"], cell["account"]) for cell in row["cells"]] == expected
    balance = next(cell for cell in card["members"][0]["checklist"] if cell["document_type"] == "BALANCE")
    assert balance["account"] is None

api/tests/test_schemas_consolidations.py, test_checklist_cell_status_is_the_domain_enum — конструкторы:

        cell = ChecklistCellOut(
            document_type=DocumentType.OSV, account="60", status=status.value, missing_quarters=[], document_ids=[]  # type: ignore[arg-type]
        )
    ...
        ChecklistCellOut(
            document_type=DocumentType.OSV, account="60", status="UNKNOWN", missing_quarters=[], document_ids=[]  # type: ignore[arg-type]
        )

и импорт DocumentType в from src.models.enums import …. Добавить тест:

def test_checklist_cell_requires_its_rule() -> None:
    """D-2: `document_type` обязателен, `account` — обязателен, но может быть `None` (ББ/ОФР)."""
    with pytest.raises(ValidationError):
        ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[], document_ids=[])  # type: ignore[call-arg]
    cell = ChecklistCellOut(
        document_type=DocumentType.BALANCE, account=None, status=CellStatus.MISSING, missing_quarters=[], document_ids=[]
    )
    assert cell.account is None

api/tests/test_domain_checklist_csv.py — в test_render ячейки:

                    ChecklistCellOut(
                        document_type=DocumentType.BALANCE,
                        account=None,
                        status=CellStatus.CONFIRMED,
                        missing_quarters=[],
                        document_ids=[],
                    ),
                    ChecklistCellOut(
                        document_type=DocumentType.OSV,
                        account="60",
                        status=CellStatus.MISSING,
                        missing_quarters=[PeriodQuarter.Q2],
                        document_ids=[],
                    ),

и помощник _cell:

def _cell(status: CellStatus, *quarters: PeriodQuarter) -> ChecklistCellOut:
    return ChecklistCellOut(
        document_type=DocumentType.OSV, account="60", status=status, missing_quarters=list(quarters), document_ids=[]
    )

api/tests/test_openapi_routes.py — в конец:

def test_checklist_cell_declares_its_rule(schema: dict[str, Any]) -> None:
    """front-api-gaps D-2: ячейка чек-листа самоописывающая."""
    cell = schema["components"]["schemas"]["ChecklistCellOut"]
    assert {"document_type", "account", "status", "missing_quarters", "document_ids"} <= set(cell["required"])
  • Step 2: Запустить — падают

Run: cd api && .venv/bin/pytest tests/test_schemas_consolidations.py tests/test_domain_checklist_csv.py tests/test_openapi_routes.py -v; .venv/bin/pytest -m integration tests/test_api_consolidation_checklist.py -v Expected: FAIL — ChecklistCellOut не принимает/не отдаёт document_type, account.

  • Step 3: Схема ячейки
class ChecklistCellOut(BaseModel):
    """Ячейка чек-листа — самоописывающая (front-api-gaps D-2): правило ячейки (`document_type`, `account` — счёт ОСВ,
    `None` у ББ/ОФР), статус пары (ЮЛ, правило), недостающие кварталы ОСВ и привязанные документы."""

    document_type: DocumentType
    account: str | None
    status: CellStatus  # единственное определение — `domain.checklist.CellStatus` (T2.8)
    missing_quarters: list[PeriodQuarter]
    document_ids: list[uuid.UUID]
  • Step 4: Сервис

_cell_out:

    @staticmethod
    def _cell_out(rule: checklist_domain.Rule, cell: checklist_domain.Cell) -> ChecklistCellOut:
        """Ячейка ответа с правилом (D-2); `rule` — правило того же столбца, что и `cell`."""
        return ChecklistCellOut(
            document_type=rule.document_type,
            account=rule.account,
            status=cell.status,
            missing_quarters=list(cell.missing_quarters),
            document_ids=list(cell.document_ids),
        )

В checklist: cells=[self._cell_out(rule, cell) for rule, cell in zip(rules, cells, strict=True)],. В _card: rules, cells = await self._checklist_cells(...) (вместо _, cells = …) и в MemberOut(...): checklist=[self._cell_out(rule, cell) for rule, cell in zip(rules, cells[position][1], strict=True)],.

  • Step 5: Запустить — проходят

Run: cd api && .venv/bin/pytest tests/test_schemas_consolidations.py tests/test_domain_checklist_csv.py tests/test_openapi_routes.py -v && .venv/bin/pytest -m integration tests/test_api_consolidation_checklist.py tests/test_api_consolidations.py tests/test_api_consolidation_members.py -v Expected: PASS; CSV-тесты (test_checklist_csv_*) проходят без изменения ожидаемого текста.

  • Step 6: Линтеры и коммит

Run: make lint → без ошибок.

git add api/src/schemas/consolidations.py api/src/services/consolidations.py api/tests/test_api_consolidation_checklist.py api/tests/test_schemas_consolidations.py api/tests/test_domain_checklist_csv.py api/tests/test_openapi_routes.py
git commit -m "feat(consolidations): ячейки чек-листа с document_type и account своего правила (#42)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2"

Task 3: Участник периметра — тип ЮЛ и счета ОСВ

Files:

  • Modify: api/src/repositories/consolidations.py (новая функция после unconfirmed_documents, ~стр. 351)
  • Modify: api/src/schemas/consolidations.py (MemberOut, импорт EntityType)
  • Modify: api/src/services/consolidations.py (_card; импорт quarters_through)
  • Test: api/tests/test_api_consolidations.py, api/tests/test_openapi_routes.py

Interfaces:

  • Consumes: account_sort_key(code: str) из src.domain.osv_mapping; quarters_through(quarter: PeriodQuarter) -> tuple[PeriodQuarter, ...] из src.domain.consolidation_periods; после Task 2 в _card есть rules, cells.

  • Produces: repo.osv_accounts(session: AsyncSession, entity_ids: Collection[uuid.UUID], year: int, quarters: Collection[PeriodQuarter]) -> dict[uuid.UUID, list[str]]; MemberOut.entity_type: EntityType, MemberOut.osv_accounts: list[str].

  • Step 1: Интеграционные тесты карточки

В api/tests/test_api_consolidations.py в импорты фабрик добавить make_document, make_stored_file; в импорт enums — DocumentStatus, EntityType, FileStatus, RecognitionStatus. Добавить в конец файла:

async def _osv(  # noqa: PLR0913 - параметры документа сценария
    session: AsyncSession,
    entity: LegalEntity,
    account: str,
    year: int,
    quarter: PeriodQuarter,
    *,
    status: DocumentStatus = DocumentStatus.RECOGNIZED,
    file_status: FileStatus = FileStatus.PROCESSED,
) -> None:
    """ОСВ-документ ЮЛ в отдельном файле (на файл — одна ОСВ по счёту)."""
    file, job = await make_stored_file(session, entity=entity, status=file_status, job_status=RecognitionStatus.done)
    await make_document(session, file=file, job=job, account=account, year=year, quarter=quarter, status=status)


async def _card_world(
    session: AsyncSession, quarter: PeriodQuarter = PeriodQuarter.Q3
) -> tuple[ConsolidationVersion, LegalEntity, LegalEntity]:
    """Версия 2024 `quarter` ГК Ростовского филиала: ЮЛ «ООО А» и ИП «ИП Б»."""
    group = await make_company_group(session, name="ГК Карточка", office=await _office(session, ROSTOV))
    company = await make_legal_entity(session, name="ООО А", group=group)
    person = await make_legal_entity(
        session, name="ИП Б", group=group, entity_type=EntityType.ИП, inn="770100000012"
    )
    _, version = await make_consolidation(
        session, group=group, year=2024, quarter=quarter, members=[company, person], initiator=await _user(session, KA_RF)
    )
    return version, company, person


@pytest.mark.integration
async def test_card_member_entity_type(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
    version, company, person = await _card_world(seeded)
    client = await auth_client(KA_RF)

    card = (await client.get(f"{VERSIONS}/{version.id}")).json()

    types = {member["id"]: member["entity_type"] for member in card["members"]}
    assert types == {str(company.id): "ЮЛ", str(person.id): "ИП"}


@pytest.mark.integration
async def test_card_member_osv_accounts_loaded_to_version_period(
    auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
    """D-3 (вариант B): счета действующих ОСВ за год версии с кварталом ≤ версии, в любом статусе, кроме ERROR."""
    version, company, person = await _card_world(seeded)
    q1, q2, q3, q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4
    await _osv(seeded, company, "60", 2024, q1)  # RECOGNIZED
    await _osv(seeded, company, "60", 2024, q2)  # тот же счёт ещё раз — один раз в списке
    await _osv(seeded, company, "51", 2024, q3, status=DocumentStatus.CONFIRMED)
    await _osv(seeded, company, "58.03", 2024, q3, status=DocumentStatus.NEEDS_CHECK)
    await _osv(seeded, company, "62", 2024, q4)  # позже квартала версии
    await _osv(seeded, company, "01", 2023, q3)  # прошлый год
    await _osv(seeded, company, "76", 2024, q3, file_status=FileStatus.SUPERSEDED)  # заменённый файл
    await _osv(seeded, company, "90", 2024, q3, status=DocumentStatus.ERROR)  # документ в ошибке
    await _osv(seeded, company, "10", 2024, q3, file_status=FileStatus.ERROR)  # файл в ошибке
    client = await auth_client(KA_RF)

    card = (await client.get(f"{VERSIONS}/{version.id}")).json()

    accounts = {member["id"]: member["osv_accounts"] for member in card["members"]}
    assert accounts == {str(company.id): ["51", "58.03", "60"], str(person.id): []}


@pytest.mark.integration
async def test_card_member_osv_accounts_q1_version_ignores_later_quarters(
    auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
    """Review Focus: версия за Q1 — документы Q2 того же года не входят."""
    version, company, _ = await _card_world(seeded, PeriodQuarter.Q1)
    await _osv(seeded, company, "60", 2024, PeriodQuarter.Q1)
    await _osv(seeded, company, "62", 2024, PeriodQuarter.Q2)
    client = await auth_client(KA_RF)

    card = (await client.get(f"{VERSIONS}/{version.id}")).json()

    member = next(member for member in card["members"] if member["id"] == str(company.id))
    assert member["osv_accounts"] == ["60"]

Импорт make_consolidation в тестовом модуле уже есть. Если make_stored_file с status=FileStatus.ERROR требует другой job_status — передать job_status=RecognitionStatus.error (значение enum проверить в api/src/models/enums.py, класс RecognitionStatus).

api/tests/test_openapi_routes.py — в конец:

def test_version_member_declares_type_and_osv_accounts(schema: dict[str, Any]) -> None:
    """front-api-gaps D-3: участник периметра — тип ЮЛ и счета ОСВ к периоду версии."""
    member = schema["components"]["schemas"]["MemberOut"]
    assert {"entity_type", "osv_accounts", "checklist"} <= set(member["required"])
  • Step 2: Запустить — падают

Run: cd api && .venv/bin/pytest tests/test_openapi_routes.py -v; .venv/bin/pytest -m integration tests/test_api_consolidations.py -k "card_member" -v Expected: FAIL — KeyError: 'entity_type' / 'osv_accounts'.

  • Step 3: Репозиторий

api/src/repositories/consolidations.py: импорт from src.domain.osv_mapping import account_sort_key; в импорт enums добавить PeriodQuarter (если нет — он уже есть в списке; DocumentStatus, DocumentType, FileStatus — есть). После unconfirmed_documents:

async def osv_accounts(
    session: AsyncSession, entity_ids: Collection[uuid.UUID], year: int, quarters: Collection[PeriodQuarter]
) -> dict[uuid.UUID, list[str]]:
    """Счета ОСВ, загруженные к периоду версии (колонка «ОСВ» панели [34]; front-api-gaps D-3): уникальные
    `osv_account_code` документов `OSV` ЮЛ за год `year` и кварталы `quarters`; документ не `ERROR`, файл не
    `SUPERSEDED`/`ERROR`, квартал и код счёта заданы; статус подтверждения и привязка к версии не важны. Порядок —
    натуральный (`account_sort_key`); ЮЛ без документов в словаре нет."""
    if not entity_ids:
        return {}
    stmt = (
        select(Document.legal_entity_id, Document.osv_account_code)
        .join(StoredFile, StoredFile.id == Document.stored_file_id)
        .where(
            Document.legal_entity_id.in_(list(entity_ids)),
            Document.document_type == DocumentType.OSV,
            Document.period_year == year,
            Document.period_quarter.in_(list(quarters)),
            Document.status != DocumentStatus.ERROR,
            StoredFile.status.not_in([FileStatus.SUPERSEDED, FileStatus.ERROR]),
            Document.osv_account_code.is_not(None),
            Document.osv_account_code != "",
        )
        .distinct()
    )
    accounts: dict[uuid.UUID, list[str]] = defaultdict(list)
    for entity_id, code in await session.execute(stmt):
        accounts[entity_id].append(code)
    return {entity_id: sorted(codes, key=account_sort_key) for entity_id, codes in accounts.items()}

(period_quarter IN (...) отбрасывает NULL сам — документы без квартала не попадают.)

  • Step 4: Схема участника

api/src/schemas/consolidations.py: в импорт enums добавить EntityType. Класс:

class MemberOut(BaseModel):
    """ЮЛ периметра версии (front-api-gaps D-2, D-3): тип ЮЛ, самоописывающие ячейки чек-листа в порядке правил подхода
    и счета ОСВ, загруженные к периоду версии (`osv_accounts` — «что загружено», готовность — в ячейках)."""

    id: uuid.UUID
    name: str
    inn: str
    entity_type: EntityType
    is_head_entity: bool
    checklist: list[ChecklistCellOut]
    osv_accounts: list[str]
  • Step 5: Сервис

api/src/services/consolidations.py: from src.domain.consolidation_periods import quarters_through, reporting_date. В _card после получения rules, cells:

        accounts = await repo.osv_accounts(
            self._session,
            [entity.id for _, entity in members],
            consolidation.year,
            quarters_through(consolidation.quarter),
        )

и в MemberOut(...) добавить entity_type=entity.entity_type, и osv_accounts=accounts.get(entity.id, []),. Docstring _card: дописать «у членов — тип ЮЛ и счета ОСВ к периоду версии (D-3)».

  • Step 6: Запустить — проходят

Run: cd api && .venv/bin/pytest tests/test_openapi_routes.py tests/test_schemas_consolidations.py -v && .venv/bin/pytest -m integration tests/test_api_consolidations.py tests/test_api_consolidation_checklist.py tests/test_api_consolidation_members.py tests/test_consolidation_fixtures.py -v Expected: PASS.

  • Step 7: Линтеры и коммит

Run: make lint → без ошибок.

git add api/src/repositories/consolidations.py api/src/schemas/consolidations.py api/src/services/consolidations.py api/tests/test_api_consolidations.py api/tests/test_openapi_routes.py
git commit -m "feat(consolidations): тип ЮЛ и счета ОСВ к периоду версии у участника периметра (#42)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2"

Task 4: Документы workspace (bounded-PR в arm-ka-docs)

Files (репозиторий docs, отдельный worktree):

  • Create: dev/api-contract-files-directories.md
  • Modify: dev/api-contract-slice-4.md, dev/api-contract-slice-3b.md (ссылка), context/07-open-questions.md (OQ-43), context/10-user-journey.md (шаги 2.1, 2.2, 2.6, 2.5), mockup/INDEX.md ([42])

Interfaces:

  • Consumes: итоговые схемы Tasks 1–3 (ConsolidationCreate, ChecklistCellOut, MemberOut); существующие схемы api/src/schemas/files.py (FileOut, JobBrief, DocumentBrief), api/src/schemas/documents.py (DocumentOut, EntryOut — имя модуля проверить), api/src/schemas/directories.py (CompanyGroupOut, LegalEntityOut, RelatedGroupOut, RelatedGroupDetail, RegionalOfficeOut, фильтры CompanyGroupFilter, фильтр списка ЮЛ), api/src/schemas/common.py (Page, лимиты), api/src/api/endpoints/files/*.py (коды ошибок), README.md arm-core («Ошибки API»).

  • Produces: PR в RSHB/arm-ka-docs (номер — в описание PR arm-core, Task 5).

  • Step 1: Worktree и issue docs

cd /Users/andrey.epifancev/projects/awp-ca/.wt/front-gaps
rm docs                                  # это симлинк на основной checkout docs
git -C /Users/andrey.epifancev/projects/awp-ca/docs fetch -q
git -C /Users/andrey.epifancev/projects/awp-ca/docs worktree add -b tmp/front-gaps-docs /Users/andrey.epifancev/projects/awp-ca/.wt/front-gaps/docs origin/main
touch /Users/andrey.epifancev/projects/awp-ca/.wt/front-gaps/docs/.bounded
tea issues create --login home --repo RSHB/arm-ka-docs --title "Документы к arm-core#42: контракт файлов и справочников, OQ-43, модалка [42]" --description "Bounded: контракт для фронта (файлы/документы, справочники, новые поля консолидаций), OQ-43 (решение владельца 07.10.2026, П-3), баннер «Осталось: N» = все несопоставленные (П-1), шаг 2.1 пути и [42] в mockup/INDEX.md (блок «ЮЛ консолидации»), устаревшее «CSV в Срезе 4 нет». Код — RSHB/arm-core#42."
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/front-gaps/docs branch -m tmp/front-gaps-docs feat/<N>-front-api-gaps-docs

<N> — номер созданной issue (из вывода tea). .bounded в .gitignore docs — проверить git -C … status: файла в списке быть не должно; если он виден — не добавлять его в коммит.

  • Step 2: Новый контракт dev/api-contract-files-directories.md

Структура — как у dev/api-contract-slice-3b.md (шапка: назначение, общие правила со ссылкой на api-contract-slice-4.md «Общее»; таблица маршрутов; схемы; нюансы). Содержание — по коду, не по памяти:

  1. Файлы и документы (Срез 2c, используются панелью [39]/[40] и экраном [41]):
    • GET /legal-entities/{legal_entity_id}/files — параметры (include_superseded, limit (по умолчанию / максимум — из schemas/common.py), offset), порядок (по убыванию uploaded_at — проверить в репозитории), ответ Page[FileOut]; опрос во время распознавания — этим же списком или GET /files/{id}.
    • GET /files/{file_id} — FileOut; DELETE /files/{file_id} — 204, 409 FILE_HAS_CONFIRMED_DOCUMENTS / FILE_SUPERSEDES_OTHERS (коды и тексты — из endpoints/files/read.py и README «Ошибки API»).
    • POST /legal-entities/{id}/files — коды rejected[].code (UNSUPPORTED_FILE, FILE_TOO_LARGE, DUPLICATE_FILE, STORAGE_UNAVAILABLE — сверить с services/primary_files.py), 201 vs 422 {accepted, rejected} и второе тело 422 — доменная ошибка ENTITY_NOT_IN_PERIMETER ({"detail": {"code", "message"}}): клиент различает по форме.
    • GET /documents/{document_id} (DocumentOut), GET /documents/{document_id}/entries (EntryOut, пагинация).
    • Схемы FileOut, JobBrief, DocumentBrief — все поля с типами и nullable (из schemas/files.py); как показать «Период» в [39] (period_year + period_quarter + is_cumulative: Q3 нарастающий — «2024 · 9 мес.»), «Проверка» (counterparty_check; period_check всегда UNKNOWN — показывать «—», OQ-120), статус строки (файл UPLOADED/ PROCESSING — «Обработка», ERROR — «Ошибка»; документ RECOGNIZED/CONFIRMED — «Загружен», NEEDS_CHECK — «Требуется проверка»; подписи — 10-user-journey.md «Словарь UI»).
    • Фильтра по версии консолидации нет: drawer показывает все документы ЮЛ (решение владельца 07.10.2026).
  2. Справочники для экранов [03], [36], [42], [60]: GET /directories/company-groups (CompanyGroupOut, q ищет по name/crm_id/code, legal_entities_count; имя РФ — через GET /directories/regional-offices), GET /directories/company-groups/{id}/related-groups (ГСК/ГСЗ ГК, kind), GET /directories/related-groups/{id} (RelatedGroupDetail.members), GET /directories/legal-entities (фильтры company_group_id, outside_group, q; entity_type, documents_count); как собрать модалку [42]: ЮЛ ГК — legal-entities?company_group_id=, отметки — члены выбранной ГСК из related-groups/{id}; права — DIRECTORY_VIEW.
  • Step 3: dev/api-contract-slice-4.md

  • Таблица маршрутов, POST /consolidations: тело {company_group_id, related_group_id, year, quarter, legal_entity_ids?}; ошибки + 422 ENTITY_NOT_IN_GROUP (legal_entity_ids), PERIMETER_TOO_SMALL по выбранным; нюанс: без поля / null — периметр из ГСК, [] — 422; ГСК обязательна, в версии не хранится (П-2), колонка «ГСК» [34] до верификации пустая.

  • Схемы: members[] — {id, name, inn, entity_type ("ЮЛ"|"ИП"), is_head_entity, checklist[], osv_accounts[]}; ChecklistCellOut — {document_type, account (string|null), status, missing_quarters[], document_ids[]}: ячейка самоописывающая, искать по document_type/account, а не по индексу; osv_accounts — счета действующих ОСВ за год версии до её квартала в любом статусе, кроме ошибки; готовность — по ячейкам.

  • «Ответственный» на [34]: lock.locked_by_name, если есть активная блокировка, иначе initiator.name.

  • П-1: «Осталось: N» и «Показать ЮЛ без ИНН» — summary.unmapped_total и GET …/osv?unmapped=true: все строки свода без сопоставленного ЮЛ, в том числе с ИНН, которого нет в справочнике (решение владельца 07.10.2026).

  • П-3: «К отчетности» доступна во всех статусах (OQ-43 пересмотрен).

  • Строка | [37] Чек-лист | … (CSV в Срезе 4 нет) | → «… ?format=csv — CSV (Срез 3b)».

  • Nullable-поля ConsolidatedRowOut (account_code, account_root, row_number, counterparty_*, match_kind) и VersionBrief.created_at — пометить ?/| null (сверить с schemas/consolidations.py).

  • В «Общее» уточнить: лимиты страниц у разных списков разные (сводная ОСВ — 500/2000, файлы и справочники — из schemas/common.py); ссылка на новый api-contract-files-directories.md.

  • Step 4: OQ-43, путь пользователя, INDEX

  • context/07-open-questions.md, строка OQ-43: в колонку статуса — «решено владельцем 07.10.2026 (arm-core#42, П-3): «К отчетности» доступна во всех статусах начиная с INITIATED для ролей с CONSOLIDATION_VIEW; правка свода (сопоставление) — только «в работе» со своей блокировкой». Прежнее допущение не удалять — зачеркнуть или оставить с пометкой «было».

  • context/10-user-journey.md:

    • 2.1: после «ГК → ГСК → Год → Квартал» — «после выбора ГСК — блок «ЮЛ консолидации»: все неархивные ЮЛ ГК, отмечены члены выбранной ГСК, отметки меняются; «Выбрано ЮЛ: N. Минимум для консолидации: 2»»; API — POST /consolidations с legal_entity_ids; периметр — отмеченные ЮЛ.
    • 2.2: «Ответственный» — правило из Step 3; колонки ОФР / Бух. баланс / ОСВ — members[].checklist и osv_accounts, «Тип» — entity_type.
    • 2.3: «К отчетности» — во всех статусах (OQ-43).
    • 2.6: «CSV в Срезе 4 нет» → «CSV — ?format=csv (Срез 3b)».
    • 3.1: «Показать ЮЛ без ИНН» = все несопоставленные (П-1).
  • mockup/INDEX.md, строка 42: в описание добавить «после ГК и ГСК — блок «ЮЛ консолидации» (все неархивные ЮЛ ГК, отмечены члены ГСК, минимум 2; снимок снят до выбора ГК — блока на нём нет)».

  • Step 5: Проверка и коммит docs

Run: git -C /Users/andrey.epifancev/projects/awp-ca/.wt/front-gaps/docs diff --stat — изменены только перечисленные файлы; docs/source/** не тронут.

git -C /Users/andrey.epifancev/projects/awp-ca/.wt/front-gaps/docs add dev/api-contract-files-directories.md dev/api-contract-slice-4.md dev/api-contract-slice-3b.md context/07-open-questions.md context/10-user-journey.md mockup/INDEX.md
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/front-gaps/docs commit -m "docs: контракт файлов и справочников, поля консолидаций #42, OQ-43, модалка [42]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2"
git -C /Users/andrey.epifancev/projects/awp-ca/.wt/front-gaps/docs push -u origin feat/<N>-front-api-gaps-docs
tea pulls create --login home --repo RSHB/arm-ka-docs --head feat/<N>-front-api-gaps-docs --base main --title "Документы к arm-core#42: контракт файлов и справочников, OQ-43, модалка [42]" --description "Closes #<N>. Код — RSHB/arm-core#42 (PR ссылается сюда).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2"

Task 5: Завершение — README, CLAUDE.md, проверки, ревью, PR arm-core

Files:

  • Modify: README.md (раздел «Консолидация (Срез 4)», таблица эндпоинтов ~стр. 636–645; «Ошибки API» ~стр. 843–845)
  • Modify: CLAUDE.md (абзац «Консолидации (Срез 4)»)
  • Modify: openspec/changes/front-api-gaps/tasks.md

Interfaces:

  • Consumes: Tasks 1–4 (номер docs-PR из Task 4).

  • Produces: PR в RSHB/arm-core (Closes #42).

  • Step 1: README и CLAUDE.md

README, таблица эндпоинтов:

  • POST /consolidations → «создать консолидацию ГК за период и версию №1 (INITIATED); периметр — текущие члены ГСК (≥ 2) или выбранные ЮЛ ГК legal_entity_ids (≥ 2, front-api-gaps)».
  • GET /consolidations/versions/{id} → «карточка версии: периметр (тип ЮЛ, счета ОСВ к периоду версии), чек-лист по ЮЛ (самоописывающие ячейки), история статусов, блокировка». «Ошибки API»: PERIMETER_TOO_SMALL — «в ГСК (при создании без списка), в выбранных ЮЛ (legal_entity_ids) или в новом периметре (PUT …/members) меньше двух ЮЛ …»; ENTITY_NOT_IN_GROUP — «POST /consolidations с legal_entity_ids и PUT …/members: …». После таблицы эндпоинтов абзац: «Доработка #42 (openspec/changes/front-api-gaps/): создание с выбранным периметром, ячейки чек-листа с document_type/account, members[].entity_type/osv_accounts; контракт — ../docs/dev/api-contract-slice-4.md, файлы и справочники — ../docs/dev/api-contract-files-directories.md».

CLAUDE.md, абзац «Консолидации (Срез 4)», после перечня сервисов: «Создание и PUT …/members проверяют периметр одной функцией _validate_perimeter (#42); ячейки чек-листа самоописывающие (document_type, account); members[].osv_accounts — repositories/consolidations.osv_accounts (не подходящесть §3, а «что загружено к периоду»)».

openspec/changes/front-api-gaps/tasks.md — отметить [x] выполненные пункты 1–5 (пункт 4 этого tasks.md — README/ CLAUDE.md/OpenAPI — выполнен Tasks 1–3 и этим шагом; пункт 5 — Task 4 с номером docs-PR).

  • Step 2: Слить main и прогнать всё
git fetch -q origin && git merge --no-edit origin/main
make check-full 2>&1 | tail -40
openspec validate front-api-gaps --strict

Expected: check-full EXIT 0 (unit, integration, dbml-check, docker-build, web-check; e2e — skip без профиля services); Change 'front-api-gaps' is valid. Итоговые числа passed — в описание PR.

  • Step 3: Коммит описания
git add README.md CLAUDE.md openspec/changes/front-api-gaps/tasks.md
git commit -m "docs: README и CLAUDE.md — периметр при создании, ячейки и участник периметра (#42)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2"
  • Step 4: Финальное ревью ветки

Ревью всей ветки (git diff origin/main...HEAD) на модели opus против спеки openspec/changes/front-api-gaps/ и раздела Review Focus. Находки — маркеры C-n/I-n/M-n со статусом fixed <commit> / deferred / rejected <причина>; исправления — отдельными коммитами, повтор make check-full. Решения, принятые без владельца, — дописать в openspec/changes/front-api-gaps/design.md «Decisions made without discussion» (с № 9).

  • Step 5: Push и PR
git push -u origin feat/42-front-api-gaps
tea pulls create --login home --repo RSHB/arm-core --head feat/42-front-api-gaps --base main \
  --title "API до сводной ОСВ: периметр при создании, ячейки чек-листа, участник периметра (#42)" \
  --description "<описание>"

Описание PR: «Closes #42»; что сделано (3 пункта); docs-PR RSHB/arm-ka-docs#<M> (мержить вместе); вывод make check-full (числа); таблица находок ревью с маркерами и статусами; «Rulings I made» (решения №1–8 design.md + новые); deferred — отдельным списком; в конце:

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2

Merge — только по команде владельца.