feat(mapping): автоподтверждение чистых документов в цикле маппинга (#64, OQ-157)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-08 17:15:12 +04:00
parent b48d9e6f4d
commit 3830e718f4
3 changed files with 170 additions and 25 deletions

View File

@ -12,7 +12,8 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn
вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат
старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла, старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла,
документы и строки (листы ОСВ, затем формы ББ/ОФР Среза 3b и расшифровки), `group_schema` (только листы ОСВ), документы и строки (листы ОСВ, затем формы ББ/ОФР Среза 3b и расшифровки), `group_schema` (только листы ОСВ),
предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, D-10, аудит `FILE_REPLACE`). предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, D-10, аудит `FILE_REPLACE`) →
автоподтверждение чистых документов (`services/auto_confirm.py`, OQ-157) → переход версий по `NEEDS_CHECK` (D-6).
Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов) Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов)
— сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции — сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции
@ -895,10 +896,15 @@ async def _map_candidate(
index = await CounterpartyIndex.load(session) index = await CounterpartyIndex.load(session)
now = clock() now = clock()
warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now) warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now)
# Переход версий (D-6) — ПОСЛЕ `map_file`, а значит после автозамены (`supersede_previous` берёт advisory ЮЛ → # После `map_file`, а значит после автозамены (advisory ЮЛ → старые файлы → advisory ГК → версии): версии
# старые файлы → advisory ГК → версии): версии блокируются последними, порядок «задание → файл → документы → # блокируются последними, порядок «задание → файл → документы → версии» (D-9) не нарушается. Сначала
# версии» (D-9) не нарушается. До автозамены хук держал бы версии и затем ждал блокировки старых файлов. # автоподтверждение чистых документов (привязка и переход версии, OQ-157, design D-1), затем переход по
# `NEEDS_CHECK` (D-6) — он уже видит версию после автоподтверждения и второго перехода не делает.
documents = list((await session.execute(select(Document).where(Document.stored_file_id == file.id))).scalars()) documents = list((await session.execute(select(Document).where(Document.stored_file_id == file.id))).scalars())
# отложенный импорт: цикл documents → result_mapping → auto_confirm → statements → documents
from src.services.auto_confirm import auto_confirm # noqa: PLC0415
await auto_confirm(session, file=file, documents=documents, now=now)
await consolidation_documents.on_documents_mapped(session, file=file, documents=documents, now=now) await consolidation_documents.on_documents_mapped(session, file=file, documents=documents, now=now)
logger.info( logger.info(
"Результат распознавания смапплен: файл PROCESSED. job_id={job_id}, file_id={file_id}, warnings={warnings}", "Результат распознавания смапплен: файл PROCESSED. job_id={job_id}, file_id={file_id}, warnings={warnings}",

View File

@ -141,13 +141,13 @@ async def test_real_osv_is_parsed_end_to_end(
assert (brief["period_year"], brief["period_quarter"], brief["is_cumulative"]) == (2024, "Q4", True) assert (brief["period_year"], brief["period_quarter"], brief["is_cumulative"]) == (2024, "Q4", True)
# MATCH достигается именно по имени: ИНН в шапке ОСВ нет (D-8), имя ЮЛ совпадает с шапкой # MATCH достигается именно по имени: ИНН в шапке ОСВ нет (D-8), имя ЮЛ совпадает с шапкой
assert brief["counterparty_check"] == "MATCH" assert brief["counterparty_check"] == "MATCH"
assert brief["status"] == "RECOGNIZED" assert brief["status"] == "CONFIRMED" # чистая ОСВ подтверждена автоматически (OQ-157)
document = (await client.get(f"/documents/{brief['id']}")).json() document = (await client.get(f"/documents/{brief['id']}")).json()
assert document["file_id"] == str(file_id) assert document["file_id"] == str(file_id)
assert document["legal_entity_id"] == str(entity.id) assert document["legal_entity_id"] == str(entity.id)
assert (document["period_year"], document["period_quarter"]) == (2024, "Q4") assert (document["period_year"], document["period_quarter"]) == (2024, "Q4")
assert (document["counterparty_check"], document["status"]) == ("MATCH", "RECOGNIZED") assert (document["counterparty_check"], document["status"]) == ("MATCH", "CONFIRMED")
entries = (await client.get(f"/documents/{brief['id']}/entries")).json() entries = (await client.get(f"/documents/{brief['id']}/entries")).json()
assert entries["total"] == ENTRY_ROWS assert entries["total"] == ENTRY_ROWS

View File

@ -49,6 +49,7 @@ from src.models.enums import (
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.repositories.counterparties import CounterpartyIndex from src.repositories.counterparties import CounterpartyIndex
from src.services import consolidation_documents
from src.services import result_mapping as rm from src.services import result_mapping as rm
from src.services.recognition_exchange import expire_deadlines, handle_message from src.services.recognition_exchange import expire_deadlines, handle_message
from src.services.result_mapping import ( from src.services.result_mapping import (
@ -236,7 +237,7 @@ async def test_golden_osv_66_mapped( # noqa: PLR0915 - один сценари
assert document.is_cumulative is True assert document.is_cumulative is True
assert document.locator == "sheet:Лист_1" assert document.locator == "sheet:Лист_1"
assert document.legal_entity_id == entity_id assert document.legal_entity_id == entity_id
assert document.status is DocumentStatus.RECOGNIZED # done, проверки чистые, шапка = владелец по имени assert document.status is DocumentStatus.CONFIRMED # done, чистые проверки, шапка = владелец → автоподтверждение
assert document.counterparty_check is DocumentCheckResult.MATCH assert document.counterparty_check is DocumentCheckResult.MATCH
assert document.period_check is DocumentCheckResult.UNKNOWN assert document.period_check is DocumentCheckResult.UNKNOWN
@ -617,19 +618,20 @@ async def test_cumulative_by_q1_data(
assert document.status is DocumentStatus.NEEDS_CHECK assert document.status is DocumentStatus.NEEDS_CHECK
else: else:
assert warnings == [] assert warnings == []
assert document.status is DocumentStatus.RECOGNIZED assert document.status is DocumentStatus.CONFIRMED # чистый → автоподтверждение (OQ-157)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("job_status", "expected"), ("job_status", "expected"),
[(RecognitionStatus.done, DocumentStatus.RECOGNIZED), (RecognitionStatus.needs_check, DocumentStatus.NEEDS_CHECK)], [(RecognitionStatus.done, DocumentStatus.CONFIRMED), (RecognitionStatus.needs_check, DocumentStatus.NEEDS_CHECK)],
) )
async def test_clean_result( async def test_clean_result(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_sessionmaker: async_sessionmaker[AsyncSession],
job_status: RecognitionStatus, job_status: RecognitionStatus,
expected: DocumentStatus, expected: DocumentStatus,
): ):
"""«Чистый результат»: задание `done`, шапка = владелец (ИНН), нарушений нет → `RECOGNIZED`, `MATCH`, `UNKNOWN`; """«Чистый результат»: задание `done`, шапка = владелец (ИНН), нарушений нет → `CONFIRMED` (автоподтверждение,
OQ-157), `MATCH`, `UNKNOWN`;
задание `needs_check` → `NEEDS_CHECK`.""" задание `needs_check` → `NEEDS_CHECK`."""
maker, storage = committed_sessionmaker, FakeStorage() maker, storage = committed_sessionmaker, FakeStorage()
file_id, _, _ = await _prepare(maker, storage, _minimal(), owner_inn="7700000000", job_status=job_status) file_id, _, _ = await _prepare(maker, storage, _minimal(), owner_inn="7700000000", job_status=job_status)
@ -1409,13 +1411,14 @@ async def _load_entity(maker: async_sessionmaker[AsyncSession], entity_id: uuid.
@pytest.mark.parametrize( @pytest.mark.parametrize(
("job_status", "moved"), ("job_status", "moved"),
[(RecognitionStatus.needs_check, True), (RecognitionStatus.done, False)], # NEEDS_CHECK / RECOGNIZED (чистый) [(RecognitionStatus.needs_check, True), (RecognitionStatus.done, False)], # NEEDS_CHECK / CONFIRMED (чистый)
) )
async def test_mapping_moves_documents_processing_version_only_for_needs_check( async def test_mapping_moves_documents_processing_version_only_for_needs_check(
committed_sessionmaker: async_sessionmaker[AsyncSession], job_status: RecognitionStatus, moved: bool committed_sessionmaker: async_sessionmaker[AsyncSession], job_status: RecognitionStatus, moved: bool
): ):
"""«Предупреждение маппинга двигает статус» и «Документ RECOGNIZED»: ОСВ 60 за 2025 Q1 (квартальная) подходит версии """«Предупреждение маппинга двигает статус» и «Документ RECOGNIZED»: ОСВ 60 за 2025 Q1 (квартальная) подходит версии
2025 Q3; `NEEDS_CHECK` → `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший; привязок нет.""" 2025 Q3; `NEEDS_CHECK` → `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший, привязок нет; чистый
документ → `CONFIRMED`, привязка и тот же переход (OQ-157)."""
maker, storage = committed_sessionmaker, FakeStorage() maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker) entity_id, version_id, user_id = await _version_in(maker)
file_id, _, _ = await _prepare( file_id, _, _ = await _prepare(
@ -1430,22 +1433,14 @@ async def test_mapping_moves_documents_processing_version_only_for_needs_check(
assert await process_once(maker, storage, _settings()) is True assert await process_once(maker, storage, _settings()) is True
[document] = await _documents(maker, file_id) [document] = await _documents(maker, file_id)
assert document.status is (DocumentStatus.NEEDS_CHECK if moved else DocumentStatus.RECOGNIZED) assert document.status is (DocumentStatus.NEEDS_CHECK if moved else DocumentStatus.CONFIRMED)
async with maker() as session: async with maker() as session:
version = await session.get(ConsolidationVersion, version_id) version = await session.get(ConsolidationVersion, version_id)
assert version is not None assert version is not None
history = list( history = await _history_of(maker, version_id)
(
await session.execute(
select(ConsolidationStatusHistory)
.where(ConsolidationStatusHistory.version_id == version_id)
.order_by(ConsolidationStatusHistory.changed_at)
)
).scalars()
)
bindings = (await session.execute(select(func.count()).select_from(ConsolidationDocument))).scalar_one() bindings = (await session.execute(select(func.count()).select_from(ConsolidationDocument))).scalar_one()
assert bindings == 0
if moved: if moved:
assert bindings == 0
assert version.status is ConsolidationStatus.DATA_CORRECTION assert version.status is ConsolidationStatus.DATA_CORRECTION
last = history[-1] last = history[-1]
assert (last.from_status, last.to_status, last.changed_by) == ( assert (last.from_status, last.to_status, last.changed_by) == (
@ -1454,8 +1449,16 @@ async def test_mapping_moves_documents_processing_version_only_for_needs_check(
user_id, user_id,
) )
else: else:
assert version.status is ConsolidationStatus.DOCUMENTS_PROCESSING # чистый документ подтверждён системой (OQ-157): привязан, версия перешла от загрузившего с комментарием
assert len(history) == FACTORY_HISTORY # создание и переход фабрики в DOCUMENTS_PROCESSING; новых записей нет assert bindings == 1
assert version.status is ConsolidationStatus.DATA_CORRECTION
last = history[-1]
assert (last.from_status, last.to_status, last.changed_by, last.comment) == (
ConsolidationStatus.DOCUMENTS_PROCESSING,
ConsolidationStatus.DATA_CORRECTION,
user_id,
"автоподтверждение документа OSV 60",
)
async def test_mapping_does_not_move_initiated_version(committed_sessionmaker: async_sessionmaker[AsyncSession]): async def test_mapping_does_not_move_initiated_version(committed_sessionmaker: async_sessionmaker[AsyncSession]):
@ -1479,6 +1482,142 @@ async def test_mapping_does_not_move_initiated_version(committed_sessionmaker: a
assert version.status is ConsolidationStatus.INITIATED assert version.status is ConsolidationStatus.INITIATED
async def _history_of(
maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID
) -> list[ConsolidationStatusHistory]:
async with maker() as session:
stmt = (
select(ConsolidationStatusHistory)
.where(ConsolidationStatusHistory.version_id == version_id)
.order_by(ConsolidationStatusHistory.changed_at)
)
return list((await session.execute(stmt)).scalars())
async def _bound_ids(maker: async_sessionmaker[AsyncSession], version_id: uuid.UUID) -> set[uuid.UUID]:
async with maker() as session:
stmt = select(ConsolidationDocument.document_id).where(
ConsolidationDocument.consolidation_version_id == version_id
)
return set((await session.execute(stmt)).scalars())
async def test_clean_document_is_auto_confirmed_bound_and_moves_version(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""osv-result-mapping «Привязка и переход версии»: чистая ОСВ 60 за 2025 Q1 → `CONFIRMED` системой, привязана к
версии 2025 Q3 (`DOCUMENTS_PROCESSING`), переход в `DATA_CORRECTION` от загрузившего с комментарием."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker)
file_id, _, _ = await _prepare(
maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id
)
assert await process_once(maker, storage, _settings()) is True
[document] = await _documents(maker, file_id)
assert (document.status, document.confirmed_by) == (DocumentStatus.CONFIRMED, None)
assert document.confirmed_at is not None
assert await _bound_ids(maker, version_id) == {document.id}
last = (await _history_of(maker, version_id))[-1]
assert (last.to_status, last.changed_by, last.comment) == (
ConsolidationStatus.DATA_CORRECTION,
user_id,
"автоподтверждение документа OSV 60",
)
async with maker() as session:
events = (
(
await session.execute(
select(AuditEvent).where(
AuditEvent.action == AuditAction.DOCUMENT_CONFIRM, AuditEvent.object_id == document.id
)
)
)
.scalars()
.all()
)
assert [(event.user_id, event.description) for event in events] == [(None, "автоподтверждение: без замечаний")]
async def test_mixed_file_confirms_clean_and_moves_version_once(
committed_sessionmaker: async_sessionmaker[AsyncSession],
):
"""Review Focus 1: в файле чистый и `NEEDS_CHECK` документы — версия получает ровно один переход."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker)
result = _minimal()
second = copy.deepcopy(result["documents"][0])
second["header"]["account_code_raw"] = "62"
second["locator"] = "sheet:Лист_2"
second["checks"] = {"total_row_matches_root": False} # нарушение проверок → NEEDS_CHECK
result["documents"].append(second)
file_id, _, _ = await _prepare(
maker, storage, result, entity=await _load_entity(maker, entity_id), uploaded_by=user_id
)
assert await process_once(maker, storage, _settings()) is True
statuses = sorted((d.osv_account_code, d.status) for d in await _documents(maker, file_id))
assert statuses == [("60", DocumentStatus.CONFIRMED), ("62", DocumentStatus.NEEDS_CHECK)]
history = await _history_of(maker, version_id)
assert [h.to_status for h in history].count(ConsolidationStatus.DATA_CORRECTION) == 1
async def test_supersede_rebinds_auto_confirmed_document(committed_sessionmaker: async_sessionmaker[AsyncSession]):
"""Review Focus 2: новая чистая версия файла того же ключа заменяет привязку старой."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, version_id, user_id = await _version_in(maker)
entity = await _load_entity(maker, entity_id)
first_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id)
assert await process_once(maker, storage, _settings()) is True
[first] = await _documents(maker, first_id)
assert await _bound_ids(maker, version_id) == {first.id}
second_id, _, _ = await _prepare(maker, storage, _minimal(), entity=entity, uploaded_by=user_id)
assert await process_once(maker, storage, _settings()) is True
[second] = await _documents(maker, second_id)
assert second.status is DocumentStatus.CONFIRMED
assert await _bound_ids(maker, version_id) == {second.id}
assert (await _file(maker, first_id)).status is FileStatus.SUPERSEDED
async def test_auto_confirm_rolls_back_with_mapping(
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
):
"""Review Focus 3: сбой после автоподтверждения откатывает и его; повтор — одно событие `DOCUMENT_CONFIRM`."""
maker, storage = committed_sessionmaker, FakeStorage()
entity_id, _, user_id = await _version_in(maker)
file_id, job_id, _ = await _prepare(
maker, storage, _minimal(), entity=await _load_entity(maker, entity_id), uploaded_by=user_id
)
original = consolidation_documents.on_documents_mapped
calls = {"n": 0}
async def flaky(*args: Any, **kwargs: Any) -> list[uuid.UUID]:
calls["n"] += 1
if calls["n"] == 1:
raise RuntimeError("сбой после автоподтверждения")
return await original(*args, **kwargs)
monkeypatch.setattr(consolidation_documents, "on_documents_mapped", flaky)
assert await process_once(maker, storage, _settings()) is False # повторяемая неудача (R-15)
assert await _documents(maker, file_id) == [] # маппинг вместе с автоподтверждением откатился целиком
assert await process_once(maker, storage, _settings()) is True
[document] = await _documents(maker, file_id)
assert document.status is DocumentStatus.CONFIRMED
async with maker() as session:
count = (
await session.execute(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM)
)
).scalar_one()
assert count == 1
async def test_mapping_without_uploader_skips_transition_without_error( async def test_mapping_without_uploader_skips_transition_without_error(
committed_sessionmaker: async_sessionmaker[AsyncSession], committed_sessionmaker: async_sessionmaker[AsyncSession],
): ):