diff --git a/CLAUDE.md b/CLAUDE.md index 455fe1d..4eacc03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`; diff --git a/README.md b/README.md index 8d1e09c..4410a4a 100644 --- a/README.md +++ b/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` без комментария (из одних пробелов — тоже) | diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index fb2a4f3..4f4801b 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -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 ( diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index c235249..35d37eb 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -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): diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index 01e54aa..d94ad88 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -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) ], diff --git a/api/tests/test_api_consolidation_checklist.py b/api/tests/test_api_consolidation_checklist.py index b69ed3e..93261b1 100644 --- a/api/tests/test_api_consolidation_checklist.py +++ b/api/tests/test_api_consolidation_checklist.py @@ -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): diff --git a/api/tests/test_api_consolidations.py b/api/tests/test_api_consolidations.py index 8bc64f6..910ef67 100644 --- a/api/tests/test_api_consolidations.py +++ b/api/tests/test_api_consolidations.py @@ -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} diff --git a/api/tests/test_domain_checklist_csv.py b/api/tests/test_domain_checklist_csv.py index c7f7d31..d159e6c 100644 --- a/api/tests/test_domain_checklist_csv.py +++ b/api/tests/test_domain_checklist_csv.py @@ -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: diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 90d2758..0ce684e 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -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"]) diff --git a/api/tests/test_schemas_consolidations.py b/api/tests/test_schemas_consolidations.py index 6cdbdf6..a5416cc 100644 --- a/api/tests/test_schemas_consolidations.py +++ b/api/tests/test_schemas_consolidations.py @@ -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: diff --git a/docs/superpowers/plans/2026-10-07-front-api-gaps.md b/docs/superpowers/plans/2026-10-07-front-api-gaps.md new file mode 100644 index 0000000..a5599d4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-front-api-gaps.md @@ -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 + 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 +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 +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 +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/-front-api-gaps-docs +``` + +`` — номер созданной 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 +Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2" +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/front-gaps/docs push -u origin feat/-front-api-gaps-docs +tea pulls create --login home --repo RSHB/arm-ka-docs --head feat/-front-api-gaps-docs --base main --title "Документы к arm-core#42: контракт файлов и справочников, OQ-43, модалка [42]" --description "Closes #. Код — 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 +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 ` / `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#` (мержить вместе); вывод +`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 — только по команде владельца. diff --git a/openspec/changes/front-api-gaps/.openspec.yaml b/openspec/changes/front-api-gaps/.openspec.yaml new file mode 100644 index 0000000..a26662f --- /dev/null +++ b/openspec/changes/front-api-gaps/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-07 diff --git a/openspec/changes/front-api-gaps/design.md b/openspec/changes/front-api-gaps/design.md new file mode 100644 index 0000000..2d75c5a --- /dev/null +++ b/openspec/changes/front-api-gaps/design.md @@ -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` — правка контракта | diff --git a/openspec/changes/front-api-gaps/proposal.md b/openspec/changes/front-api-gaps/proposal.md new file mode 100644 index 0000000..c9f181f --- /dev/null +++ b/openspec/changes/front-api-gaps/proposal.md @@ -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`. +- Фронт: новые поля только добавляются; существующие клиенты не ломаются. diff --git a/openspec/changes/front-api-gaps/specs/consolidations-api/spec.md b/openspec/changes/front-api-gaps/specs/consolidations-api/spec.md new file mode 100644 index 0000000..b6c54b4 --- /dev/null +++ b/openspec/changes/front-api-gaps/specs/consolidations-api/spec.md @@ -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` без ожидания diff --git a/openspec/changes/front-api-gaps/tasks.md b/openspec/changes/front-api-gaps/tasks.md new file mode 100644 index 0000000..66b4991 --- /dev/null +++ b/openspec/changes/front-api-gaps/tasks.md @@ -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)