From 1ae6b8d2dac0430555a3725dc05ce38539b5e30f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 19:17:01 +0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(consolidation):=20=D0=B2=D1=80=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=BC=20=C2=AB=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=20=D0=9E?= =?UTF-8?q?=D0=A1=D0=92=20=D0=B8=D0=B7=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=C2=BB=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Настройка CONSOLIDATION_PERIOD_FROM_VERSION (по умолчанию false; в контуре — OPENBAO__APP__CONSOLIDATION_PERIOD_FROM_VERSION): ОСВ, загруженная в консолидацию (stored_file.consolidation_id), считается нарастающей за год и квартал консолидации, какой бы период ни стоял в шапке. Нужен для тестов на произвольных ОСВ: без него ОСВ за 1 кв. 2025 в консолидации 2026 Q1 молча не привязывается и свод пуст. - domain/version_period.py: представление документа с периодом версии (первичка не меняется) - consolidation_documents.for_consolidation: единая точка подмены; её используют привязка (fits, ключ R-1, bind_confirmed, хуки подтверждения и маппинга), свод, чек-лист, колонка «ОСВ» периметра (osv_accounts) - ББ/ОФР и файлы с рабочего стола режим не затрагивает; склейки кварталов в режиме нет - тесты: unit на чистые функции, интеграционные — сценарий 2025 Q1 → 2026 Q1 с режимом и без, файл вне консолидации, чек-лист и карточка, подхват «один слот на счёт» Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 7 + api/.env.example | 3 + api/src/core/config.py | 5 + api/src/domain/version_period.py | 84 +++++++++ api/src/repositories/consolidations.py | 45 ++++- api/src/services/consolidated_osv.py | 14 +- api/src/services/consolidation_documents.py | 79 ++++++-- api/src/services/consolidations.py | 18 +- api/tests/test_domain_version_period.py | 83 +++++++++ api/tests/test_version_period.py | 196 ++++++++++++++++++++ 11 files changed, 506 insertions(+), 30 deletions(-) create mode 100644 api/src/domain/version_period.py create mode 100644 api/tests/test_domain_version_period.py create mode 100644 api/tests/test_version_period.py diff --git a/CLAUDE.md b/CLAUDE.md index 8cd038f..53ee62e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,7 +114,7 @@ (`lock_visible_version`/`lock_editable_version`: версия `FOR UPDATE` → видимость 404 → статус «в работе» → своя блокировка), `consolidation_locks` (взять/перехватить/снять; без истечения, OQ-119), `consolidation_status` (`transition` + ручной `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, лист «Пробелы»). Создание и `PUT …/members` проверяют периметр одной функцией `_validate_perimeter` (#42); ячейки чек-листа самоописывающие (`document_type`, `account`); `members[].osv_accounts` — `repositories/consolidations.osv_accounts` («что загружено к периоду diff --git a/README.md b/README.md index f7fd220..144596d 100644 --- a/README.md +++ b/README.md @@ -712,6 +712,12 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` и удаление ЮЛ из периметра. Версии `CANCELLED` не трогаются. - **Удаление файла с документом в версии:** файл с документом, подтверждённым пользователем, удалить нельзя; файл с автоподтверждённым документом — можно, пока все версии с его привязками «в работе» (привязки снимаются), иначе 409 `FILE_HAS_CONFIRMED_DOCUMENTS` (OQ-110, уточнено OQ-157). - `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 +933,7 @@ email без учёта регистра проверяет только сер | `UPLOAD_MAX_BYTES` | `52428800` (50 МиБ) | предельный размер файла первичной отчётности при загрузке; больше — `FILE_TOO_LARGE` в `rejected` | | `MAPPING_POLL_SECONDS` | `5` | период опроса цикла маппинга результатов распознавания | | `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_SECRET` | пусто | секрет HS256 dev-эмитента (не короче 32 байт); только для разработки | | `AUTH_JWKS_URL` или `AUTH_PUBLIC_KEY` | пусто | проверка токенов РАИСА: JWKS по URL либо публичный ключ (PEM) | diff --git a/api/.env.example b/api/.env.example index 8b44d9c..3eb0770 100644 --- a/api/.env.example +++ b/api/.env.example @@ -78,3 +78,6 @@ UPLOAD_MAX_BYTES=52428800 # Маппинг результата распознавания: период опроса готовых заданий (секунды) и число попыток до отметки ошибки MAPPING_POLL_SECONDS=5 MAPPING_MAX_ATTEMPTS=3 +# ВРЕМЕННО, для тестов на произвольных ОСВ (arm-core#103): ОСВ, загруженная в консолидацию, считается нарастающей за год и +# квартал консолидации, а не за период из шапки. В контуре — OPENBAO__APP__CONSOLIDATION_PERIOD_FROM_VERSION +CONSOLIDATION_PERIOD_FROM_VERSION=false diff --git a/api/src/core/config.py b/api/src/core/config.py index 6c97316..f9583d0 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -112,6 +112,11 @@ class Settings(BaseSettings): MAPPING_POLL_SECONDS: PositiveInt = 5 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 def settings_customise_sources( cls, diff --git a/api/src/domain/version_period.py b/api/src/domain/version_period.py new file mode 100644 index 0000000..63619fd --- /dev/null +++ b/api/src/domain/version_period.py @@ -0,0 +1,84 @@ +"""ВРЕМЕННЫЙ режим «период ОСВ из версии консолидации» (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, +) -> bool: + """Берёт ли документ период версии: режим включён, документ — ОСВ, его файл загружен в эту консолидацию.""" + return enabled and document_type == DocumentType.OSV and file_consolidation_id == consolidation_id + + +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, + ) diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 8ddc373..ac7cdd9 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -10,7 +10,7 @@ import uuid from collections import defaultdict 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 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)] -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). + `consolidation_id` (режим «период из версии», arm-core#103) — плюс ОСВ, загруженные в эту консолидацию, любого года. Кандидаты чек-листа: «подходящесть» версии (`fits`) проверяет сервис. Порядок — по ЮЛ, типу, счёту, кварталу, `id`. """ if not entity_ids: 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 = ( select(Document) .join(StoredFile, StoredFile.id == Document.stored_file_id) .where( Document.legal_entity_id.in_(list(entity_ids)), - Document.period_year == year, + in_period, Document.status.in_(UNCONFIRMED_STATUSES), StoredFile.status != FileStatus.SUPERSEDED, ) @@ -409,22 +433,29 @@ async def unconfirmed_documents(session: AsyncSession, entity_ids: Collection[uu 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]]: """Счета ОСВ, загруженные к периоду версии (колонка «ОСВ» панели [34]; front-api-gaps D-3): уникальные `osv_account_code` документов `OSV` ЮЛ за год `year` и кварталы `quarters`; документ не `ERROR`, файл не `SUPERSEDED`/`ERROR`, квартал и код счёта заданы; статус подтверждения и привязка к версии не важны. Порядок — - натуральный (`account_sort_key`); ЮЛ без документов в словаре нет.""" + натуральный (`account_sort_key`); ЮЛ без документов в словаре нет. `consolidation_id` (режим «период из версии», + arm-core#103) — плюс ОСВ, загруженные в эту консолидацию, любого периода.""" if not entity_ids: 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 = ( 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)), + in_period, Document.status != DocumentStatus.ERROR, StoredFile.status.not_in([FileStatus.SUPERSEDED, FileStatus.ERROR]), Document.osv_account_code.is_not(None), diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index c20686b..f47dc20 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -24,10 +24,10 @@ from src.common.exceptions import DomainValidationError, NotFoundError from src.domain import consolidated_osv as domain from src.domain.aliases import normalize_alias from src.domain.policy import VisibilityScope +from src.domain.version_period import DocumentFields from src.models.consolidation import Consolidation, ConsolidationVersion from src.models.directories import CompanyGroup, LegalEntity, RelatedGroup from src.models.enums import AuditAction, AuditSection, DocumentType, RelatedGroupKind -from src.models.files import Document from src.models.osv import OsvEntry from src.repositories import consolidations as repo from src.repositories.counterparties import CounterpartyIndex @@ -41,6 +41,7 @@ from src.schemas.consolidations import ( OsvSummaryOut, RefOut, ) +from src.services import consolidation_documents from src.services.consolidation_guard import VERSION_NOT_FOUND 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) -def _source_document(document: Document) -> domain.SourceDocument: +def _source_document(document: DocumentFields) -> domain.SourceDocument: return domain.SourceDocument( id=document.id, legal_entity_id=document.legal_entity_id, @@ -141,11 +142,16 @@ class ConsolidatedOsvService: raise NotFoundError(VERSION_NOT_FOUND) version, consolidation = found members = {member.id: _entity(member) for _, member in await repo.members(self._session, version.id)} - documents = [ - _source_document(document) + bound = [ + document for document in await repo.bound_documents(self._session, version.id) 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`, домен их не перефильтровывает rows = [_source_row(entry) for entry, _, _ in await repo.load_osv_rows(self._session, version.id)] index = await CounterpartyIndex.load(self._session) diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index c2bf285..a2b9924 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -25,12 +25,14 @@ from collections.abc import Collection, Iterable, Sequence from typing import Any from loguru import logger -from sqlalchemy import delete, select +from sqlalchemy import and_, delete, or_, select from sqlalchemy.engine import CursorResult 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.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.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter from src.models.files import Document, StoredFile @@ -41,9 +43,11 @@ __all__ = [ "bind_confirmed", "bound_versions", "fits", + "for_consolidation", "on_document_confirmed", "on_documents_mapped", "on_documents_superseded", + "period_from_version", "unbind_documents", "unbind_entity", ] @@ -59,7 +63,35 @@ def _rowcount(result: Any) -> int: 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, + ) + else document + for document in documents + ] + + +def _cumulative(document: DocumentFields) -> bool: """Нарастающий ли документ для привязки и ключа замены (R-1, решение №10, R-14, R-14a): NULL — нарастающий; ОСВ за `Q1` — квартальная при любом флаге (`effective_cumulative`). Равнозначность `Q1` — только для ОСВ: ББ/ОФР за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4` (`counts_as_cumulative`, всегда нарастающие).""" @@ -68,7 +100,7 @@ def _cumulative(document: Document) -> bool: return counts_as_cumulative(document.is_cumulative) -def _key(document: Document) -> BindingKey: +def _key(document: DocumentFields) -> BindingKey: """Ключ замены привязки (R-1): ЮЛ, тип, счёт, квартал и признак нарастающего.""" return ( document.legal_entity_id, @@ -79,14 +111,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 = document.confirmed_at or datetime.datetime.min.replace(tzinfo=datetime.UTC) return confirmed_at, document.id def fits( - document: Document, + document: DocumentFields, version: ConsolidationVersion, # noqa: ARG001 - сигнатура D-5: подходящесть — функция документа и версии consolidation: Consolidation, member_ids: Collection[uuid.UUID], @@ -94,7 +126,8 @@ def fits( """Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или NULL) — квартал версии; квартальный — не позже квартала версии; ОСВ за `Q1` — любой версии года при любом флаге (R-14; ББ/ОФР за `Q1` — только версии `Q1`, R-14a). Статус документа и файла не проверяется — это - дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Чистая функция, без БД.""" + дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Период — у переданного представления: + в режиме «период из версии» вызывающий передаёт `for_consolidation(...)`. Чистая функция, без БД.""" if document.legal_entity_id not in member_ids or document.period_year != consolidation.year: return False if document.period_quarter is None: @@ -136,9 +169,14 @@ async def _unbind(session: AsyncSession, version_id: uuid.UUID, document_ids: Co return _rowcount(result) -async def _bind(session: AsyncSession, version_id: uuid.UUID, document: Document) -> None: - """Привязывает `document` к версии, предварительно отвязав привязанные документы с тем же ключом (R-1).""" - bound = await _bound_documents(session, version_id, [document.legal_entity_id]) +async def _bind( + session: AsyncSession, version_id: uuid.UUID, consolidation: Consolidation, document: DocumentFields +) -> 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)]) session.add(ConsolidationDocument(consolidation_version_id=version_id, document_id=document.id)) await session.flush() @@ -162,21 +200,28 @@ async def bind_confirmed( if not entity_ids: return 0 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 = ( select(Document) .join(StoredFile, StoredFile.id == Document.stored_file_id) .where( Document.legal_entity_id.in_(list(entity_ids)), Document.status == DocumentStatus.CONFIRMED, - Document.period_year == consolidation.year, + in_period, StoredFile.status != FileStatus.SUPERSEDED, ) ) - candidates = [doc for doc in (await session.execute(stmt)).scalars() if fits(doc, version, consolidation, members)] - winners: dict[BindingKey, Document] = {} + documents = await for_consolidation(session, list((await session.execute(stmt)).scalars()), consolidation) + 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): 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} 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) @@ -261,9 +306,10 @@ async def on_document_confirmed( # noqa: PLR0913 - автор и коммент consolidation = await session.get(Consolidation, version.consolidation_id) if consolidation is None: # FK гарантирует наличие 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 - await _bind(session, version.id, document) + await _bind(session, version.id, consolidation, view) if version.status in MOVES_TO_CORRECTION: if changed_by is None: logger.info( @@ -353,7 +399,8 @@ async def on_documents_mapped( if consolidation is None: # FK гарантирует наличие continue 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 await transition( session, diff --git a/api/src/services/consolidations.py b/api/src/services/consolidations.py index d94ad88..3d05f0c 100644 --- a/api/src/services/consolidations.py +++ b/api/src/services/consolidations.py @@ -223,18 +223,31 @@ class ConsolidationService: for rule, account in await repo.rules_for(self._session, version.approach) ) 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)] rows = checklist_domain.build( rules, version_quarter=consolidation.quarter, 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, files_state=await repo.entity_files_state(self._session, entity_ids), ) 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 def _cell_out(rule: checklist_domain.Rule, cell: checklist_domain.Cell) -> ChecklistCellOut: """Ячейка ответа с правилом (D-2); `rule` — правило того же столбца, что и `cell`.""" @@ -397,6 +410,7 @@ class ConsolidationService: [entity.id for _, entity in members], consolidation.year, quarters_through(consolidation.quarter), + self._period_from_version(consolidation), ) return VersionCard( **brief.model_dump(), diff --git a/api/tests/test_domain_version_period.py b/api/tests/test_domain_version_period.py new file mode 100644 index 0000000..4ed3e34 --- /dev/null +++ b/api/tests/test_domain_version_period.py @@ -0,0 +1,83 @@ +"""Временный режим «период ОСВ из версии консолидации» (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", "expected"), + [ + (True, DocumentType.OSV, CONSOLIDATION, True), # ОСВ загружена в эту консолидацию + (False, DocumentType.OSV, CONSOLIDATION, False), # режим выключен — период из шапки + (True, DocumentType.OSV, OTHER, False), # загружена в другую консолидацию + (True, DocumentType.OSV, None, False), # загружена с рабочего стола, без консолидации + (True, DocumentType.BALANCE, CONSOLIDATION, False), # ББ/ОФР — только период из шапки + (True, DocumentType.INCOME, CONSOLIDATION, False), + ], +) +def test_takes_version_period( + enabled: bool, document_type: DocumentType, file_consolidation_id: uuid.UUID | None, expected: bool +) -> None: + assert ( + takes_version_period( + enabled=enabled, + document_type=document_type, + file_consolidation_id=file_consolidation_id, + consolidation_id=CONSOLIDATION, + ) + 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 diff --git a/api/tests/test_version_period.py b/api/tests/test_version_period.py new file mode 100644 index 0000000..6d8f291 --- /dev/null +++ b/api/tests/test_version_period.py @@ -0,0 +1,196 @@ +"""Временный режим «период ОСВ из версии консолидации» (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} From a1614b05ef7d49240c1b3df897410de4eb754a07 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 19:17:50 +0400 Subject: [PATCH 2/3] =?UTF-8?q?chore(config):=20=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=20CONSOLIDATION=5FPERIOD=5FFROM=5FVERSION=20=D0=B2=20.env.test?= =?UTF-8?q?=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/.env.test | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/.env.test b/api/.env.test index 326f4dc..b807ba7 100644 --- a/api/.env.test +++ b/api/.env.test @@ -58,3 +58,5 @@ IMPORT_MAX_BYTES=20971520 UPLOAD_MAX_BYTES=52428800 MAPPING_POLL_SECONDS=5 MAPPING_MAX_ATTEMPTS=3 +# временный режим «период ОСВ из версии консолидации» (arm-core#103); тесты включают его через monkeypatch +CONSOLIDATION_PERIOD_FROM_VERSION=false From a6418ca475c90764608f244c8dd7e94b91769af8 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 12 Oct 2026 19:42:31 +0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(consolidation):=20=D1=80=D0=B5=D0=B6?= =?UTF-8?q?=D0=B8=D0=BC=20=C2=AB=D0=BF=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=C2=BB=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BC=D0=B5=D0=BD=D1=8F=D0=B5=D1=82=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D0=BE=D0=B4=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20=D1=83=20=D0=9E=D0=A1=D0=92=20=C2=AB=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=82=D0=BE=D0=B3=D0=BE=C2=BB=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=20(#103)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Квартальные ОСВ, загруженные в консолидацию своего периода и уже привязанные, в режиме становились «нарастающими за квартал версии»: select_sources возвращал все такие документы, и свод брал их строки как есть — консолидация РБ 2024 Q3 на стенде задвоилась бы. Теперь подмена — только если период шапки версии не подходит (fits_by_header); регрессионный тест test_mode_keeps_header_period_when_it_fits_version. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++-- api/src/domain/version_period.py | 26 +++++++++---- api/src/services/consolidation_documents.py | 15 ++++++-- api/tests/test_domain_version_period.py | 22 +++++++---- api/tests/test_version_period.py | 41 +++++++++++++++++++++ 5 files changed, 89 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 144596d..c21a76d 100644 --- a/README.md +++ b/README.md @@ -714,10 +714,11 @@ Q2–Q4 не подходят (R-14a). Привязываются только ` - `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`). Документ - не меняется (карточка показывает период из шапки); склейки кварталов нет — на счёт ЮЛ один слот, побеждает - подтверждённая последней; ББ/ОФР и файлы с рабочего стола режим не затрагивает. Только для тестов на произвольных ОСВ. + консолидацию (`stored_file.consolidation_id`), период шапки которой версии **не подходит**, считается нарастающей за год + и квартал консолидации — для привязки, свода, чек-листа и колонки «ОСВ» периметра (`src/domain/version_period.py`). + ОСВ, подходящие по шапке, не трогаются (квартальные склеиваются как раньше). Документ не меняется (карточка показывает + период из шапки); у подменённых ОСВ склейки нет — на счёт ЮЛ один слот, побеждает подтверждённая последней; ББ/ОФР и + файлы с рабочего стола режим не затрагивает. Только для тестов на произвольных ОСВ. ### Чек-лист diff --git a/api/src/domain/version_period.py b/api/src/domain/version_period.py index 63619fd..d047f2e 100644 --- a/api/src/domain/version_period.py +++ b/api/src/domain/version_period.py @@ -1,12 +1,15 @@ """ВРЕМЕННЫЙ режим «период ОСВ из версии консолидации» (arm-core#103, настройка `CONSOLIDATION_PERIOD_FROM_VERSION`). -Для тестов на произвольных ОСВ: документ ОСВ, загруженный в консолидацию (`stored_file.consolidation_id`), считается -нарастающим за год и квартал этой консолидации, какой бы период ни стоял в его шапке. Первичка не мутируется: -подменяется только представление документа (`VersionPeriodDocument`) при привязке к версии (`fits`, ключ замены R-1), -сборке свода (`select_sources`), чек-листе и колонке «ОСВ» периметра; карточка документа показывает период из шапки. +Для тестов на произвольных ОСВ: документ ОСВ, загруженный в консолидацию (`stored_file.consolidation_id`), период шапки +которого версии НЕ подходит, считается нарастающим за год и квартал этой консолидации. Документ, подходящий версии по +шапке, остаётся как есть — квартальные ОСВ по-прежнему склеиваются, уже привязанные не задваиваются. Первичка не +мутируется: подменяется только представление документа (`VersionPeriodDocument`) при привязке к версии (`fits`, ключ +замены R-1), сборке свода (`select_sources`), чек-листе и колонке «ОСВ» периметра; карточка документа показывает период +из шапки. -Следствие: склейки кварталов нет — все ОСВ одного счёта одного ЮЛ в консолидации занимают один слот привязки, побеждает -подтверждённая последней. ББ/ОФР и файлы без консолидации (рабочий стол) режим не затрагивает. +Следствие: подменённые ОСВ одного счёта одного ЮЛ занимают один слот привязки (нарастающий за квартал версии), побеждает +подтверждённая последней, и склейки кварталов у них нет. ББ/ОФР и файлы без консолидации (рабочий стол) режим не +затрагивает. ASSUMPTION(OQ-161): правила в `05-domain-rules.md` нет — режим временный, по решению владельца 12.10.2026; по умолчанию выключен, без настройки поведение §2 / D-5 Среза 4 не меняется. @@ -64,9 +67,16 @@ def takes_version_period( document_type: DocumentType, file_consolidation_id: uuid.UUID | None, consolidation_id: uuid.UUID, + fits_by_header: bool, ) -> bool: - """Берёт ли документ период версии: режим включён, документ — ОСВ, его файл загружен в эту консолидацию.""" - return enabled and document_type == DocumentType.OSV and file_consolidation_id == consolidation_id + """Берёт ли документ период версии: режим включён, документ — ОСВ, его файл загружен в эту консолидацию, а период + шапки версии не подходит (`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: diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index a2b9924..13d4b02 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -71,9 +71,9 @@ def period_from_version() -> bool: async def for_consolidation( session: AsyncSession, documents: Sequence[Document], consolidation: Consolidation ) -> list[DocumentFields]: - """Документы так, как их видит консолидация: при режиме `period_from_version` ОСВ, загруженные в неё, — с периодом - версии (`with_version_period`), прочие — как есть. Режим выключен — `documents` без запросов к БД; включён — один - запрос `consolidation_id` файлов.""" + """Документы так, как их видит консолидация: при режиме `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} @@ -85,6 +85,7 @@ async def for_consolidation( 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 @@ -128,7 +129,13 @@ def fits( (R-14; ББ/ОФР за `Q1` — только версии `Q1`, R-14a). Статус документа и файла не проверяется — это дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Период — у переданного представления: в режиме «период из версии» вызывающий передаёт `for_consolidation(...)`. Чистая функция, без БД.""" - if document.legal_entity_id not in member_ids or document.period_year != consolidation.year: + 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 if document.period_quarter is None: return False diff --git a/api/tests/test_domain_version_period.py b/api/tests/test_domain_version_period.py index 4ed3e34..6400156 100644 --- a/api/tests/test_domain_version_period.py +++ b/api/tests/test_domain_version_period.py @@ -17,18 +17,23 @@ MEMBER = uuid.uuid4() @pytest.mark.parametrize( - ("enabled", "document_type", "file_consolidation_id", "expected"), + ("enabled", "document_type", "file_consolidation_id", "fits_by_header", "expected"), [ - (True, DocumentType.OSV, CONSOLIDATION, True), # ОСВ загружена в эту консолидацию - (False, DocumentType.OSV, CONSOLIDATION, False), # режим выключен — период из шапки - (True, DocumentType.OSV, OTHER, False), # загружена в другую консолидацию - (True, DocumentType.OSV, None, False), # загружена с рабочего стола, без консолидации - (True, DocumentType.BALANCE, CONSOLIDATION, False), # ББ/ОФР — только период из шапки - (True, DocumentType.INCOME, CONSOLIDATION, False), + (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, expected: bool + enabled: bool, + document_type: DocumentType, + file_consolidation_id: uuid.UUID | None, + fits_by_header: bool, + expected: bool, ) -> None: assert ( takes_version_period( @@ -36,6 +41,7 @@ def test_takes_version_period( document_type=document_type, file_consolidation_id=file_consolidation_id, consolidation_id=CONSOLIDATION, + fits_by_header=fits_by_header, ) is expected ) diff --git a/api/tests/test_version_period.py b/api/tests/test_version_period.py index 6d8f291..44cc1b3 100644 --- a/api/tests/test_version_period.py +++ b/api/tests/test_version_period.py @@ -194,3 +194,44 @@ async def test_perimeter_pickup_binds_one_osv_per_account_latest_confirmed(seede 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"