feat(#190): факт исхода SUPERSEDED/SUPERSEDED_AFTER_CONFIRM при автозамене
§14.3 (design quality-metrics §4.2, AURORA-1759): supersede_previous пишет один факт исхода на каждый документ BALANCE/INCOME/OSV заменённого файла — и выпавший (без ключа среди документов нового файла, OQ-154), и совпавший по ключу («rest»): замена идёт файлом целиком (design №33), поэтому факт не ограничивается только выпавшими документами. CONFIRMED на момент замены (в т. ч. вручную — здесь, в отличие от удаления файла, нет 409-проверки) → SUPERSEDED_AFTER_CONFIRM, иначе SUPERSEDED; primary_cause всегда NULL (вне матрицы причин). Счётчик нарушений — одним запросом на все документы всех заменяемых файлов. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
3a325e886c
commit
5edcad6e9c
@ -53,7 +53,7 @@ from src.domain.osv_mapping import (
|
|||||||
document_status,
|
document_status,
|
||||||
opening_matches,
|
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_issues import IssueDraft, mapping_warning
|
||||||
from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok
|
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
|
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,
|
OsvDataType,
|
||||||
OsvGroupType,
|
OsvGroupType,
|
||||||
PeriodQuarter,
|
PeriodQuarter,
|
||||||
|
QualityOutcome,
|
||||||
RecognitionStatus,
|
RecognitionStatus,
|
||||||
StatementLineStatus,
|
StatementLineStatus,
|
||||||
)
|
)
|
||||||
@ -81,7 +82,7 @@ from src.models.osv import OsvEntry
|
|||||||
from src.models.statements import DocumentIssue
|
from src.models.statements import DocumentIssue
|
||||||
from src.repositories import statements as statements_repo
|
from src.repositories import statements as statements_repo
|
||||||
from src.repositories.counterparties import CounterpartyIndex
|
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 import consolidation_documents
|
||||||
from src.services.audit import AuditService
|
from src.services.audit import AuditService
|
||||||
from src.services.file_status import set_file_status_for_job
|
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()]
|
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,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
new_file: StoredFile,
|
new_file: StoredFile,
|
||||||
@ -892,6 +893,11 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена
|
|||||||
(`_replaceable`) — документы другой организации, загруженные в карточку, не вытесняют друг друга. Файл, оставленный
|
(`_replaceable`) — документы другой организации, загруженные в карточку, не вытесняют друг друга. Файл, оставленный
|
||||||
только из-за этого, — предупреждение в `warnings` (`_skip_warning`); `UNKNOWN` заменяется как прежде.
|
только из-за этого, — предупреждение в `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-блокировка ЮЛ сериализует автозамены одного ЮЛ (два новых файла одного документа, смапленные
|
Блокировки: advisory-блокировка ЮЛ сериализует автозамены одного ЮЛ (два новых файла одного документа, смапленные
|
||||||
параллельно, иначе не увидели бы друг друга и оба остались бы действующими); заменяемые файлы — `FOR UPDATE` в
|
параллельно, иначе не увидели бы друг друга и оба остались бы действующими); заменяемые файлы — `FOR UPDATE` в
|
||||||
порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`); затем
|
порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`); затем
|
||||||
@ -969,11 +975,38 @@ async def supersede_previous( # noqa: PLR0913, PLR0915 - автозамена
|
|||||||
documents_by_file: dict[uuid.UUID, list[Document]] = {}
|
documents_by_file: dict[uuid.UUID, list[Document]] = {}
|
||||||
for document in file_documents.scalars():
|
for document in file_documents.scalars():
|
||||||
documents_by_file.setdefault(document.stored_file_id, []).append(document)
|
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]] = {}
|
unbound: dict[uuid.UUID, list[uuid.UUID]] = {}
|
||||||
dropped_by_file: dict[uuid.UUID, list[Document]] = {} # документы без ключа среди новых (OQ-154)
|
dropped_by_file: dict[uuid.UUID, list[Document]] = {} # документы без ключа среди новых (OQ-154)
|
||||||
dropped_versions: dict[uuid.UUID, list[uuid.UUID]] = {} # версии, где сняты привязки именно выпавших документов
|
dropped_versions: dict[uuid.UUID, list[uuid.UUID]] = {} # версии, где сняты привязки именно выпавших документов
|
||||||
for old in locked: # по возрастанию id; advisory ГК и версии берутся первым вызовом (порядок D-9)
|
for old in locked: # по возрастанию id; advisory ГК и версии берутся первым вызовом (порядок D-9)
|
||||||
old_documents = documents_by_file.get(old.id, [])
|
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 = [document for document in old_documents if _document_key(document) not in keys]
|
||||||
dropped_by_file[old.id] = dropped
|
dropped_by_file[old.id] = dropped
|
||||||
# выпавшие снимаются отдельным первым вызовом: он возвращает версии именно их привязок (после блокировки
|
# выпавшие снимаются отдельным первым вызовом: он возвращает версии именно их привязок (после блокировки
|
||||||
|
|||||||
@ -50,11 +50,12 @@ from src.models.enums import (
|
|||||||
OsvDataType,
|
OsvDataType,
|
||||||
OsvGroupType,
|
OsvGroupType,
|
||||||
PeriodQuarter,
|
PeriodQuarter,
|
||||||
|
QualityOutcome,
|
||||||
RecognitionStatus,
|
RecognitionStatus,
|
||||||
)
|
)
|
||||||
from src.models.files import Document, RecognitionJob, StoredFile
|
from src.models.files import Document, RecognitionJob, StoredFile
|
||||||
from src.models.osv import OsvEntry
|
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 (
|
from src.models.statements import (
|
||||||
DocumentIssue,
|
DocumentIssue,
|
||||||
FinancialStatementLine,
|
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)]
|
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) ------------------------------
|
# --- osv-result-mapping «Документ NEEDS_CHECK переводит версию в корректировку» (D-6) ------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user