diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 5268450..32929ae 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -16,9 +16,10 @@ audit-trail «События файлов и документов»; design D-4) уборка таких объектов — ретеншн бакета (OQ-22). """ +import datetime import hashlib import uuid -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Mapping, Sequence from dataclasses import dataclass from typing import Literal @@ -37,6 +38,7 @@ from src.core.config import Settings from src.core.storage import ObjectNotFoundError, ObjectStorage from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file from src.domain.policy import IN_WORK_STATUSES, VisibilityScope +from src.domain.quality_facts import build_outcome_payload from src.models.consolidation import Consolidation from src.models.directories import LegalEntity from src.models.enums import ( @@ -44,15 +46,18 @@ from src.models.enums import ( AuditSection, ConsolidationStatus, DocumentStatus, + DocumentType, FileStatus, + QualityOutcome, RecognitionStatus, S3BucketRole, ) -from src.models.files import RecognitionJob, S3Object, StoredFile +from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.repositories import consolidations as consolidations_repo from src.repositories import directories as directories_repo from src.repositories import files as files_repo from src.repositories import statements as statements_repo +from src.repositories.quality_facts import insert_outcome_fact from src.schemas.common import PageParams from src.schemas.files import FileOut from src.services import consolidation_documents @@ -73,6 +78,12 @@ FILE_NOT_FOUND = "файл не найден" # одинаково для чу FILE_CONTENT_NOT_FOUND = "FILE_CONTENT_NOT_FOUND" FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS" FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS" +# §14.3 (design quality-metrics §4.2, AURORA-1759): факт исхода `DELETED`/`DELETED_AFTER_CONFIRM` пишется только +# для этих типов — тот же набор, что `OUTCOME_FACT_DOCUMENT_TYPES` в services/auto_confirm.py (Task 6) и +# services/documents.py (Task 7); не импортируется оттуда во избежание цикла (то же решение, что в Task 6/7) +OUTCOME_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV}) +# статусы документа, у которого исход подтверждения ещё не было: удаление пишет `DELETED`, а не `DELETED_AFTER_CONFIRM` +_NEVER_CONFIRMED_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK}) class StorageUnavailableError(DomainError): @@ -304,6 +315,50 @@ class PrimaryFilesService: media_type = CONTENT_TYPES.get(file.file_type.value.lower(), "application/octet-stream") return _chain(first, iterator), media_type, header + async def _write_delete_outcome_facts( + self, + documents: Sequence[Document], + issue_counts: Mapping[uuid.UUID, statements_repo.IssueCounts], + now: datetime.datetime, + ) -> None: + """Факт исхода `DELETED`/`DELETED_AFTER_CONFIRM` на документ удаляемого файла (§14.3; design quality-metrics + §4.2, §6; AURORA-1759). Гейт — `OUTCOME_FACT_DOCUMENT_TYPES`: DISCLOSURE/OTHER без факта (как у `CONFIRMED`/ + `AUTO_CONFIRMED`, Task 6/7). + + Документ `CONFIRMED`, дошедший до этой точки, — ВСЕГДА автоподтверждённый: вручную подтверждённый + (`confirmed_by IS NOT NULL`) даёт 409 `FILE_HAS_CONFIRMED_DOCUMENTS` раньше в `delete` (см. docstring метода), + поэтому здесь — `DELETED_AFTER_CONFIRM`. `RECOGNIZED`/`NEEDS_CHECK` — документ без исхода подтверждения — + `DELETED`. Иной статус (`ERROR`) факта не получает: у него не было осмысленного состояния «как распознано», + которое стоило бы фиксировать как удалённый исход (решение задачи 8, design.md). + + `primary_cause=None` явно — §14.3 правило 1: `DELETED*` вне матрицы причин. `payload` — минимальный снимок + без построчных данных (`build_outcome_payload(document, fixes=())`): это событие удаления, не разбор правок, + и строки формы/ОСВ документа всё равно исчезнут за этим же вызовом (решение задачи 8). Пишется в транзакции + вызывающего (`delete`), без `flush`/`commit`. + """ + for document in documents: + if document.document_type not in OUTCOME_FACT_DOCUMENT_TYPES: + continue + if document.status is DocumentStatus.CONFIRMED: + outcome = QualityOutcome.DELETED_AFTER_CONFIRM + elif document.status in _NEVER_CONFIRMED_STATUSES: + outcome = QualityOutcome.DELETED + else: + continue + counts = issue_counts.get(document.id) + issue_count_at_outcome = (counts.error + counts.info) if counts is not None else 0 + await insert_outcome_fact( + self._session, + document_id=document.id, + outcome=outcome, + outcome_at=now, + primary_cause=None, + report_date=document.reporting_date, + confirmed_by=document.confirmed_by, + issue_count_at_outcome=issue_count_at_outcome, + payload=build_outcome_payload(document, fixes=()), + ) + async def delete(self, file_id: uuid.UUID) -> None: """Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10). @@ -319,7 +374,13 @@ class PrimaryFilesService: и `stored_file`; `s3_object` файла и результатов заданий остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и `results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает - ретеншн бакета. `before_value` аудита — карточка файла.""" + ретеншн бакета. `before_value` аудита — карточка файла. + + §14.3 (design quality-metrics §4.2, AURORA-1759): на каждый документ файла типа BALANCE/INCOME/OSV ДО + физического удаления строк пишется факт исхода `DELETED` (документ не подтверждался) или + `DELETED_AFTER_CONFIRM` (автоподтверждённый — вручную подтверждённый сюда не доходит, см. 409 выше); + `_write_delete_outcome_facts`. + """ visible = await files_repo.get_file(self._session, self._scope, file_id) if visible is None: raise NotFoundError(FILE_NOT_FOUND) @@ -356,6 +417,7 @@ class PrimaryFilesService: ) before = file_out(file, current, documents, counts, issue_counts).model_dump(mode="json") object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] + await self._write_delete_outcome_facts(documents, issue_counts, utc_now()) await files_repo.delete_file_rows(self._session, file) # M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла owned_prefixes = [ diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 6af74d1..3190e1b 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -27,12 +27,15 @@ from src.models.enums import ( AuditSection, ConsolidationStatus, DocumentStatus, + DocumentType, FileStatus, PeriodQuarter, + QualityOutcome, RecognitionStatus, ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.models.quality_facts import QualityOutcomeFact from src.models.statements import ( DocumentIssue, FinancialStatementColumn, @@ -88,6 +91,17 @@ async def _delete_events(session: AsyncSession) -> list[AuditEvent]: return list(rows.scalars()) +async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact: + return ( + await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) + ).scalar_one() + + +async def _outcome_facts(session: AsyncSession, document_id: uuid.UUID) -> list[QualityOutcomeFact]: + rows = await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) + return list(rows.scalars()) + + # --- успешное удаление ---------------------------------------------------------------------------------------------- @@ -570,3 +584,83 @@ async def test_delete_auto_confirmed_without_bindings(seeded: AsyncSession, auth assert response.status_code == HTTP_NO_CONTENT, response.text [event] = await _delete_events(seeded) assert event.after_value == {"unbound_version_ids": []} + + +# --- факты исхода DELETED/DELETED_AFTER_CONFIRM (задача 8, §14.3, AURORA-1759) ---------------------------------------- + + +async def test_delete_file_writes_deleted_fact_for_unconfirmed_document( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Документ `NEEDS_CHECK` — подтверждения не было вовсе: удаление файла пишет факт исхода `DELETED`, + `primary_cause` не входит в матрицу причин (§14.3 правило 1) и остаётся `NULL`.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + document = await make_document(seeded, file=file, job=job, status=DocumentStatus.NEEDS_CHECK) + 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_NO_CONTENT, response.text + fact = await _outcome_fact(seeded, document_id) + assert fact.outcome == QualityOutcome.DELETED + assert fact.primary_cause is None + assert fact.confirmed_by is None + assert fact.report_date == document.reporting_date + assert fact.payload["fixes"] == [] + + +async def test_delete_file_writes_deleted_after_confirm_for_auto_confirmed_document( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Документ уже auto_confirm'нут (`confirmed_by IS NULL`), без привязок к версиям консолидации: ручного + подтверждения (OQ-157/409 `FILE_HAS_CONFIRMED_DOCUMENTS`) здесь нет, файл удаляется — факт исхода + `DELETED_AFTER_CONFIRM`.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + document = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED) # confirmed_by NULL + 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_NO_CONTENT, response.text + fact = await _outcome_fact(seeded, document_id) + assert fact.outcome == QualityOutcome.DELETED_AFTER_CONFIRM + assert fact.primary_cause is None + assert fact.confirmed_by is None + + +async def test_delete_other_document_writes_no_outcome_fact( + seeded: AsyncSession, auth_client: AuthClientFactory +) -> None: + """Гейт типа документа (тот же набор `OUTCOME_FACT_DOCUMENT_TYPES`, что у `CONFIRMED`/`AUTO_CONFIRMED`, + Task 6/7): `OTHER` удаляется, как прежде, но факта исхода не получает.""" + entity = await _rostov_entity(seeded) + file, _job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done + ) + now = utc_now() + document = Document( + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=DocumentType.OTHER, + file_ordinal=0, + status=DocumentStatus.NEEDS_CHECK, + created_at=now, + updated_at=now, + ) + seeded.add(document) + await seeded.flush() + 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_NO_CONTENT, response.text + assert await _outcome_facts(seeded, document_id) == []