feat(#190): метрики качества первички (AURORA-1759) #229
@ -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
|
||||
# выпавшие снимаются отдельным первым вызовом: он возвращает версии именно их привязок (после блокировки
|
||||
|
||||
@ -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) ------------------------------
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user