arm-core/api/tests/test_api_documents.py

882 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Документ, строки ОСВ и подтверждение (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_outcome_fact_with_analyst_added_cell_matches_contract(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""I-9: ячейка, созданная правкой аналитика (`is_recognized = NULL`), попадает в payload и проходит схему."""
office = await _rostov_office(seeded)
income, _meta = await _income_form(seeded, fixtures_dir, office=office)
value = await _set_value_fix(
seeded, income, source_text="1", original_amount=Decimal("1.00"), confirmed_amount=Decimal("2.00")
)
value.is_recognized = None
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) # валидирует payload по контракту
assert any(item["is_recognized"] is None for item in fact.payload["values"])
@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 == []