"""Документ, строки ОСВ и подтверждение (spec documents-api «Документ и строки», «Подтверждение документа»; audit-trail `DOCUMENT_CONFIRM`; design D-11; Review Focus 5 плана). Основные сценарии — поверх фикстуры `app` (транзакция теста). Гонка «подтвердить» vs «автозамена» — на `committed_*` (временная БД, сессия запроса как в проде): транзакция автозамены держит файл под `FOR UPDATE`, подтверждение ждёт её и после commit видит `SUPERSEDED`. """ import asyncio import datetime import json import uuid from decimal import Decimal from pathlib import Path from typing import Any import pytest from sqlalchemy import func, select, text, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.models.access import AppUser from src.models.audit_ex import AuditEvent from src.models.directories import LegalEntity, RegionalOffice from src.models.enums import ( AuditAction, AuditSection, CounterpartyMatchKind, DocumentStatus, DocumentType, FileStatus, PeriodQuarter, QualityCause, QualityOutcome, RecognitionStatus, ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry from src.models.quality_facts import QualityOutcomeFact from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue from src.services.statement_recompute import recompute_form from src.services.statements_engine import default_engine from tests.conftest import AuthClientFactory from tests.factories import ( make_company_group, make_document, make_entries_from_result, make_legal_entity, make_stored_file, ) from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot from tests.quality_payload_contract import assert_outcome_payload_valid HTTP_OK = 200 HTTP_FORBIDDEN = 403 HTTP_NOT_FOUND = 404 HTTP_CONFLICT = 409 HTTP_UNPROCESSABLE = 422 KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT RKA = "rka@armka.local" # Ростовский филиал, только DOCUMENT_VIEW CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал CURATOR_GO = "curator_go@armka.local" # головной офис, права DOCUMENT_VIEW нет ROSTOV = "Ростовский региональный филиал" GOLDEN_OSV_66 = Path("golden") / "expected_osv_parse" / "rb" / "2024-FY" / "ОСВ_66 за 2024.json" GOLDEN_OSV_66_ENTRIES = 9 # 10 строк снимка минус TOTAL (R-13) DEFAULT_ENTRIES_LIMIT = 500 MAX_ENTRIES_LIMIT = 2000 LOCK_WAIT_SECONDS = 10.0 pytestmark = pytest.mark.integration async def _rostov_office(session: AsyncSession) -> RegionalOffice: return (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() async def _rostov_entity(session: AsyncSession, name: str = "ООО Документы") -> LegalEntity: office = await _rostov_office(session) group = await make_company_group(session, name=f"ГК {name}", office=office) return await make_legal_entity(session, name=name, group=group) async def _document( session: AsyncSession, *, status: DocumentStatus = DocumentStatus.NEEDS_CHECK, file_status: FileStatus = FileStatus.PROCESSED, ) -> tuple[StoredFile, RecognitionJob, Document]: entity = await _rostov_entity(session) file, job = await make_stored_file(session, entity=entity, status=file_status, job_status=RecognitionStatus.done) document = await make_document(session, file=file, job=job, status=status) return file, job, document async def _user_id(session: AsyncSession, email: str) -> uuid.UUID: return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one() async def _confirm_events(session: AsyncSession, document_id: uuid.UUID) -> list[AuditEvent]: stmt = select(AuditEvent).where( AuditEvent.action == AuditAction.DOCUMENT_CONFIRM, AuditEvent.object_id == document_id ) return list((await session.execute(stmt)).scalars().all()) def _golden(fixtures_dir: Path) -> dict[str, Any]: data: dict[str, Any] = json.loads((fixtures_dir / GOLDEN_OSV_66).read_text("utf-8")) return data # --- карточка документа ---------------------------------------------------------------------------------------------- async def test_document_card(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: file, job, document = await _document(seeded) document.locator = "sheet:Лист_2" job.group_schema = [ {"locator": "sheet:Лист_1", "groups": [{"type": "ACCOUNT", "name": "Счет", "has_inn": None}]}, { "locator": "sheet:Лист_2", "groups": [ {"type": "ACCOUNT", "name": "Счет", "has_inn": None}, {"type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": True}, ], }, ] job.metrics = {"warnings": ["парсер"], "mapping_warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"]} await seeded.flush() client = await auth_client(KA_RF) response = await client.get(f"/documents/{document.id}") assert response.status_code == HTTP_OK, response.text assert response.json() == { "id": str(document.id), "file_id": str(file.id), "legal_entity_id": str(file.legal_entity_id), "document_type": "OSV", "osv_account_code": "60", "period_year": 2025, "period_quarter": "Q1", "is_cumulative": True, "locator": "sheet:Лист_2", "status": "NEEDS_CHECK", "period_check": None, "counterparty_check": None, "confirmed_by": None, "confirmed_at": None, "group_schema": [ {"type": "ACCOUNT", "name": "Счет", "has_inn": None}, {"type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": True}, ], # R-6: предупреждения маппинга файла целиком, без фильтра по листу "warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"], "comments_count": 0, "is_modified": False, "original_values": None, "modified_by": None, "modified_at": None, "issues": [], "issue_counts": {"error": 0, "info": 0}, } async def test_document_card_without_group_schema(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: _, _, document = await _document(seeded) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}")).json() assert body["group_schema"] is None assert body["warnings"] == [] # --- строки ---------------------------------------------------------------------------------------------------------- @pytest.mark.fixtures async def test_entries_as_tree_on_golden_osv_66( seeded: AsyncSession, auth_client: AuthClientFactory, fixtures_dir: Path ) -> None: """«Строки деревом»: первая строка — счёт 66 уровня 0 без родителя, у строк уровня 2 родитель — субсчёт.""" entity = await _rostov_entity(seeded) file, job = await make_stored_file( seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) (document,) = await make_entries_from_result(seeded, file, job, _golden(fixtures_dir)) client = await auth_client(KA_RF) response = await client.get(f"/documents/{document.id}/entries") assert response.status_code == HTTP_OK, response.text body = response.json() assert (body["total"], body["limit"], body["offset"]) == (GOLDEN_OSV_66_ENTRIES, DEFAULT_ENTRIES_LIMIT, 0) rows = body["items"] assert [row["row_number"] for row in rows] == sorted(row["row_number"] for row in rows) assert all(row["group_type"] != "TOTAL" for row in rows) first = rows[0] assert (first["row_value"], first["account_code"], first["level"], first["parent_entry_id"]) == ( "66", "66", 0, None, ) assert first["closing_credit"] == "54.64" # деньги — строкой, без float assert first["account_root"] == "66" by_id = {row["id"]: row for row in rows} level_2 = [row for row in rows if row["level"] == 2] # noqa: PLR2004 assert level_2 for row in level_2: parent = by_id[row["parent_entry_id"]] assert (parent["level"], parent["group_type"]) == (1, "ACCOUNT") assert parent["account_code"] == row["account_code"] assert row["group_type"] == "COUNTERPARTY" yug = next(row for row in rows if row["row_number"] == 18) # noqa: PLR2004 assert yug["account_id"] is not None # значения снимка ОСВ_66 за 2024, строка 18: контрагент без ИНН в колонке assert (yug["counterparty_name"], yug["counterparty_inn"]) == ("Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ", None) assert set(first) == { "id", "row_number", "level", "parent_entry_id", "row_value", "group_type", "group_name", "data_type", "account_code", "account_root", "account_id", "counterparty_name", "counterparty_inn", "counterparty_id", "match_kind", "opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit", } async def test_entries_expose_match_kind(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """v5.16: `match_kind` — способ сопоставления контрагента; у несопоставленной строки `null`.""" entity = await _rostov_entity(seeded) counterparty = await make_legal_entity(seeded, name="ООО Контрагент Вид") file, job = await make_stored_file( seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) result = json.loads( (Path(__file__).resolve().parents[1] / "contracts" / "examples" / "v1" / "result-osv-minimal.json").read_text( "utf-8" ) ) (document,) = await make_entries_from_result(seeded, file, job, result) await seeded.execute( update(OsvEntry) .where(OsvEntry.document_id == document.id, OsvEntry.row_number == 2) # noqa: PLR2004 .values(counterparty_id=counterparty.id, counterparty_match_kind=CounterpartyMatchKind.MANUAL) ) client = await auth_client(KA_RF) rows = (await client.get(f"/documents/{document.id}/entries")).json()["items"] assert [(row["row_number"], row["counterparty_id"], row["match_kind"]) for row in rows] == [ (1, None, None), (2, str(counterparty.id), "MANUAL"), ] async def test_entries_pagination_and_limits(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) file, job = await make_stored_file( seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) result = json.loads( (Path(__file__).resolve().parents[1] / "contracts" / "examples" / "v1" / "result-osv-minimal.json").read_text( "utf-8" ) ) (document,) = await make_entries_from_result(seeded, file, job, result) client = await auth_client(KA_RF) url = f"/documents/{document.id}/entries" page = (await client.get(url, params={"limit": 1, "offset": 1})).json() widest = await client.get(url, params={"limit": MAX_ENTRIES_LIMIT}) too_wide = await client.get(url, params={"limit": MAX_ENTRIES_LIMIT + 1}) assert (page["total"], page["limit"], page["offset"]) == (2, 1, 1) assert [row["row_number"] for row in page["items"]] == [2] assert widest.status_code == HTTP_OK assert too_wide.status_code == HTTP_UNPROCESSABLE # --- подтверждение --------------------------------------------------------------------------------------------------- async def test_confirm_needs_check_document(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """«Подтверждение»: `ka_rf` подтверждает документ `NEEDS_CHECK` своего филиала.""" _, _, document = await _document(seeded) user_id = await _user_id(seeded, KA_RF) client = await auth_client(KA_RF) response = await client.post(f"/documents/{document.id}/confirm") assert response.status_code == HTTP_OK, response.text body = response.json() assert body["status"] == "CONFIRMED" assert body["confirmed_by"] == str(user_id) assert body["confirmed_at"] is not None await seeded.refresh(document) assert document.status == DocumentStatus.CONFIRMED assert document.confirmed_by == user_id assert document.confirmed_at is not None (event,) = await _confirm_events(seeded, document.id) assert event.section == AuditSection.PRIMARY_DOCUMENTS assert event.object_type == "document" assert event.user_id == user_id assert event.before_value == {"status": "NEEDS_CHECK"} assert event.after_value == {"status": "CONFIRMED"} async def test_confirm_recognized_document(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: _, _, document = await _document(seeded, status=DocumentStatus.RECOGNIZED) client = await auth_client(KA_RF) response = await client.post(f"/documents/{document.id}/confirm") assert response.status_code == HTTP_OK, response.text (event,) = await _confirm_events(seeded, document.id) assert event.before_value == {"status": "RECOGNIZED"} async def test_repeated_confirm_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: _, _, document = await _document(seeded) client = await auth_client(KA_RF) first = await client.post(f"/documents/{document.id}/confirm") second = await client.post(f"/documents/{document.id}/confirm") assert first.status_code == HTTP_OK assert second.status_code == HTTP_CONFLICT assert second.json()["detail"]["code"] == "ALREADY_CONFIRMED" assert len(await _confirm_events(seeded, document.id)) == 1 async def test_error_document_is_not_confirmable(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: _, _, document = await _document(seeded, status=DocumentStatus.ERROR) client = await auth_client(KA_RF) response = await client.post(f"/documents/{document.id}/confirm") assert response.status_code == HTTP_CONFLICT assert response.json()["detail"]["code"] == "DOCUMENT_NOT_CONFIRMABLE" assert await _confirm_events(seeded, document.id) == [] async def test_document_of_superseded_file_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: _, _, document = await _document(seeded, file_status=FileStatus.SUPERSEDED) client = await auth_client(KA_RF) response = await client.post(f"/documents/{document.id}/confirm") assert response.status_code == HTTP_CONFLICT assert response.json()["detail"]["code"] == "FILE_SUPERSEDED" await seeded.refresh(document) assert document.status == DocumentStatus.NEEDS_CHECK assert await _confirm_events(seeded, document.id) == [] async def test_confirm_rereads_file_status(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Файл заменён между чтением и подтверждением: объект файла в сессии устарел (`PROCESSED`), а в БД — `SUPERSEDED`; подтверждение перечитывает файл под блокировкой и отвечает 409.""" file, _, document = await _document(seeded) await seeded.execute( text("UPDATE stored_file SET status = 'SUPERSEDED' WHERE id = :id"), {"id": file.id} ) # мимо identity map assert file.status == FileStatus.PROCESSED client = await auth_client(KA_RF) response = await client.post(f"/documents/{document.id}/confirm") assert response.status_code == HTTP_CONFLICT assert response.json()["detail"]["code"] == "FILE_SUPERSEDED" async def _committed_document(maker: async_sessionmaker[AsyncSession]) -> tuple[uuid.UUID, uuid.UUID]: async with maker() as session, session.begin(): entity = await _rostov_entity(session, "ООО Гонка") file, job = await make_stored_file( session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) document = await make_document(session, file=file, job=job, status=DocumentStatus.NEEDS_CHECK) return file.id, document.id async def _wait_for_lock_waiter(maker: async_sessionmaker[AsyncSession]) -> None: """Ждёт, пока какой-то сеанс БД встанет в ожидание блокировки строки (запрос подтверждения).""" deadline = asyncio.get_running_loop().time() + LOCK_WAIT_SECONDS while asyncio.get_running_loop().time() < deadline: async with maker() as session: waiting = ( await session.execute( text( "SELECT count(*) FROM pg_stat_activity " "WHERE datname = current_database() AND wait_event_type = 'Lock'" ) ) ).scalar_one() if waiting: return await asyncio.sleep(0.05) raise AssertionError("подтверждение не встало в ожидание блокировки файла") async def test_confirm_races_with_supersede( committed_sessionmaker: async_sessionmaker[AsyncSession], committed_client: AuthClientFactory ) -> None: """Review Focus 5: автозамена держит файл под `FOR UPDATE` и переводит его в `SUPERSEDED`; подтверждение, начатое в это время, ждёт блокировку и после commit автозамены отвечает 409 `FILE_SUPERSEDED`.""" file_id, document_id = await _committed_document(committed_sessionmaker) client = await committed_client(KA_RF) async with committed_sessionmaker() as replacer, replacer.begin(): await replacer.execute(select(StoredFile.id).where(StoredFile.id == file_id).with_for_update()) await replacer.execute( update(StoredFile) .where(StoredFile.id == file_id) .values(status=FileStatus.SUPERSEDED, updated_at=datetime.datetime.now(datetime.UTC)) ) confirm = asyncio.create_task(client.post(f"/documents/{document_id}/confirm")) await _wait_for_lock_waiter(committed_sessionmaker) assert not confirm.done() response = await asyncio.wait_for(confirm, LOCK_WAIT_SECONDS) assert response.status_code == HTTP_CONFLICT, response.text assert response.json()["detail"]["code"] == "FILE_SUPERSEDED" async with committed_sessionmaker() as session: document = await session.get(Document, document_id) assert document is not None assert document.status == DocumentStatus.NEEDS_CHECK events = ( await session.execute( select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.DOCUMENT_CONFIRM) ) ).scalar_one() assert events == 0 # --- видимость и права ----------------------------------------------------------------------------------------------- async def test_confirm_requires_document_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: _, _, document = await _document(seeded) client = await auth_client(RKA) # свой филиал, только DOCUMENT_VIEW assert (await client.get(f"/documents/{document.id}")).status_code == HTTP_OK response = await client.post(f"/documents/{document.id}/confirm") assert response.status_code == HTTP_FORBIDDEN await seeded.refresh(document) assert document.status == DocumentStatus.NEEDS_CHECK async def test_reading_documents_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: _, _, document = await _document(seeded) client = await auth_client(CURATOR_GO) assert (await client.get(f"/documents/{document.id}")).status_code == HTTP_FORBIDDEN assert (await client.get(f"/documents/{document.id}/entries")).status_code == HTTP_FORBIDDEN @pytest.mark.parametrize( ("method", "suffix"), [("get", ""), ("get", "/entries"), ("post", "/confirm")], ) async def test_foreign_and_missing_document_are_same_404( seeded: AsyncSession, auth_client: AuthClientFactory, method: str, suffix: str ) -> None: _, _, document = await _document(seeded) client = await auth_client(CLIENT_MANAGER) # Краснодарский филиал: ЮЛ Ростова не видит foreign = await client.request(method, f"/documents/{document.id}{suffix}") missing = await client.request(method, f"/documents/{uuid.uuid4()}{suffix}") assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND assert foreign.json() == missing.json() assert foreign.json()["detail"]["code"] == "NOT_FOUND" await seeded.refresh(document) assert document.status == DocumentStatus.NEEDS_CHECK async def test_period_quarter_enum_in_card(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) file, job = await make_stored_file(seeded, entity=entity, status=FileStatus.PROCESSED) document = await make_document(seeded, file=file, job=job, quarter=PeriodQuarter.Q3, is_cumulative=False) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}")).json() assert (body["period_quarter"], body["is_cumulative"]) == ("Q3", False) async def test_osv_v2_warnings_in_card_issues(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Предупреждения листа ОСВ результата `2.0` (`OSV_*`) — в `DocumentOut.issues` (источник `service`, `info`).""" # ленивые импорты: `tests.fixtures_helpers` тяжёлый и нужен одному тесту from src.contract import RecognitionResultV2 # noqa: PLC0415 from tests.fixtures_helpers import map_golden_snapshot # noqa: PLC0415 def build(_path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResultV2: example = Path(__file__).resolve().parents[1] / "contracts" / "examples" / "result-osv-needs-check.json" payload = json.loads(example.read_text("utf-8")) return RecognitionResultV2.model_validate({**payload, "job_id": str(job_id), "file_id": str(file_id)}) entity = await _rostov_entity(seeded) (document,) = await map_golden_snapshot( seeded, entity=entity, path=Path("result-v2.json"), uploaded_by=None, result_builder=build ) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}")).json() service = [issue for issue in body["issues"] if issue["source"] == "service"] assert {issue["code"] for issue in service} == {"OSV_HIERARCHY_BY_OUTLINE", "OSV_BALANCE_IDENTITY"} assert all(issue["kind"] == "warning" and issue["severity"] == "info" for issue in service) assert body["status"] == "NEEDS_CHECK" # --- факт исхода CONFIRMED (§14.2-14.4, design quality-metrics §4.2/§5/§6, AURORA-1759) ------------------------------- INCOME_FORM = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" SYNTHETIC_DISCLOSURE = GOLDEN_STATEMENTS / "synthetic" / "st06_header_variants.json" async def _golden_owner(session: AsyncSession, path: Path, *, office: RegionalOffice, name: str) -> LegalEntity: """Владелец с ИНН шапки golden-снимка `path` (совпадает с шапкой — `counterparty_check = MATCH`).""" data = json.loads(path.read_text("utf-8")) inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) return await make_legal_entity(session, name=name, inn=inn, office=office) async def _income_form( session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice ) -> tuple[Document, FinancialStatementMeta]: """Чистая (confirmable) форма ОФР из golden-снимка — база для тестов факта исхода `CONFIRMED`.""" path = fixtures_dir / INCOME_FORM owner = await _golden_owner(session, path, office=office, name="ООО Владелец Исход") (income,) = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None) assert income.document_type is DocumentType.INCOME meta = ( await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) ).scalar_one() return income, meta async def _synthetic_disclosure(session: AsyncSession, fixtures_dir: Path, *, office: RegionalOffice) -> Document: """RECOGNIZED `DISCLOSURE` из синтетического снимка st06 (та же фикстура, что в test_auto_confirm.py).""" path = fixtures_dir / SYNTHETIC_DISCLOSURE owner = await _golden_owner(session, path, office=office, name="ООО Синтетика Документы") documents = await map_golden_snapshot(session, entity=owner, path=path, uploaded_by=None) disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) assert disclosure.status is DocumentStatus.RECOGNIZED return disclosure async def _set_value_fix( session: AsyncSession, income: Document, *, source_text: str, original_amount: Decimal, confirmed_amount: Decimal, ) -> FinancialStatementValue: """Правит одно значение формы напрямую (ORM, минуя `PATCH .../statement`) — `is_modified=True` и `source_text` для проверки отнесения правки (§14.2 «Сумма»).""" meta = ( await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) ).scalar_one() value = ( await session.execute( select(FinancialStatementValue) .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) .where(FinancialStatementLine.meta_id == meta.id) .limit(1) ) ).scalar_one() value.is_modified = True value.source_text = source_text value.original_amount = original_amount value.amount = confirmed_amount await session.flush() return value async def _break_2200_and_accept( session: AsyncSession, document: Document, meta: FinancialStatementMeta, *, user_id: uuid.UUID, now: datetime.datetime, ) -> None: """Ломает итог 2200 (как `test_form_confirmable.py::_break_2200`) и сразу акцептует нарушение — документ остаётся подтверждаемым (`form_blocker` не видит нарушения при акцепте), но `checks_accepted_by` задан.""" values = ( await session.execute( select(FinancialStatementValue) .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200") ) ).scalars() for value in values: value.amount = (value.amount or 0) + 1000 await session.flush() job = await session.get(RecognitionJob, meta.recognition_job_id) await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job) meta.checks_accepted_by = user_id meta.checks_accepted_at = now meta.checks_accepted_comment = "акцептовано для теста факта исхода" await session.flush() async def _outcome_fact(session: AsyncSession, document_id: uuid.UUID) -> QualityOutcomeFact: fact = ( await session.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == document_id)) ).scalar_one() assert_outcome_payload_valid(fact.payload) # payload соответствует контракту quality-facts return fact @pytest.mark.fixtures async def test_confirm_writes_outcome_fact_as_is_when_no_fixes( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """§14.3 п. 6: ручное подтверждение чистой формы без правок и без акцепта — `AS_IS`.""" office = await _rostov_office(seeded) income, _meta = await _income_form(seeded, fixtures_dir, office=office) user_id = await _user_id(seeded, KA_RF) client = await auth_client(KA_RF) response = await client.post(f"/documents/{income.id}/confirm") assert response.status_code == HTTP_OK, response.text fact = await _outcome_fact(seeded, income.id) assert fact.outcome == QualityOutcome.CONFIRMED assert fact.primary_cause == QualityCause.AS_IS assert fact.checks_accepted is False assert fact.confirmed_by == user_id assert all( getattr(fact, key) == 0 for key in ( "fixes_value_service", "fixes_value_ours", "fixes_code_service", "fixes_code_ours", "fixes_added_row_service", "fixes_added_row_ours", "fixes_header_service", "fixes_header_ours", "fixes_osv_period_account", ) ) assert fact.payload["fixes"] == [] @pytest.mark.fixtures async def test_confirm_writes_outcome_fact_our_error_when_fix_found_in_raw_text( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """§14.2 «Сумма»: верный ответ есть в сыром тексте сервиса (разбор ошибся мы) — `OUR_ERROR`.""" office = await _rostov_office(seeded) income, _meta = await _income_form(seeded, fixtures_dir, office=office) fixed = await _set_value_fix( seeded, income, source_text="1 234", # сервис прочитал верно (тысячи через пробел) original_amount=Decimal("1234000.00"), # наш разбор тысячного разделителя ошибся confirmed_amount=Decimal("1234.00"), # аналитик подтвердил то, что действительно в сыром тексте ) fixed_column_id = fixed.column_id # до HTTP-запроса: после него объекты сессии теста могут устареть fixed_line = await seeded.get(FinancialStatementLine, fixed.line_id) assert fixed_line is not None fixed_line_ref = fixed_line.source_ref or str(fixed_line.id) client = await auth_client(KA_RF) response = await client.post(f"/documents/{income.id}/confirm") assert response.status_code == HTTP_OK, response.text fact = await _outcome_fact(seeded, income.id) assert fact.primary_cause == QualityCause.OUR_ERROR assert (fact.fixes_value_ours, fact.fixes_value_service) == (1, 0) assert fact.payload["fixes"] == [ { "field": "value", "line_ref": fixed_line_ref, "column": str(fixed_column_id), "source": "ours", "rule": "Сумма", "before_raw": "1 234", "before_recognized": "1234000.00", "after": "1234.00", } ] @pytest.mark.fixtures async def test_confirm_writes_outcome_fact_service_error_when_fix_not_in_raw_text( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """§14.2 «Сумма»: верного ответа в сыром тексте нет — сервис прочитал не те цифры — `SERVICE_ERROR`.""" office = await _rostov_office(seeded) income, _meta = await _income_form(seeded, fixtures_dir, office=office) await _set_value_fix( seeded, income, source_text="99", # сервис прочитал совсем другие цифры original_amount=Decimal("99.00"), confirmed_amount=Decimal("1234.00"), ) client = await auth_client(KA_RF) response = await client.post(f"/documents/{income.id}/confirm") assert response.status_code == HTTP_OK, response.text fact = await _outcome_fact(seeded, income.id) assert fact.primary_cause == QualityCause.SERVICE_ERROR assert (fact.fixes_value_service, fact.fixes_value_ours) == (1, 0) @pytest.mark.fixtures async def test_confirm_writes_outcome_fact_client_document_when_checks_accepted( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """§14.3/§14.4: подтверждение с акцептом нарушенного жёсткого соотношения §7, без правок — `CLIENT_DOCUMENT`.""" office = await _rostov_office(seeded) income, meta = await _income_form(seeded, fixtures_dir, office=office) user_id = await _user_id(seeded, KA_RF) await _break_2200_and_accept(seeded, income, meta, user_id=user_id, now=datetime.datetime.now(datetime.UTC)) client = await auth_client(KA_RF) response = await client.post(f"/documents/{income.id}/confirm") assert response.status_code == HTTP_OK, response.text fact = await _outcome_fact(seeded, income.id) assert fact.checks_accepted is True assert fact.primary_cause == QualityCause.CLIENT_DOCUMENT assert fact.payload["fixes"] == [] @pytest.mark.fixtures async def test_confirm_outcome_fact_report_date_fix_compares_parsed_dates( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """I-5: правка отчётной даты шапки сравнивается как дата (`parse_date`), а не как подстрока ISO в сыром тексте: «31.03.2025» и подтверждённая 2025-03-31 — «наш разбор» (сервис прочитал верно).""" office = await _rostov_office(seeded) income, meta = await _income_form(seeded, fixtures_dir, office=office) meta.report_date = datetime.date(2025, 3, 31) meta.report_date_modified = True meta.report_date_original_value = "2024-03-31" meta.header_source = {"report_date": {"text": "31.03.2025", "confidence": "99.00"}} await seeded.flush() client = await auth_client(KA_RF) response = await client.post(f"/documents/{income.id}/confirm") assert response.status_code == HTTP_OK, response.text fact = await _outcome_fact(seeded, income.id) assert (fact.fixes_header_ours, fact.fixes_header_service) == (1, 0) assert fact.payload["fixes"] == [ { "field": "header", "header_field": "report_date", "source": "ours", "rule": "Шапка", "before_raw": "31.03.2025", "before_recognized": "2024-03-31", "after": "2025-03-31", } ] @pytest.mark.fixtures async def test_confirm_outcome_fact_code_fix_matches_contract( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Правка кода строки: запись `fixes[]` по контракту (`field`/`source`/`rule`/`before_raw`/`before_recognized`), ссылка `line_ref` совпадает с `ref` строки в `lines[]`.""" office = await _rostov_office(seeded) income, meta = await _income_form(seeded, fixtures_dir, office=office) line = ( ( await seeded.execute( select(FinancialStatementLine).where( FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.row_index.is_not(None) ) ) ) .scalars() .first() ) assert line is not None line.line_code_modified = True line.line_code_original_value = "9999" line.line_code_text = "9999\n" + (line.line_code or "") await seeded.flush() client = await auth_client(KA_RF) response = await client.post(f"/documents/{income.id}/confirm") assert response.status_code == HTTP_OK, response.text fact = await _outcome_fact(seeded, income.id) (fix,) = fact.payload["fixes"] assert (fix["field"], fix["source"], fix["rule"]) == ("code", "ours", "Код строки") assert fix["before_recognized"] == "9999" assert fix["before_raw"] == line.line_code_text assert fix["line_ref"] in {entry["ref"] for entry in fact.payload["lines"]} async def test_confirm_osv_outcome_fact_period_account_fix_matches_contract( seeded: AsyncSession, auth_client: AuthClientFactory ) -> None: """ОСВ с уточнённым периодом/счётом: правка `osv_period_account` — «наш разбор», payload по контракту.""" _, _, document = await _document(seeded) document.is_modified = True await seeded.flush() client = await auth_client(KA_RF) response = await client.post(f"/documents/{document.id}/confirm") assert response.status_code == HTTP_OK, response.text fact = await _outcome_fact(seeded, document.id) (fix,) = fact.payload["fixes"] assert (fix["field"], fix["source"], fix["rule"]) == ("osv_period_account", "ours", "Период / счёт ОСВ") assert fix["after"]["osv_account_code"] == "60" @pytest.mark.fixtures async def test_disclosure_confirm_has_no_outcome_fact( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Гейт типа документа (тот же набор, что `INTAKE_FACT_DOCUMENT_TYPES`/`OUTCOME_FACT_DOCUMENT_TYPES`, Task 5/6): `DISCLOSURE` подтверждается (симметрично `test_recognized_disclosure_is_manually_confirmable`, test_auto_confirm.py), но факт исхода `CONFIRMED` не пишется.""" office = await _rostov_office(seeded) disclosure = await _synthetic_disclosure(seeded, fixtures_dir, office=office) client = await auth_client(KA_RF) response = await client.post(f"/documents/{disclosure.id}/confirm") assert response.status_code == HTTP_OK, response.text assert response.json()["status"] == "CONFIRMED" facts = ( (await seeded.execute(select(QualityOutcomeFact).where(QualityOutcomeFact.document_id == disclosure.id))) .scalars() .all() ) assert facts == []