feat(#190): метрики качества первички (AURORA-1759) #229

Merged
aep merged 45 commits from feat/190-ocr-metrics into main 2026-11-07 07:50:00 +03:00
2 changed files with 151 additions and 4 deletions
Showing only changes of commit 5edcad6e9c - Show all commits

View File

@ -53,7 +53,7 @@ from src.domain.osv_mapping import (
document_status,
opening_matches,
)
from src.domain.quality_facts import build_intake_payload
from src.domain.quality_facts import build_intake_payload, build_outcome_payload
from src.domain.statement_issues import IssueDraft, mapping_warning
from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok
from src.domain.statement_status import duplicate_period_forms, has_error_warning, osv_status_v2
@ -73,6 +73,7 @@ from src.models.enums import (
OsvDataType,
OsvGroupType,
PeriodQuarter,
QualityOutcome,
RecognitionStatus,
StatementLineStatus,
)
@ -81,7 +82,7 @@ from src.models.osv import OsvEntry
from src.models.statements import DocumentIssue
from src.repositories import statements as statements_repo
from src.repositories.counterparties import CounterpartyIndex
from src.repositories.quality_facts import insert_intake_fact
from src.repositories.quality_facts import insert_intake_fact, insert_outcome_fact
from src.services import consolidation_documents
from src.services.audit import AuditService
from src.services.file_status import set_file_status_for_job
@ -861,7 +862,7 @@ async def _skip_warnings(
return [_skip_warning(old, skipped[old.id]) for old in kept.scalars()]
async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена одной транзакцией: входы и шаги именованные
async def supersede_previous( # noqa: PLR0912, PLR0913, PLR0915 - автозамена одной транзакцией: входы и шаги именованные
session: AsyncSession,
*,
new_file: StoredFile,
@ -892,6 +893,11 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена
(`_replaceable`) — документы другой организации, загруженные в карточку, не вытесняют друг друга. Файл, оставленный
только из-за этого, — предупреждение в `warnings` (`_skip_warning`); `UNKNOWN` заменяется как прежде.
§14.3 (design quality-metrics §4.2, AURORA-1759): на каждый документ BALANCE/INCOME/OSV заменённого файла —
факт исхода `SUPERSEDED` (не подтверждался) или `SUPERSEDED_AFTER_CONFIRM` (был `CONFIRMED` на момент замены, в
т. ч. вручную — здесь, в отличие от удаления файла (задача 8), нет 409-проверки, исключающей такие документы).
На КАЖДЫЙ документ заменённого файла, а не только на выпавшие (OQ-154 выше): замена — файл целиком (design №33).
Блокировки: advisory-блокировка ЮЛ сериализует автозамены одного ЮЛ (два новых файла одного документа, смапленные
параллельно, иначе не увидели бы друг друга и оба остались бы действующими); заменяемые файлы — `FOR UPDATE` в
порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`); затем
@ -969,11 +975,38 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена
documents_by_file: dict[uuid.UUID, list[Document]] = {}
for document in file_documents.scalars():
documents_by_file.setdefault(document.stored_file_id, []).append(document)
# §14.3 (design quality-metrics §4.2, AURORA-1759): факт исхода на КАЖДЫЙ документ заменённого файла — и выпавший,
# и совпавший по ключу («rest»): замена — файл целиком (design №33, тот же принцип, что у снятия привязок выше).
# Счётчик нарушений — одним запросом на все документы всех заменяемых файлов (без N+1 на документ/файл).
outcome_issue_counts = await statements_repo.issue_counts_by_document(
session, [document.id for group in documents_by_file.values() for document in group]
)
unbound: dict[uuid.UUID, list[uuid.UUID]] = {}
dropped_by_file: dict[uuid.UUID, list[Document]] = {} # документы без ключа среди новых (OQ-154)
dropped_versions: dict[uuid.UUID, list[uuid.UUID]] = {} # версии, где сняты привязки именно выпавших документов
for old in locked: # по возрастанию id; advisory ГК и версии берутся первым вызовом (порядок D-9)
old_documents = documents_by_file.get(old.id, [])
for document in old_documents:
if document.document_type not in INTAKE_FACT_DOCUMENT_TYPES: # тот же гейт, что у §14.1 (задачи 5-8)
continue
outcome = (
QualityOutcome.SUPERSEDED_AFTER_CONFIRM
if document.status is DocumentStatus.CONFIRMED
else QualityOutcome.SUPERSEDED
)
counts = outcome_issue_counts.get(document.id)
issue_count_at_outcome = (counts.error + counts.info) if counts is not None else 0
await insert_outcome_fact(
session,
document_id=document.id,
outcome=outcome,
outcome_at=now,
primary_cause=None, # SUPERSEDED*/DELETED* вне матрицы причин (§14.3 правило 1)
report_date=document.reporting_date,
confirmed_by=document.confirmed_by, # может быть не NULL: здесь нет 409-проверки задачи 8
issue_count_at_outcome=issue_count_at_outcome,
payload=build_outcome_payload(document, fixes=()),
)
dropped = [document for document in old_documents if _document_key(document) not in keys]
dropped_by_file[old.id] = dropped
# выпавшие снимаются отдельным первым вызовом: он возвращает версии именно их привязок (после блокировки

View File

@ -50,11 +50,12 @@ from src.models.enums import (
OsvDataType,
OsvGroupType,
PeriodQuarter,
QualityOutcome,
RecognitionStatus,
)
from src.models.files import Document, RecognitionJob, StoredFile
from src.models.osv import OsvEntry
from src.models.quality_facts import QualityIntakeFact
from src.models.quality_facts import QualityIntakeFact, QualityOutcomeFact
from src.models.statements import (
DocumentIssue,
FinancialStatementLine,
@ -1539,6 +1540,119 @@ async def test_supersede_previous_reports_dropped_documents_without_changing_rep
assert event.after_value is not None and event.after_value["unbound_version_ids"] == [str(in_work_id)]
# --- факт исхода SUPERSEDED/SUPERSEDED_AFTER_CONFIRM (quality_outcome_fact, §14.3, design quality-metrics §4.2,
# AURORA-1759/#190) ---------------------------------------------------------------------------------------------
async def _outcome_fact(maker: async_sessionmaker[AsyncSession], document_id: uuid.UUID) -> QualityOutcomeFact:
async with maker() as session:
return (
await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id))
).scalar_one()
async def test_supersede_previous_writes_superseded_fact_for_dropped_document(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""Задача 9 (Review Focus докс-плана): документ, выпавший из автозамены (ключа нет среди документов нового
файла — OQ-154), не подтверждавшийся (`NEEDS_CHECK`) — факт исхода `SUPERSEDED`, без причины (вне матрицы §6)."""
maker = committed_sessionmaker
async with maker() as session, session.begin():
entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000001")
old_file, old_job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
old_62 = await make_document(
session, file=old_file, job=old_job, account="62", status=DocumentStatus.NEEDS_CHECK
)
new_file, new_job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
new_60 = await make_document(session, file=new_file, job=new_job, account="60")
old_62_id, new_file_id = old_62.id, new_file.id
old_62_report_date = old_62.reporting_date
async with maker() as session, session.begin():
new = await session.get(StoredFile, new_file_id)
assert new is not None
await supersede_previous(session, new_file=new, new_documents=[new_60], uploader_id=None, now=_now())
fact = await _outcome_fact(maker, old_62_id)
assert fact.outcome == QualityOutcome.SUPERSEDED
assert fact.primary_cause is None
assert fact.confirmed_by is None
assert fact.report_date == old_62_report_date
assert fact.payload["fixes"] == []
async def test_supersede_previous_writes_superseded_after_confirm_for_confirmed_document(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""Задача 9: автозамена подтверждённого документа — в отличие от удаления (задача 8), здесь нет 409-проверки,
блокирующей вручную подтверждённые документы, замена их не исключает. `confirmed_by` факта — из документа
(может быть не NULL, читается как есть, не считается всегда системным)."""
maker = committed_sessionmaker
async with maker() as session, session.begin():
confirmer = (await session.execute(select(AppUser).order_by(AppUser.email).limit(1))).scalar_one()
entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000002")
old_file, old_job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
old_62 = await make_document(session, file=old_file, job=old_job, account="62", status=DocumentStatus.CONFIRMED)
old_62.confirmed_by = confirmer.id
new_file, new_job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
new_60 = await make_document(session, file=new_file, job=new_job, account="60")
old_62_id, new_file_id, confirmer_id = old_62.id, new_file.id, confirmer.id
async with maker() as session, session.begin():
new = await session.get(StoredFile, new_file_id)
assert new is not None
await supersede_previous(session, new_file=new, new_documents=[new_60], uploader_id=None, now=_now())
fact = await _outcome_fact(maker, old_62_id)
assert fact.outcome == QualityOutcome.SUPERSEDED_AFTER_CONFIRM
assert fact.primary_cause is None
assert fact.confirmed_by == confirmer_id
async def test_supersede_previous_writes_fact_for_matched_document_too(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""Review Focus докс-плана (обязательный тест, не опциональный): замена — «файл ЦЕЛИКОМ со всеми документами в
нём» — факт исхода пишется не только для выпавших документов, но и для совпавшего по ключу с новым файлом
(`replaced_documents`/«rest»). Реализация, пишущая факты только по `dropped`, прошла бы два теста выше, но не
этот: здесь у совпавшего документа (account 60, CONFIRMED) тоже должен появиться факт."""
maker = committed_sessionmaker
async with maker() as session, session.begin():
entity = await make_legal_entity(session, name=OWNER_NAME, inn="7700000003")
old_file, old_job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
old_60 = await make_document(
session, file=old_file, job=old_job, account="60", status=DocumentStatus.CONFIRMED
) # совпадёт по ключу с документом нового файла — «rest», не выпавший
old_62 = await make_document(
session, file=old_file, job=old_job, account="62", status=DocumentStatus.NEEDS_CHECK
) # ключа среди документов нового файла нет — выпавший (OQ-154)
new_file, new_job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
new_60 = await make_document(session, file=new_file, job=new_job, account="60")
old_60_id, old_62_id, new_file_id = old_60.id, old_62.id, new_file.id
async with maker() as session, session.begin():
new = await session.get(StoredFile, new_file_id)
assert new is not None
await supersede_previous(session, new_file=new, new_documents=[new_60], uploader_id=None, now=_now())
matched_fact = await _outcome_fact(maker, old_60_id)
assert matched_fact.outcome == QualityOutcome.SUPERSEDED_AFTER_CONFIRM # совпал по ключу, но факт тоже пишется
dropped_fact = await _outcome_fact(maker, old_62_id)
assert dropped_fact.outcome == QualityOutcome.SUPERSEDED
# --- osv-result-mapping «Документ NEEDS_CHECK переводит версию в корректировку» (D-6) ------------------------------