861 lines
38 KiB
Python
861 lines
38 KiB
Python
"""Документ, строки ОСВ и подтверждение (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 == []
|