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` / `POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed` /
`on_documents_superseded` / `on_documents_mapped`), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты, `on_documents_superseded` / `on_documents_mapped`), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты,
экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»). экспорт), `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` у **Правила:** (1) статус версии меняет ТОЛЬКО `consolidation_status.transition`; версия к вызову уже под `FOR UPDATE` у
вызывающего (иначе параллельный переход или перехват читает устаревший статус); (2) **порядок блокировок: файл → документ → вызывающего (иначе параллельный переход или перехват читает устаревший статус); (2) **порядок блокировок: файл → документ →
advisory-lock ГК → версии (по возрастанию `id`)**; правка версии начинается с `consolidation_version FOR UPDATE`; 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 /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}` — перехват чужой блокировки с причиной | | `POST /consolidations/versions/{id}/lock` | EDIT | «взять в работу»; `{takeover_reason}` — перехват чужой блокировки с причиной |
| `DELETE /consolidations/versions/{id}/lock` | EDIT | снять свою блокировку (204; нет активной — тоже 204) | | `DELETE /consolidations/versions/{id}/lock` | EDIT | снять свою блокировку (204; нет активной — тоже 204) |
| `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` | | `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` |
@ -650,6 +650,10 @@ S3 (storage); без S3 — предупреждение в логе, цикл
Деньги в ответах — строки JSON с двумя знаками (`"100.00"`); версия любого статуса (включая `CANCELLED`) читается. Деньги в ответах — строки 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`; Автоматические переходы (`services/consolidation_status.transition` — единственное место смены `consolidation_version.status`;
@ -840,9 +844,9 @@ email без учёта регистра проверяет только сер
| `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID | | `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID |
| `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов | | `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов |
| `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) | | `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`) | | `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`: ЮЛ файла не входит в периметр версии | | `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка с `consolidation_version_id`: ЮЛ файла не входит в периметр версии |
| `QUERY_TOO_SHORT` | 422 | кандидаты `scope=all`: `q` короче 3 символов после обрезки | | `QUERY_TOO_SHORT` | 422 | кандидаты `scope=all`: `q` короче 3 символов после обрезки |
| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) | | `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 sqlalchemy.ext.asyncio import AsyncSession
from src.domain.checklist import EntityFilesState 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.domain.policy import VisibilityScope, company_groups_visible
from src.models.access import AppUser from src.models.access import AppUser
from src.models.accounting import ChartOfAccount 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()) 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]]: def _summary_entries(version_id: uuid.UUID) -> Select[tuple[OsvEntry]]:
"""Строки свода версии: контрагентские (`COUNTERPARTY`) строки BU/SUM ОСВ-документов, привязанных к версии.""" """Строки свода версии: контрагентские (`COUNTERPARTY`) строки BU/SUM ОСВ-документов, привязанных к версии."""
return ( return (

View File

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

View File

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

View File

@ -3,6 +3,7 @@
`CONSOLIDATION_CREATE`; design D-15, D-16).""" `CONSOLIDATION_CREATE`; design D-15, D-16)."""
import uuid import uuid
from collections.abc import Sequence
from typing import Any from typing import Any
import pytest import pytest
@ -18,16 +19,28 @@ from src.models.consolidation import (
ConsolidationStatusHistory, ConsolidationStatusHistory,
ConsolidationVersion, ConsolidationVersion,
) )
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup, RelatedGroupMember
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter, RelatedGroupKind from src.models.enums import (
AuditAction,
AuditSection,
ConsolidationStatus,
DocumentStatus,
EntityType,
FileStatus,
PeriodQuarter,
RecognitionStatus,
RelatedGroupKind,
)
from tests.conftest import AuthClientFactory from tests.conftest import AuthClientFactory
from tests.factories import ( from tests.factories import (
make_company_group, make_company_group,
make_consolidation, make_consolidation,
make_document,
make_legal_entity, make_legal_entity,
make_lock, make_lock,
make_membership, make_membership,
make_related_group, make_related_group,
make_stored_file,
make_version, 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} 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: async def _count(session: AsyncSession, model: type) -> int:
return (await session.execute(select(func.count()).select_from(model))).scalar_one() 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"]] 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 @pytest.mark.integration
async def test_create_repeated_period_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: async def test_create_repeated_period_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
group, gsk, _ = await _scenario(seeded) 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 нет client = await auth_client("admin@armka.local") # у роли ADMIN права CONSOLIDATION_VIEW нет
response = await client.get(f"{VERSIONS}/{versions['q3v1'].id}") response = await client.get(f"{VERSIONS}/{versions['q3v1'].id}")
assert response.status_code == HTTP_FORBIDDEN 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_name='ООО "Альфа; Бета"',
legal_entity_inn="7700000000", legal_entity_inn="7700000000",
cells=[ cells=[
ChecklistCellOut(status=CellStatus.CONFIRMED, missing_quarters=[], document_ids=[]), ChecklistCellOut(
ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[PeriodQuarter.Q2], document_ids=[]), 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: 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: 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: elif not has_bearer:
missing.append(f"{method.upper()} {path}") missing.append(f"{method.upper()} {path}")
assert missing == [], f"маршруты без схемы {BEARER_SCHEME} (Authorize в Swagger не подставит токен): {missing}" 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.checklist import CellStatus, EntityFilesState
from src.domain.consolidated_osv import CounterpartyType as DomainCounterpartyType 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 import consolidations as schemas
from src.schemas.consolidations import ( from src.schemas.consolidations import (
MAX_MEMBERS, MAX_MEMBERS,
@ -37,6 +37,16 @@ def test_consolidation_create_year_bounds() -> None:
ConsolidationCreate(year=year, **ids) 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: def test_osv_page_params_limit_bounds() -> None:
assert (OsvPageParams().limit, OsvPageParams().offset) == (500, 0) # noqa: PLR2004 assert (OsvPageParams().limit, OsvPageParams().offset) == (500, 0) # noqa: PLR2004
assert OsvPageParams(limit=MAX_LIMIT).limit == MAX_LIMIT 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: def test_checklist_cell_status_is_the_domain_enum() -> None:
"""Статус ячейки в схеме — `domain.checklist.CellStatus` (одно определение, T2.8): все значения принимаются.""" """Статус ячейки в схеме — `domain.checklist.CellStatus` (одно определение, T2.8): все значения принимаются."""
for status in CellStatus: 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 assert cell.status is status
with pytest.raises(ValidationError): 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: 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)