369 lines
22 KiB
Python
369 lines
22 KiB
Python
"""Привязка подтверждённых документов к версиям консолидации (spec documents-api «Подтверждение документа», «Подхват
|
||
документов при создании версии и расширении периметра»; primary-files «Автозамена прежних версий»; audit-trail
|
||
`CONSOLIDATION_STATUS_CHANGE`, `FILE_REPLACE.unbound_version_ids`; design D-5, D-6, D-9; рулинги R-1, R-2, R-3).
|
||
|
||
Точки привязки: подтверждение документа (`on_document_confirmed` — ко всем подходящим версиям «в работе», с переходом
|
||
`INITIATED`/`DOCUMENTS_PROCESSING → DATA_CORRECTION`), создание версии и добавление ЮЛ в периметр (`bind_confirmed`,
|
||
статус не меняется). Точки отвязки: автозамена файла (`on_documents_superseded`), удаление ЮЛ из периметра
|
||
(`unbind_entity`). Версии вне «в работе» не трогаются.
|
||
|
||
Ключ замены (R-1): у одного ЮЛ в одной версии на ключ `(document_type, osv_account_code, period_quarter, cumulative)`
|
||
привязан один документ — с самым поздним `confirmed_at`; `cumulative = _cumulative(document)` (NULL — ББ/ОФР —
|
||
нарастающий, решение №10). Квартальный и нарастающий документы одного квартала друг друга не вытесняют — кроме ОСВ за
|
||
`Q1`: она равнозначна обоим видам (R-14), нарастающая и квартальная `Q1` — один слот, побеждает поздняя; ББ/ОФР
|
||
равнозначность не касается (R-14a).
|
||
|
||
Порядок блокировок (D-9, R-2, R-3): файл → документ (`FOR NO KEY UPDATE`) → advisory ГК → версии `FOR UPDATE` по `id`
|
||
(`repo.lock_versions_for_entity`); автозамена — advisory ЮЛ → старые файлы → advisory ГК → версии; создание версии —
|
||
advisory ГК первой; правка периметра — версия первой, advisory не берёт. Вставка привязки берёт на документ
|
||
`FOR KEY SHARE`, с `FOR NO KEY UPDATE` подтверждения она совместима.
|
||
"""
|
||
|
||
import datetime
|
||
import uuid
|
||
from collections.abc import Collection, Iterable, Sequence
|
||
from typing import Any
|
||
|
||
from loguru import logger
|
||
from sqlalchemy import delete, select
|
||
from sqlalchemy.engine import CursorResult
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from src.domain.consolidation_periods import QUARTER_ORDER, counts_as_cumulative, effective_cumulative
|
||
from src.domain.policy import IN_WORK_STATUSES
|
||
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
|
||
from src.repositories import consolidations as repo
|
||
from src.services.audit import AuditService
|
||
|
||
__all__ = [
|
||
"bind_confirmed",
|
||
"bound_versions",
|
||
"fits",
|
||
"on_document_confirmed",
|
||
"on_documents_mapped",
|
||
"on_documents_superseded",
|
||
"unbind_documents",
|
||
"unbind_entity",
|
||
]
|
||
|
||
#: Статусы, из которых подтверждение привязанного документа переводит версию в `DATA_CORRECTION` (D-6).
|
||
MOVES_TO_CORRECTION = frozenset({ConsolidationStatus.INITIATED, ConsolidationStatus.DOCUMENTS_PROCESSING})
|
||
|
||
BindingKey = tuple[uuid.UUID, DocumentType, str | None, PeriodQuarter | None, bool]
|
||
|
||
|
||
def _rowcount(result: Any) -> int:
|
||
"""Число строк DML (как `services.file_status`): у `CursorResult` — `rowcount`, иначе 0."""
|
||
return result.rowcount if isinstance(result, CursorResult) else 0
|
||
|
||
|
||
def _cumulative(document: Document) -> bool:
|
||
"""Нарастающий ли документ для привязки и ключа замены (R-1, решение №10, R-14, R-14a): NULL — нарастающий;
|
||
ОСВ за `Q1` — квартальная при любом флаге (`effective_cumulative`). Равнозначность `Q1` — только для ОСВ: ББ/ОФР
|
||
за `Q1` — отчёт на конец квартала, не источник версий `Q2`–`Q4` (`counts_as_cumulative`, всегда нарастающие)."""
|
||
if document.document_type == DocumentType.OSV:
|
||
return effective_cumulative(document.is_cumulative, document.period_quarter)
|
||
return counts_as_cumulative(document.is_cumulative)
|
||
|
||
|
||
def _key(document: Document) -> BindingKey:
|
||
"""Ключ замены привязки (R-1): ЮЛ, тип, счёт, квартал и признак нарастающего."""
|
||
return (
|
||
document.legal_entity_id,
|
||
document.document_type,
|
||
document.osv_account_code,
|
||
document.period_quarter,
|
||
_cumulative(document),
|
||
)
|
||
|
||
|
||
def _latest_first(document: Document) -> 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,
|
||
version: ConsolidationVersion, # noqa: ARG001 - сигнатура D-5: подходящесть — функция документа и версии
|
||
consolidation: Consolidation,
|
||
member_ids: Collection[uuid.UUID],
|
||
) -> bool:
|
||
"""Подходит ли документ версии (D-5): ЮЛ — член периметра, год версии, квартал определён и: нарастающий (или
|
||
NULL) — квартал версии; квартальный — не позже квартала версии; ОСВ за `Q1` — любой версии года при любом флаге
|
||
(R-14; ББ/ОФР за `Q1` — только версии `Q1`, R-14a). Статус документа и файла не проверяется — это
|
||
дело вызывающего (привязываются только `CONFIRMED` незаменённых файлов). Чистая функция, без БД."""
|
||
if document.legal_entity_id not in member_ids or document.period_year != consolidation.year:
|
||
return False
|
||
if document.period_quarter is None:
|
||
return False
|
||
if _cumulative(document):
|
||
return document.period_quarter == consolidation.quarter
|
||
return QUARTER_ORDER.index(document.period_quarter) <= QUARTER_ORDER.index(consolidation.quarter)
|
||
|
||
|
||
async def _member_ids(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:
|
||
stmt = select(ConsolidationMember.legal_entity_id).where(ConsolidationMember.consolidation_version_id == version_id)
|
||
return set((await session.execute(stmt)).scalars())
|
||
|
||
|
||
async def _bound_documents(
|
||
session: AsyncSession, version_id: uuid.UUID, entity_ids: Iterable[uuid.UUID]
|
||
) -> list[Document]:
|
||
"""Документы ЮЛ `entity_ids`, уже привязанные к версии."""
|
||
stmt = (
|
||
select(Document)
|
||
.join(ConsolidationDocument, ConsolidationDocument.document_id == Document.id)
|
||
.where(
|
||
ConsolidationDocument.consolidation_version_id == version_id,
|
||
Document.legal_entity_id.in_(list(entity_ids)),
|
||
)
|
||
)
|
||
return list((await session.execute(stmt)).scalars())
|
||
|
||
|
||
async def _unbind(session: AsyncSession, version_id: uuid.UUID, document_ids: Collection[uuid.UUID]) -> int:
|
||
if not document_ids:
|
||
return 0
|
||
result = await session.execute(
|
||
delete(ConsolidationDocument).where(
|
||
ConsolidationDocument.consolidation_version_id == version_id,
|
||
ConsolidationDocument.document_id.in_(list(document_ids)),
|
||
)
|
||
)
|
||
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])
|
||
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()
|
||
|
||
|
||
async def bind_confirmed(
|
||
session: AsyncSession,
|
||
*,
|
||
version: ConsolidationVersion,
|
||
consolidation: Consolidation,
|
||
entity_ids: Sequence[uuid.UUID],
|
||
) -> int:
|
||
"""Подхват (spec «Подхват документов…»): привязывает к версии `CONFIRMED` документы ЮЛ `entity_ids` из файлов не
|
||
`SUPERSEDED`, подходящие версии (`fits`); на ключ (R-1) — документ с самым поздним `confirmed_at`. Привязанный
|
||
документ того же ключа заменяется; уже привязанный победитель не трогается. Статус версии не меняется. Возвращает
|
||
число новых привязок.
|
||
|
||
Версия должна быть под `FOR UPDATE` вызывающего (правка периметра) либо только что создана под advisory ГК
|
||
(`ConsolidationService.create`, R-2).
|
||
"""
|
||
if not entity_ids:
|
||
return 0
|
||
members = await _member_ids(session, version.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,
|
||
StoredFile.status != FileStatus.SUPERSEDED,
|
||
)
|
||
)
|
||
candidates = [doc for doc in (await session.execute(stmt)).scalars() if fits(doc, version, consolidation, members)]
|
||
winners: dict[BindingKey, Document] = {}
|
||
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_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)
|
||
fresh = [document for document in winners.values() if document.id not in bound_ids]
|
||
session.add_all(ConsolidationDocument(consolidation_version_id=version.id, document_id=doc.id) for doc in fresh)
|
||
await session.flush()
|
||
return len(fresh)
|
||
|
||
|
||
async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_id: uuid.UUID) -> int:
|
||
"""Снимает все привязки документов ЮЛ `entity_id` к версии `version_id` (удаление ЮЛ из периметра); возвращает
|
||
число снятых. Статус версии не меняется; версия должна быть под `FOR UPDATE` вызывающего."""
|
||
owned = select(Document.id).where(Document.legal_entity_id == entity_id)
|
||
result = await session.execute(
|
||
delete(ConsolidationDocument).where(
|
||
ConsolidationDocument.consolidation_version_id == version_id,
|
||
ConsolidationDocument.document_id.in_(owned),
|
||
)
|
||
)
|
||
return _rowcount(result)
|
||
|
||
|
||
async def bound_versions(
|
||
session: AsyncSession, *, documents: Sequence[Document]
|
||
) -> list[tuple[uuid.UUID, ConsolidationStatus]]:
|
||
"""Привязки `documents` к версиям любого статуса — `(version_id, status)` по возрастанию `version_id` (удаление
|
||
файла с автоподтверждёнными документами, OQ-157, design D-4). Сначала блокируются версии «в работе» каждого ЮЛ
|
||
(`lock_versions_for_entity`, advisory ГК → версии по `id`), чтобы статус не сменился между проверкой и снятием
|
||
привязки; версии вне «в работе» читаются без блокировки — из них удаление и так запрещено."""
|
||
if not documents:
|
||
return []
|
||
for entity_id in sorted({document.legal_entity_id for document in documents}):
|
||
await repo.lock_versions_for_entity(session, entity_id, IN_WORK_STATUSES)
|
||
stmt = (
|
||
select(ConsolidationDocument.consolidation_version_id, ConsolidationVersion.status)
|
||
.join(ConsolidationVersion, ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id)
|
||
.where(ConsolidationDocument.document_id.in_([document.id for document in documents]))
|
||
.order_by(ConsolidationDocument.consolidation_version_id)
|
||
.execution_options(populate_existing=True)
|
||
)
|
||
return [(version_id, status) for version_id, status in (await session.execute(stmt)).all()]
|
||
|
||
|
||
async def unbind_documents(session: AsyncSession, *, document_ids: Collection[uuid.UUID]) -> int:
|
||
"""Снимает все привязки `document_ids` (удаление файла, D-4); статус версий не меняется. Версии — под блокировкой
|
||
вызывающего (`bound_versions`)."""
|
||
if not document_ids:
|
||
return 0
|
||
result = await session.execute(
|
||
delete(ConsolidationDocument).where(ConsolidationDocument.document_id.in_(list(document_ids)))
|
||
)
|
||
return _rowcount(result)
|
||
|
||
|
||
async def on_document_confirmed( # noqa: PLR0913 - автор и комментарий перехода задаёт вызывающий (OQ-157)
|
||
session: AsyncSession,
|
||
audit: AuditService,
|
||
*,
|
||
document: Document,
|
||
changed_by: uuid.UUID | None,
|
||
now: datetime.datetime,
|
||
comment: str | None = None,
|
||
) -> list[uuid.UUID]:
|
||
"""Хук подтверждения (spec «Подтверждение документа», osv-result-mapping «Автоподтверждение чистого документа»):
|
||
привязывает уже `CONFIRMED` документ ко всем версиям «в работе», которым он подходит, заменяя привязанный документ
|
||
того же ключа (R-1); версия из `INITIATED` / `DOCUMENTS_PROCESSING` переходит в `DATA_CORRECTION` с `changed_by` и
|
||
`comment` вызывающего (ручное подтверждение — пользователь и `None`, автоподтверждение — загрузивший файл и
|
||
«автоподтверждение документа …», D-2). `changed_by is None` (файл без `uploaded_by`) — привязка создаётся, переход
|
||
пропускается с записью в лог (`consolidation_status_history.changed_by` NOT NULL). Возвращает id версий, получивших
|
||
привязку (по возрастанию).
|
||
|
||
Вызывать после блокировки файла и документа (D-9): версии блокирует `repo.lock_versions_for_entity` — advisory ГК
|
||
(создание версии ∥ подтверждение) и `FOR UPDATE` надмножества версий ГК (правка периметра ∥ подтверждение, R-2).
|
||
Блокировка версии (`consolidation_lock`) не требуется.
|
||
"""
|
||
# цикл импортов: consolidation_status → consolidations → consolidation_documents
|
||
from src.services.consolidation_status import transition # noqa: PLC0415
|
||
|
||
versions = await repo.lock_versions_for_entity(session, document.legal_entity_id, IN_WORK_STATUSES)
|
||
bound: list[uuid.UUID] = []
|
||
for version in versions:
|
||
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)):
|
||
continue
|
||
await _bind(session, version.id, document)
|
||
if version.status in MOVES_TO_CORRECTION:
|
||
if changed_by is None:
|
||
logger.info(
|
||
"Переход версии по подтверждению пропущен: нет автора (changed_by обязателен). "
|
||
"version_id={version_id}, document_id={document_id}",
|
||
version_id=version.id,
|
||
document_id=document.id,
|
||
)
|
||
else:
|
||
await transition(
|
||
session,
|
||
audit,
|
||
version=version,
|
||
to=ConsolidationStatus.DATA_CORRECTION,
|
||
changed_by=changed_by,
|
||
comment=comment,
|
||
now=now,
|
||
)
|
||
bound.append(version.id)
|
||
return bound
|
||
|
||
|
||
async def on_documents_superseded(
|
||
session: AsyncSession,
|
||
*,
|
||
documents: Sequence[Document],
|
||
now: datetime.datetime, # noqa: ARG001 - сигнатура фиксирована планом (Task 7/8); статус версии не меняется
|
||
) -> list[uuid.UUID]:
|
||
"""Хук автозамены (primary-files «Автозамена прежних версий»): снимает привязки `documents` (документы заменённого
|
||
файла) ко всем версиям «в работе»; версии других статусов и статус версии не трогаются. Возвращает id версий, где
|
||
привязки сняты (`unbound_version_ids`, по возрастанию).
|
||
|
||
Вызывать после блокировки старых файлов: версии блокирует `repo.lock_versions_for_entity` по ЮЛ документов (R-2:
|
||
подхват, ждавший нас, уже закоммитил привязку — она видна и снимается).
|
||
"""
|
||
document_ids = [document.id for document in documents]
|
||
if not document_ids:
|
||
return []
|
||
unbound: set[uuid.UUID] = set()
|
||
for entity_id in sorted({document.legal_entity_id for document in documents}):
|
||
versions = await repo.lock_versions_for_entity(session, entity_id, IN_WORK_STATUSES)
|
||
for version in versions:
|
||
if await _unbind(session, version.id, document_ids):
|
||
unbound.add(version.id)
|
||
return sorted(unbound)
|
||
|
||
|
||
async def on_documents_mapped(
|
||
session: AsyncSession,
|
||
*,
|
||
file: StoredFile,
|
||
documents: Sequence[Document],
|
||
now: datetime.datetime,
|
||
) -> list[uuid.UUID]:
|
||
"""Хук маппинга (osv-result-mapping «Документ NEEDS_CHECK переводит версию в корректировку», D-6): каждая версия в
|
||
`DOCUMENTS_PROCESSING`, в периметр которой входит ЮЛ файла, переходит в `DATA_CORRECTION`
|
||
(`changed_by = file.uploaded_by`), если среди `documents` есть документ `NEEDS_CHECK`, подходящий версии (`fits`).
|
||
Привязки `consolidation_document` НЕ создаются (их создаёт только подтверждение и подхват). Возвращает id версий,
|
||
переведённых в `DATA_CORRECTION` (по возрастанию). Файл без `uploaded_by` — переход пропускается с записью в лог
|
||
(`changed_by NOT NULL`, design №2).
|
||
|
||
Блокировки (R-2): `lock_versions_for_entity` берёт advisory ГК и блокирует НАДМНОЖЕСТВО версий «в работе» ГК
|
||
(`IN_WORK_STATUSES`, а не только `DOCUMENTS_PROCESSING`): статус фильтруется ПОСЛЕ блокировки по свежему состоянию
|
||
(`populate_existing`), поэтому версия, которую параллельно перевели в `DOCUMENTS_PROCESSING` или в которую
|
||
параллельно добавили ЮЛ, не теряется. Вызывать в транзакции маппинга ПОСЛЕ `map_file` (файл и документы уже
|
||
заблокированы, автозамена прежних версий уже взяла advisory ЮЛ → старые файлы → advisory ГК → версии): порядок
|
||
«задание → файл → документы → версии» не нарушается, версии блокируются последними.
|
||
"""
|
||
needs_check = [document for document in documents if document.status is DocumentStatus.NEEDS_CHECK]
|
||
if not needs_check:
|
||
return []
|
||
if file.uploaded_by is None: # лог — только когда переход мог состояться (есть NEEDS_CHECK)
|
||
logger.info(
|
||
"Переход версий по маппингу пропущен: у файла нет uploaded_by (changed_by обязателен). file_id={file_id}",
|
||
file_id=file.id,
|
||
)
|
||
return []
|
||
# цикл импортов: consolidation_status → consolidations → consolidation_documents (design №36)
|
||
from src.services.consolidation_status import transition # noqa: PLC0415
|
||
|
||
audit = AuditService(session)
|
||
moved: list[uuid.UUID] = []
|
||
for version in await repo.lock_versions_for_entity(session, file.legal_entity_id, IN_WORK_STATUSES):
|
||
if version.status is not ConsolidationStatus.DOCUMENTS_PROCESSING:
|
||
continue
|
||
consolidation = await session.get(Consolidation, version.consolidation_id)
|
||
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):
|
||
continue
|
||
await transition(
|
||
session,
|
||
audit,
|
||
version=version,
|
||
to=ConsolidationStatus.DATA_CORRECTION,
|
||
changed_by=file.uploaded_by,
|
||
comment=None,
|
||
now=now,
|
||
)
|
||
moved.append(version.id)
|
||
return moved
|