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

981 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` добавить:
```python
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` — тесты:
```python
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` в конец файла:
```python
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`:
```python
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`) добавить:
```python
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` одной строкой:
```python
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` (по ГСК):
```python
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: Коммит**
```bash
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`:
```python
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`:
```python
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` добавить:
```python
@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` — конструкторы:
```python
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 …`. Добавить тест:
```python
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` ячейки:
```python
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`:
```python
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` — в конец:
```python
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: Схема ячейки**
```python
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`:
```python
@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` → без ошибок.
```bash
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`. Добавить в конец файла:
```python
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` — в конец:
```python
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`:
```python
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`. Класс:
```python
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`:
```python
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` → без ошибок.
```bash
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**
```bash
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/**` не тронут.
```bash
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 и прогнать всё**
```bash
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: Коммит описания**
```bash
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**
```bash
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 — только по команде владельца.