Merge pull request 'Временный режим: период ОСВ из версии консолидации (#103)' (#104) from feat/103-osv-period-from-version into main

Reviewed-on: #104
This commit is contained in:
aep 2026-10-12 19:04:19 +03:00
commit 880eab6c4b
12 changed files with 574 additions and 31 deletions

View File

@ -114,7 +114,7 @@
(`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка), (`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка),
`consolidation_locks` (взять/перехватить/снять; без истечения, OQ-119), `consolidation_status` (`transition` + ручной `consolidation_locks` (взять/перехватить/снять; без истечения, OQ-119), `consolidation_status` (`transition` + ручной
`POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed(changed_by, comment)` / `POST …/status`), `consolidation_documents` (`fits`, `bind_confirmed`, `unbind_entity`, хуки `on_document_confirmed(changed_by, comment)` /
`on_documents_superseded` / `on_documents_mapped`; `bound_versions` / `unbind_documents` — снятие привязок автоподтверждённых документов при удалении файла, OQ-157), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты, `on_documents_superseded` / `on_documents_mapped`; `bound_versions` / `unbind_documents` — снятие привязок автоподтверждённых документов при удалении файла, OQ-157; `for_consolidation` — ВРЕМЕННЫЙ режим «период ОСВ из версии» `CONSOLIDATION_PERIOD_FROM_VERSION`, `domain/version_period.py`, arm-core#103: привязка, свод, чек-лист и `osv_accounts` читают период документа только через него), `consolidated_osv` (единая сборка `_build`: страница, сводка, кандидаты,
экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»). экспорт), `counterparty_mapping` (ручное сопоставление, алиас, автоприменение), `osv_export` (openpyxl, лист «Пробелы»).
Создание и `PUT …/members` проверяют периметр одной функцией `_validate_perimeter` (#42); ячейки чек-листа самоописывающие Создание и `PUT …/members` проверяют периметр одной функцией `_validate_perimeter` (#42); ячейки чек-листа самоописывающие
(`document_type`, `account`); `members[].osv_accounts` — `repositories/consolidations.osv_accounts` («что загружено к периоду (`document_type`, `account`); `members[].osv_accounts` — `repositories/consolidations.osv_accounts` («что загружено к периоду

View File

@ -712,6 +712,13 @@ Q2–Q4 не подходят (R-14a). Привязываются только `
и удаление ЮЛ из периметра. Версии `CANCELLED` не трогаются. и удаление ЮЛ из периметра. Версии `CANCELLED` не трогаются.
- **Удаление файла с документом в версии:** файл с документом, подтверждённым пользователем, удалить нельзя; файл с автоподтверждённым документом — можно, пока все версии с его привязками «в работе» (привязки снимаются), иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS` (OQ-110, уточнено OQ-157). - **Удаление файла с документом в версии:** файл с документом, подтверждённым пользователем, удалить нельзя; файл с автоподтверждённым документом — можно, пока все версии с его привязками «в работе» (привязки снимаются), иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS` (OQ-110, уточнено OQ-157).
- `period_check` документа остаётся `UNKNOWN` — подходящесть — функция (документ, версия), а не свойство документа (OQ-120). - `period_check` документа остаётся `UNKNOWN` — подходящесть — функция (документ, версия), а не свойство документа (OQ-120).
- **Временный режим «период ОСВ из версии»** (`CONSOLIDATION_PERIOD_FROM_VERSION=true`, в контуре —
`OPENBAO__APP__CONSOLIDATION_PERIOD_FROM_VERSION`; по умолчанию выключен; arm-core#103, OQ-161): ОСВ, загруженная в
консолидацию (`stored_file.consolidation_id`), период шапки которой версии **не подходит**, считается нарастающей за год
и квартал консолидации — для привязки, свода, чек-листа и колонки «ОСВ» периметра (`src/domain/version_period.py`).
ОСВ, подходящие по шапке, не трогаются (квартальные склеиваются как раньше). Документ не меняется (карточка показывает
период из шапки); у подменённых ОСВ склейки нет — на счёт ЮЛ один слот, побеждает подтверждённая последней; ББ/ОФР и
файлы с рабочего стола режим не затрагивает. Только для тестов на произвольных ОСВ.
### Чек-лист ### Чек-лист
@ -927,6 +934,7 @@ email без учёта регистра проверяет только сер
| `UPLOAD_MAX_BYTES` | `52428800` (50 МиБ) | предельный размер файла первичной отчётности при загрузке; больше — `FILE_TOO_LARGE` в `rejected` | | `UPLOAD_MAX_BYTES` | `52428800` (50 МиБ) | предельный размер файла первичной отчётности при загрузке; больше — `FILE_TOO_LARGE` в `rejected` |
| `MAPPING_POLL_SECONDS` | `5` | период опроса цикла маппинга результатов распознавания | | `MAPPING_POLL_SECONDS` | `5` | период опроса цикла маппинга результатов распознавания |
| `MAPPING_MAX_ATTEMPTS` | `3` | число попыток маппинга файла до `ERROR` | | `MAPPING_MAX_ATTEMPTS` | `3` | число попыток маппинга файла до `ERROR` |
| `CONSOLIDATION_PERIOD_FROM_VERSION` | `false` | **временно**, для тестов на произвольных ОСВ: ОСВ, загруженная в консолидацию, берёт год и квартал консолидации (раздел «Привязка документов», arm-core#103); в контуре — `OPENBAO__APP__CONSOLIDATION_PERIOD_FROM_VERSION` |
| `AUTH_DEV_ISSUER` | `false` | dev-эмитент токенов: работает **только** при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true` | | `AUTH_DEV_ISSUER` | `false` | dev-эмитент токенов: работает **только** при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true` |
| `AUTH_DEV_SECRET` | пусто | секрет HS256 dev-эмитента (не короче 32 байт); только для разработки | | `AUTH_DEV_SECRET` | пусто | секрет HS256 dev-эмитента (не короче 32 байт); только для разработки |
| `AUTH_JWKS_URL` или `AUTH_PUBLIC_KEY` | пусто | проверка токенов РАИСА: JWKS по URL либо публичный ключ (PEM) | | `AUTH_JWKS_URL` или `AUTH_PUBLIC_KEY` | пусто | проверка токенов РАИСА: JWKS по URL либо публичный ключ (PEM) |

View File

@ -78,3 +78,6 @@ UPLOAD_MAX_BYTES=52428800
# Маппинг результата распознавания: период опроса готовых заданий (секунды) и число попыток до отметки ошибки # Маппинг результата распознавания: период опроса готовых заданий (секунды) и число попыток до отметки ошибки
MAPPING_POLL_SECONDS=5 MAPPING_POLL_SECONDS=5
MAPPING_MAX_ATTEMPTS=3 MAPPING_MAX_ATTEMPTS=3
# ВРЕМЕННО, для тестов на произвольных ОСВ (arm-core#103): ОСВ, загруженная в консолидацию, считается нарастающей за год и
# квартал консолидации, а не за период из шапки. В контуре — OPENBAO__APP__CONSOLIDATION_PERIOD_FROM_VERSION
CONSOLIDATION_PERIOD_FROM_VERSION=false

View File

@ -58,3 +58,5 @@ IMPORT_MAX_BYTES=20971520
UPLOAD_MAX_BYTES=52428800 UPLOAD_MAX_BYTES=52428800
MAPPING_POLL_SECONDS=5 MAPPING_POLL_SECONDS=5
MAPPING_MAX_ATTEMPTS=3 MAPPING_MAX_ATTEMPTS=3
# временный режим «период ОСВ из версии консолидации» (arm-core#103); тесты включают его через monkeypatch
CONSOLIDATION_PERIOD_FROM_VERSION=false

View File

@ -112,6 +112,11 @@ class Settings(BaseSettings):
MAPPING_POLL_SECONDS: PositiveInt = 5 MAPPING_POLL_SECONDS: PositiveInt = 5
MAPPING_MAX_ATTEMPTS: PositiveInt = 3 MAPPING_MAX_ATTEMPTS: PositiveInt = 3
# ВРЕМЕННЫЙ режим для тестов на произвольных ОСВ (arm-core#103): ОСВ, загруженная в консолидацию, считается
# нарастающей за год и квартал консолидации, а не за период из шапки (`domain/version_period.py`). В контуре —
# OPENBAO__APP__CONSOLIDATION_PERIOD_FROM_VERSION. Снять вместе с предупреждением о несовпадении периода
CONSOLIDATION_PERIOD_FROM_VERSION: bool = False
@classmethod @classmethod
def settings_customise_sources( def settings_customise_sources(
cls, cls,

View File

@ -0,0 +1,94 @@
"""ВРЕМЕННЫЙ режим «период ОСВ из версии консолидации» (arm-core#103, настройка `CONSOLIDATION_PERIOD_FROM_VERSION`).
Для тестов на произвольных ОСВ: документ ОСВ, загруженный в консолидацию (`stored_file.consolidation_id`), период шапки
которого версии НЕ подходит, считается нарастающим за год и квартал этой консолидации. Документ, подходящий версии по
шапке, остаётся как есть — квартальные ОСВ по-прежнему склеиваются, уже привязанные не задваиваются. Первичка не
мутируется: подменяется только представление документа (`VersionPeriodDocument`) при привязке к версии (`fits`, ключ
замены R-1), сборке свода (`select_sources`), чек-листе и колонке «ОСВ» периметра; карточка документа показывает период
из шапки.
Следствие: подменённые ОСВ одного счёта одного ЮЛ занимают один слот привязки (нарастающий за квартал версии), побеждает
подтверждённая последней, и склейки кварталов у них нет. ББ/ОФР и файлы без консолидации (рабочий стол) режим не
затрагивает.
ASSUMPTION(OQ-161): правила в `05-domain-rules.md` нет — режим временный, по решению владельца 12.10.2026; по умолчанию
выключен, без настройки поведение §2 / D-5 Среза 4 не меняется.
"""
import datetime
import uuid
from dataclasses import dataclass
from typing import Protocol
from src.models.enums import DocumentStatus, DocumentType, PeriodQuarter
class DocumentFields(Protocol):
"""Поля документа, которые читают привязка, свод и чек-лист (структурный тип: `Document` подходит)."""
@property
def id(self) -> uuid.UUID: ...
@property
def legal_entity_id(self) -> uuid.UUID: ...
@property
def document_type(self) -> DocumentType: ...
@property
def osv_account_code(self) -> str | None: ...
@property
def period_year(self) -> int | None: ...
@property
def period_quarter(self) -> PeriodQuarter | None: ...
@property
def is_cumulative(self) -> bool | None: ...
@property
def status(self) -> DocumentStatus: ...
@property
def confirmed_at(self) -> datetime.datetime | None: ...
@dataclass(frozen=True)
class VersionPeriodDocument:
"""Документ ОСВ с периодом версии: поля документа как есть, период — год и квартал консолидации, нарастающий."""
id: uuid.UUID
legal_entity_id: uuid.UUID
document_type: DocumentType
osv_account_code: str | None
period_year: int
period_quarter: PeriodQuarter
is_cumulative: bool
status: DocumentStatus
confirmed_at: datetime.datetime | None
def takes_version_period(
*,
enabled: bool,
document_type: DocumentType,
file_consolidation_id: uuid.UUID | None,
consolidation_id: uuid.UUID,
fits_by_header: bool,
) -> bool:
"""Берёт ли документ период версии: режим включён, документ — ОСВ, его файл загружен в эту консолидацию, а период
шапки версии не подходит (`fits_by_header` — период шапки подходит версии, как в `fits` без проверки периметра)."""
return (
enabled
and document_type == DocumentType.OSV
and file_consolidation_id == consolidation_id
and not fits_by_header
)
def with_version_period(document: DocumentFields, *, year: int, quarter: PeriodQuarter) -> VersionPeriodDocument:
"""Представление `document` как нарастающего за `year`/`quarter` версии; сам документ не меняется."""
return VersionPeriodDocument(
id=document.id,
legal_entity_id=document.legal_entity_id,
document_type=document.document_type,
osv_account_code=document.osv_account_code,
period_year=year,
period_quarter=quarter,
is_cumulative=True,
status=document.status,
confirmed_at=document.confirmed_at,
)

View File

@ -10,7 +10,7 @@ import uuid
from collections import defaultdict from collections import defaultdict
from collections.abc import Collection, Sequence from collections.abc import Collection, Sequence
from sqlalchemy import Select, case, exists, false, func, or_, select from sqlalchemy import ColumnElement, Select, and_, case, exists, false, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.checklist import EntityFilesState from src.domain.checklist import EntityFilesState
@ -381,19 +381,43 @@ async def rules_for(
return [(row[0], row[1]) for row in await session.execute(stmt)] return [(row[0], row[1]) for row in await session.execute(stmt)]
async def unconfirmed_documents(session: AsyncSession, entity_ids: Collection[uuid.UUID], year: int) -> list[Document]: def _osv_of_consolidation(consolidation_id: uuid.UUID) -> ColumnElement[bool]:
"""ОСВ, файл которой загружен в консолидацию: в режиме «период из версии» (arm-core#103) — любого периода шапки."""
return and_(Document.document_type == DocumentType.OSV, StoredFile.consolidation_id == consolidation_id)
async def file_consolidation_ids(
session: AsyncSession, file_ids: Collection[uuid.UUID]
) -> dict[uuid.UUID, uuid.UUID | None]:
"""`stored_file.consolidation_id` по id файлов одним запросом (режим «период из версии», arm-core#103)."""
if not file_ids:
return {}
stmt = select(StoredFile.id, StoredFile.consolidation_id).where(StoredFile.id.in_(list(file_ids)))
return {file_id: consolidation_id for file_id, consolidation_id in await session.execute(stmt)}
async def unconfirmed_documents(
session: AsyncSession,
entity_ids: Collection[uuid.UUID],
year: int,
consolidation_id: uuid.UUID | None = None,
) -> list[Document]:
"""Неподтверждённые документы ЮЛ за год (`RECOGNIZED`/`NEEDS_CHECK`) из файлов не `SUPERSEDED`; любых типов (R-5). """Неподтверждённые документы ЮЛ за год (`RECOGNIZED`/`NEEDS_CHECK`) из файлов не `SUPERSEDED`; любых типов (R-5).
`consolidation_id` (режим «период из версии», arm-core#103) — плюс ОСВ, загруженные в эту консолидацию, любого года.
Кандидаты чек-листа: «подходящесть» версии (`fits`) проверяет сервис. Порядок — по ЮЛ, типу, счёту, кварталу, `id`. Кандидаты чек-листа: «подходящесть» версии (`fits`) проверяет сервис. Порядок — по ЮЛ, типу, счёту, кварталу, `id`.
""" """
if not entity_ids: if not entity_ids:
return [] return []
in_period: ColumnElement[bool] = Document.period_year == year
if consolidation_id is not None:
in_period = or_(in_period, _osv_of_consolidation(consolidation_id))
stmt = ( stmt = (
select(Document) select(Document)
.join(StoredFile, StoredFile.id == Document.stored_file_id) .join(StoredFile, StoredFile.id == Document.stored_file_id)
.where( .where(
Document.legal_entity_id.in_(list(entity_ids)), Document.legal_entity_id.in_(list(entity_ids)),
Document.period_year == year, in_period,
Document.status.in_(UNCONFIRMED_STATUSES), Document.status.in_(UNCONFIRMED_STATUSES),
StoredFile.status != FileStatus.SUPERSEDED, StoredFile.status != FileStatus.SUPERSEDED,
) )
@ -409,22 +433,29 @@ async def unconfirmed_documents(session: AsyncSession, entity_ids: Collection[uu
async def osv_accounts( async def osv_accounts(
session: AsyncSession, entity_ids: Collection[uuid.UUID], year: int, quarters: Collection[PeriodQuarter] session: AsyncSession,
entity_ids: Collection[uuid.UUID],
year: int,
quarters: Collection[PeriodQuarter],
consolidation_id: uuid.UUID | None = None,
) -> dict[uuid.UUID, list[str]]: ) -> dict[uuid.UUID, list[str]]:
"""Счета ОСВ, загруженные к периоду версии (колонка «ОСВ» панели [34]; front-api-gaps D-3): уникальные """Счета ОСВ, загруженные к периоду версии (колонка «ОСВ» панели [34]; front-api-gaps D-3): уникальные
`osv_account_code` документов `OSV` ЮЛ за год `year` и кварталы `quarters`; документ не `ERROR`, файл не `osv_account_code` документов `OSV` ЮЛ за год `year` и кварталы `quarters`; документ не `ERROR`, файл не
`SUPERSEDED`/`ERROR`, квартал и код счёта заданы; статус подтверждения и привязка к версии не важны. Порядок — `SUPERSEDED`/`ERROR`, квартал и код счёта заданы; статус подтверждения и привязка к версии не важны. Порядок —
натуральный (`account_sort_key`); ЮЛ без документов в словаре нет.""" натуральный (`account_sort_key`); ЮЛ без документов в словаре нет. `consolidation_id` (режим «период из версии»,
arm-core#103) — плюс ОСВ, загруженные в эту консолидацию, любого периода."""
if not entity_ids: if not entity_ids:
return {} return {}
in_period: ColumnElement[bool] = and_(Document.period_year == year, Document.period_quarter.in_(list(quarters)))
if consolidation_id is not None:
in_period = or_(in_period, _osv_of_consolidation(consolidation_id))
stmt = ( stmt = (
select(Document.legal_entity_id, Document.osv_account_code) select(Document.legal_entity_id, Document.osv_account_code)
.join(StoredFile, StoredFile.id == Document.stored_file_id) .join(StoredFile, StoredFile.id == Document.stored_file_id)
.where( .where(
Document.legal_entity_id.in_(list(entity_ids)), Document.legal_entity_id.in_(list(entity_ids)),
Document.document_type == DocumentType.OSV, Document.document_type == DocumentType.OSV,
Document.period_year == year, in_period,
Document.period_quarter.in_(list(quarters)),
Document.status != DocumentStatus.ERROR, Document.status != DocumentStatus.ERROR,
StoredFile.status.not_in([FileStatus.SUPERSEDED, FileStatus.ERROR]), StoredFile.status.not_in([FileStatus.SUPERSEDED, FileStatus.ERROR]),
Document.osv_account_code.is_not(None), Document.osv_account_code.is_not(None),

View File

@ -24,10 +24,10 @@ from src.common.exceptions import DomainValidationError, NotFoundError
from src.domain import consolidated_osv as domain from src.domain import consolidated_osv as domain
from src.domain.aliases import normalize_alias from src.domain.aliases import normalize_alias
from src.domain.policy import VisibilityScope from src.domain.policy import VisibilityScope
from src.domain.version_period import DocumentFields
from src.models.consolidation import Consolidation, ConsolidationVersion from src.models.consolidation import Consolidation, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity, RelatedGroup from src.models.directories import CompanyGroup, LegalEntity, RelatedGroup
from src.models.enums import AuditAction, AuditSection, DocumentType, RelatedGroupKind from src.models.enums import AuditAction, AuditSection, DocumentType, RelatedGroupKind
from src.models.files import Document
from src.models.osv import OsvEntry from src.models.osv import OsvEntry
from src.repositories import consolidations as repo from src.repositories import consolidations as repo
from src.repositories.counterparties import CounterpartyIndex from src.repositories.counterparties import CounterpartyIndex
@ -41,6 +41,7 @@ from src.schemas.consolidations import (
OsvSummaryOut, OsvSummaryOut,
RefOut, RefOut,
) )
from src.services import consolidation_documents
from src.services.consolidation_guard import VERSION_NOT_FOUND from src.services.consolidation_guard import VERSION_NOT_FOUND
from src.services.osv_export import build_workbook from src.services.osv_export import build_workbook
@ -76,7 +77,7 @@ def _ref(item: CompanyGroup | RelatedGroup | None) -> RefOut | None:
return None if item is None else RefOut(id=item.id, name=item.name) return None if item is None else RefOut(id=item.id, name=item.name)
def _source_document(document: Document) -> domain.SourceDocument: def _source_document(document: DocumentFields) -> domain.SourceDocument:
return domain.SourceDocument( return domain.SourceDocument(
id=document.id, id=document.id,
legal_entity_id=document.legal_entity_id, legal_entity_id=document.legal_entity_id,
@ -141,11 +142,16 @@ class ConsolidatedOsvService:
raise NotFoundError(VERSION_NOT_FOUND) raise NotFoundError(VERSION_NOT_FOUND)
version, consolidation = found version, consolidation = found
members = {member.id: _entity(member) for _, member in await repo.members(self._session, version.id)} members = {member.id: _entity(member) for _, member in await repo.members(self._session, version.id)}
documents = [ bound = [
_source_document(document) document
for document in await repo.bound_documents(self._session, version.id) for document in await repo.bound_documents(self._session, version.id)
if document.document_type == DocumentType.OSV if document.document_type == DocumentType.OSV
] ]
# режим «период из версии» (arm-core#103): ОСВ, загруженные в консолидацию, — с периодом версии
documents = [
_source_document(document)
for document in await consolidation_documents.for_consolidation(self._session, bound, consolidation)
]
# ровно то, что отдал `load_osv_rows`: он оставляет `COUNTERPARTY`/`BU`+`SUM`, домен их не перефильтровывает # ровно то, что отдал `load_osv_rows`: он оставляет `COUNTERPARTY`/`BU`+`SUM`, домен их не перефильтровывает
rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)] rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)]
index = await CounterpartyIndex.load(self._session) index = await CounterpartyIndex.load(self._session)

View File

@ -25,12 +25,14 @@ from collections.abc import Collection, Iterable, Sequence
from typing import Any from typing import Any
from loguru import logger from loguru import logger
from sqlalchemy import delete, select from sqlalchemy import and_, delete, or_, select
from sqlalchemy.engine import CursorResult from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.core.config import settings
from src.domain.consolidation_periods import QUARTER_ORDER, counts_as_cumulative, effective_cumulative from src.domain.consolidation_periods import QUARTER_ORDER, counts_as_cumulative, effective_cumulative
from src.domain.policy import IN_WORK_STATUSES from src.domain.policy import IN_WORK_STATUSES
from src.domain.version_period import DocumentFields, takes_version_period, with_version_period
from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion
from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter
from src.models.files import Document, StoredFile from src.models.files import Document, StoredFile
@ -41,9 +43,11 @@ __all__ = [
"bind_confirmed", "bind_confirmed",
"bound_versions", "bound_versions",
"fits", "fits",
"for_consolidation",
"on_document_confirmed", "on_document_confirmed",
"on_documents_mapped", "on_documents_mapped",
"on_documents_superseded", "on_documents_superseded",
"period_from_version",
"unbind_documents", "unbind_documents",
"unbind_entity", "unbind_entity",
] ]
@ -59,7 +63,36 @@ def _rowcount(result: Any) -> int:
return result.rowcount if isinstance(result, CursorResult) else 0 return result.rowcount if isinstance(result, CursorResult) else 0
def _cumulative(document: Document) -> bool: def period_from_version() -> bool:
"""Включён ли временный режим «период ОСВ из версии консолидации» (`domain/version_period.py`, arm-core#103)."""
return settings.CONSOLIDATION_PERIOD_FROM_VERSION
async def for_consolidation(
session: AsyncSession, documents: Sequence[Document], consolidation: Consolidation
) -> list[DocumentFields]:
"""Документы так, как их видит консолидация: при режиме `period_from_version` ОСВ, загруженные в неё и не подходящие
версии по периоду шапки, — с периодом версии (`with_version_period`), прочие — как есть. Режим выключен —
`documents` без запросов к БД; включён — один запрос `consolidation_id` файлов."""
if not period_from_version() or not documents:
return list(documents)
file_ids = {document.stored_file_id for document in documents}
file_consolidations = await repo.file_consolidation_ids(session, file_ids)
return [
with_version_period(document, year=consolidation.year, quarter=consolidation.quarter)
if takes_version_period(
enabled=True,
document_type=document.document_type,
file_consolidation_id=file_consolidations.get(document.stored_file_id),
consolidation_id=consolidation.id,
fits_by_header=_period_fits(document, consolidation),
)
else document
for document in documents
]
def _cumulative(document: DocumentFields) -> bool:
"""Нарастающий ли документ для привязки и ключа замены (R-1, решение №10, R-14, R-14a): NULL — нарастающий; """Нарастающий ли документ для привязки и ключа замены (R-1, решение №10, R-14, R-14a): NULL — нарастающий;
ОСВ за `Q1` — квартальная при любом флаге (`effective_cumulative`). Равнозначность `Q1` — только для ОСВ: ББ/ОФР ОСВ за `Q1` — квартальная при любом флаге (`effective_cumulative`). Равнозначность `Q1` — только для ОСВ: ББ/ОФР
за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4` (`counts_as_cumulative`, всегда нарастающие).""" за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4` (`counts_as_cumulative`, всегда нарастающие)."""
@ -68,7 +101,7 @@ def _cumulative(document: Document) -> bool:
return counts_as_cumulative(document.is_cumulative) return counts_as_cumulative(document.is_cumulative)
def _key(document: Document) -> BindingKey: def _key(document: DocumentFields) -> BindingKey:
"""Ключ замены привязки (R-1): ЮЛ, тип, счёт, квартал и признак нарастающего.""" """Ключ замены привязки (R-1): ЮЛ, тип, счёт, квартал и признак нарастающего."""
return ( return (
document.legal_entity_id, document.legal_entity_id,
@ -79,14 +112,14 @@ def _key(document: Document) -> BindingKey:
) )
def _latest_first(document: Document) -> tuple[datetime.datetime, uuid.UUID]: def _latest_first(document: DocumentFields) -> tuple[datetime.datetime, uuid.UUID]:
"""Порядок «самый поздний `confirmed_at` первым»; равные — по `id` (детерминированно).""" """Порядок «самый поздний `confirmed_at` первым»; равные — по `id` (детерминированно)."""
confirmed_at = document.confirmed_at or datetime.datetime.min.replace(tzinfo=datetime.UTC) confirmed_at = document.confirmed_at or datetime.datetime.min.replace(tzinfo=datetime.UTC)
return confirmed_at, document.id return confirmed_at, document.id
def fits( def fits(
document: Document, document: DocumentFields,
version: ConsolidationVersion, # noqa: ARG001 - сигнатура D-5: подходящесть — функция документа и версии version: ConsolidationVersion, # noqa: ARG001 - сигнатура D-5: подходящесть — функция документа и версии
consolidation: Consolidation, consolidation: Consolidation,
member_ids: Collection[uuid.UUID], member_ids: Collection[uuid.UUID],
@ -94,8 +127,15 @@ def fits(
"""Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или """Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или
NULL) — квартал версии; квартальный — не позже квартала версии; ОСВ за `Q1` — любой версии года при любом флаге NULL) — квартал версии; квартальный — не позже квартала версии; ОСВ за `Q1` — любой версии года при любом флаге
(R-14; ББ/ОФР за `Q1` — только версии `Q1`, R-14a). Статус документа и файла не проверяется — это (R-14; ББ/ОФР за `Q1` — только версии `Q1`, R-14a). Статус документа и файла не проверяется — это
дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Чистая функция, без БД.""" дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Период — у переданного представления:
if document.legal_entity_id not in member_ids or document.period_year != consolidation.year: в режиме «период из версии» вызывающий передаёт `for_consolidation(...)`. Чистая функция, без БД."""
return document.legal_entity_id in member_ids and _period_fits(document, consolidation)
def _period_fits(document: DocumentFields, consolidation: Consolidation) -> bool:
"""Периодная часть `fits` (D-5, R-14, R-14a): год версии, квартал определён, нарастающий — за квартал версии,
квартальный — не позже него."""
if document.period_year != consolidation.year:
return False return False
if document.period_quarter is None: if document.period_quarter is None:
return False return False
@ -136,9 +176,14 @@ async def _unbind(session: AsyncSession, version_id: uuid.UUID, document_ids: Co
return _rowcount(result) return _rowcount(result)
async def _bind(session: AsyncSession, version_id: uuid.UUID, document: Document) -> None: async def _bind(
"""Привязывает `document` к версии, предварительно отвязав привязанные документы с тем же ключом (R-1).""" session: AsyncSession, version_id: uuid.UUID, consolidation: Consolidation, document: DocumentFields
bound = await _bound_documents(session, version_id, [document.legal_entity_id]) ) -> None:
"""Привязывает `document` к версии, предварительно отвязав привязанные документы с тем же ключом (R-1); ключи —
по представлению консолидации (`for_consolidation`)."""
bound = await for_consolidation(
session, await _bound_documents(session, version_id, [document.legal_entity_id]), consolidation
)
await _unbind(session, version_id, [other.id for other in bound if _key(other) == _key(document)]) await _unbind(session, version_id, [other.id for other in bound if _key(other) == _key(document)])
session.add(ConsolidationDocument(consolidation_version_id=version_id, document_id=document.id)) session.add(ConsolidationDocument(consolidation_version_id=version_id, document_id=document.id))
await session.flush() await session.flush()
@ -162,21 +207,28 @@ async def bind_confirmed(
if not entity_ids: if not entity_ids:
return 0 return 0
members = await _member_ids(session, version.id) members = await _member_ids(session, version.id)
in_period = Document.period_year == consolidation.year
if period_from_version(): # ОСВ, загруженные в консолидацию, — любого периода из шапки
in_period = or_(
in_period,
and_(Document.document_type == DocumentType.OSV, StoredFile.consolidation_id == consolidation.id),
)
stmt = ( stmt = (
select(Document) select(Document)
.join(StoredFile, StoredFile.id == Document.stored_file_id) .join(StoredFile, StoredFile.id == Document.stored_file_id)
.where( .where(
Document.legal_entity_id.in_(list(entity_ids)), Document.legal_entity_id.in_(list(entity_ids)),
Document.status == DocumentStatus.CONFIRMED, Document.status == DocumentStatus.CONFIRMED,
Document.period_year == consolidation.year, in_period,
StoredFile.status != FileStatus.SUPERSEDED, StoredFile.status != FileStatus.SUPERSEDED,
) )
) )
candidates = [doc for doc in (await session.execute(stmt)).scalars() if fits(doc, version, consolidation, members)] documents = await for_consolidation(session, list((await session.execute(stmt)).scalars()), consolidation)
winners: dict[BindingKey, Document] = {} candidates = [doc for doc in documents if fits(doc, version, consolidation, members)]
winners: dict[BindingKey, DocumentFields] = {}
for document in sorted(candidates, key=_latest_first, reverse=True): for document in sorted(candidates, key=_latest_first, reverse=True):
winners.setdefault(_key(document), document) winners.setdefault(_key(document), document)
bound = await _bound_documents(session, version.id, entity_ids) bound = await for_consolidation(session, await _bound_documents(session, version.id, entity_ids), consolidation)
bound_ids = {document.id for document in bound} bound_ids = {document.id for document in bound}
replaced = [doc.id for doc in bound if _key(doc) in winners and winners[_key(doc)].id != doc.id] replaced = [doc.id for doc in bound if _key(doc) in winners and winners[_key(doc)].id != doc.id]
await _unbind(session, version.id, replaced) await _unbind(session, version.id, replaced)
@ -261,9 +313,10 @@ async def on_document_confirmed( # noqa: PLR0913 - автор и коммент
consolidation = await session.get(Consolidation, version.consolidation_id) consolidation = await session.get(Consolidation, version.consolidation_id)
if consolidation is None: # FK гарантирует наличие if consolidation is None: # FK гарантирует наличие
continue continue
if not fits(document, version, consolidation, await _member_ids(session, version.id)): [view] = await for_consolidation(session, [document], consolidation)
if not fits(view, version, consolidation, await _member_ids(session, version.id)):
continue continue
await _bind(session, version.id, document) await _bind(session, version.id, consolidation, view)
if version.status in MOVES_TO_CORRECTION: if version.status in MOVES_TO_CORRECTION:
if changed_by is None: if changed_by is None:
logger.info( logger.info(
@ -353,7 +406,8 @@ async def on_documents_mapped(
if consolidation is None: # FK гарантирует наличие if consolidation is None: # FK гарантирует наличие
continue continue
members = await _member_ids(session, version.id) members = await _member_ids(session, version.id)
if not any(fits(document, version, consolidation, members) for document in needs_check): views = await for_consolidation(session, needs_check, consolidation)
if not any(fits(document, version, consolidation, members) for document in views):
continue continue
await transition( await transition(
session, session,

View File

@ -223,18 +223,31 @@ class ConsolidationService:
for rule, account in await repo.rules_for(self._session, version.approach) for rule, account in await repo.rules_for(self._session, version.approach)
) )
entity_ids = [entity.id for entity in entities] entity_ids = [entity.id for entity in entities]
candidates = await repo.unconfirmed_documents(self._session, entity_ids, consolidation.year) candidates = await consolidation_documents.for_consolidation(
self._session,
await repo.unconfirmed_documents(
self._session, entity_ids, consolidation.year, self._period_from_version(consolidation)
),
consolidation,
)
unbound = [doc for doc in candidates if consolidation_documents.fits(doc, version, consolidation, entity_ids)] unbound = [doc for doc in candidates if consolidation_documents.fits(doc, version, consolidation, entity_ids)]
rows = checklist_domain.build( rows = checklist_domain.build(
rules, rules,
version_quarter=consolidation.quarter, version_quarter=consolidation.quarter,
members=entities, members=entities,
bound=await repo.bound_documents(self._session, version.id), bound=await consolidation_documents.for_consolidation(
self._session, await repo.bound_documents(self._session, version.id), consolidation
),
unbound=unbound, unbound=unbound,
files_state=await repo.entity_files_state(self._session, entity_ids), files_state=await repo.entity_files_state(self._session, entity_ids),
) )
return rules, rows return rules, rows
@staticmethod
def _period_from_version(consolidation: Consolidation) -> uuid.UUID | None:
"""`consolidation_id` для выборок кандидатов в режиме «период из версии» (arm-core#103); иначе `None`."""
return consolidation.id if consolidation_documents.period_from_version() else None
@staticmethod @staticmethod
def _cell_out(rule: checklist_domain.Rule, cell: checklist_domain.Cell) -> ChecklistCellOut: def _cell_out(rule: checklist_domain.Rule, cell: checklist_domain.Cell) -> ChecklistCellOut:
"""Ячейка ответа с правилом (D-2); `rule` — правило того же столбца, что и `cell`.""" """Ячейка ответа с правилом (D-2); `rule` — правило того же столбца, что и `cell`."""
@ -397,6 +410,7 @@ class ConsolidationService:
[entity.id for _, entity in members], [entity.id for _, entity in members],
consolidation.year, consolidation.year,
quarters_through(consolidation.quarter), quarters_through(consolidation.quarter),
self._period_from_version(consolidation),
) )
return VersionCard( return VersionCard(
**brief.model_dump(), **brief.model_dump(),

View File

@ -0,0 +1,89 @@
"""Временный режим «период ОСВ из версии консолидации» (arm-core#103): чистые функции `domain.version_period`."""
import datetime
import uuid
import pytest
from src.domain.version_period import takes_version_period, with_version_period
from src.models.consolidation import Consolidation, ConsolidationVersion
from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, PeriodQuarter
from src.models.files import Document
from src.services.consolidation_documents import fits
CONSOLIDATION = uuid.uuid4()
OTHER = uuid.uuid4()
MEMBER = uuid.uuid4()
@pytest.mark.parametrize(
("enabled", "document_type", "file_consolidation_id", "fits_by_header", "expected"),
[
(True, DocumentType.OSV, CONSOLIDATION, False, True), # ОСВ «не того» периода загружена в эту консолидацию
(True, DocumentType.OSV, CONSOLIDATION, True, False), # период шапки и так подходит — склейка кварталов цела
(False, DocumentType.OSV, CONSOLIDATION, False, False), # режим выключен — период из шапки
(True, DocumentType.OSV, OTHER, False, False), # загружена в другую консолидацию
(True, DocumentType.OSV, None, False, False), # загружена с рабочего стола, без консолидации
(True, DocumentType.BALANCE, CONSOLIDATION, False, False), # ББ/ОФР — только период из шапки
(True, DocumentType.INCOME, CONSOLIDATION, False, False),
],
)
def test_takes_version_period(
enabled: bool,
document_type: DocumentType,
file_consolidation_id: uuid.UUID | None,
fits_by_header: bool,
expected: bool,
) -> None:
assert (
takes_version_period(
enabled=enabled,
document_type=document_type,
file_consolidation_id=file_consolidation_id,
consolidation_id=CONSOLIDATION,
fits_by_header=fits_by_header,
)
is expected
)
def _document(*, year: int | None = 2025, quarter: PeriodQuarter | None = PeriodQuarter.Q1) -> Document:
return Document(
id=uuid.uuid4(),
legal_entity_id=MEMBER,
document_type=DocumentType.OSV,
osv_account_code="60",
period_year=year,
period_quarter=quarter,
is_cumulative=False,
status=DocumentStatus.CONFIRMED,
confirmed_at=datetime.datetime(2026, 10, 12, tzinfo=datetime.UTC),
)
def test_with_version_period_takes_year_and_quarter_of_version_as_cumulative() -> None:
document = _document()
view = with_version_period(document, year=2026, quarter=PeriodQuarter.Q3)
assert (view.period_year, view.period_quarter, view.is_cumulative) == (2026, PeriodQuarter.Q3, True)
assert (view.id, view.legal_entity_id, view.document_type, view.osv_account_code) == (
document.id,
MEMBER,
DocumentType.OSV,
"60",
)
assert (view.status, view.confirmed_at) == (document.status, document.confirmed_at)
# первичка не мутируется: документ сохраняет период из шапки
assert (document.period_year, document.period_quarter, document.is_cumulative) == (2025, PeriodQuarter.Q1, False)
@pytest.mark.parametrize("quarter", [None, PeriodQuarter.Q4])
def test_version_period_fits_version_whatever_header_period(quarter: PeriodQuarter | None) -> None:
"""Документ без квартала или за другой год/квартал в представлении версии подходит ей (если ЮЛ — член)."""
consolidation = Consolidation(year=2026, quarter=PeriodQuarter.Q2)
version = ConsolidationVersion(status=ConsolidationStatus.INITIATED)
view = with_version_period(_document(year=2024, quarter=quarter), year=2026, quarter=PeriodQuarter.Q2)
assert fits(view, version, consolidation, {MEMBER}) is True
assert fits(view, version, consolidation, {uuid.uuid4()}) is False

View File

@ -0,0 +1,237 @@
"""Временный режим «период ОСВ из версии консолидации» (arm-core#103, `CONSOLIDATION_PERIOD_FROM_VERSION`).
ОСВ, загруженная в консолидацию (`stored_file.consolidation_id`), при включённом режиме считается нарастающей за год и
квартал консолидации: привязывается при подтверждении и подхвате, попадает в свод, чек-лист и колонку «ОСВ» периметра.
Выключенный режим, файлы вне консолидации и ББ/ОФР — период из шапки, как раньше.
"""
import datetime
import uuid
from collections.abc import Sequence
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.config import settings
from src.models.access import AppUser
from src.models.consolidation import (
Consolidation,
ConsolidationDocument,
ConsolidationMember,
ConsolidationVersion,
)
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import DocumentStatus, PeriodQuarter
from src.models.files import Document, StoredFile
from src.services import consolidation_documents
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_confirmed_osv, make_consolidation, make_legal_entity
HTTP_OK = 200
KA_RF = "ka_rf@armka.local"
ROSTOV = "Ростовский региональный филиал"
VERSION_YEAR = 2026 # консолидация 2026 Q1
HEADER_YEAR = 2025 # а ОСВ в шапке — за 1 кв. 2025
ACCOUNT = "60"
ROWS = [{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}]
Q1, Q2 = PeriodQuarter.Q1, PeriodQuarter.Q2
@pytest.fixture
def period_from_version(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "CONSOLIDATION_PERIOD_FROM_VERSION", True)
async def _world(
session: AsyncSession, members: int = 2
) -> tuple[Consolidation, ConsolidationVersion, list[LegalEntity]]:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
entities = [await make_legal_entity(session, name=f"ООО Член {n}", group=group) for n in range(members + 1)]
user = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
consolidation, version = await make_consolidation(
session, group=group, year=VERSION_YEAR, quarter=Q1, members=entities[:members], initiator=user
)
return consolidation, version, entities
async def _osv( # noqa: PLR0913 - тестовая фабрика: ЮЛ, консолидация файла, период, статус, время подтверждения
session: AsyncSession,
entity: LegalEntity,
consolidation: Consolidation | None,
*,
quarter: PeriodQuarter = Q1,
cumulative: bool = True,
status: DocumentStatus = DocumentStatus.NEEDS_CHECK,
confirmed_at: datetime.datetime | None = None,
) -> Document:
"""ОСВ 60 за `HEADER_YEAR` со строкой контрагента; файл загружен в `consolidation` (`None` — рабочий стол)."""
document = await make_confirmed_osv(
session, entity=entity, account=ACCOUNT, year=HEADER_YEAR, quarter=quarter, is_cumulative=cumulative, rows=ROWS
)
file = await session.get(StoredFile, document.stored_file_id)
assert file is not None
file.consolidation_id = consolidation.id if consolidation is not None else None
document.status = status
document.confirmed_at = confirmed_at if status is DocumentStatus.CONFIRMED else None
await session.flush()
return document
async def _bound(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
stmt = select(ConsolidationDocument.document_id).where(ConsolidationDocument.consolidation_version_id == version_id)
return set((await session.execute(stmt)).scalars())
async def _confirm(auth_client: AuthClientFactory, documents: Sequence[Document]) -> None:
client = await auth_client(KA_RF)
for document in documents:
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTP_OK, response.text
@pytest.mark.integration
@pytest.mark.usefixtures("period_from_version")
async def test_confirmed_osv_of_other_period_goes_to_version_and_summary(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Сценарий владельца 12.10.2026: ОСВ за 1 кв. 2025 в консолидации 2026 Q1 после подтверждения — в своде."""
consolidation, version, (first, second, _) = await _world(seeded)
documents = [await _osv(seeded, entity, consolidation) for entity in (first, second)]
await _confirm(auth_client, documents)
assert await _bound(seeded, version.id) == {document.id for document in documents}
client = await auth_client(KA_RF)
summary = await client.get(f"/consolidations/versions/{version.id}/osv?limit=100")
assert summary.status_code == HTTP_OK, summary.text
assert summary.json()["total"] == len(documents) * len(ROWS)
# первичка не мутируется: период документа — из шапки
document = await seeded.get(Document, documents[0].id, populate_existing=True)
assert document is not None
assert (document.period_year, document.period_quarter) == (HEADER_YEAR, Q1)
@pytest.mark.integration
async def test_without_mode_osv_of_other_period_stays_unbound(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
consolidation, version, (first, _, _) = await _world(seeded)
document = await _osv(seeded, first, consolidation)
await _confirm(auth_client, [document])
assert await _bound(seeded, version.id) == set()
@pytest.mark.integration
@pytest.mark.usefixtures("period_from_version")
async def test_mode_ignores_osv_uploaded_outside_consolidation(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Файл с рабочего стола (`consolidation_id` пуст) — период из шапки: 2025 не подходит версии 2026."""
_, version, (first, _, _) = await _world(seeded)
document = await _osv(seeded, first, None)
await _confirm(auth_client, [document])
assert await _bound(seeded, version.id) == set()
@pytest.mark.integration
@pytest.mark.usefixtures("period_from_version")
async def test_checklist_and_card_see_unconfirmed_osv_of_other_period(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
consolidation, version, (first, _, _) = await _world(seeded)
await _osv(seeded, first, consolidation)
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()
rules = [(rule["document_type"], rule["account"]) for rule in checklist["rules"]]
row = next(row for row in checklist["rows"] if row["legal_entity_id"] == str(first.id))
assert row["cells"][rules.index(("OSV", ACCOUNT))]["status"] == "NEEDS_CHECK"
member = next(member for member in card["members"] if member["id"] == str(first.id))
assert member["osv_accounts"] == [ACCOUNT]
@pytest.mark.integration
@pytest.mark.usefixtures("period_from_version")
async def test_perimeter_pickup_binds_one_osv_per_account_latest_confirmed(seeded: AsyncSession) -> None:
"""Подхват (`bind_confirmed`): все ОСВ счёта одного ЮЛ в консолидации — один слот, побеждает поздняя."""
consolidation, version, (_, _, newcomer) = await _world(seeded)
earlier = datetime.datetime(2026, 10, 12, 9, tzinfo=datetime.UTC)
later = earlier + datetime.timedelta(hours=1)
await _osv(
seeded,
newcomer,
consolidation,
quarter=Q1,
cumulative=False,
status=DocumentStatus.CONFIRMED,
confirmed_at=earlier,
)
latest = await _osv(
seeded,
newcomer,
consolidation,
quarter=Q2,
cumulative=False,
status=DocumentStatus.CONFIRMED,
confirmed_at=later,
)
seeded.add(
ConsolidationMember(consolidation_version_id=version.id, legal_entity_id=newcomer.id, is_head_entity=False)
)
await seeded.flush()
bound = await consolidation_documents.bind_confirmed(
seeded, version=version, consolidation=consolidation, entity_ids=[newcomer.id]
)
assert bound == 1
assert await _bound(seeded, version.id) == {latest.id}
@pytest.mark.integration
async def test_mode_keeps_header_period_when_it_fits_version(
seeded: AsyncSession, auth_client: AuthClientFactory, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Регрессия: квартальные ОСВ Q1–Q3, загруженные в консолидацию Q3 и привязанные до включения режима, в режиме
по-прежнему склеиваются в одну строку свода (их период из шапки подходит версии — подмены нет), а не идут «как есть»
трижды как нарастающие за Q3."""
office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(seeded, name=f"ГК {uuid.uuid4().hex[:6]}", office=office)
entities = [await make_legal_entity(seeded, name=f"ООО Член {n}", group=group) for n in range(2)]
user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
consolidation, version = await make_consolidation(
seeded, group=group, year=HEADER_YEAR, quarter=PeriodQuarter.Q3, members=entities, initiator=user
)
quarters = (Q1, Q2, PeriodQuarter.Q3)
for quarter in quarters:
document = await make_confirmed_osv(
seeded,
entity=entities[0],
account=ACCOUNT,
year=HEADER_YEAR,
quarter=quarter,
is_cumulative=False,
rows=[{"name": "ООО Контрагент", "inn": "7701234567", "turnover_debit": 10}],
)
file = await seeded.get(StoredFile, document.stored_file_id)
assert file is not None
file.consolidation_id = consolidation.id
await seeded.flush()
assert await consolidation_documents.bind_confirmed(
seeded, version=version, consolidation=consolidation, entity_ids=[entities[0].id]
) == len(quarters) # режим выключен: три квартальных источника
monkeypatch.setattr(settings, "CONSOLIDATION_PERIOD_FROM_VERSION", True)
client = await auth_client(KA_RF)
body = (await client.get(f"/consolidations/versions/{version.id}/osv?limit=100")).json()
assert body["total"] == 1, body
assert body["items"][0]["turnover_debit"] == "30.00"