feat(#190): метрики качества первички (AURORA-1759) #229
@ -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 = [
|
||||
|
||||
@ -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) == []
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user