From 256ddcb34ab312c195eb5807a8b300007a027af4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 17:27:07 +0400 Subject: [PATCH] =?UTF-8?q?feat(files):=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=20=D1=81=20?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20(#64,=20OQ-157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/api/endpoints/files/read.py | 3 +- api/src/services/consolidation_documents.py | 34 ++++++ api/src/services/primary_files.py | 26 +++- api/tests/test_api_files_delete.py | 126 +++++++++++++++++++- 4 files changed, 179 insertions(+), 10 deletions(-) diff --git a/api/src/api/endpoints/files/read.py b/api/src/api/endpoints/files/read.py index 1aa9c37..6205f84 100644 --- a/api/src/api/endpoints/files/read.py +++ b/api/src/api/endpoints/files/read.py @@ -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 " "(файл заменил более ранние версии)", }, } diff --git a/api/src/services/consolidation_documents.py b/api/src/services/consolidation_documents.py index b178b40..c2bf285 100644 --- a/api/src/services/consolidation_documents.py +++ b/api/src/services/consolidation_documents.py @@ -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, diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index bd3e34a..df13a32 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -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]: diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 918bfe0..7273dc5 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -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": []}