feat(#190): факт исхода DELETED/DELETED_AFTER_CONFIRM

Удаление файла (PrimaryFilesService.delete) пишет факт качества quality_outcome_fact
по каждому документу типа BALANCE/INCOME/OSV до физического удаления строк: DELETED —
документ не подтверждался (RECOGNIZED/NEEDS_CHECK), DELETED_AFTER_CONFIRM —
автоподтверждённый CONFIRMED (вручную подтверждённые сюда не доходят, дают 409 раньше
в том же методе). primary_cause=None явно (§14.3 правило 1, вне матрицы причин),
payload — минимальный снимок без построчных данных (build_outcome_payload(document, fixes=())).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-06 11:06:35 +04:00
parent 9bb9fba901
commit 6390b36188
2 changed files with 159 additions and 3 deletions

View File

@ -16,9 +16,10 @@ audit-trail «События файлов и документов»; design D-4)
уборка таких объектов — ретеншн бакета (OQ-22). уборка таких объектов — ретеншн бакета (OQ-22).
""" """
import datetime
import hashlib import hashlib
import uuid import uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
@ -37,6 +38,7 @@ from src.core.config import Settings
from src.core.storage import ObjectNotFoundError, ObjectStorage from src.core.storage import ObjectNotFoundError, ObjectStorage
from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file
from src.domain.policy import IN_WORK_STATUSES, VisibilityScope 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.consolidation import Consolidation
from src.models.directories import LegalEntity from src.models.directories import LegalEntity
from src.models.enums import ( from src.models.enums import (
@ -44,15 +46,18 @@ from src.models.enums import (
AuditSection, AuditSection,
ConsolidationStatus, ConsolidationStatus,
DocumentStatus, DocumentStatus,
DocumentType,
FileStatus, FileStatus,
QualityOutcome,
RecognitionStatus, RecognitionStatus,
S3BucketRole, 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 consolidations as consolidations_repo
from src.repositories import directories as directories_repo from src.repositories import directories as directories_repo
from src.repositories import files as files_repo from src.repositories import files as files_repo
from src.repositories import statements as statements_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.common import PageParams
from src.schemas.files import FileOut from src.schemas.files import FileOut
from src.services import consolidation_documents from src.services import consolidation_documents
@ -73,6 +78,12 @@ FILE_NOT_FOUND = "файл не найден" # одинаково для чу
FILE_CONTENT_NOT_FOUND = "FILE_CONTENT_NOT_FOUND" FILE_CONTENT_NOT_FOUND = "FILE_CONTENT_NOT_FOUND"
FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS" FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS"
FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS" 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): class StorageUnavailableError(DomainError):
@ -304,6 +315,50 @@ class PrimaryFilesService:
media_type = CONTENT_TYPES.get(file.file_type.value.lower(), "application/octet-stream") media_type = CONTENT_TYPES.get(file.file_type.value.lower(), "application/octet-stream")
return _chain(first, iterator), media_type, header 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: async def delete(self, file_id: uuid.UUID) -> None:
"""Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10). """Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10).
@ -319,7 +374,13 @@ class PrimaryFilesService:
и `stored_file`; `s3_object` файла и результатов заданий и `stored_file`; `s3_object` файла и результатов заданий
остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и
`results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает `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) visible = await files_repo.get_file(self._session, self._scope, file_id)
if visible is None: if visible is None:
raise NotFoundError(FILE_NOT_FOUND) raise NotFoundError(FILE_NOT_FOUND)
@ -356,6 +417,7 @@ class PrimaryFilesService:
) )
before = file_out(file, current, documents, counts, issue_counts).model_dump(mode="json") 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)] 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) await files_repo.delete_file_rows(self._session, file)
# M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла # M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла
owned_prefixes = [ owned_prefixes = [

View File

@ -27,12 +27,15 @@ from src.models.enums import (
AuditSection, AuditSection,
ConsolidationStatus, ConsolidationStatus,
DocumentStatus, DocumentStatus,
DocumentType,
FileStatus, FileStatus,
PeriodQuarter, PeriodQuarter,
QualityOutcome,
RecognitionStatus, RecognitionStatus,
) )
from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry from src.models.osv import OsvEntry
from src.models.quality_facts import QualityOutcomeFact
from src.models.statements import ( from src.models.statements import (
DocumentIssue, DocumentIssue,
FinancialStatementColumn, FinancialStatementColumn,
@ -88,6 +91,17 @@ async def _delete_events(session: AsyncSession) -> list[AuditEvent]:
return list(rows.scalars()) 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 assert response.status_code == HTTP_NO_CONTENT, response.text
[event] = await _delete_events(seeded) [event] = await _delete_events(seeded)
assert event.after_value == {"unbound_version_ids": []} 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) == []