Merge pull request 'Временный режим: период ОСВ из версии консолидации (#103)' (#104) from feat/103-osv-period-from-version into main
Reviewed-on: #104
This commit is contained in:
commit
880eab6c4b
@ -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` («что загружено к периоду
|
||||||
|
|||||||
@ -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) |
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
94
api/src/domain/version_period.py
Normal file
94
api/src/domain/version_period.py
Normal 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,
|
||||||
|
)
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
89
api/tests/test_domain_version_period.py
Normal file
89
api/tests/test_domain_version_period.py
Normal 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
|
||||||
237
api/tests/test_version_period.py
Normal file
237
api/tests/test_version_period.py
Normal 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"
|
||||||
Loading…
x
Reference in New Issue
Block a user