Автоподтверждение чистых документов (#64, OQ-157) #69

Merged
aep merged 10 commits from feat/64-auto-confirm into main 2026-10-08 17:08:36 +03:00
4 changed files with 179 additions and 10 deletions
Showing only changes of commit 256ddcb34a - Show all commits

View File

@ -31,7 +31,8 @@ DELETE_RESPONSES: dict[int | str, dict[str, Any]] = {
**NOT_FOUND,
status.HTTP_409_CONFLICT: {
"model": ErrorDetail,
"description": "FILE_HAS_CONFIRMED_DOCUMENTS (есть подтверждённый документ) или FILE_SUPERSEDES_OTHERS "
"description": "FILE_HAS_CONFIRMED_DOCUMENTS (документ подтверждён пользователем или автоподтверждённый "
"документ в версии на согласовании) или FILE_SUPERSEDES_OTHERS "
"(файл заменил более ранние версии)",
},
}

View File

@ -39,10 +39,12 @@ 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",
]
@ -197,6 +199,38 @@ async def unbind_entity(session: AsyncSession, *, version_id: uuid.UUID, entity_
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,

View File

@ -54,6 +54,7 @@ from src.repositories import directories as directories_repo
from src.repositories import files as files_repo
from src.schemas.common import PageParams
from src.schemas.files import FileOut
from src.services import consolidation_documents
from src.services.audit import AuditService
from src.services.consolidation_guard import VERSION_NOT_FOUND
from src.services.documents import file_out
@ -283,9 +284,11 @@ class PrimaryFilesService:
Порядок блокировок — как в обмене и маппинге: задания файла `FOR UPDATE`, затем `stored_file`, затем документы
(confirm берёт «файл → документ», автозамена — advisory → старые файлы по id). Состояние перечитывается
под блокировками, и 409 проверяются после них: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ `CONFIRMED`,
`FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка версий не рвётся). Файл,
исчезнувший, пока ждали блокировку, — 404.
под блокировками, и 409 проверяются после них, в порядке: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ,
подтверждённый пользователем; `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка
версий не рвётся); `FILE_HAS_CONFIRMED_DOCUMENTS` — автоподтверждённый документ привязан к версии вне «в
работе» (OQ-157). Привязки автоподтверждённых документов снимаются (после блокировки advisory ГК → версии),
id версий — в `after_value.unbound_version_ids`. Файл, исчезнувший, пока ждали блокировку, — 404.
Удаляются формы ББ/ОФР (`financial_statement_*`), комментарии документов, `osv_entry`, `document`, все задания
и `stored_file`; `s3_object` файла и результатов заданий
@ -300,15 +303,27 @@ class PrimaryFilesService:
if file is None: # удалён параллельно, пока ждали блокировку
raise NotFoundError(FILE_NOT_FOUND)
documents = await files_repo.lock_file_documents(self._session, file_id)
if any(document.status is DocumentStatus.CONFIRMED for document in documents):
confirmed = [document for document in documents if document.status is DocumentStatus.CONFIRMED]
if any(document.confirmed_by is not None for document in confirmed):
raise ConflictError(
FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть подтверждённые документы"
FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть документы, подтверждённые пользователем"
)
if await files_repo.supersedes_others(self._session, file_id):
raise ConflictError(
FILE_SUPERSEDES_OTHERS,
"файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется",
)
# OQ-157, design D-4: автоподтверждённые документы удаляются, пока их версии «в работе»; привязки снимаются
bound = await consolidation_documents.bound_versions(self._session, documents=confirmed)
if any(status not in IN_WORK_STATUSES for _, status in bound):
raise ConflictError(
FILE_HAS_CONFIRMED_DOCUMENTS,
"файл нельзя удалить: документ уже участвует в версии консолидации на согласовании",
)
unbound_version_ids = sorted({version_id for version_id, _ in bound})
await consolidation_documents.unbind_documents(
self._session, document_ids=[document.id for document in confirmed]
)
current = next((job for job in jobs if job.id == file.current_recognition_job_id), None)
counts = await files_repo.comments_count_by_document(self._session, [document.id for document in documents])
before = file_out(file, current, documents, counts).model_dump(mode="json")
@ -326,6 +341,7 @@ class PrimaryFilesService:
object_type=OBJECT_TYPE,
object_id=file_id,
before_value=before,
after_value={"unbound_version_ids": unbound_version_ids},
)
async def _cards(self, files: list[StoredFile]) -> list[FileOut]:

View File

@ -16,11 +16,21 @@ import pytest
from sqlalchemy import func, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.common.clock import utc_now
from src.core.config import get_settings
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent, Comment
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus, RecognitionStatus
from src.models.enums import (
AuditAction,
AuditSection,
ConsolidationStatus,
DocumentStatus,
FileStatus,
PeriodQuarter,
RecognitionStatus,
)
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry
from src.models.statements import (
@ -33,6 +43,7 @@ from src.services.result_mapping import process_once
from tests.conftest import AuthClientFactory, FakeStorage
from tests.factories import (
make_company_group,
make_consolidation,
make_document,
make_entries_from_result,
make_legal_entity,
@ -108,7 +119,7 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa
assert event.before_value["status"] == "ERROR"
assert event.before_value["job"]["id"] == str(job_id)
assert event.before_value["job"]["error_code"] == "PARSE_ERROR"
assert event.after_value is None
assert event.after_value == {"unbound_version_ids": []}
MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json"
@ -248,7 +259,9 @@ async def test_confirmed_document_blocks_delete(seeded: AsyncSession, auth_clien
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.NEEDS_CHECK)
await make_document(seeded, file=file, job=job, account="62", status=DocumentStatus.CONFIRMED)
confirmed = await make_document(seeded, file=file, job=job, account="62", status=DocumentStatus.CONFIRMED)
confirmed.confirmed_by = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one().id
await seeded.flush()
file_id, job_id = file.id, job.id
client = await auth_client(KA_RF)
@ -383,8 +396,11 @@ async def test_delete_rereads_state_after_locks(
deletion = asyncio.create_task(client.delete(f"/files/{file_id}"))
await _wait_for_lock_waiter(committed_sessionmaker)
assert not deletion.done()
user_id = (await confirmer.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
await confirmer.execute(
update(Document).where(Document.id == document_id).values(status=DocumentStatus.CONFIRMED)
update(Document)
.where(Document.id == document_id)
.values(status=DocumentStatus.CONFIRMED, confirmed_by=user_id)
)
response = await asyncio.wait_for(deletion, LOCK_WAIT_SECONDS)
@ -441,3 +457,105 @@ async def test_mapping_loop_ignores_deleted_file(
result_key = f"results/{file_id}/{job_id}/result.json"
expires = (await session.execute(select(S3Object.expires_at).where(S3Object.key == result_key))).scalar_one()
assert expires is not None
# --- автоподтверждённые документы (OQ-157, design D-4) ---------------------------------------------------------------
async def _auto_confirmed_in_versions(
session: AsyncSession, statuses: list[ConsolidationStatus]
) -> tuple[StoredFile, Document, list[ConsolidationVersion]]:
"""ЮЛ Ростова в ГК; по версии на каждый статус (2024 Q3, Q4 — разные консолидации); файл с автоподтверждённой ОСВ 60
за 2024 Q2 (`confirmed_by` NULL), привязанной ко всем версиям."""
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)
entity = await make_legal_entity(session, name="ООО Автоподтверждение", group=group)
other = await make_legal_entity(session, name="ООО Второе", group=group)
initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
versions = []
for quarter, status in zip([PeriodQuarter.Q3, PeriodQuarter.Q4], statuses, strict=False):
_, version = await make_consolidation(
session,
group=group,
year=2024,
quarter=quarter,
members=[entity, other],
initiator=initiator,
status=status,
)
versions.append(version)
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
document = await make_document(
session,
file=file,
job=job,
account="60",
year=2024,
quarter=PeriodQuarter.Q2,
status=DocumentStatus.CONFIRMED,
is_cumulative=False,
)
document.confirmed_at = utc_now()
for version in versions:
session.add(ConsolidationDocument(consolidation_version_id=version.id, document_id=document.id))
await session.flush()
return file, document, versions
async def test_delete_auto_confirmed_in_correction_unbinds(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
file, document, [version] = await _auto_confirmed_in_versions(seeded, [ConsolidationStatus.DATA_CORRECTION])
file_id, version_id = file.id, version.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 0
assert (
await _count(seeded, ConsolidationDocument, ConsolidationDocument.consolidation_version_id == version_id) == 0
)
refreshed = await seeded.get(ConsolidationVersion, version_id, populate_existing=True)
assert refreshed is not None and refreshed.status is ConsolidationStatus.DATA_CORRECTION
[event] = await _delete_events(seeded)
assert event.after_value == {"unbound_version_ids": [str(version_id)]}
async def test_delete_blocked_if_any_bound_version_left_in_work(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Review Focus 5: одна из двух версий на согласовании — 409, ни одна привязка не снята."""
file, document, versions = await _auto_confirmed_in_versions(
seeded, [ConsolidationStatus.DATA_CORRECTION, ConsolidationStatus.CONSOLIDATION_RF]
)
file_id, document_id = file.id, document.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "FILE_HAS_CONFIRMED_DOCUMENTS"
assert await _count(seeded, StoredFile, StoredFile.id == file_id) == 1
assert await _count(seeded, ConsolidationDocument, ConsolidationDocument.document_id == document_id) == len(
versions
)
assert await _delete_events(seeded) == []
async def test_delete_auto_confirmed_without_bindings(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
await make_document(seeded, file=file, job=job, account="60", status=DocumentStatus.CONFIRMED) # confirmed_by NULL
file_id = file.id
client = await auth_client(KA_RF)
response = await client.delete(f"/files/{file_id}")
assert response.status_code == HTTP_NO_CONTENT, response.text
[event] = await _delete_events(seeded)
assert event.after_value == {"unbound_version_ids": []}