From 5edcad6e9ccc3aca9a97177995cfaf38ad38fe75 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 11:22:09 +0400 Subject: [PATCH] =?UTF-8?q?feat(#190):=20=D1=84=D0=B0=D0=BA=D1=82=20=D0=B8?= =?UTF-8?q?=D1=81=D1=85=D0=BE=D0=B4=D0=B0=20SUPERSEDED/SUPERSEDED=5FAFTER?= =?UTF-8?q?=5FCONFIRM=20=D0=BF=D1=80=D0=B8=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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 --- api/src/services/result_mapping.py | 39 +++++++++- api/tests/test_result_mapping.py | 116 ++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 95d0bca..d882aeb 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -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 # выпавшие снимаются отдельным первым вызовом: он возвращает версии именно их привязок (после блокировки diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index b4e99af..9708bdb 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -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) ------------------------------