Merge pull request 'API до сводной ОСВ: периметр при создании, ячейки чек-листа, участник периметра (#42)' (#46) from feat/42-front-api-gaps into main
Reviewed-on: #46
This commit is contained in:
commit
a6fae0b151
@ -113,6 +113,9 @@
|
||||
`POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed` /
|
||||
`on_documents_superseded` / `on_documents_mapped`), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты,
|
||||
экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»).
|
||||
Создание и `PUT …/members` проверяют периметр одной функцией `_validate_perimeter` (#42); ячейки чек-листа самоописывающие
|
||||
(`document_type`, `account`); `members[].osv_accounts` — `repositories/consolidations.osv_accounts` («что загружено к периоду
|
||||
версии», а не подходящесть §3).
|
||||
**Правила:** (1) статус версии меняет ТОЛЬКО `consolidation_status.transition`; версия к вызову уже под `FOR UPDATE` у
|
||||
вызывающего (иначе параллельный переход или перехват читает устаревший статус); (2) **порядок блокировок: файл → документ →
|
||||
advisory-lock ГК → версии (по возрастанию `id`)**; правка версии начинается с `consolidation_version FOR UPDATE`;
|
||||
|
||||
12
README.md
12
README.md
@ -633,9 +633,9 @@ S3 (storage); без S3 — предупреждение в логе, цикл
|
||||
|
||||
| Метод и путь | Право | Что делает |
|
||||
|---|---|---|
|
||||
| `POST /consolidations` | EDIT | создать консолидацию ГК за период и версию №1 (`INITIATED`); члены — текущие члены ГСК (≥ 2) |
|
||||
| `POST /consolidations` | EDIT | создать консолидацию ГК за период и версию №1 (`INITIATED`); периметр — текущие члены ГСК (≥ 2) или выбранные ЮЛ ГК `legal_entity_ids` (≥ 2, #42) |
|
||||
| `GET /company-groups/{id}/consolidations` | VIEW | периоды ГК и их версии (`in_work_only`) |
|
||||
| `GET /consolidations/versions/{id}` | VIEW | карточка версии: периметр, чек-лист по ЮЛ, история статусов, блокировка |
|
||||
| `GET /consolidations/versions/{id}` | VIEW | карточка версии: периметр (тип ЮЛ, счета ОСВ к периоду версии), чек-лист по ЮЛ (самоописывающие ячейки), история статусов, блокировка |
|
||||
| `POST /consolidations/versions/{id}/lock` | EDIT | «взять в работу»; `{takeover_reason}` — перехват чужой блокировки с причиной |
|
||||
| `DELETE /consolidations/versions/{id}/lock` | EDIT | снять свою блокировку (204; нет активной — тоже 204) |
|
||||
| `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` |
|
||||
@ -650,6 +650,10 @@ S3 (storage); без S3 — предупреждение в логе, цикл
|
||||
|
||||
Деньги в ответах — строки JSON с двумя знаками (`"100.00"`); версия любого статуса (включая `CANCELLED`) читается.
|
||||
|
||||
Доработка #42 (`openspec/changes/front-api-gaps/`): создание с выбранным периметром (`legal_entity_ids`), ячейки чек-листа с
|
||||
`document_type`/`account`, `members[].entity_type`/`osv_accounts`; контракт — `../docs/dev/api-contract-slice-4.md`, файлы и
|
||||
справочники — `../docs/dev/api-contract-files-directories.md`.
|
||||
|
||||
### Статусы версии
|
||||
|
||||
Автоматические переходы (`services/consolidation_status.transition` — единственное место смены `consolidation_version.status`;
|
||||
@ -840,9 +844,9 @@ email без учёта регистра проверяет только сер
|
||||
| `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID |
|
||||
| `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов |
|
||||
| `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) |
|
||||
| `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется |
|
||||
| `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании без списка), в выбранных ЮЛ (`legal_entity_ids` при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется |
|
||||
| `RELATED_GROUP_MISMATCH` | 422 | ГСК принадлежит другой ГК или имеет вид `gsz` (невидимая/неизвестная ГСК — 404 `NOT_FOUND`) |
|
||||
| `ENTITY_NOT_IN_GROUP` | 422 | `PUT …/members`: ЮЛ не из ГК консолидации, без ГК или несуществующее (`legal_entity_ids` — перечень); проверка раньше `PERIMETER_TOO_SMALL` |
|
||||
| `ENTITY_NOT_IN_GROUP` | 422 | `POST /consolidations` с `legal_entity_ids` и `PUT …/members`: ЮЛ не из ГК консолидации, без ГК или несуществующее (`legal_entity_ids` — перечень); проверка раньше `PERIMETER_TOO_SMALL` |
|
||||
| `ENTITY_NOT_IN_PERIMETER` | 422 | загрузка с `consolidation_version_id`: ЮЛ файла не входит в периметр версии |
|
||||
| `QUERY_TOO_SHORT` | 422 | кандидаты `scope=all`: `q` короче 3 символов после обрезки |
|
||||
| `COMMENT_REQUIRED` | 422 | `POST …/status` в `CANCELLED` без комментария (из одних пробелов — тоже) |
|
||||
|
||||
@ -14,6 +14,7 @@ from sqlalchemy import Select, case, exists, false, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domain.checklist import EntityFilesState
|
||||
from src.domain.osv_mapping import account_sort_key
|
||||
from src.domain.policy import VisibilityScope, company_groups_visible
|
||||
from src.models.access import AppUser
|
||||
from src.models.accounting import ChartOfAccount
|
||||
@ -350,6 +351,36 @@ async def unconfirmed_documents(session: AsyncSession, entity_ids: Collection[uu
|
||||
return list((await session.execute(stmt)).scalars())
|
||||
|
||||
|
||||
async def osv_accounts(
|
||||
session: AsyncSession, entity_ids: Collection[uuid.UUID], year: int, quarters: Collection[PeriodQuarter]
|
||||
) -> dict[uuid.UUID, list[str]]:
|
||||
"""Счета ОСВ, загруженные к периоду версии (колонка «ОСВ» панели [34]; front-api-gaps D-3): уникальные
|
||||
`osv_account_code` документов `OSV` ЮЛ за год `year` и кварталы `quarters`; документ не `ERROR`, файл не
|
||||
`SUPERSEDED`/`ERROR`, квартал и код счёта заданы; статус подтверждения и привязка к версии не важны. Порядок —
|
||||
натуральный (`account_sort_key`); ЮЛ без документов в словаре нет."""
|
||||
if not entity_ids:
|
||||
return {}
|
||||
stmt = (
|
||||
select(Document.legal_entity_id, Document.osv_account_code)
|
||||
.join(StoredFile, StoredFile.id == Document.stored_file_id)
|
||||
.where(
|
||||
Document.legal_entity_id.in_(list(entity_ids)),
|
||||
Document.document_type == DocumentType.OSV,
|
||||
Document.period_year == year,
|
||||
Document.period_quarter.in_(list(quarters)),
|
||||
Document.status != DocumentStatus.ERROR,
|
||||
StoredFile.status.not_in([FileStatus.SUPERSEDED, FileStatus.ERROR]),
|
||||
Document.osv_account_code.is_not(None),
|
||||
Document.osv_account_code != "",
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
accounts: dict[uuid.UUID, list[str]] = defaultdict(list)
|
||||
for entity_id, code in await session.execute(stmt):
|
||||
accounts[entity_id].append(code)
|
||||
return {entity_id: sorted(codes, key=account_sort_key) for entity_id, codes in accounts.items()}
|
||||
|
||||
|
||||
def _summary_entries(version_id: uuid.UUID) -> Select[tuple[OsvEntry]]:
|
||||
"""Строки свода версии: контрагентские (`COUNTERPARTY`) строки BU/SUM ОСВ-документов, привязанных к версии."""
|
||||
return (
|
||||
|
||||
@ -20,6 +20,7 @@ from src.models.enums import (
|
||||
ConsolidationStatus,
|
||||
CounterpartyMatchKind,
|
||||
DocumentType,
|
||||
EntityType,
|
||||
PeriodQuarter,
|
||||
)
|
||||
|
||||
@ -42,12 +43,18 @@ class RefOut(BaseModel):
|
||||
|
||||
|
||||
class ConsolidationCreate(BaseModel):
|
||||
"""Тело `POST /consolidations`: ГК, ГСК периметра, период."""
|
||||
"""Тело `POST /consolidations`: ГК, ГСК, период и необязательный выбранный периметр (модалка [42],
|
||||
front-api-gaps D-1).
|
||||
|
||||
`legal_entity_ids` нет или `null` — периметр из текущих членов ГСК; список — ровно эти ЮЛ ГК (дубли схлопывает
|
||||
сервис).
|
||||
"""
|
||||
|
||||
company_group_id: uuid.UUID
|
||||
related_group_id: uuid.UUID
|
||||
year: int = Field(ge=YEAR_MIN, le=YEAR_MAX)
|
||||
quarter: PeriodQuarter
|
||||
legal_entity_ids: list[uuid.UUID] | None = Field(default=None, max_length=MAX_MEMBERS)
|
||||
|
||||
|
||||
class LockOut(BaseModel):
|
||||
@ -93,21 +100,27 @@ class ConsolidationPeriodOut(BaseModel):
|
||||
|
||||
|
||||
class ChecklistCellOut(BaseModel):
|
||||
"""Ячейка чек-листа: статус пары (ЮЛ, правило), недостающие кварталы ОСВ и привязанные документы."""
|
||||
"""Ячейка чек-листа — самоописывающая (front-api-gaps D-2): правило ячейки (`document_type`, `account` — счёт ОСВ,
|
||||
`None` у ББ/ОФР), статус пары (ЮЛ, правило), недостающие кварталы ОСВ и привязанные документы."""
|
||||
|
||||
document_type: DocumentType
|
||||
account: str | None
|
||||
status: CellStatus # единственное определение — `domain.checklist.CellStatus` (T2.8)
|
||||
missing_quarters: list[PeriodQuarter]
|
||||
document_ids: list[uuid.UUID]
|
||||
|
||||
|
||||
class MemberOut(BaseModel):
|
||||
"""ЮЛ периметра версии; `checklist` — ячейки в порядке правил подхода (как `rules[]` чек-листа)."""
|
||||
"""ЮЛ периметра версии (front-api-gaps D-2, D-3): тип ЮЛ, самоописывающие ячейки чек-листа в порядке правил подхода
|
||||
и счета ОСВ, загруженные к периоду версии (`osv_accounts` — «что загружено», готовность — в ячейках)."""
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
inn: str
|
||||
entity_type: EntityType | None # DBML: `legal_entity.entity_type` nullable (I-1)
|
||||
is_head_entity: bool
|
||||
checklist: list[ChecklistCellOut]
|
||||
osv_accounts: list[str]
|
||||
|
||||
|
||||
class HistoryOut(BaseModel):
|
||||
|
||||
@ -3,13 +3,15 @@
|
||||
audit-trail `CONSOLIDATION_CREATE`; design D-15, D-16).
|
||||
|
||||
Порядок операции: право (`require` на маршруте) → видимость (ГК/версия — через ГК, невидимое = несуществующее, 404
|
||||
с тем же телом) → проверки → запись. Перимeтр версии при создании — текущие члены ГСК, `is_head_entity = false`;
|
||||
ГСК в `consolidation.related_group_id` не сохраняется (фиксируется при верификации, Срез 7). Блокировка на создателя
|
||||
не ставится. Параллельное создание того же периода ловит UNIQUE `uq_consolidation_group_year_quarter` →
|
||||
`IntegrityError` → 409 `CONSOLIDATION_EXISTS` (`common/constraint_conflicts.py`).
|
||||
с тем же телом) → проверки → запись. Перимeтр версии при создании — текущие члены ГСК или выбранные ЮЛ ГК
|
||||
(`legal_entity_ids`, front-api-gaps D-1), `is_head_entity = false`; ГСК в `consolidation.related_group_id` не
|
||||
сохраняется (фиксируется при верификации, Срез 7). Блокировка на создателя не ставится. Параллельное создание того
|
||||
же периода ловит UNIQUE `uq_consolidation_group_year_quarter` → `IntegrityError` → 409 `CONSOLIDATION_EXISTS`
|
||||
(`common/constraint_conflicts.py`).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@ -17,7 +19,7 @@ from src.api.dependencies.audit import RequestAudit
|
||||
from src.common.clock import utc_now
|
||||
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
||||
from src.domain import checklist as checklist_domain
|
||||
from src.domain.consolidation_periods import reporting_date
|
||||
from src.domain.consolidation_periods import quarters_through, reporting_date
|
||||
from src.domain.policy import FINISHED_STATUSES, VisibilityScope
|
||||
from src.models.access import AppUser
|
||||
from src.models.consolidation import (
|
||||
@ -87,11 +89,13 @@ class ConsolidationService:
|
||||
self._principal = principal
|
||||
|
||||
async def create(self, data: ConsolidationCreate) -> VersionCard:
|
||||
"""Консолидация ГК за период с версией №1 (`INITIATED`), периметром из ГСК, историей и аудитом.
|
||||
"""Консолидация ГК за период с версией №1 (`INITIATED`), периметром (ГСК или выбранные ЮЛ), историей и аудитом.
|
||||
|
||||
Проверки по порядку: ГК видима (404) → ГСК видима (404), принадлежит ГК и `kind = gsk` (422
|
||||
`RELATED_GROUP_MISMATCH`) → в ГСК не меньше двух текущих членов (422 `PERIMETER_TOO_SMALL`) → периода ещё нет
|
||||
(409 `CONSOLIDATION_EXISTS`; гонку закрывает UNIQUE). Всё — в транзакции запроса: любая ошибка откатывает всё.
|
||||
`RELATED_GROUP_MISMATCH`) → периметр: без `legal_entity_ids` — текущие члены ГСК, не меньше двух (422
|
||||
`PERIMETER_TOO_SMALL`); со списком — `_validate_perimeter` (422 `ENTITY_NOT_IN_GROUP` / `PERIMETER_TOO_SMALL`) →
|
||||
периода ещё нет (409 `CONSOLIDATION_EXISTS`; гонку закрывает UNIQUE). Всё — в транзакции запроса: любая ошибка
|
||||
откатывает всё.
|
||||
После проверки ГК берётся advisory-блокировка привязок ГК (R-2): подхват документов (`bind_confirmed`) не
|
||||
разминётся с параллельным подтверждением или автозаменой документа ЮЛ этой ГК.
|
||||
"""
|
||||
@ -106,15 +110,18 @@ class ConsolidationService:
|
||||
raise NotFoundError(RELATED_GROUP_NOT_FOUND)
|
||||
if related.kind != RelatedGroupKind.gsk or related.company_group_id != group.id:
|
||||
raise DomainValidationError("RELATED_GROUP_MISMATCH", "ГСК должна принадлежать выбранной ГК")
|
||||
entities = [entity for _, entity in await directories_repo.current_members(self._session, related.id)]
|
||||
if len(entities) < MIN_PERIMETER:
|
||||
raise DomainValidationError(
|
||||
"PERIMETER_TOO_SMALL", f"В ГСК должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(entities)}"
|
||||
)
|
||||
if data.legal_entity_ids is None:
|
||||
entity_ids = [entity.id for _, entity in await directories_repo.current_members(self._session, related.id)]
|
||||
if len(entity_ids) < MIN_PERIMETER:
|
||||
raise DomainValidationError(
|
||||
"PERIMETER_TOO_SMALL", f"В ГСК должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(entity_ids)}"
|
||||
)
|
||||
else:
|
||||
entity_ids = await self._validate_perimeter(group.id, data.legal_entity_ids)
|
||||
if await repo.get_consolidation_for_group(self._session, self._scope, group.id, data.year, data.quarter):
|
||||
raise ConflictError("CONSOLIDATION_EXISTS", "Консолидация ГК за этот период уже создана")
|
||||
|
||||
consolidation, version = await self._insert(group.id, data, entities)
|
||||
consolidation, version = await self._insert(group.id, data, entity_ids)
|
||||
await self._audit.record(
|
||||
action=AuditAction.CONSOLIDATION_CREATE,
|
||||
section=AuditSection.CONSOLIDATION,
|
||||
@ -125,7 +132,7 @@ class ConsolidationService:
|
||||
"consolidation_id": consolidation.id,
|
||||
"year": consolidation.year,
|
||||
"quarter": consolidation.quarter,
|
||||
"members": sorted(entity.id for entity in entities),
|
||||
"members": sorted(entity_ids),
|
||||
},
|
||||
)
|
||||
return await self._card(version, consolidation)
|
||||
@ -185,7 +192,7 @@ class ConsolidationService:
|
||||
legal_entity_id=entity.id,
|
||||
legal_entity_name=entity.name,
|
||||
legal_entity_inn=entity.inn,
|
||||
cells=[self._cell_out(cell) for cell in cells],
|
||||
cells=[self._cell_out(rule, cell) for rule, cell in zip(rules, cells, strict=True)],
|
||||
)
|
||||
for entity, cells in rows
|
||||
],
|
||||
@ -229,9 +236,14 @@ class ConsolidationService:
|
||||
return rules, rows
|
||||
|
||||
@staticmethod
|
||||
def _cell_out(cell: checklist_domain.Cell) -> ChecklistCellOut:
|
||||
def _cell_out(rule: checklist_domain.Rule, cell: checklist_domain.Cell) -> ChecklistCellOut:
|
||||
"""Ячейка ответа с правилом (D-2); `rule` — правило того же столбца, что и `cell`."""
|
||||
return ChecklistCellOut(
|
||||
status=cell.status, missing_quarters=list(cell.missing_quarters), document_ids=list(cell.document_ids)
|
||||
document_type=rule.document_type,
|
||||
account=rule.account,
|
||||
status=cell.status,
|
||||
missing_quarters=list(cell.missing_quarters),
|
||||
document_ids=list(cell.document_ids),
|
||||
)
|
||||
|
||||
async def set_members(self, version_id: uuid.UUID, data: MembersIn) -> VersionCard:
|
||||
@ -247,17 +259,7 @@ class ConsolidationService:
|
||||
"""
|
||||
user = self._principal.user
|
||||
version, consolidation = await lock_editable_version(self._session, self._scope, version_id, user.id)
|
||||
requested = list(dict.fromkeys(data.legal_entity_ids))
|
||||
valid = await repo.entities_in_group(self._session, consolidation.company_group_id, requested)
|
||||
foreign = [entity_id for entity_id in requested if entity_id not in valid]
|
||||
if foreign:
|
||||
raise DomainValidationError(
|
||||
"ENTITY_NOT_IN_GROUP", "ЮЛ не принадлежат ГК консолидации", legal_entity_ids=foreign
|
||||
)
|
||||
if len(requested) < MIN_PERIMETER:
|
||||
raise DomainValidationError(
|
||||
"PERIMETER_TOO_SMALL", f"В периметре должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(requested)}"
|
||||
)
|
||||
requested = await self._validate_perimeter(consolidation.company_group_id, data.legal_entity_ids)
|
||||
|
||||
current = {member.legal_entity_id: member for member, _ in await repo.members(self._session, version.id)}
|
||||
added = [entity_id for entity_id in requested if entity_id not in current]
|
||||
@ -291,8 +293,27 @@ class ConsolidationService:
|
||||
)
|
||||
return await self._card(version, consolidation)
|
||||
|
||||
async def _validate_perimeter(
|
||||
self, company_group_id: uuid.UUID, entity_ids: Sequence[uuid.UUID]
|
||||
) -> list[uuid.UUID]:
|
||||
"""Периметр консолидации — одно правило для создания и `PUT …/members` (front-api-gaps D-1): дубли схлопываются
|
||||
в порядке первого вхождения → все ЮЛ существуют и принадлежат ГК (422 `ENTITY_NOT_IN_GROUP`, `legal_entity_ids`
|
||||
— чужие в порядке запроса) → не меньше двух (422 `PERIMETER_TOO_SMALL`)."""
|
||||
requested = list(dict.fromkeys(entity_ids))
|
||||
valid = await repo.entities_in_group(self._session, company_group_id, requested)
|
||||
foreign = [entity_id for entity_id in requested if entity_id not in valid]
|
||||
if foreign:
|
||||
raise DomainValidationError(
|
||||
"ENTITY_NOT_IN_GROUP", "ЮЛ не принадлежат ГК консолидации", legal_entity_ids=foreign
|
||||
)
|
||||
if len(requested) < MIN_PERIMETER:
|
||||
raise DomainValidationError(
|
||||
"PERIMETER_TOO_SMALL", f"В периметре должно быть не меньше {MIN_PERIMETER} ЮЛ, сейчас {len(requested)}"
|
||||
)
|
||||
return requested
|
||||
|
||||
async def _insert(
|
||||
self, group_id: uuid.UUID, data: ConsolidationCreate, entities: list[LegalEntity]
|
||||
self, group_id: uuid.UUID, data: ConsolidationCreate, entity_ids: list[uuid.UUID]
|
||||
) -> tuple[Consolidation, ConsolidationVersion]:
|
||||
"""Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы."""
|
||||
now = utc_now()
|
||||
@ -322,8 +343,8 @@ class ConsolidationService:
|
||||
self._session.add(version)
|
||||
await self._session.flush()
|
||||
self._session.add_all(
|
||||
ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity.id, is_head_entity=False)
|
||||
for entity in entities
|
||||
ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=entity_id, is_head_entity=False)
|
||||
for entity_id in entity_ids
|
||||
)
|
||||
self._session.add(
|
||||
ConsolidationStatusHistory(
|
||||
@ -336,7 +357,7 @@ class ConsolidationService:
|
||||
)
|
||||
await self._session.flush()
|
||||
await consolidation_documents.bind_confirmed(
|
||||
self._session, version=version, consolidation=consolidation, entity_ids=[entity.id for entity in entities]
|
||||
self._session, version=version, consolidation=consolidation, entity_ids=entity_ids
|
||||
)
|
||||
return consolidation, version
|
||||
|
||||
@ -358,7 +379,8 @@ class ConsolidationService:
|
||||
async def _card(self, version: ConsolidationVersion, consolidation: Consolidation) -> VersionCard:
|
||||
"""Карточка версии: версия, консолидация, ГК, инициатор, периметр, история, активная блокировка.
|
||||
|
||||
Чек-лист у членов — те же ячейки, что у `GET …/checklist`, в порядке правил подхода.
|
||||
Чек-лист у членов — те же ячейки, что у `GET …/checklist`, в порядке правил подхода; у членов — тип ЮЛ и счета
|
||||
ОСВ к периоду версии (front-api-gaps D-3).
|
||||
"""
|
||||
group = await directories_repo.get_company_group(self._session, self._scope, consolidation.company_group_id)
|
||||
initiator = await self._session.get(AppUser, version.initiator_id)
|
||||
@ -369,7 +391,13 @@ class ConsolidationService:
|
||||
locks = await repo.active_locks(self._session, [version.id])
|
||||
history = await repo.history(self._session, version.id)
|
||||
brief = self._brief(version, locks.get(version.id), len(members))
|
||||
_, cells = await self._checklist_cells(version, consolidation, [entity for _, entity in members])
|
||||
rules, cells = await self._checklist_cells(version, consolidation, [entity for _, entity in members])
|
||||
accounts = await repo.osv_accounts(
|
||||
self._session,
|
||||
[entity.id for _, entity in members],
|
||||
consolidation.year,
|
||||
quarters_through(consolidation.quarter),
|
||||
)
|
||||
return VersionCard(
|
||||
**brief.model_dump(),
|
||||
consolidation_id=consolidation.id,
|
||||
@ -387,8 +415,12 @@ class ConsolidationService:
|
||||
id=entity.id,
|
||||
name=entity.name,
|
||||
inn=entity.inn,
|
||||
entity_type=entity.entity_type,
|
||||
is_head_entity=member.is_head_entity,
|
||||
checklist=[self._cell_out(cell) for cell in cells[position][1]],
|
||||
checklist=[
|
||||
self._cell_out(rule, cell) for rule, cell in zip(rules, cells[position][1], strict=True)
|
||||
],
|
||||
osv_accounts=accounts.get(entity.id, []),
|
||||
)
|
||||
for position, (member, entity) in enumerate(members)
|
||||
],
|
||||
|
||||
@ -83,8 +83,20 @@ async def test_checklist_osv_60_confirmed_osv_90_missing(auth_client: AuthClient
|
||||
assert body["rows"][1]["legal_entity_inn"] == second.inn
|
||||
cells = _cells(body, "ООО А")
|
||||
assert len(cells) == len(EXPECTED_RULES)
|
||||
assert cells[OSV_60] == {"status": "CONFIRMED", "missing_quarters": [], "document_ids": [str(i) for i in documents]}
|
||||
assert cells[OSV_90] == {"status": "MISSING", "missing_quarters": [], "document_ids": []}
|
||||
assert cells[OSV_60] == {
|
||||
"document_type": "OSV",
|
||||
"account": "60",
|
||||
"status": "CONFIRMED",
|
||||
"missing_quarters": [],
|
||||
"document_ids": [str(i) for i in documents],
|
||||
}
|
||||
assert cells[OSV_90] == {
|
||||
"document_type": "OSV",
|
||||
"account": "90",
|
||||
"status": "MISSING",
|
||||
"missing_quarters": [],
|
||||
"document_ids": [],
|
||||
}
|
||||
assert {cell["status"] for cell in _cells(body, "ООО Б")} == {"MISSING"}
|
||||
|
||||
|
||||
@ -107,6 +119,8 @@ async def test_checklist_gap_needs_check_and_processing(auth_client: AuthClientF
|
||||
|
||||
first_cells = _cells(body, "ООО А")
|
||||
assert first_cells[OSV_60] == {
|
||||
"document_type": "OSV",
|
||||
"account": "60",
|
||||
"status": "MISSING",
|
||||
"missing_quarters": ["Q2"],
|
||||
"document_ids": [str(first_q1), str(third_q3)],
|
||||
@ -130,6 +144,25 @@ async def test_card_members_have_same_cells_as_checklist(auth_client: AuthClient
|
||||
assert card["members"][0]["id"] == str(first.id)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_card_cells_are_self_describing(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""front-api-gaps D-2: ячейка несёт правило — фронт не сопоставляет ячейки с `rules` по индексу."""
|
||||
version, _ = await _bound_world(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
checklist = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json()
|
||||
card = (await client.get(f"/consolidations/versions/{version.id}")).json()
|
||||
|
||||
expected = [(rule["document_type"], rule["account"]) for rule in checklist["rules"]]
|
||||
assert expected == EXPECTED_RULES
|
||||
for member in card["members"]:
|
||||
assert [(cell["document_type"], cell["account"]) for cell in member["checklist"]] == expected
|
||||
for row in checklist["rows"]:
|
||||
assert [(cell["document_type"], cell["account"]) for cell in row["cells"]] == expected
|
||||
balance = next(cell for cell in card["members"][0]["checklist"] if cell["document_type"] == "BALANCE")
|
||||
assert balance["account"] is None
|
||||
|
||||
|
||||
async def _bound_world(session: AsyncSession) -> tuple[ConsolidationVersion, list[LegalEntity]]:
|
||||
version, entities = await _world(session)
|
||||
for quarter in (Q1, Q2, Q3):
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
`CONSOLIDATION_CREATE`; design D-15, D-16)."""
|
||||
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@ -18,16 +19,28 @@ from src.models.consolidation import (
|
||||
ConsolidationStatusHistory,
|
||||
ConsolidationVersion,
|
||||
)
|
||||
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup
|
||||
from src.models.enums import AuditAction, AuditSection, ConsolidationStatus, PeriodQuarter, RelatedGroupKind
|
||||
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice, RelatedGroup, RelatedGroupMember
|
||||
from src.models.enums import (
|
||||
AuditAction,
|
||||
AuditSection,
|
||||
ConsolidationStatus,
|
||||
DocumentStatus,
|
||||
EntityType,
|
||||
FileStatus,
|
||||
PeriodQuarter,
|
||||
RecognitionStatus,
|
||||
RelatedGroupKind,
|
||||
)
|
||||
from tests.conftest import AuthClientFactory
|
||||
from tests.factories import (
|
||||
make_company_group,
|
||||
make_consolidation,
|
||||
make_document,
|
||||
make_legal_entity,
|
||||
make_lock,
|
||||
make_membership,
|
||||
make_related_group,
|
||||
make_stored_file,
|
||||
make_version,
|
||||
)
|
||||
|
||||
@ -73,6 +86,18 @@ def _body(group: CompanyGroup, gsk: RelatedGroup, year: int = 2024, quarter: str
|
||||
return {"company_group_id": str(group.id), "related_group_id": str(gsk.id), "year": year, "quarter": quarter}
|
||||
|
||||
|
||||
def _with_ids(body: dict[str, Any], ids: Sequence[uuid.UUID | str]) -> dict[str, Any]:
|
||||
"""Тело создания с явным периметром (модалка [42], front-api-gaps D-1)."""
|
||||
return {**body, "legal_entity_ids": [str(entity_id) for entity_id in ids]}
|
||||
|
||||
|
||||
async def _gsk_members(session: AsyncSession, gsk: RelatedGroup) -> set[uuid.UUID]:
|
||||
stmt = select(RelatedGroupMember.legal_entity_id).where(
|
||||
RelatedGroupMember.related_group_id == gsk.id, RelatedGroupMember.is_current.is_(True)
|
||||
)
|
||||
return set((await session.execute(stmt)).scalars())
|
||||
|
||||
|
||||
async def _count(session: AsyncSession, model: type) -> int:
|
||||
return (await session.execute(select(func.count()).select_from(model))).scalar_one()
|
||||
|
||||
@ -175,6 +200,149 @@ async def test_create_ignores_closed_memberships(auth_client: AuthClientFactory,
|
||||
assert "ООО Бывший" not in [member["name"] for member in response.json()["members"]]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_with_selected_perimeter_wider_than_gsk(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""Выбранный периметр шире ГСК: ровно эти ЮЛ, одно событие создания, состав ГСК не меняется."""
|
||||
group, gsk, entities = await _scenario(seeded)
|
||||
outsider = await make_legal_entity(seeded, name="ООО Вне ГСК", group=group)
|
||||
selected = [*entities, outsider]
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [e.id for e in selected]))
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
assert {member["id"] for member in response.json()["members"]} == {str(e.id) for e in selected}
|
||||
event = (
|
||||
await seeded.execute(select(AuditEvent).where(AuditEvent.action == AuditAction.CONSOLIDATION_CREATE))
|
||||
).scalar_one()
|
||||
assert event.after_value is not None
|
||||
assert event.after_value["members"] == sorted(str(e.id) for e in selected)
|
||||
membership = select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.MEMBERSHIP_CHANGE)
|
||||
assert (await seeded.execute(membership)).scalar_one() == 0
|
||||
assert await _gsk_members(seeded, gsk) == {e.id for e in entities}
|
||||
assert await _count(seeded, ConsolidationLock) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_gsk_of_one_with_second_entity_selected(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""ГСК из одного ЮЛ, второе отмечено вручную — периметр из двух (проверка размера — по выбранным)."""
|
||||
group, gsk, (single,) = await _scenario(seeded, members=1)
|
||||
second = await make_legal_entity(seeded, name="ООО Второе", group=group)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [single.id, second.id]))
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
assert {member["id"] for member in response.json()["members"]} == {str(single.id), str(second.id)}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_with_entity_of_other_group_is_rejected(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
group, gsk, entities = await _scenario(seeded)
|
||||
other_group = await make_company_group(seeded, name="ГК Соседняя", office=await _office(seeded, ROSTOV))
|
||||
stranger = await make_legal_entity(seeded, name="ООО Чужое", group=other_group)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
body = _with_ids(_body(group, gsk), [*[e.id for e in entities], stranger.id])
|
||||
response = await client.post(CONSOLIDATIONS, json=body)
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||||
detail = response.json()["detail"]
|
||||
assert detail["code"] == "ENTITY_NOT_IN_GROUP"
|
||||
assert detail["legal_entity_ids"] == [str(stranger.id)]
|
||||
assert await _count(seeded, Consolidation) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_with_unknown_entity_is_not_in_group(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group, gsk, entities = await _scenario(seeded)
|
||||
ghost = uuid.uuid4()
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [*[e.id for e in entities], ghost]))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||||
assert response.json()["detail"]["code"] == "ENTITY_NOT_IN_GROUP"
|
||||
assert response.json()["detail"]["legal_entity_ids"] == [str(ghost)]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_with_duplicates_collapsing_to_one_is_too_small(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
group, gsk, entities = await _scenario(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [entities[0].id, entities[0].id]))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||||
assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
|
||||
assert await _count(seeded, Consolidation) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_with_empty_selected_perimeter_is_too_small(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""Review Focus: `[]` — явный пустой периметр, а не «поле не передано» (ГСК из двух не подставляется)."""
|
||||
group, gsk, _ = await _scenario(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), []))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||||
assert response.json()["detail"]["code"] == "PERIMETER_TOO_SMALL"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_with_null_selected_perimeter_takes_gsk(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""Решение №3: `legal_entity_ids: null` равнозначно отсутствию поля."""
|
||||
group, gsk, entities = await _scenario(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(CONSOLIDATIONS, json={**_body(group, gsk), "legal_entity_ids": None})
|
||||
|
||||
assert response.status_code == HTTP_CREATED, response.text
|
||||
assert {member["id"] for member in response.json()["members"]} == {str(e.id) for e in entities}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_gsk_mismatch_reported_before_perimeter(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""Порядок проверок (решение №5): ГСК раньше периметра."""
|
||||
group, _, _ = await _scenario(seeded)
|
||||
gsz = await make_related_group(seeded, group=group, kind=RelatedGroupKind.gsz, name="ГСЗ")
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsz), [uuid.uuid4()]))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||||
assert response.json()["detail"]["code"] == "RELATED_GROUP_MISMATCH"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_selected_perimeter_longer_than_limit(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
group, gsk, _ = await _scenario(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.post(CONSOLIDATIONS, json=_with_ids(_body(group, gsk), [uuid.uuid4() for _ in range(501)]))
|
||||
|
||||
assert response.status_code == HTTP_UNPROCESSABLE, response.text
|
||||
assert response.json()["detail"][0]["loc"] == ["body", "legal_entity_ids"]
|
||||
assert await _count(seeded, Consolidation) == 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_create_repeated_period_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
group, gsk, _ = await _scenario(seeded)
|
||||
@ -404,3 +572,102 @@ async def test_version_card_requires_consolidation_view(auth_client: AuthClientF
|
||||
client = await auth_client("admin@armka.local") # у роли ADMIN права CONSOLIDATION_VIEW нет
|
||||
response = await client.get(f"{VERSIONS}/{versions['q3v1'].id}")
|
||||
assert response.status_code == HTTP_FORBIDDEN
|
||||
|
||||
|
||||
async def _osv( # noqa: PLR0913 - параметры документа сценария
|
||||
session: AsyncSession,
|
||||
entity: LegalEntity,
|
||||
account: str,
|
||||
year: int,
|
||||
quarter: PeriodQuarter,
|
||||
*,
|
||||
status: DocumentStatus = DocumentStatus.RECOGNIZED,
|
||||
file_status: FileStatus = FileStatus.PROCESSED,
|
||||
) -> None:
|
||||
"""ОСВ-документ ЮЛ в отдельном файле (на файл — одна ОСВ по счёту)."""
|
||||
file, job = await make_stored_file(session, entity=entity, status=file_status, job_status=RecognitionStatus.done)
|
||||
await make_document(session, file=file, job=job, account=account, year=year, quarter=quarter, status=status)
|
||||
|
||||
|
||||
async def _card_world(
|
||||
session: AsyncSession, quarter: PeriodQuarter = PeriodQuarter.Q3
|
||||
) -> tuple[ConsolidationVersion, LegalEntity, LegalEntity]:
|
||||
"""Версия 2024 `quarter` ГК Ростовского филиала: ЮЛ «ООО А» и ИП «ИП Б»."""
|
||||
group = await make_company_group(session, name="ГК Карточка", office=await _office(session, ROSTOV))
|
||||
company = await make_legal_entity(session, name="ООО А", group=group)
|
||||
person = await make_legal_entity(session, name="ИП Б", group=group, entity_type=EntityType.ИП, inn="770100000012")
|
||||
_, version = await make_consolidation(
|
||||
session,
|
||||
group=group,
|
||||
year=2024,
|
||||
quarter=quarter,
|
||||
members=[company, person],
|
||||
initiator=await _user(session, KA_RF),
|
||||
)
|
||||
return version, company, person
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_card_member_entity_type(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
version, company, person = await _card_world(seeded)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
card = (await client.get(f"{VERSIONS}/{version.id}")).json()
|
||||
|
||||
types = {member["id"]: member["entity_type"] for member in card["members"]}
|
||||
assert types == {str(company.id): "ЮЛ", str(person.id): "ИП"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_card_member_osv_accounts_loaded_to_version_period(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""D-3 (вариант B): счета действующих ОСВ за год версии с кварталом ≤ версии, в любом статусе, кроме ERROR."""
|
||||
version, company, person = await _card_world(seeded)
|
||||
q1, q2, q3, q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4
|
||||
await _osv(seeded, company, "60", 2024, q1) # RECOGNIZED
|
||||
await _osv(seeded, company, "60", 2024, q2) # тот же счёт ещё раз — один раз в списке
|
||||
await _osv(seeded, company, "51", 2024, q3, status=DocumentStatus.CONFIRMED)
|
||||
await _osv(seeded, company, "58.03", 2024, q3, status=DocumentStatus.NEEDS_CHECK)
|
||||
await _osv(seeded, company, "62", 2024, q4) # позже квартала версии
|
||||
await _osv(seeded, company, "01", 2023, q3) # прошлый год
|
||||
await _osv(seeded, company, "76", 2024, q3, file_status=FileStatus.SUPERSEDED) # заменённый файл
|
||||
await _osv(seeded, company, "90", 2024, q3, status=DocumentStatus.ERROR) # документ в ошибке
|
||||
await _osv(seeded, company, "10", 2024, q3, file_status=FileStatus.ERROR) # файл в ошибке
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
card = (await client.get(f"{VERSIONS}/{version.id}")).json()
|
||||
|
||||
accounts = {member["id"]: member["osv_accounts"] for member in card["members"]}
|
||||
assert accounts == {str(company.id): ["51", "58.03", "60"], str(person.id): []}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_card_member_osv_accounts_q1_version_ignores_later_quarters(
|
||||
auth_client: AuthClientFactory, seeded: AsyncSession
|
||||
) -> None:
|
||||
"""Review Focus: версия за Q1 — документы Q2 того же года не входят."""
|
||||
version, company, _ = await _card_world(seeded, PeriodQuarter.Q1)
|
||||
await _osv(seeded, company, "60", 2024, PeriodQuarter.Q1)
|
||||
await _osv(seeded, company, "62", 2024, PeriodQuarter.Q2)
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
card = (await client.get(f"{VERSIONS}/{version.id}")).json()
|
||||
|
||||
member = next(member for member in card["members"] if member["id"] == str(company.id))
|
||||
assert member["osv_accounts"] == ["60"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_card_member_without_entity_type_is_served(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
|
||||
"""I-1: `legal_entity.entity_type` nullable в DBML — член периметра без типа не роняет карточку (тип `null`)."""
|
||||
version, company, person = await _card_world(seeded)
|
||||
person.entity_type = None
|
||||
await seeded.flush()
|
||||
client = await auth_client(KA_RF)
|
||||
|
||||
response = await client.get(f"{VERSIONS}/{version.id}")
|
||||
|
||||
assert response.status_code == HTTP_OK, response.text
|
||||
types = {member["id"]: member["entity_type"] for member in response.json()["members"]}
|
||||
assert types == {str(company.id): "ЮЛ", str(person.id): None}
|
||||
|
||||
@ -20,8 +20,20 @@ def test_render() -> None:
|
||||
legal_entity_name='ООО "Альфа; Бета"',
|
||||
legal_entity_inn="7700000000",
|
||||
cells=[
|
||||
ChecklistCellOut(status=CellStatus.CONFIRMED, missing_quarters=[], document_ids=[]),
|
||||
ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[PeriodQuarter.Q2], document_ids=[]),
|
||||
ChecklistCellOut(
|
||||
document_type=DocumentType.BALANCE,
|
||||
account=None,
|
||||
status=CellStatus.CONFIRMED,
|
||||
missing_quarters=[],
|
||||
document_ids=[],
|
||||
),
|
||||
ChecklistCellOut(
|
||||
document_type=DocumentType.OSV,
|
||||
account="60",
|
||||
status=CellStatus.MISSING,
|
||||
missing_quarters=[PeriodQuarter.Q2],
|
||||
document_ids=[],
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
@ -34,7 +46,9 @@ def test_render() -> None:
|
||||
|
||||
|
||||
def _cell(status: CellStatus, *quarters: PeriodQuarter) -> ChecklistCellOut:
|
||||
return ChecklistCellOut(status=status, missing_quarters=list(quarters), document_ids=[])
|
||||
return ChecklistCellOut(
|
||||
document_type=DocumentType.OSV, account="60", status=status, missing_quarters=list(quarters), document_ids=[]
|
||||
)
|
||||
|
||||
|
||||
def test_missing_quarters_listed_in_order() -> None:
|
||||
|
||||
@ -244,3 +244,22 @@ def test_protected_routes_require_bearer_and_public_do_not(schema: dict[str, Any
|
||||
elif not has_bearer:
|
||||
missing.append(f"{method.upper()} {path}")
|
||||
assert missing == [], f"маршруты без схемы {BEARER_SCHEME} (Authorize в Swagger не подставит токен): {missing}"
|
||||
|
||||
|
||||
def test_consolidation_create_has_optional_selected_perimeter(schema: dict[str, Any]) -> None:
|
||||
"""front-api-gaps D-1: `legal_entity_ids` в теле создания — необязательный массив."""
|
||||
create = schema["components"]["schemas"]["ConsolidationCreate"]
|
||||
assert "legal_entity_ids" in create["properties"]
|
||||
assert "legal_entity_ids" not in create.get("required", [])
|
||||
|
||||
|
||||
def test_checklist_cell_declares_its_rule(schema: dict[str, Any]) -> None:
|
||||
"""front-api-gaps D-2: ячейка чек-листа самоописывающая."""
|
||||
cell = schema["components"]["schemas"]["ChecklistCellOut"]
|
||||
assert {"document_type", "account", "status", "missing_quarters", "document_ids"} <= set(cell["required"])
|
||||
|
||||
|
||||
def test_version_member_declares_type_and_osv_accounts(schema: dict[str, Any]) -> None:
|
||||
"""front-api-gaps D-3: участник периметра — тип ЮЛ и счета ОСВ к периоду версии."""
|
||||
member = schema["components"]["schemas"]["MemberOut"]
|
||||
assert {"entity_type", "osv_accounts", "checklist"} <= set(member["required"])
|
||||
|
||||
@ -10,7 +10,7 @@ from pydantic import ValidationError
|
||||
|
||||
from src.domain.checklist import CellStatus, EntityFilesState
|
||||
from src.domain.consolidated_osv import CounterpartyType as DomainCounterpartyType
|
||||
from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter
|
||||
from src.models.enums import CounterpartyMatchKind, DocumentType, FileStatus, PeriodQuarter
|
||||
from src.schemas import consolidations as schemas
|
||||
from src.schemas.consolidations import (
|
||||
MAX_MEMBERS,
|
||||
@ -37,6 +37,16 @@ def test_consolidation_create_year_bounds() -> None:
|
||||
ConsolidationCreate(year=year, **ids)
|
||||
|
||||
|
||||
def test_consolidation_create_selected_perimeter_bounds() -> None:
|
||||
"""D-1: `legal_entity_ids` необязателен (`None` — периметр из ГСК), не больше `MAX_MEMBERS`."""
|
||||
ids = {"company_group_id": uuid.uuid4(), "related_group_id": uuid.uuid4(), "year": 2024, "quarter": "Q3"}
|
||||
assert ConsolidationCreate(**ids).legal_entity_ids is None # type: ignore[arg-type]
|
||||
selected = [uuid.uuid4() for _ in range(MAX_MEMBERS)]
|
||||
assert len(ConsolidationCreate(**ids, legal_entity_ids=selected).legal_entity_ids or []) == MAX_MEMBERS # type: ignore[arg-type]
|
||||
with pytest.raises(ValidationError):
|
||||
ConsolidationCreate(**ids, legal_entity_ids=[*selected, uuid.uuid4()]) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_osv_page_params_limit_bounds() -> None:
|
||||
assert (OsvPageParams().limit, OsvPageParams().offset) == (500, 0) # noqa: PLR2004
|
||||
assert OsvPageParams(limit=MAX_LIMIT).limit == MAX_LIMIT
|
||||
@ -113,10 +123,36 @@ def test_entity_files_state_is_frozen() -> None:
|
||||
def test_checklist_cell_status_is_the_domain_enum() -> None:
|
||||
"""Статус ячейки в схеме — `domain.checklist.CellStatus` (одно определение, T2.8): все значения принимаются."""
|
||||
for status in CellStatus:
|
||||
cell = ChecklistCellOut(status=status.value, missing_quarters=[], document_ids=[]) # type: ignore[arg-type]
|
||||
cell = ChecklistCellOut(
|
||||
document_type=DocumentType.OSV,
|
||||
account="60",
|
||||
status=status.value, # type: ignore[arg-type]
|
||||
missing_quarters=[],
|
||||
document_ids=[],
|
||||
)
|
||||
assert cell.status is status
|
||||
with pytest.raises(ValidationError):
|
||||
ChecklistCellOut(status="UNKNOWN", missing_quarters=[], document_ids=[]) # type: ignore[arg-type]
|
||||
ChecklistCellOut(
|
||||
document_type=DocumentType.OSV,
|
||||
account="60",
|
||||
status="UNKNOWN", # type: ignore[arg-type]
|
||||
missing_quarters=[],
|
||||
document_ids=[],
|
||||
)
|
||||
|
||||
|
||||
def test_checklist_cell_requires_its_rule() -> None:
|
||||
"""D-2: `document_type` обязателен, `account` — обязателен, но может быть `None` (ББ/ОФР)."""
|
||||
with pytest.raises(ValidationError):
|
||||
ChecklistCellOut(status=CellStatus.MISSING, missing_quarters=[], document_ids=[]) # type: ignore[call-arg]
|
||||
cell = ChecklistCellOut(
|
||||
document_type=DocumentType.BALANCE,
|
||||
account=None,
|
||||
status=CellStatus.MISSING,
|
||||
missing_quarters=[],
|
||||
document_ids=[],
|
||||
)
|
||||
assert cell.account is None
|
||||
|
||||
|
||||
def test_counterparty_type_is_the_domain_enum() -> None:
|
||||
|
||||
980
docs/superpowers/plans/2026-10-07-front-api-gaps.md
Normal file
980
docs/superpowers/plans/2026-10-07-front-api-gaps.md
Normal 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 — только по команде владельца.
|
||||
2
openspec/changes/front-api-gaps/.openspec.yaml
Normal file
2
openspec/changes/front-api-gaps/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-07
|
||||
110
openspec/changes/front-api-gaps/design.md
Normal file
110
openspec/changes/front-api-gaps/design.md
Normal 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` — правка контракта |
|
||||
65
openspec/changes/front-api-gaps/proposal.md
Normal file
65
openspec/changes/front-api-gaps/proposal.md
Normal 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`.
|
||||
- Фронт: новые поля только добавляются; существующие клиенты не ломаются.
|
||||
147
openspec/changes/front-api-gaps/specs/consolidations-api/spec.md
Normal file
147
openspec/changes/front-api-gaps/specs/consolidations-api/spec.md
Normal 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` без ожидания
|
||||
17
openspec/changes/front-api-gaps/tasks.md
Normal file
17
openspec/changes/front-api-gaps/tasks.md
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user