arm-core/api/src/services/consolidation_documents.py

369 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Привязка подтверждённых документов к версиям консолидации (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