Merge pull request 'API до сводной ОСВ: периметр при создании, ячейки чек-листа, участник периметра (#42)' (#46) from feat/42-front-api-gaps into main

Reviewed-on: #46
This commit is contained in:
aep 2026-10-07 17:21:34 +03:00
commit a6fae0b151
16 changed files with 1826 additions and 53 deletions

View File

@ -113,6 +113,9 @@
`POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed` /
`on_documents_superseded` / `on_documents_mapped`), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты,
экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»).
Создание и `PUT …/members` проверяют периметр одной функцией `_validate_perimeter` (#42); ячейки чек-листа самоописывающие
(`document_type`, `account`); `members[].osv_accounts` — `repositories/consolidations.osv_accounts` («что загружено к периоду
версии», а не подходящесть §3).
**Правила:** (1) статус версии меняет ТОЛЬКО `consolidation_status.transition`; версия к вызову уже под `FOR UPDATE` у
вызывающего (иначе параллельный переход или перехват читает устаревший статус); (2) **порядок блокировок: файл → документ →
advisory-lock ГК → версии (по возрастанию `id`)**; правка версии начинается с `consolidation_version FOR UPDATE`;

View File

@ -633,9 +633,9 @@ S3 (storage); без S3 — предупреждение в логе, цикл
| Метод и путь | Право | Что делает |
|---|---|---|
| `POST /consolidations` | EDIT | создать консолидацию ГК за период и версию №1 (`INITIATED`); члены — текущие члены ГСК (≥ 2) |
| `POST /consolidations` | EDIT | создать консолидацию ГК за период и версию №1 (`INITIATED`); периметр — текущие члены ГСК (≥ 2) или выбранные ЮЛ ГК `legal_entity_ids` (≥ 2, #42) |
| `GET /company-groups/{id}/consolidations` | VIEW | периоды ГК и их версии (`in_work_only`) |
| `GET /consolidations/versions/{id}` | VIEW | карточка версии: периметр, чек-лист по ЮЛ, история статусов, блокировка |
| `GET /consolidations/versions/{id}` | VIEW | карточка версии: периметр (тип ЮЛ, счета ОСВ к периоду версии), чек-лист по ЮЛ (самоописывающие ячейки), история статусов, блокировка |
| `POST /consolidations/versions/{id}/lock` | EDIT | «взять в работу»; `{takeover_reason}` — перехват чужой блокировки с причиной |
| `DELETE /consolidations/versions/{id}/lock` | EDIT | снять свою блокировку (204; нет активной — тоже 204) |
| `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` |
@ -650,6 +650,10 @@ S3 (storage); без S3 — предупреждение в логе, цикл
Деньги в ответах — строки JSON с двумя знаками (`"100.00"`); версия любого статуса (включая `CANCELLED`) читается.
Доработка #42 (`openspec/changes/front-api-gaps/`): создание с выбранным периметром (`legal_entity_ids`), ячейки чек-листа с
`document_type`/`account`, `members[].entity_type`/`osv_accounts`; контракт — `../docs/dev/api-contract-slice-4.md`, файлы и
справочники — `../docs/dev/api-contract-files-directories.md`.
### Статусы версии
Автоматические переходы (`services/consolidation_status.transition` — единственное место смены `consolidation_version.status`;
@ -840,9 +844,9 @@ email без учёта регистра проверяет только сер
| `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID |
| `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов |
| `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) |
| `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется |
| `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании без списка), в выбранных ЮЛ (`legal_entity_ids` при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется |
| `RELATED_GROUP_MISMATCH` | 422 | ГСК принадлежит другой ГК или имеет вид `gsz` (невидимая/неизвестная ГСК — 404 `NOT_FOUND`) |
| `ENTITY_NOT_IN_GROUP` | 422 | `PUT …/members`: ЮЛ не из ГК консолидации, без ГК или несуществующее (`legal_entity_ids` — перечень); проверка раньше `PERIMETER_TOO_SMALL` |
| `ENTITY_NOT_IN_GROUP` | 422 | `POST /consolidations` с `legal_entity_ids` и `PUT …/members`: ЮЛ не из ГК консолидации, без ГК или несуществующее (`legal_entity_ids` — перечень); проверка раньше `PERIMETER_TOO_SMALL` |
| `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка с `consolidation_version_id`: ЮЛ файла не входит в периметр версии |
| `QUERY_TOO_SHORT` | 422 | кандидаты `scope=all`: `q` короче 3 символов после обрезки |
| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) |

View File

@ -14,6 +14,7 @@ from sqlalchemy import Select, case, exists, false, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.checklist import EntityFilesState
from src.domain.osv_mapping import account_sort_key
from src.domain.policy import VisibilityScope, company_groups_visible
from src.models.access import AppUser
from src.models.accounting import ChartOfAccount
@ -350,6 +351,36 @@ async def unconfirmed_documents(session: AsyncSession, entity_ids: Collection[uu
return list((await session.execute(stmt)).scalars())
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()}
def _summary_entries(version_id: uuid.UUID) -> Select[tuple[OsvEntry]]:
"""Строки свода версии: контрагентские (`COUNTERPARTY`) строки BU/SUM ОСВ-документов, привязанных к версии."""
return (

View File

@ -20,6 +20,7 @@ from src.models.enums import (
ConsolidationStatus,
CounterpartyMatchKind,
DocumentType,
EntityType,
PeriodQuarter,
)
@ -42,12 +43,18 @@ class RefOut(BaseModel):
class ConsolidationCreate(BaseModel):
"""Тело `POST /consolidations`: ГК, ГСК периметра, период."""
"""Тело `POST /consolidations`: ГК, ГСК, период и необязательный выбранный периметр (модалка [42],
front-api-gaps 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)
class LockOut(BaseModel):
@ -93,21 +100,27 @@ class ConsolidationPeriodOut(BaseModel):
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]
class MemberOut(BaseModel):
"""ЮЛ периметра версии; `checklist` — ячейки в порядке правил подхода (как `rules[]` чек-листа)."""
"""ЮЛ периметра версии (front-api-gaps D-2, D-3): тип ЮЛ, самоописывающие ячейки чек-листа в порядке правил подхода
и счета ОСВ, загруженные к периоду версии (`osv_accounts` — «что загружено», готовность — в ячейках)."""
id: uuid.UUID
name: str
inn: str
entity_type: EntityType | None # DBML: `legal_entity.entity_type` nullable (I-1)
is_head_entity: bool
checklist: list[ChecklistCellOut]
osv_accounts: list[str]
class HistoryOut(BaseModel):

View File

@ -3,13 +3,15 @@
audit-trail `CONSOLIDATION_CREATE`; design D-15, D-16).
Порядок операции: право (`require` на маршруте) → видимость (ГК/версия — через ГК, невидимое = несуществующее, 404
с тем же телом) → проверки → запись. Перимeтр версии при создании — текущие члены ГСК, `is_head_entity = false`;
ГСК в `consolidation.related_group_id` не сохраняется (фиксируется при верификации, Срез 7). Блокировка на создателя
не ставится. Параллельное создание того же периода ловит UNIQUE `uq_consolidation_group_year_quarter` →
`IntegrityError` → 409 `CONSOLIDATION_EXISTS` (`common/constraint_conflicts.py`).
с тем же телом) → проверки → запись. Перимeтр версии при создании — текущие члены ГСК или выбранные ЮЛ ГК
(`legal_entity_ids`, front-api-gaps D-1), `is_head_entity = false`; ГСК в `consolidation.related_group_id` не
сохраняется (фиксируется при верификации, Срез 7). Блокировка на создателя не ставится. Параллельное создание того
же периода ловит UNIQUE `uq_consolidation_group_year_quarter` → `IntegrityError` → 409 `CONSOLIDATION_EXISTS`
(`common/constraint_conflicts.py`).
"""
import uuid
from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncSession
@ -17,7 +19,7 @@ from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
from src.domain import checklist as checklist_domain
from src.domain.consolidation_periods import reporting_date
from src.domain.consolidation_periods import quarters_through, reporting_date
from src.domain.policy import FINISHED_STATUSES, VisibilityScope
from src.models.access import AppUser
from src.models.consolidation import (
@ -87,11 +89,13 @@ class ConsolidationService:
self._principal = principal
async def create(self, data: ConsolidationCreate) -> VersionCard:
"""Консолидация ГК за период с версией №1 (`INITIATED`), периметром из ГСК, историей и аудитом.
"""Консолидация ГК за период с версией №1 (`INITIATED`), периметром (ГСК или выбранные ЮЛ), историей и аудитом.
Проверки по порядку: ГК видима (404) → ГСК видима (404), принадлежит ГК и `kind = gsk` (422
`RELATED_GROUP_MISMATCH`) → в ГСК не меньше двух текущих членов (422 `PERIMETER_TOO_SMALL`) → периода ещё нет
(409 `CONSOLIDATION_EXISTS`; гонку закрывает UNIQUE). Всё — в транзакции запроса: любая ошибка откатывает всё.
`RELATED_GROUP_MISMATCH`) → периметр: без `legal_entity_ids` — текущие члены ГСК, не меньше двух (422
`PERIMETER_TOO_SMALL`); со списком — `_validate_perimeter` (422 `ENTITY_NOT_IN_GROUP` / `PERIMETER_TOO_SMALL`) →
периода ещё нет (409 `CONSOLIDATION_EXISTS`; гонку закрывает UNIQUE). Всё — в транзакции запроса: любая ошибка
откатывает всё.
После проверки ГК берётся advisory-блокировка привязок ГК (R-2): подхват документов (`bind_confirmed`) не
разминётся с параллельным подтверждением или автозаменой документа ЮЛ этой ГК.
"""
@ -106,15 +110,18 @@ class ConsolidationService:
raise NotFoundError(RELATED_GROUP_NOT_FOUND)
if related.kind != RelatedGroupKind.gsk or related.company_group_id != group.id:
raise DomainValidationError("RELATED_GROUP_MISMATCH", "ГСК должна принадлежать выбранной ГК")
entities = [entity for _, entity in await directories_repo.current_members(self._session, related.id)]
if len(entities) < MIN_PERIMETER:
raise DomainValidationError(
"PERIMETER_TOO_SMALL", f"В ГСК должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(entities)}"
)
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)
if await repo.get_consolidation_for_group(self._session, self._scope, group.id, data.year, data.quarter):
raise ConflictError("CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана")
consolidation, version = await self._insert(group.id, data, entities)
consolidation, version = await self._insert(group.id, data, entity_ids)
await self._audit.record(
action=AuditAction.CONSOLIDATION_CREATE,
section=AuditSection.CONSOLIDATION,
@ -125,7 +132,7 @@ class ConsolidationService:
"consolidation_id": consolidation.id,
"year": consolidation.year,
"quarter": consolidation.quarter,
"members": sorted(entity.id for entity in entities),
"members": sorted(entity_ids),
},
)
return await self._card(version, consolidation)
@ -185,7 +192,7 @@ class ConsolidationService:
legal_entity_id=entity.id,
legal_entity_name=entity.name,
legal_entity_inn=entity.inn,
cells=[self._cell_out(cell) for cell in cells],
cells=[self._cell_out(rule, cell) for rule, cell in zip(rules, cells, strict=True)],
)
for entity, cells in rows
],
@ -229,9 +236,14 @@ class ConsolidationService:
return rules, rows
@staticmethod
def _cell_out(cell: checklist_domain.Cell) -> ChecklistCellOut:
def _cell_out(rule: checklist_domain.Rule, cell: checklist_domain.Cell) -> ChecklistCellOut:
"""Ячейка ответа с правилом (D-2); `rule` — правило того же столбца, что и `cell`."""
return ChecklistCellOut(
status=cell.status, missing_quarters=list(cell.missing_quarters), document_ids=list(cell.document_ids)
document_type=rule.document_type,
account=rule.account,
status=cell.status,
missing_quarters=list(cell.missing_quarters),
document_ids=list(cell.document_ids),
)
async def set_members(self, version_id: uuid.UUID, data: MembersIn) -> VersionCard:
@ -247,17 +259,7 @@ class ConsolidationService:
"""
user = self._principal.user
version, consolidation = await lock_editable_version(self._session, self._scope, version_id, user.id)
requested = list(dict.fromkeys(data.legal_entity_ids))
valid = await repo.entities_in_group(self._session, consolidation.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)}"
)
requested = await self._validate_perimeter(consolidation.company_group_id, data.legal_entity_ids)
current = {member.legal_entity_id: member for member, _ in await repo.members(self._session, version.id)}
added = [entity_id for entity_id in requested if entity_id not in current]
@ -291,8 +293,27 @@ class ConsolidationService:
)
return await self._card(version, consolidation)
async def _validate_perimeter(
self, company_group_id: uuid.UUID, entity_ids: Sequence[uuid.UUID]
) -> list[uuid.UUID]:
"""Периметр консолидации — одно правило для создания и `PUT …/members` (front-api-gaps 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
async def _insert(
self, group_id: uuid.UUID, data: ConsolidationCreate, entities: list[LegalEntity]
self, group_id: uuid.UUID, data: ConsolidationCreate, entity_ids: list[uuid.UUID]
) -> tuple[Consolidation, ConsolidationVersion]:
"""Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы."""
now = utc_now()
@ -322,8 +343,8 @@ class ConsolidationService:
self._session.add(version)
await self._session.flush()
self._session.add_all(
ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity.id, is_head_entity=False)
for entity in entities
ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity_id, is_head_entity=False)
for entity_id in entity_ids
)
self._session.add(
ConsolidationStatusHistory(
@ -336,7 +357,7 @@ class ConsolidationService:
)
await self._session.flush()
await consolidation_documents.bind_confirmed(
self._session, version=version, consolidation=consolidation, entity_ids=[entity.id for entity in entities]
self._session, version=version, consolidation=consolidation, entity_ids=entity_ids
)
return consolidation, version
@ -358,7 +379,8 @@ class ConsolidationService:
async def _card(self, version: ConsolidationVersion, consolidation: Consolidation) -> VersionCard:
"""Карточка версии: версия, консолидация, ГК, инициатор, периметр, история, активная блокировка.
Чек-лист у членов — те же ячейки, что у `GET …/checklist`, в порядке правил подхода.
Чек-лист у членов — те же ячейки, что у `GET …/checklist`, в порядке правил подхода; у членов — тип ЮЛ и счета
ОСВ к периоду версии (front-api-gaps D-3).
"""
group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id)
initiator = await self._session.get(AppUser, version.initiator_id)
@ -369,7 +391,13 @@ class ConsolidationService:
locks = await repo.active_locks(self._session, [version.id])
history = await repo.history(self._session, version.id)
brief = self._brief(version, locks.get(version.id), len(members))
_, cells = await self._checklist_cells(version, consolidation, [entity for _, entity in members])
rules, cells = await self._checklist_cells(version, consolidation, [entity for _, entity in members])
accounts = await repo.osv_accounts(
self._session,
[entity.id for _, entity in members],
consolidation.year,
quarters_through(consolidation.quarter),
)
return VersionCard(
**brief.model_dump(),
consolidation_id=consolidation.id,
@ -387,8 +415,12 @@ class ConsolidationService:
id=entity.id,
name=entity.name,
inn=entity.inn,
entity_type=entity.entity_type,
is_head_entity=member.is_head_entity,
checklist=[self._cell_out(cell) for cell in cells[position][1]],
checklist=[
self._cell_out(rule, cell) for rule, cell in zip(rules, cells[position][1], strict=True)
],
osv_accounts=accounts.get(entity.id, []),
)
for position, (member, entity) in enumerate(members)
],

View File

@ -83,8 +83,20 @@ async def test_checklist_osv_60_confirmed_osv_90_missing(auth_client: AuthClient
assert body["rows"][1]["legal_entity_inn"] == second.inn
cells = _cells(body, "ООО А")
assert len(cells) == len(EXPECTED_RULES)
assert cells[OSV_60] == {"status": "CONFIRMED", "missing_quarters": [], "document_ids": [str(i) for i in documents]}
assert cells[OSV_90] == {"status": "MISSING", "missing_quarters": [], "document_ids": []}
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": [],
}
assert {cell["status"] for cell in _cells(body, "ООО Б")} == {"MISSING"}
@ -107,6 +119,8 @@ async def test_checklist_gap_needs_check_and_processing(auth_client: AuthClientF
first_cells = _cells(body, "ООО А")
assert first_cells[OSV_60] == {
"document_type": "OSV",
"account": "60",
"status": "MISSING",
"missing_quarters": ["Q2"],
"document_ids": [str(first_q1), str(third_q3)],
@ -130,6 +144,25 @@ async def test_card_members_have_same_cells_as_checklist(auth_client: AuthClient
assert card["members"][0]["id"] == str(first.id)
@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
async def _bound_world(session: AsyncSession) -> tuple[ConsolidationVersion, list[LegalEntity]]:
version, entities = await _world(session)
for quarter in (Q1, Q2, Q3):

View File

@ -3,6 +3,7 @@
`CONSOLIDATION_CREATE`; design D-15, D-16)."""
import uuid
from collections.abc import Sequence
from typing import Any
import pytest
@ -18,16 +19,28 @@ from src.models.consolidation import (
ConsolidationStatusHistory,
ConsolidationVersion,
)
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter, RelatedGroupKind
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup, RelatedGroupMember
from src.models.enums import (
AuditAction,
AuditSection,
ConsolidationStatus,
DocumentStatus,
EntityType,
FileStatus,
PeriodQuarter,
RecognitionStatus,
RelatedGroupKind,
)
from tests.conftest import AuthClientFactory
from tests.factories import (
make_company_group,
make_consolidation,
make_document,
make_legal_entity,
make_lock,
make_membership,
make_related_group,
make_stored_file,
make_version,
)
@ -73,6 +86,18 @@ def _body(group: CompanyGroup, gsk: RelatedGroup, year: int = 2024, quarter: str
return {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": year, "quarter": quarter}
def _with_ids(body: dict[str, Any], ids: Sequence[uuid.UUID | str]) -> dict[str, Any]:
"""Тело создания с явным периметром (модалка [42], front-api-gaps 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())
async def _count(session: AsyncSession, model: type) -> int:
return (await session.execute(select(func.count()).select_from(model))).scalar_one()
@ -175,6 +200,149 @@ async def test_create_ignores_closed_memberships(auth_client: AuthClientFactory,
assert "ООО Бывший" not in [member["name"] for member in response.json()["members"]]
@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 is not None
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)
body = _with_ids(_body(group, gsk), [*[e.id for e in entities], stranger.id])
response = await client.post(CONSOLIDATIONS, json=body)
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
@pytest.mark.integration
async def test_create_repeated_period_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group, gsk, _ = await _scenario(seeded)
@ -404,3 +572,102 @@ async def test_version_card_requires_consolidation_view(auth_client: AuthClientF
client = await auth_client("admin@armka.local") # у роли ADMIN права CONSOLIDATION_VIEW нет
response = await client.get(f"{VERSIONS}/{versions['q3v1'].id}")
assert response.status_code == HTTP_FORBIDDEN
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"]
@pytest.mark.integration
async def test_card_member_without_entity_type_is_served(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""I-1: `legal_entity.entity_type` nullable в DBML — член периметра без типа не роняет карточку (тип `null`)."""
version, company, person = await _card_world(seeded)
person.entity_type = None
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.get(f"{VERSIONS}/{version.id}")
assert response.status_code == HTTP_OK, response.text
types = {member["id"]: member["entity_type"] for member in response.json()["members"]}
assert types == {str(company.id): "ЮЛ", str(person.id): None}

View File

@ -20,8 +20,20 @@ def test_render() -> None:
legal_entity_name='ООО "Альфа; Бета"',
legal_entity_inn="7700000000",
cells=[
ChecklistCellOut(status=CellStatus.CONFIRMED, missing_quarters=[], document_ids=[]),
ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[PeriodQuarter.Q2], document_ids=[]),
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=[],
),
],
)
],
@ -34,7 +46,9 @@ def test_render() -> None:
def _cell(status: CellStatus, *quarters: PeriodQuarter) -> ChecklistCellOut:
return ChecklistCellOut(status=status, missing_quarters=list(quarters), document_ids=[])
return ChecklistCellOut(
document_type=DocumentType.OSV, account="60", status=status, missing_quarters=list(quarters), document_ids=[]
)
def test_missing_quarters_listed_in_order() -> None:

View File

@ -244,3 +244,22 @@ def test_protected_routes_require_bearer_and_public_do_not(schema: dict[str, Any
elif not has_bearer:
missing.append(f"{method.upper()} {path}")
assert missing == [], f"маршруты без схемы {BEARER_SCHEME} (Authorize в Swagger не подставит токен): {missing}"
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", [])
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"])
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"])

View File

@ -10,7 +10,7 @@ from pydantic import ValidationError
from src.domain.checklist import CellStatus, EntityFilesState
from src.domain.consolidated_osv import CounterpartyType as DomainCounterpartyType
from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter
from src.models.enums import CounterpartyMatchKind, DocumentType, FileStatus, PeriodQuarter
from src.schemas import consolidations as schemas
from src.schemas.consolidations import (
MAX_MEMBERS,
@ -37,6 +37,16 @@ def test_consolidation_create_year_bounds() -> None:
ConsolidationCreate(year=year, **ids)
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 # type: ignore[arg-type]
selected = [uuid.uuid4() for _ in range(MAX_MEMBERS)]
assert len(ConsolidationCreate(**ids, legal_entity_ids=selected).legal_entity_ids or []) == MAX_MEMBERS # type: ignore[arg-type]
with pytest.raises(ValidationError):
ConsolidationCreate(**ids, legal_entity_ids=[*selected, uuid.uuid4()]) # type: ignore[arg-type]
def test_osv_page_params_limit_bounds() -> None:
assert (OsvPageParams().limit, OsvPageParams().offset) == (500, 0) # noqa: PLR2004
assert OsvPageParams(limit=MAX_LIMIT).limit == MAX_LIMIT
@ -113,10 +123,36 @@ def test_entity_files_state_is_frozen() -> None:
def test_checklist_cell_status_is_the_domain_enum() -> None:
"""Статус ячейки в схеме — `domain.checklist.CellStatus` (одно определение, T2.8): все значения принимаются."""
for status in CellStatus:
cell = ChecklistCellOut(status=status.value, missing_quarters=[], document_ids=[]) # type: ignore[arg-type]
cell = ChecklistCellOut(
document_type=DocumentType.OSV,
account="60",
status=status.value, # type: ignore[arg-type]
missing_quarters=[],
document_ids=[],
)
assert cell.status is status
with pytest.raises(ValidationError):
ChecklistCellOut(status="UNKNOWN", missing_quarters=[], document_ids=[]) # type: ignore[arg-type]
ChecklistCellOut(
document_type=DocumentType.OSV,
account="60",
status="UNKNOWN", # type: ignore[arg-type]
missing_quarters=[],
document_ids=[],
)
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
def test_counterparty_type_is_the_domain_enum() -> None:

View File

@ -0,0 +1,980 @@
# 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 — только по команде владельца.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-07

View File

@ -0,0 +1,110 @@
# Design: API до сводной ОСВ — пробелы по макету и комментариям фронтенда
## Context
Срезы 4 и 3b закрыли API пути пользователя до сводной ОСВ по `10-user-journey.md`. Аудит «экраны макета ↔ API»
(07.10.2026, находки П-1…П-14) и комментарии фронтенд-разработчика показали три места, где экран собирается только
цепочкой вызовов или не собирается вовсе:
- модалка [42] «Новая консолидация» — в коде макета (`docs/mockup/dkb_24.09.26/dkb_24.09.26.html`, блок «ЮЛ
консолидации») после выбора ГК и ГСК показывает все неархивные ЮЛ ГК, отмечены члены ГСК, отметки меняются, минимум
два; создаётся консолидация с отмеченным списком. Снимок `42-new-consolidation.png` снят до выбора ГК, поэтому
`10-user-journey.md` шаг 2.1 и `mockup/INDEX.md` этот блок не описывают (П-17);
- панель «Организации» [34] — ячейки чек-листа участника позиционные (П-15); нет «Тип» и перечня счетов ОСВ (П-9);
- комментарий фронта «нет ручки для файлов ЮЛ» — эндпоинт есть (`GET /legal-entities/{id}/files`, Срез 2c), но не описан
в контракте для фронта (П-5).
Модель данных не меняется: всё вычисляется из существующих таблиц DBML v5.16.
## Goals / Non-Goals
**Goals:** экран [42] создаёт консолидацию одним запросом с выбранным периметром; экран [34] строится из одной карточки
версии; обратная совместимость ответов (поля только добавляются, вход без `legal_entity_ids` ведёт себя как прежде).
**Non-Goals:** см. `proposal.md` «Non-goals» — хранение ГСК версии, поля Среза 7, `period_check` (OQ-120), фильтр
файлов по версии, П-4/П-6/П-11/П-12/П-13, DBML и контракт распознавания.
## Decisions
### D-1. Периметр при создании — необязательное `legal_entity_ids` (решение владельца, П-17)
`ConsolidationCreate.legal_entity_ids: list[uuid] | None = Field(default=None, max_length=MAX_MEMBERS)`. Без поля —
прежнее поведение (члены ГСК, `PERIMETER_TOO_SMALL` по ГСК). С полем — ровно выбранные ЮЛ ГК. ГСК остаётся
обязательной: модалка требует её всегда, по ней предзаполняются отметки, проверка `RELATED_GROUP_MISMATCH` сохраняется.
ГСК в версии не хранится (П-2): `consolidation.related_group_id` — NULL до верификации.
Проверка списка — та же, что у `PUT …/members`: дубли схлопываются в порядке первого вхождения,
`repo.entities_in_group` → 422 `ENTITY_NOT_IN_GROUP` с перечнем, затем `PERIMETER_TOO_SMALL`. Выношу её в функцию
сервиса `_validate_perimeter(company_group_id, ids) -> list[uuid]`, которую вызывают `create` и `set_members`: правила не
разойдутся. В `create` проверка стоит на месте проверки числа членов ГСК — после ГСК, до проверки периода (порядок
ошибок не меняется для запросов без списка).
`_insert` получает список ЮЛ (сейчас — сущности ГСК); подхват документов и аудит — как сейчас, по фактическому периметру.
Архивность ЮЛ отдельно не проверяется: принадлежность ГК определяется `legal_entity.company_group_id`, как в
`PUT …/members` (выведенное из ГК ЮЛ ГК уже не принадлежит).
Альтернатива «фронт делает create → lock → PUT members» отвергнута: не атомарна, два события аудита, не позволяет ГСК
из одного ЮЛ.
### D-2. Самоописывающие ячейки чек-листа (подход B)
`ChecklistCellOut` получает `document_type: DocumentType` и `account: str | None` правила. `_cell_out(rule, cell)`
заполняет их в обоих местах — карточка версии и `GET …/checklist`; порядок ячеек прежний. Доменный `checklist.build`
не меняется; `checklist_csv.render_checklist_csv` читает `ChecklistOut` (подписи столбцов — из `rules`, статусы — из
ячеек), новые поля ячейки не использует — вывод CSV не меняется, меняются только конструкторы ячеек в его тестах.
Альтернатива A (`rules[]` на уровне карточки) отвергнута: сохраняет позиционную связь, на которой споткнулся фронт.
Повтор двух коротких полей в каждой ячейке дешевле ошибки сопоставления по индексу.
### D-3. Участник периметра: `entity_type` и `osv_accounts` (вариант B)
`MemberOut.entity_type: EntityType` — из `legal_entity`. `MemberOut.osv_accounts: list[str]` — новый запрос репозитория
`osv_accounts(session, entity_ids, year, quarter) -> dict[uuid, list[str]]` одним `SELECT DISTINCT legal_entity_id,
osv_account_code` по `document ⋈ stored_file`: `document_type = OSV`, `period_year = year`, `period_quarter IN
quarters_through(quarter)`, `document.status != ERROR`, `stored_file.status NOT IN (SUPERSEDED, ERROR)`,
`osv_account_code IS NOT NULL`. Сортировка — в Python `account_sort_key` (та же натуральная, что у столбцов чек-листа);
ЮЛ без документов — пустой список.
Смысл колонки — «какие счета загружены к периоду версии», а не «что подойдёт своду»: подходящесть по §3 (нарастающая
vs квартальная, Q1-эквивалентность) здесь не применяется, готовность счетов комплекта видна по ячейкам чек-листа.
Квартал «не позже квартала версии», потому что свод склеивает квартальные ОСВ Q1…Qn.
### D-4. Документы — bounded-PR в docs после реализации
Контракт для фронта (файлы и документы, справочники, новые поля, правило «Ответственный» = `lock.locked_by_name` при
активной блокировке, иначе `initiator.name`), OQ-43 (П-3), формулировка баннера (П-1), шаг 2.1 пути и [42] в
`mockup/INDEX.md`, устаревшее «CSV в Срезе 4 нет». Сквозного change в docs нет: модель и контракт обмена не меняются.
## Risks / Trade-offs
- **Шире ответ карточки** — `osv_accounts` добавляет один запрос на карточку (индексы `document(legal_entity_id)` есть);
на реальных группах — десятки ЮЛ, нагрузка несущественна.
- **Фронт может спутать `osv_accounts` с «готово для свода»** — в контракте прямо сказать, что готовность — в ячейках
чек-листа.
- **Выбранный периметр без членов ГСК** допустим (отметки можно снять все, кроме двух любых ЮЛ ГК) — так в макете; ГСК
версии в Срезе 7 будет определяться периметром, а не выбранной ГСК (П-2).
## Migration Plan
Миграций нет. Ответы только расширяются; запросы без `legal_entity_ids` работают как прежде. Откат — revert PR.
## Open Questions
Новых нет. Решения П-1/П-2/П-3 владельца — в docs-PR (OQ-43 переписывается).
## Decisions made without discussion
| № | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 2026-10-07 | Явный периметр не обязан включать членов выбранной ГСК | так в макете (отметки ГСК снимаются); ГСК версии не хранится (П-2) | версия без ЮЛ ГСК — исправляется `PUT …/members` |
| 2 | 2026-10-07 | Архивность ЮЛ при создании не проверяется отдельно — только `company_group_id`, как в `PUT …/members` | одно правило периметра для создания и правки | фронт может отправить ЮЛ, которое в макете скрыто как архивное; ядро примет, если ЮЛ ещё в ГК |
| 3 | 2026-10-07 | `legal_entity_ids: null` равнозначно отсутствию поля | pydantic `None` по умолчанию; фронту проще | — |
| 4 | 2026-10-07 | `osv_accounts` не учитывает документы без квартала и без кода счёта | их не к чему отнести в колонке; пустой код — дефект распознавания, виден в drawer | ОСВ без периода не видна в колонке «ОСВ» до правки периода |
| 5 | 2026-10-07 | Порядок проверок создания: ГК → ГСК → периметр → период | периметр зависит от ГК и ГСК, конфликт периода — последним, как сейчас | при одновременной ошибке периметра и занятом периоде клиент увидит 422, а не 409 |
| 6 | 2026-10-07 | Фильтр файлов ЮЛ по версии не делается (решение владельца, вопрос 1, вариант A) | drawer [39] показывает все документы ЮЛ; OQ-120 | — |
| 7 | 2026-10-07 | `osv_accounts` — документы за год версии с кварталом ≤ версии в любом статусе, кроме `ERROR` (решение владельца, вопрос 2, вариант B) | «что загружено к периоду»; свод склеивает кварталы | — |
| 8 | 2026-10-07 | Ячейки чек-листа самоописывающие, без `rules[]` в карточке (решение владельца, подход B) | фронт не сопоставляет по индексу | — |
| 9 | 2026-10-07 | «Неархивные ЮЛ ГК» модалки [42] в контракте — `legal-entities?closed=false` (без `close_date`) | в API архив ЮЛ выражен только `close_date`; вывод из ГК и так снимает `company_group_id` | фронт покажет/скроет не те ЮЛ — правка строки контракта |
| 10 | 2026-10-07 | Интервал опроса файлов 3–5 с — рекомендация в контракте | спека молчит, фронту нужен ориентир | лишняя нагрузка или медленный отклик — правится в контракте |
| 11 | 2026-10-07 | `MemberOut.entity_type` nullable (I-1 финального ревью) | `legal_entity.entity_type` nullable в DBML; не-nullable схема давала бы 500 на карточке и создании | фронт обязан показать «—» для `null` |
| 12 | 2026-10-07 | Подписи статусов drawer [39] в контракте — как в чек-листе: `RECOGNIZED`/`NEEDS_CHECK` — «Требуется проверка», `CONFIRMED` — «Загружен» (I-4) | одна подпись на один статус документа во всех экранах | если владелец хочет «Загружен» для `RECOGNIZED` — правка контракта |

View File

@ -0,0 +1,65 @@
# Proposal: API до сводной ОСВ — пробелы по макету и комментариям фронтенда
**Срез:** доработка Срезов 4 и 3b (`../docs/context/09-delivery-plan.md`) — API пути пользователя до сводной ОСВ.
**Репозиторий:** arm-core. **Issue:** RSHB/arm-core#42. Документация — bounded-PR в docs (контракт для фронта, OQ-43,
`10-user-journey.md`, `mockup/INDEX.md`) после реализации; сквозного change нет: DBML и контракт распознавания не
меняются.
## Why
Аудит «экраны макета ↔ API» (07.10.2026) и первые комментарии фронтенд-разработчика показали, что три экрана пути
пользователя нельзя собрать без обходных цепочек вызовов:
- **[42] «Новая консолидация»** — в макете после выбора ГК и ГСК открывается блок «ЮЛ консолидации»: все неархивные ЮЛ
ГК, отмечены члены выбранной ГСК, отметки меняются, «Создать» активна при двух и более отмеченных. `POST /consolidations`
берёт периметр только из состава ГСК; обход «создать → взять в работу → `PUT …/members`» не атомарен, пишет два события
аудита и не спасает ГСК из одного ЮЛ (422 `PERIMETER_TOO_SMALL` по ГСК).
- **[34] панель «Организации»** — ячейки `members[].checklist[]` карточки версии позиционные: какая ячейка ББ, ОФР или
ОСВ 60, фронт узнаёт только из второго запроса `GET …/checklist` (`rules[]`). Колонок «Тип» и «ОСВ» (перечень счетов
загруженных ОСВ, в макете — и вне комплекта: 51, 52, 55) взять неоткуда.
## What Changes
- **`POST /consolidations`** — необязательное `legal_entity_ids` (≤ 500): без него периметр — текущие члены ГСК, как
сейчас; с ним — ровно выбранные ЮЛ ГК (422 `ENTITY_NOT_IN_GROUP`, `PERIMETER_TOO_SMALL` — по выбранным после
схлопывания дублей). ГСК остаётся обязательной и проверяется (`RELATED_GROUP_MISMATCH`), в версии не хранится.
Одна транзакция, одно событие `CONSOLIDATION_CREATE` с выбранным периметром.
- **Ячейки чек-листа самоописывающие** — `ChecklistCellOut` получает `document_type` и `account` своего правила; меняются
и карточка версии, и `GET …/checklist` (поля только добавляются; CSV не меняется).
- **Участник периметра в карточке** — `MemberOut.entity_type` (`ЮЛ`/`ИП`) и `MemberOut.osv_accounts` — счета действующих
ОСВ-документов ЮЛ за год версии с кварталом не позже квартала версии, в натуральном порядке.
Таблицы DBML v5.16 (только чтение, без изменений): `consolidation`, `consolidation_version`, `consolidation_member`,
`legal_entity`, `related_group`, `related_group_member`, `document`, `stored_file`, `required_document_rule`,
`audit_event`. Правила: `05-domain-rules.md` §9 (статусы версии), §3 п. 1 (подходящесть документа версии — не меняется).
## Решения владельца (07.10.2026)
- **П-1:** баннер «Осталось: N» и «Показать ЮЛ без ИНН» — все несопоставленные строки свода (`unmapped`,
`unmapped_total`); API не меняется, меняется описание в docs.
- **П-2:** ГСК в версии не хранится; периметр живёт в `consolidation_member`, в справочник переезжает по факту
верификации (Срез 7). Колонка «ГСК» на [34] до верификации не заполняется.
- **П-3:** допущение OQ-43 пересматривается — «К отчетности» доступна и в статусах «в работе» (только docs; API читает
свод в любом статусе).
- Фильтр файлов ЮЛ по версии не делается: drawer [39] в макете показывает все документы ЮЛ; существующий
`GET /legal-entities/{id}/files` описывается в контракте; «Проверка: Период» остаётся «—» (OQ-120).
- Колонка «ОСВ» — действующие документы за год версии до её квартала в любом статусе (вариант B).
- Ячейки чек-листа — самоописывающие, без `rules[]` в карточке (подход B).
## Non-goals
- UI (делает другой разработчик).
- Хранение ГСК версии, «Утверждён в РФ» / «Верифицирован в ГО» / «Дата верификации» (Срез 7).
- `period_check` документа относительно версии (OQ-120), фильтр файлов по версии.
- Шапка формы ОКЕИ/ОПФ/форма собственности/аудит (П-4, нужна новая версия контракта), монозаёмщики [60] (П-6),
поиск ГК по РФ (П-11), «Прочие документы» в чек-листе (П-12), единицы в экспорте (П-13).
- Изменение DBML, миграции, контракт распознавания.
## Impact
- `api/src/schemas/consolidations.py` (`ConsolidationCreate`, `ChecklistCellOut`, `MemberOut`),
`api/src/services/consolidations.py` (`create`, `set_members`, `_card`, `checklist`, `_cell_out`),
`api/src/repositories/consolidations.py` (счета ОСВ участников).
- Тесты: `test_api_consolidations.py`, `test_api_consolidation_members.py`, `test_api_consolidation_checklist.py`,
`test_schemas_consolidations.py`, `test_openapi_routes.py`.
- Фронт: новые поля только добавляются; существующие клиенты не ломаются.

View File

@ -0,0 +1,147 @@
# Spec Delta: consolidations-api
## MODIFIED Requirements
### Requirement: Создание консолидации
`POST /consolidations` (`CONSOLIDATION_EDIT`, policy по ГК) с телом `{company_group_id, related_group_id, year, quarter,
legal_entity_ids?}` SHALL в одной транзакции создать `consolidation` (`reporting_date`: Q1 → 01.04.year, Q2 → 01.07.year,
Q3 → 01.10.year, Q4 → 01.01.year+1; ASSUMPTION(OQ-40)), `consolidation_version` №1 (`INITIATED`, `scenario = CREATE`,
`approach = BASIC`, `goodwill_enabled = false`, `initiator_id` и `initiator_office_id` из пользователя,
`is_in_work = true`), `consolidation_member` периметра (`is_head_entity = false`), первую запись
`consolidation_status_history` (`from_status` NULL → `INITIATED`), подхват подтверждённых документов ЮЛ периметра (см.
`documents-api` «Подхват документов») и одно событие `audit_event(CONSOLIDATION_CREATE)`, у которого
`after_value.members` — отсортированный список ЮЛ периметра; ответ 201 с карточкой версии. Периметр SHALL быть: без
`legal_entity_ids` (поле отсутствует или `null`) — текущие члены ГСК; с `legal_entity_ids` — ровно эти ЮЛ после
схлопывания дублей (порядок первого вхождения), каждое SHALL принадлежать ГК консолидации
(`legal_entity.company_group_id`), иначе 422 `ENTITY_NOT_IN_GROUP` с перечнем `legal_entity_ids` чужих в порядке
запроса; состав ГСК при явном списке не проверяется и не меняется. ГСК SHALL быть обязательной, принадлежать ГК и иметь
`kind = gsk`, иначе 422 `RELATED_GROUP_MISMATCH`. Периметр меньше двух ЮЛ (членов ГСК без списка либо выбранных ЮЛ после
схлопывания) → 422 `PERIMETER_TOO_SMALL`; пара (ГК, год, квартал) уже есть → 409 `CONSOLIDATION_EXISTS` (в том числе при
гонке двух запросов — UNIQUE `(company_group_id, year, quarter)`); `year` вне 2000..2100 → 422. Порядок проверок: ГК
видима (404) → ГСК видима (404) и соответствует ГК (422) → периметр (422) → период свободен (409). Блокировка на
создателя MUST NOT ставиться; `MEMBERSHIP_CHANGE` при создании MUST NOT писаться. `related_group_id` в `consolidation`
SHALL оставаться NULL (фиксируется при верификации, Срез 7); `related_group_member` MUST NOT изменяться.
#### Scenario: Консолидация РБ за Q3 2024
- **WHEN** `ka_rf` Санкт-Петербургского РФ создаёт консолидацию ГК «Русский бисквит», ГСК из двух ЮЛ, 2024, Q3, без
`legal_entity_ids`
- **THEN** 201; версия №1 `INITIATED`, `reporting_date = 2024-10-01`, два `consolidation_member`, история с одной записью,
`audit_event(CONSOLIDATION_CREATE)` с `consolidation_version_id`; активной блокировки нет
#### Scenario: Выбранный периметр шире ГСК
- **WHEN** в ГК три ЮЛ, в ГСК два из них, создаётся консолидация с `legal_entity_ids` из всех трёх ЮЛ ГК
- **THEN** 201; в `members` три ЮЛ; `after_value.members` события `CONSOLIDATION_CREATE` — три ЮЛ; состав ГСК не изменился
#### Scenario: ГСК из одного ЮЛ, второе отмечено вручную
- **WHEN** в ГСК один текущий член, в `legal_entity_ids` он и ещё одно ЮЛ той же ГК
- **THEN** 201; в `members` два ЮЛ
#### Scenario: ГСК из одного ЮЛ
- **WHEN** в ГСК один текущий член, `legal_entity_ids` не передан
- **THEN** 422 `PERIMETER_TOO_SMALL`; `consolidation` и версия не созданы
#### Scenario: ЮЛ другой ГК в списке
- **WHEN** в `legal_entity_ids` попало ЮЛ, `company_group_id` которого не равен ГК консолидации
- **THEN** 422 `ENTITY_NOT_IN_GROUP` с этим ЮЛ в `legal_entity_ids`; `consolidation` не создана
#### Scenario: Дубли схлопываются до одного ЮЛ
- **WHEN** `legal_entity_ids` — один и тот же идентификатор дважды
- **THEN** 422 `PERIMETER_TOO_SMALL`
#### Scenario: Повтор периода
- **WHEN** консолидация ГК за 2024 Q3 уже есть, создаётся ещё одна
- **THEN** 409 `CONSOLIDATION_EXISTS`
### Requirement: Список консолидаций ГК и карточка версии
`GET /company-groups/{id}/consolidations` (`CONSOLIDATION_VIEW`, policy) SHALL возвращать периоды ГК (`consolidation_id`,
`year`, `quarter`, `reporting_date`) по убыванию года и квартала, в каждом — версии по убыванию `version_no`
(`id`, `version_no`, `status`, `scenario`, `approach`, `lock` — активная блокировка или null, `created_at`,
`members_count`); параметр `in_work_only=true` SHALL оставлять только версии со статусом не из
{`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED`}. `GET /consolidations/versions/{id}` SHALL отдавать карточку: поля
версии и консолидации, `company_group {id, name}`, инициатора и его офис, `lock`, `members[]`, `history[]`
(`from_status`, `to_status`, `changed_by`, `changed_at`, `comment`). Член периметра SHALL содержать `id`, `name`, `inn`,
`entity_type` (`ЮЛ`/`ИП`, из `legal_entity`), `is_head_entity`, `checklist[]` (ячейки по правилам подхода, см.
«Чек-лист комплекта») и `osv_accounts[]` — уникальные `osv_account_code` документов ЮЛ, у которых `document_type = OSV`,
`period_year` равен году версии, `period_quarter` не позже квартала версии, статус документа не `ERROR`, файл не в
`SUPERSEDED` и не в `ERROR`; документы без квартала или без кода счёта не учитываются; статус подтверждения и привязка к
версии не важны; порядок — натуральная сортировка кода счёта (как столбцы чек-листа: `01 < 51 < 58.03 < 60`). Консолидация
ГК вне видимости и несуществующий идентификатор — 404 `NOT_FOUND` с сообщением `CONSOLIDATION_NOT_FOUND` /
`VERSION_NOT_FOUND` и одинаковым телом (так же во всех спецификациях среза: идентификаторы `…_NOT_FOUND` — текст
сообщения при фиксированном коде `NOT_FOUND`).
#### Scenario: Дерево версий
- **WHEN** у ГК консолидации 2024 Q1 (версии 1 и 2) и 2024 Q3 (версия 1), запрошен список
- **THEN** первым период 2024 Q3, затем 2024 Q1 с версиями `[2, 1]`; `members_count` у каждой версии
#### Scenario: Чужой филиал
- **WHEN** `ka_rf` другого филиала запрашивает карточку версии
- **THEN** 404 `NOT_FOUND` (сообщение `VERSION_NOT_FOUND`), тело совпадает с ответом на случайный `uuid`
#### Scenario: Счета ОСВ участника
- **WHEN** версия за 2024 Q3; у ЮЛ есть ОСВ 60 за Q1 2024 (`RECOGNIZED`), ОСВ 51 за Q3 2024 (`CONFIRMED`), ОСВ 58.03 за
Q3 2024 (`NEEDS_CHECK`), ОСВ 62 за Q4 2024, ОСВ 01 за 2023, ОСВ 76 в заменённом (`SUPERSEDED`) файле и ОСВ 90 в
документе `ERROR`
- **THEN** `osv_accounts = ["51", "58.03", "60"]`
#### Scenario: Тип ЮЛ
- **WHEN** в периметре индивидуальный предприниматель
- **THEN** у него `entity_type = "ИП"`, у остальных — `"ЮЛ"`
### Requirement: Чек-лист комплекта
`GET /consolidations/versions/{id}/checklist` (`CONSOLIDATION_VIEW`, policy) SHALL вернуть `rules[]` (правила
`required_document_rule` подхода версии: `document_type`, `account`, `is_mandatory`; порядок — `BALANCE`, `INCOME`,
затем ОСВ по коду счёта) и `rows[]` по ЮЛ периметра (по имени) с `cells[]` в порядке `rules`. Ячейка `{document_type,
account, status, missing_quarters, document_ids}`: `document_type` и `account` — правила ячейки (ячейка
самоописывающая, фронт не опирается на позицию); `CONFIRMED` — пара (ЮЛ, правило) полностью покрыта привязанными
документами (ОСВ: по правилу выбора источников `consolidated-osv`; ББ/ОФР: документ за квартал версии);
`NEEDS_CHECK` / `RECOGNIZED` — есть неподтверждённый документ ЮЛ того же типа, счёта и года, подходящий версии
(приоритет `NEEDS_CHECK`); `PROCESSING` — у ЮЛ есть файл `UPLOADED` или `PROCESSING`; `ERROR` — последний по
`uploaded_at` файл ЮЛ в `ERROR`; иначе `MISSING`. `missing_quarters` SHALL перечислять кварталы без квартальной ОСВ при
частичном покрытии. Правило ОСВ SHALL закрываться только документом с `osv_account_code`, равным `account` правила
(OQ-04). Те же ячейки (с `document_type` и `account`, в том же порядке) SHALL отдаваться в карточке версии у каждого
члена. С `?format=csv` SHALL отдаваться тот же чек-лист файлом `text/csv; charset=utf-8` с BOM и разделителем `;`:
заголовок «Организация;ИНН;<правило>…» (правила — «Бух. баланс», «ОФР», «ОСВ <счёт>»), ячейки — подписи макета:
`CONFIRMED` «Загружен», `PROCESSING` «Обработка», `NEEDS_CHECK`/`RECOGNIZED` «Требуется проверка», `ERROR` «Ошибка
загрузки», `MISSING` «Отсутствует» (с « (нет: Q1, Q2)» при `missing_quarters`); `Content-Disposition` — `attachment` с
именем `checklist-<ГК>-<год>-<квартал>.csv` в RFC 5987.
#### Scenario: Пробел Q2
- **WHEN** у ЮЛ привязаны квартальные ОСВ 60 за Q1 и Q3 2024, версия за 2024 Q3
- **THEN** ячейка ОСВ 60: `document_type = OSV`, `account = "60"`, `status = MISSING`, `missing_quarters = ["Q2"]`,
`document_ids` — два документа
#### Scenario: Субсчёт не закрывает правило
- **WHEN** у ЮЛ подтверждена только ОСВ 58.03 за квартал версии
- **THEN** ячейка ОСВ 58 — `MISSING`
#### Scenario: Файл в обработке
- **WHEN** у ЮЛ загружен файл, задание ещё не завершено, других документов нет
- **THEN** все ячейки ЮЛ — `PROCESSING`
#### Scenario: Ячейки карточки подписаны
- **WHEN** запрошена карточка версии подхода `BASIC`
- **THEN** у каждого члена `checklist[i].document_type` и `checklist[i].account` равны `rules[i]` чек-листа той же
версии; ячейка ББ — `document_type = BALANCE`, `account = null`
#### Scenario: Чек-лист в CSV
- **WHEN** запрошен чек-лист версии с `format=csv`, у ЮЛ подтверждён ББ, ОСВ 60 отсутствует за Q2
- **THEN** файл начинается с BOM, первая строка — «Организация;ИНН;Бух. баланс;ОФР;ОСВ 60…», в строке ЮЛ — «Загружен» и «Отсутствует (нет: Q2)»
### Requirement: Границы входных данных
Входы консолидаций SHALL иметь верхние границы: `legal_entity_ids` тела `PUT …/members` и тела `POST /consolidations` —
не больше 500 идентификаторов, `comment` тела `POST …/status` — не больше 1000 символов, `q` фильтров `GET …/osv` и
экспорта — не больше 500 символов. Превышение SHALL давать 422 со списком ошибок валидации (`detail[].loc` указывает
поле) до обращения к БД. Версия консолидации SHALL проверяться на видимость до блокировки `FOR UPDATE`: невидимая
версия отвечает 404 сразу, не ожидая транзакцию, которая держит её строку.
#### Scenario: Периметр длиннее предела
- **WHEN** в `PUT …/members` передано 501 ЮЛ
- **THEN** 422, `detail[0].loc = ["body", "legal_entity_ids"]`, периметр не изменился
#### Scenario: Периметр создания длиннее предела
- **WHEN** в `POST /consolidations` передано 501 ЮЛ в `legal_entity_ids`
- **THEN** 422, `detail[0].loc = ["body", "legal_entity_ids"]`, консолидация не создана
#### Scenario: Невидимая версия при занятой строке
- **WHEN** версию чужого филиала держит `FOR UPDATE` другая транзакция, пользователь без видимости вызывает `POST …/lock`
- **THEN** 404 `VERSION_NOT_FOUND` без ожидания

View File

@ -0,0 +1,17 @@
# Tasks: API до сводной ОСВ — пробелы по макету и комментариям фронтенда
План реализации — `docs/superpowers/plans/2026-10-07-front-api-gaps.md` (5 задач, исполнение inline). Репозиторий —
arm-core; команды — из корня (`make`), `pytest` — из `api/`.
- [x] 1 Создание консолидации с выбранным периметром: `ConsolidationCreate.legal_entity_ids`, общая проверка периметра
для `create` и `set_members` (`services/consolidations.py`), тесты `test_api_consolidations.py`,
`test_schemas_consolidations.py`
- [x] 2 Самоописывающие ячейки чек-листа: `ChecklistCellOut.document_type/account`, `_cell_out(rule, cell)`, тесты
`test_api_consolidation_checklist.py`, карточка версии
- [x] 3 Участник периметра: `MemberOut.entity_type`, `MemberOut.osv_accounts` (`repositories/consolidations.osv_accounts`),
тесты карточки версии
- [x] 4 OpenAPI (`test_openapi_routes.py`), README «Консолидации», CLAUDE.md репозитория
- [x] 5 Документы workspace (bounded-PR в docs): контракт для фронта (файлы/документы, справочники, новые поля,
«Ответственный»), OQ-43 (П-3), баннер (П-1), `10-user-journey.md` шаг 2.1 и строка про CSV,
`mockup/INDEX.md` [42], `dev/api-contract-slice-4.md` — PR RSHB/arm-ka-docs#51 (issue #50)
- [ ] 6 Завершение: `make check-full`, `openspec validate --strict`, финальное ревью ветки, PR arm-core (Closes #42)