Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
981 lines
60 KiB
Markdown
981 lines
60 KiB
Markdown
# 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 — только по команде владельца.
|