add_issues вставлял все записи документа одним INSERT: 16 параметров на запись, лимит asyncpg 32 767 — потолок около 2 047 замечаний. Лист ОСВ «11.xlsx» (сч. 11, 2 128 строк «Кол. доп.» на парсере контура 40287e1) давал 2 131 предупреждение, маппинг падал с InterfaceError, файл ERROR. Теперь пакеты по BATCH (1000), как у строк и значений форм. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
550 lines
31 KiB
Python
550 lines
31 KiB
Python
"""Маппер результата 2.0 для форм ББ/ОФР/раскрытий (spec arm-core «Хранение результата формы v2.0», «Условие
|
||
RECOGNIZED при приёме 2.0», «Предупреждения и нарушения документа — document_issue»; Р-10, Р-16, Р-18, Р-19, P3-6, P3-9,
|
||
P3-15, P3-39): реальный пакет `arm_statements`, `map_file` на БД теста."""
|
||
|
||
import json
|
||
import uuid
|
||
from collections.abc import Sequence
|
||
from decimal import Decimal
|
||
from http import HTTPStatus
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from src.common.clock import utc_now
|
||
from src.contract import RecognitionResultV2
|
||
from src.models.directories import LegalEntity, RegionalOffice
|
||
from src.models.enums import DocumentIssueKind, DocumentStatus, DocumentType, IssueSource, PeriodQuarter
|
||
from src.models.files import Document, RecognitionJob, StoredFile
|
||
from src.models.statements import (
|
||
DocumentIssue,
|
||
FinancialStatementColumn,
|
||
FinancialStatementLine,
|
||
FinancialStatementMeta,
|
||
FinancialStatementValue,
|
||
)
|
||
from src.services.auto_confirm import auto_confirm
|
||
from src.services.statements_engine import DocumentType as DocumentType_
|
||
from src.services.statements_engine import Edition, FormVariant, default_engine
|
||
from tests.conftest import AuthClientFactory
|
||
from tests.factories import make_company_group, make_legal_entity
|
||
from tests.factories_v2 import (
|
||
OWNER_INN,
|
||
balance_lines,
|
||
column_v2,
|
||
header_v2,
|
||
line_v2,
|
||
result_builder,
|
||
statement_doc_v2,
|
||
value_v2,
|
||
)
|
||
from tests.fixtures_helpers import map_golden_snapshot
|
||
|
||
pytestmark = pytest.mark.integration
|
||
|
||
EXAMPLES = Path(__file__).resolve().parents[1] / "contracts" / "examples"
|
||
KA_RF = "ka_rf@armka.local"
|
||
ROSTOV = "Ростовский региональный филиал"
|
||
V2_PATH = Path("result-v2.json")
|
||
HEADER_FIELDS_COUNT = 10
|
||
ERROR_JSON_FAILED = {"code": "JSON_FAILED", "severity": "error", "message": "потерян json"}
|
||
ERROR_TABLE_PARSE = {"code": "TABLE_PARSE_ERROR", "severity": "error", "message": "таблица не разобрана"}
|
||
INCOME_LINES = [line_v2(0, "2110", "Выручка"), line_v2(1, "2100", "Валовая прибыль (убыток)")]
|
||
|
||
|
||
async def _entity(session: AsyncSession, *, inn: str = OWNER_INN, rostov: bool = False) -> LegalEntity:
|
||
if not rostov:
|
||
return await make_legal_entity(session, name="ООО Владелец", inn=inn)
|
||
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
|
||
group = await make_company_group(session, name="ГК Владелец", office=office)
|
||
return await make_legal_entity(session, name="ООО Владелец", inn=inn, group=group)
|
||
|
||
|
||
async def _map(
|
||
session: AsyncSession, entity: LegalEntity, documents: Sequence[dict[str, Any]], **kwargs: Any
|
||
) -> list[Document]:
|
||
"""Документы файла по `file_ordinal`."""
|
||
await map_golden_snapshot(
|
||
session, entity=entity, path=V2_PATH, uploaded_by=None, result_builder=result_builder(documents, **kwargs)
|
||
)
|
||
stored = (await session.execute(select(StoredFile).where(StoredFile.legal_entity_id == entity.id))).scalars().all()
|
||
rows = await session.execute(
|
||
select(Document)
|
||
.where(Document.stored_file_id.in_([file.id for file in stored]))
|
||
.order_by(Document.file_ordinal)
|
||
)
|
||
return list(rows.scalars())
|
||
|
||
|
||
async def _issues(session: AsyncSession, document: Document) -> list[DocumentIssue]:
|
||
stmt = select(DocumentIssue).where(DocumentIssue.document_id == document.id).order_by(DocumentIssue.created_at)
|
||
return list((await session.execute(stmt)).scalars())
|
||
|
||
|
||
async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta:
|
||
stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)
|
||
return (await session.execute(stmt)).scalar_one()
|
||
|
||
|
||
async def _lines(session: AsyncSession, document: Document) -> list[FinancialStatementLine]:
|
||
meta = await _meta(session, document)
|
||
stmt = (
|
||
select(FinancialStatementLine)
|
||
.where(FinancialStatementLine.meta_id == meta.id)
|
||
.order_by(FinancialStatementLine.row_index)
|
||
)
|
||
return list((await session.execute(stmt)).scalars())
|
||
|
||
|
||
async def _confirm_clean(session: AsyncSession, documents: Sequence[Document]) -> None:
|
||
file = await session.get(StoredFile, documents[0].stored_file_id)
|
||
assert file is not None
|
||
await auto_confirm(session, file=file, documents=documents, now=utc_now())
|
||
|
||
|
||
def _example_builder(name: str): # noqa: ANN202 - фабрика `result_builder` golden-хелпера
|
||
def build(_path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> RecognitionResultV2:
|
||
payload = json.loads((EXAMPLES / name).read_text("utf-8"))
|
||
return RecognitionResultV2.model_validate({**payload, "job_id": str(job_id), "file_id": str(file_id)})
|
||
|
||
return build
|
||
|
||
|
||
async def test_example_minimal(seeded: AsyncSession) -> None:
|
||
"""Пример docs `result-statement-minimal.json`: `unreadable` без кода, `ROW_SPLIT` и `ORGANIZATION_UNCERTAIN`."""
|
||
entity = await _entity(seeded)
|
||
await map_golden_snapshot(
|
||
seeded,
|
||
entity=entity,
|
||
path=V2_PATH,
|
||
uploaded_by=None,
|
||
result_builder=_example_builder("result-statement-minimal.json"),
|
||
)
|
||
(balance,) = (await seeded.execute(select(Document).where(Document.legal_entity_id == entity.id))).scalars()
|
||
assert balance.status is DocumentStatus.NEEDS_CHECK and balance.file_ordinal == 0
|
||
lines = await _lines(seeded, balance)
|
||
unreadable = next(line for line in lines if line.line_status.value == "unreadable")
|
||
assert unreadable.line_code is None and unreadable.line_code_original_value is None
|
||
assert unreadable.line_code_text == "ИЧ,250" and unreadable.row_index == 2 # noqa: PLR2004
|
||
issues = {issue.code: issue for issue in await _issues(seeded, balance)}
|
||
split = issues["ROW_SPLIT"]
|
||
split_refs = {line.id for line in lines if line.source_ref in ("row:14#1", "row:14#2")}
|
||
assert set(split.line_ids or ()) == {str(line_id) for line_id in split_refs} and len(split_refs) == 2 # noqa: PLR2004
|
||
assert issues["ORGANIZATION_UNCERTAIN"].field == "organization_name"
|
||
assert all(issue.source is IssueSource.service for issue in (split, issues["ORGANIZATION_UNCERTAIN"]))
|
||
|
||
|
||
async def test_example_excel(seeded: AsyncSession) -> None:
|
||
"""Пример docs `result-statement-excel.json`: `BALANCE` с `RELATION_SOFT`, `INCOME` с `detail` и пустой целью,
|
||
один `DISCLOSURE` без строк."""
|
||
entity = await _entity(seeded)
|
||
await map_golden_snapshot(
|
||
seeded,
|
||
entity=entity,
|
||
path=V2_PATH,
|
||
uploaded_by=None,
|
||
result_builder=_example_builder("result-statement-excel.json"),
|
||
)
|
||
documents = list(
|
||
(await seeded.execute(select(Document).where(Document.legal_entity_id == entity.id).order_by("file_ordinal")))
|
||
.scalars()
|
||
.all()
|
||
)
|
||
balance, income, disclosure = documents
|
||
assert [d.document_type for d in documents] == [DocumentType.BALANCE, DocumentType.INCOME, DocumentType.DISCLOSURE]
|
||
soft = next(issue for issue in await _issues(seeded, balance) if issue.kind is DocumentIssueKind.soft)
|
||
assert (soft.code, soft.relation, soft.codes) == ("RELATION_SOFT", "1110 = 11101", ["1110", "11101"])
|
||
assert soft.expected == Decimal("905786.99") and soft.actual == Decimal("900000.00") and soft.column_id is not None
|
||
hard = next(issue for issue in await _issues(seeded, income) if issue.kind is DocumentIssueKind.relation)
|
||
assert hard.expected is None and hard.actual == Decimal("400.00")
|
||
lines = await _lines(seeded, income)
|
||
detail = next(line for line in lines if line.line_status.value == "detail")
|
||
assert detail.line_code is None and detail.parent_row_index == lines[0].row_index == 0
|
||
assert disclosure.status is DocumentStatus.RECOGNIZED and disclosure.file_ordinal == 2 # noqa: PLR2004
|
||
assert not await _lines_count(seeded, disclosure)
|
||
|
||
|
||
async def _lines_count(session: AsyncSession, document: Document) -> int:
|
||
meta = (
|
||
await session.execute(
|
||
select(FinancialStatementMeta.id).where(FinancialStatementMeta.document_id == document.id)
|
||
)
|
||
).scalar_one_or_none()
|
||
return 0 if meta is None else len(await _lines(session, document))
|
||
|
||
|
||
async def test_repeated_code_kept(seeded: AsyncSession) -> None:
|
||
"""Две строки `ok` 2410 (пустая и `(649)`) — две строки со своими `row_index`, `source_ref` и значениями."""
|
||
lines = [
|
||
line_v2(0, "2410", "Налог на прибыль", amount=None, ref="row:7"),
|
||
line_v2(1, "2410", "Налог на прибыль", amount="-649", ref="row:9"),
|
||
]
|
||
entity = await _entity(seeded)
|
||
(income,) = await _map(seeded, entity, [statement_doc_v2("INCOME", lines=lines)])
|
||
stored = await _lines(seeded, income)
|
||
assert [(line.row_index, line.source_ref, line.line_code) for line in stored] == [
|
||
(0, "row:7", "2410"),
|
||
(1, "row:9", "2410"),
|
||
]
|
||
amounts = {
|
||
value.line_id: value.amount
|
||
for value in (await seeded.execute(select(FinancialStatementValue))).scalars()
|
||
if value.line_id in {line.id for line in stored}
|
||
}
|
||
assert amounts[stored[0].id] is None and amounts[stored[1].id] == Decimal(-649)
|
||
|
||
|
||
async def test_line_without_code(seeded: AsyncSession) -> None:
|
||
line = line_v2(5, None, "Финансовые вложения", status="unreadable", code_text="ИЧ,250", code_confidence=25)
|
||
(balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=[line])])
|
||
(stored,) = await _lines(seeded, balance)
|
||
assert stored.line_code is None and stored.line_code_original_value is None
|
||
assert stored.line_status.value == "unreadable" and stored.row_index == 5 # noqa: PLR2004
|
||
assert stored.line_code_text == "ИЧ,250" and stored.is_confirmed is False
|
||
|
||
|
||
async def test_two_balances_same_period(seeded: AsyncSession) -> None:
|
||
"""Review Focus 1: два `BALANCE` одного периода — разные `file_ordinal`, оба `NEEDS_CHECK`, не `CONFIRMED`."""
|
||
entity = await _entity(seeded)
|
||
first, second = await _map(seeded, entity, [statement_doc_v2(), statement_doc_v2()])
|
||
assert (first.file_ordinal, second.file_ordinal) == (0, 1)
|
||
assert first.status is second.status is DocumentStatus.NEEDS_CHECK
|
||
await _confirm_clean(seeded, [first, second])
|
||
assert first.status is second.status is DocumentStatus.NEEDS_CHECK
|
||
warning = [i.message for i in await _issues(seeded, first) if i.code == "MAPPING_WARNING"]
|
||
assert any("другая форма BALANCE за тот же период" in message for message in warning)
|
||
|
||
|
||
async def test_two_balances_other_periods(seeded: AsyncSession) -> None:
|
||
"""Баланс на 30.09.2025 и на 31.12.2024 — правило Р-18 их не касается, каждый по своим условиям."""
|
||
entity = await _entity(seeded)
|
||
docs = [
|
||
statement_doc_v2(),
|
||
statement_doc_v2(header=header_v2(report_date="2024-12-31"), columns=[column_v2(2, date="2024-12-31")]),
|
||
]
|
||
first, second = await _map(seeded, entity, docs)
|
||
assert first.status is second.status is DocumentStatus.RECOGNIZED
|
||
assert (first.period_quarter, second.period_quarter) == (PeriodQuarter.Q3, PeriodQuarter.Q4)
|
||
|
||
|
||
async def test_neighbor_needs_check(seeded: AsyncSession) -> None:
|
||
"""Р-19: `needs_check` соседнего документа не мешает: ББ `done` автоподтверждён, ОФР `needs_check` — нет."""
|
||
entity = await _entity(seeded)
|
||
docs = [statement_doc_v2("BALANCE"), statement_doc_v2("INCOME", lines=INCOME_LINES, status="needs_check")]
|
||
balance, income = await _map(seeded, entity, docs)
|
||
assert (balance.status, income.status) == (DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK)
|
||
await _confirm_clean(seeded, [balance, income])
|
||
assert (balance.status, income.status) == (DocumentStatus.CONFIRMED, DocumentStatus.NEEDS_CHECK)
|
||
|
||
|
||
@pytest.mark.parametrize("warning", [ERROR_JSON_FAILED, ERROR_TABLE_PARSE])
|
||
async def test_file_error_warning_blocks_all(seeded: AsyncSession, warning: dict[str, str]) -> None:
|
||
"""P3-15: предупреждение файла уровня `error` в `metrics.warnings` результата — все формы файла `NEEDS_CHECK`."""
|
||
entity = await _entity(seeded)
|
||
docs = [statement_doc_v2("BALANCE"), statement_doc_v2("INCOME", lines=INCOME_LINES)]
|
||
balance, income = await _map(seeded, entity, docs, file_warnings=[warning])
|
||
assert balance.status is income.status is DocumentStatus.NEEDS_CHECK
|
||
|
||
|
||
async def test_info_file_warning_does_not_block(seeded: AsyncSession) -> None:
|
||
info = {"code": "SOMETHING", "severity": "info", "message": "к сведению"}
|
||
(balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2()], file_warnings=[info])
|
||
assert balance.status is DocumentStatus.RECOGNIZED
|
||
|
||
|
||
NAME_CASES = [
|
||
("1120", "Нематериальные активы", 50, True), # подмена кода на соседа
|
||
("1220", "НДС, начисленный по отгрузке", 50, False), # законный повтор с другим именем (fsbu4)
|
||
("1200", "Итого по разделу I", 50, True), # подмена итога раздела
|
||
("1500", "Итого по разделу V1", 56, False), # хвост OCR после номера раздела
|
||
("1120", "Нематериальные активы", 100, False), # уверенный код не проверяется
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize(("code", "name", "confidence", "mismatch"), NAME_CASES)
|
||
async def test_name_mismatch(seeded: AsyncSession, code: str, name: str, confidence: int, mismatch: bool) -> None:
|
||
lines = [*balance_lines(), line_v2(10, code, name, code_confidence=confidence)]
|
||
(balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines)])
|
||
flagged = [i for i in await _issues(seeded, balance) if i.code == "NAME_MISMATCH"]
|
||
stored = await _lines(seeded, balance)
|
||
if mismatch:
|
||
assert [i.line_ids for i in flagged] == [[str(stored[-1].id)]]
|
||
assert flagged[0].source is IssueSource.core and balance.status is DocumentStatus.NEEDS_CHECK
|
||
else:
|
||
assert not flagged
|
||
|
||
|
||
async def test_done_with_unreadable_without_values(seeded: AsyncSession) -> None:
|
||
"""Сервис прислал `done`, но есть `unreadable` без значений — функция статуса пакета не согласна."""
|
||
lines = [*balance_lines(), line_v2(9, None, "Строка", status="unreadable", amount=None, code_text="???")]
|
||
(balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines, status="done")])
|
||
assert balance.status is DocumentStatus.NEEDS_CHECK
|
||
inconsistent = [i for i in await _issues(seeded, balance) if i.code == "RESULT_STATUS_INCONSISTENT"]
|
||
assert len(inconsistent) == 1 and inconsistent[0].source is IssueSource.core
|
||
assert inconsistent[0].line_ids is None and inconsistent[0].kind is DocumentIssueKind.warning
|
||
await _confirm_clean(seeded, [balance])
|
||
assert balance.status is DocumentStatus.NEEDS_CHECK
|
||
|
||
|
||
async def test_done_with_unreadable_with_amount(seeded: AsyncSession) -> None:
|
||
lines = [*balance_lines(), line_v2(9, None, "Строка", status="unreadable", amount="50", code_text="???")]
|
||
(balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines, status="done")])
|
||
assert balance.status is DocumentStatus.NEEDS_CHECK
|
||
assert [i.code for i in await _issues(seeded, balance) if i.code == "RESULT_STATUS_INCONSISTENT"]
|
||
await _confirm_clean(seeded, [balance])
|
||
assert balance.status is DocumentStatus.NEEDS_CHECK
|
||
|
||
|
||
async def test_needs_check_without_inconsistency_record(seeded: AsyncSession) -> None:
|
||
"""`needs_check` сервиса: документ `NEEDS_CHECK`, `RESULT_STATUS_INCONSISTENT` не пишется."""
|
||
(balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(status="needs_check")])
|
||
assert balance.status is DocumentStatus.NEEDS_CHECK
|
||
assert not [i for i in await _issues(seeded, balance) if i.code == "RESULT_STATUS_INCONSISTENT"]
|
||
|
||
|
||
async def test_report_date_not_quarter_end(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||
"""Дата 15.08.2025: квартал не определён, `NEEDS_CHECK`, подтверждение — 409 `PERIOD_REQUIRED`."""
|
||
entity = await _entity(seeded, rostov=True)
|
||
docs = [statement_doc_v2(header=header_v2(report_date="2025-08-15"), columns=[column_v2(2, date="2025-08-15")])]
|
||
(balance,) = await _map(seeded, entity, docs)
|
||
assert balance.status is DocumentStatus.NEEDS_CHECK
|
||
assert (balance.period_year, balance.period_quarter) == (2025, None)
|
||
await _confirm_clean(seeded, [balance])
|
||
assert balance.status is DocumentStatus.NEEDS_CHECK
|
||
client = await auth_client(KA_RF)
|
||
blocked = await client.post(f"/documents/{balance.id}/confirm")
|
||
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED"
|
||
|
||
|
||
async def test_66n_blank_in_2025(seeded: AsyncSession) -> None:
|
||
"""Бланк 66н, сданный в 2025 году: редакция документа — `66n` по подпоследовательности кодов, `CODE_NOT_IN_EDITION`
|
||
у 1120 нет (в перечне `fsbu4` его нет)."""
|
||
names = {"1110": "Нематериальные активы", "1120": "Результаты исследований и разработок"}
|
||
codes = ["1110", "1120", "1130", "1140", "1150", "1160", "1170", "1180"]
|
||
lines = [line_v2(index, code, names.get(code, "Строка")) for index, code in enumerate(codes)]
|
||
(balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines)])
|
||
stored = {line.line_code: line.id for line in await _lines(seeded, balance)}
|
||
pairs = [(code, True) for code in codes]
|
||
edition = default_engine().edition(DocumentType_.BALANCE, pairs, balance.reporting_date, FormVariant.FULL)
|
||
assert edition is Edition.RAS_66N
|
||
info = [i for i in await _issues(seeded, balance) if i.code == "CODE_NOT_IN_EDITION"]
|
||
assert str(stored["1120"]) not in {line_id for issue in info for line_id in issue.line_ids or ()}
|
||
|
||
|
||
async def test_code_not_in_edition_for_fsbu4_form(seeded: AsyncSession) -> None:
|
||
"""Контроль к 66н: код вне перечня выбранной редакции даёт одну запись на код документа (R-1: только 4 цифры)."""
|
||
lines = [*balance_lines(), line_v2(10, "9999", "Чужая"), line_v2(11, "99999", "Подстрока Excel")]
|
||
(balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines)])
|
||
stored = {line.line_code: line.id for line in await _lines(seeded, balance)}
|
||
core = [issue for issue in await _issues(seeded, balance) if issue.source is IssueSource.core]
|
||
by_code = {(issue.code, tuple(issue.line_ids or ())) for issue in core}
|
||
assert ("CODE_NOT_IN_DICTIONARY", (str(stored["9999"]),)) in by_code
|
||
assert ("CODE_NOT_IN_EDITION", (str(stored["9999"]),)) in by_code
|
||
assert not any(str(stored["99999"]) in line_ids for _, line_ids in by_code)
|
||
|
||
|
||
async def test_column_skipped_issue(seeded: AsyncSession) -> None:
|
||
"""`COLUMN_SKIPPED` с `column_index = 5`, колонки 5 нет — `column_index == 5`, `column_id` пуст; для имеющейся
|
||
колонки — наоборот."""
|
||
warnings = [
|
||
{"code": "COLUMN_SKIPPED", "severity": "info", "message": "колонка пропущена", "column_index": 5},
|
||
{"code": "COLUMN_DATE_UNCERTAIN", "severity": "info", "message": "дата", "column_index": 2},
|
||
]
|
||
(balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(warnings=warnings)])
|
||
issues = {i.code: i for i in await _issues(seeded, balance)}
|
||
assert (issues["COLUMN_SKIPPED"].column_index, issues["COLUMN_SKIPPED"].column_id) == (5, None)
|
||
column = (await seeded.execute(select(FinancialStatementColumn))).scalars().one()
|
||
uncertain = issues["COLUMN_DATE_UNCERTAIN"]
|
||
assert (uncertain.column_id, uncertain.column_index) == (column.id, None)
|
||
|
||
|
||
async def test_header_source_hint(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
|
||
"""`organization_name_text` / `_confidence` — в `header_source` и в `GET …/statement` (`header.source`)."""
|
||
header = header_v2(organization_name_text="ООО «Ромашкa»", organization_name_confidence=67)
|
||
entity = await _entity(seeded, rostov=True)
|
||
(balance,) = await _map(seeded, entity, [statement_doc_v2(header=header)])
|
||
meta = await _meta(seeded, balance)
|
||
assert len(meta.header_source) == HEADER_FIELDS_COUNT
|
||
assert meta.header_source["organization_name"] == {"text": "ООО «Ромашкa»", "confidence": 67}
|
||
assert meta.inn_original_value == OWNER_INN and meta.organization_name_original_value == "ООО Ромашка"
|
||
assert meta.form_variant is not None and meta.form_variant.value == "full"
|
||
body = (await (await auth_client(KA_RF)).get(f"/documents/{balance.id}/statement")).json()
|
||
assert body["header"]["source"]["organization_name"] == {"text": "ООО «Ромашкa»", "confidence": "67.00"}
|
||
|
||
|
||
async def test_statements_version_and_ordinals(seeded: AsyncSession) -> None:
|
||
"""`recognition_job.statements_version` из `service.statements_version`; `BALANCE`, `INCOME` — ordinal 0, 1;
|
||
отличие от версии копии ядра — `STATEMENTS_VERSION_MISMATCH` (`info`, `core`)."""
|
||
entity = await _entity(seeded)
|
||
docs = [statement_doc_v2("BALANCE"), statement_doc_v2("INCOME", lines=INCOME_LINES)]
|
||
balance, income = await _map(seeded, entity, docs, statements_version="abc1234")
|
||
assert (balance.file_ordinal, income.file_ordinal) == (0, 1)
|
||
job = (
|
||
await seeded.execute(select(RecognitionJob).where(RecognitionJob.file_id == balance.stored_file_id))
|
||
).scalar_one()
|
||
assert job.statements_version == "abc1234"
|
||
mismatch = [i for i in await _issues(seeded, balance) if i.code == "STATEMENTS_VERSION_MISMATCH"]
|
||
assert len(mismatch) == 1 and mismatch[0].source is IssueSource.core
|
||
|
||
|
||
async def test_disclosure_one_document_with_all_warnings(seeded: AsyncSession) -> None:
|
||
"""P3-6: несколько `DISCLOSURE` — один документ (ordinal первого, `locator` через запятую); предупреждения всех — в
|
||
его `document_issue`; он `RECOGNIZED`."""
|
||
first = statement_doc_v2("DISCLOSURE", lines=[], columns=[], form_variant=None, locator="loc:1")
|
||
first["warnings"] = [{"code": "FIRST_W", "severity": "info", "message": "первое"}]
|
||
second = statement_doc_v2("DISCLOSURE", lines=[], columns=[], form_variant=None, locator="loc:2")
|
||
second["warnings"] = [{"code": "SECOND_W", "severity": "info", "message": "второе"}]
|
||
entity = await _entity(seeded)
|
||
balance, disclosure = await _map(seeded, entity, [statement_doc_v2(), first, second])
|
||
assert balance.file_ordinal == 0 and disclosure.file_ordinal == 1
|
||
assert disclosure.document_type is DocumentType.DISCLOSURE and disclosure.locator == "loc:1,loc:2"
|
||
assert disclosure.status is DocumentStatus.RECOGNIZED
|
||
assert {i.code for i in await _issues(seeded, disclosure)} == {"FIRST_W", "SECOND_W"}
|
||
documents = (await seeded.execute(select(Document).where(Document.stored_file_id == balance.stored_file_id))).all()
|
||
assert len(documents) == 2 # noqa: PLR2004
|
||
|
||
|
||
async def test_value_cells_and_empty_records(seeded: AsyncSession) -> None:
|
||
"""Значение на каждую пару «строка × колонка»; сырой текст, `needs_review` и пустая запись без `needs_review`."""
|
||
columns = [column_v2(2), column_v2(3, date="2024-12-31")]
|
||
lines = [
|
||
line_v2(0, "1210", "Запасы", values=[value_v2(2, "428311", text="42831 1", is_recognized=False)]),
|
||
line_v2(1, "1600", "БАЛАНС", values=[value_v2(2, "5", needs_review=True), value_v2(3, "4")]),
|
||
]
|
||
(balance,) = await _map(seeded, await _entity(seeded), [statement_doc_v2(lines=lines, columns=columns)])
|
||
stored = await _lines(seeded, balance)
|
||
rows = (
|
||
await seeded.execute(
|
||
select(FinancialStatementValue, FinancialStatementColumn.column_index)
|
||
.join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id)
|
||
.where(FinancialStatementValue.line_id.in_([line.id for line in stored]))
|
||
)
|
||
).all()
|
||
cells = {(value.line_id, index): value for value, index in rows}
|
||
assert len(cells) == 4 # noqa: PLR2004
|
||
typed = cells[(stored[0].id, 2)]
|
||
assert (typed.amount, typed.source_text, typed.is_recognized) == (Decimal(428311), "42831 1", False)
|
||
empty = cells[(stored[0].id, 3)]
|
||
assert (empty.amount, empty.source_text, empty.needs_review) == (None, None, False)
|
||
assert cells[(stored[1].id, 2)].needs_review is True
|
||
|
||
|
||
# --- ОСВ по контракту 2.0 (B5, Р-28) --------------------------------------------------------------------------------
|
||
|
||
|
||
def _osv_doc(
|
||
example: str = "result-osv-minimal.json", *, account: str = "60", locator: str | None = None
|
||
) -> dict[str, Any]:
|
||
"""Лист ОСВ из примера docs; `account` — счёт шапки, `locator` — уникальный `locator` листа."""
|
||
doc = json.loads((EXAMPLES / example).read_text("utf-8"))["documents"][0]
|
||
doc["header"]["account_code_raw"] = account
|
||
if locator is not None:
|
||
doc["locator"] = locator
|
||
return doc
|
||
|
||
|
||
async def _osv_issues(session: AsyncSession, document: Document) -> dict[str, DocumentIssue]:
|
||
return {issue.code: issue for issue in await _issues(session, document)}
|
||
|
||
|
||
async def test_osv_in_book_with_needs_check_income(seeded: AsyncSession) -> None:
|
||
"""Сценарий спеки: ОСВ `done` с сошедшимися суммами + ОФР `needs_check`, конверт `needs_check`, ошибок файла нет →
|
||
ОСВ `CONFIRMED` (автоподтверждение), её `OSV_*` — в `document_issue`."""
|
||
entity = await _entity(seeded)
|
||
osv = _osv_doc()
|
||
osv["warnings"] = [{"code": "OSV_HIERARCHY_BY_OUTLINE", "severity": "info", "message": "иерархия по outline"}]
|
||
income = statement_doc_v2("INCOME", status="needs_check", lines=INCOME_LINES)
|
||
documents = await _map(seeded, entity, [osv, income], status="needs_check")
|
||
osv_document, income_document = documents
|
||
assert osv_document.document_type is DocumentType.OSV and income_document.status is DocumentStatus.NEEDS_CHECK
|
||
assert osv_document.status is DocumentStatus.RECOGNIZED
|
||
issue = (await _osv_issues(seeded, osv_document))["OSV_HIERARCHY_BY_OUTLINE"]
|
||
assert issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service
|
||
assert issue.severity.value == "info" and issue.message == "иерархия по outline"
|
||
await _confirm_clean(seeded, [osv_document])
|
||
assert osv_document.status is DocumentStatus.CONFIRMED
|
||
|
||
|
||
async def test_osv_v2_examples(seeded: AsyncSession) -> None:
|
||
"""Примеры docs: `result-osv-minimal.json` — `RECOGNIZED`; `result-osv-needs-check.json` (`status` листа
|
||
`needs_check`, нарушение тождества) — `NEEDS_CHECK`, оба `OSV_*` в issues."""
|
||
entity = await _entity(seeded)
|
||
await map_golden_snapshot(
|
||
seeded,
|
||
entity=entity,
|
||
path=V2_PATH,
|
||
uploaded_by=None,
|
||
result_builder=_example_builder("result-osv-minimal.json"),
|
||
)
|
||
await map_golden_snapshot(
|
||
seeded,
|
||
entity=entity,
|
||
path=V2_PATH,
|
||
uploaded_by=None,
|
||
result_builder=_example_builder("result-osv-needs-check.json"),
|
||
)
|
||
rows = (await seeded.execute(select(Document).where(Document.legal_entity_id == entity.id))).scalars().all()
|
||
by_status = {document.status: document for document in rows}
|
||
assert set(by_status) == {DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK}
|
||
assert not await _issues(seeded, by_status[DocumentStatus.RECOGNIZED])
|
||
codes = set(await _osv_issues(seeded, by_status[DocumentStatus.NEEDS_CHECK]))
|
||
assert {"OSV_HIERARCHY_BY_OUTLINE", "OSV_BALANCE_IDENTITY"} <= codes
|
||
|
||
|
||
async def test_two_osv_v2_ordinals(seeded: AsyncSession) -> None:
|
||
"""Два листа ОСВ (60 и 62) в одном файле: `file_ordinal` 0 и 1."""
|
||
entity = await _entity(seeded)
|
||
documents = await _map(
|
||
seeded, entity, [_osv_doc(account="60", locator="sheet:A"), _osv_doc(account="62", locator="sheet:B")]
|
||
)
|
||
assert [(d.osv_account_code, d.file_ordinal) for d in documents] == [("60", 0), ("62", 1)]
|
||
|
||
|
||
async def test_osv_v2_error_file_warning_blocks_recognized(seeded: AsyncSession) -> None:
|
||
"""Предупреждение файла `error` (Р-28) — ОСВ `NEEDS_CHECK`, хотя лист `done` и суммы сошлись."""
|
||
entity = await _entity(seeded)
|
||
(document,) = await _map(seeded, entity, [_osv_doc()], file_warnings=[ERROR_JSON_FAILED])
|
||
assert document.status is DocumentStatus.NEEDS_CHECK
|
||
|
||
|
||
async def test_osv_v2_mapping_warning_blocks_recognized(seeded: AsyncSession) -> None:
|
||
"""Повтор счёта в файле — предупреждение маппинга по листу: первый лист `NEEDS_CHECK`, запись в issues
|
||
(источник `core`, `MAPPING_WARNING`)."""
|
||
entity = await _entity(seeded)
|
||
(document,) = await _map(seeded, entity, [_osv_doc(locator="sheet:A"), _osv_doc(locator="sheet:B")])
|
||
assert document.status is DocumentStatus.NEEDS_CHECK
|
||
issue = (await _osv_issues(seeded, document))["MAPPING_WARNING"]
|
||
assert issue.source is IssueSource.core and "sheet:B" in issue.message
|
||
|
||
|
||
async def test_osv_v2_sheet_needs_check_blocks_recognized(seeded: AsyncSession) -> None:
|
||
"""`osvDocument.status = needs_check` при чистых суммах — `NEEDS_CHECK` (статус конверта не важен)."""
|
||
entity = await _entity(seeded)
|
||
osv = _osv_doc()
|
||
osv["status"] = "needs_check"
|
||
(document,) = await _map(seeded, entity, [osv])
|
||
assert document.status is DocumentStatus.NEEDS_CHECK
|
||
|
||
|
||
async def test_osv_v2_many_sheet_warnings_stored(seeded: AsyncSession) -> None:
|
||
"""AURORA-1711: предупреждений листа больше, чем помещается в один INSERT `document_issue` (16 параметров на
|
||
запись, лимит asyncpg 32 767 — ≈ 2 047 записей). Так было на «11.xlsx» (сч. 11, 2 128 строк «Кол. доп.»):
|
||
документ создаётся, все предупреждения сохранены."""
|
||
entity = await _entity(seeded)
|
||
osv = _osv_doc()
|
||
count = 2131
|
||
osv["warnings"] = [
|
||
{
|
||
"code": "OSV_ROWS",
|
||
"severity": "info",
|
||
"message": f"строка {row}: метка «Кол. доп.» в колонке «Показатели» не распознана",
|
||
}
|
||
for row in range(count)
|
||
]
|
||
(document,) = await _map(seeded, entity, [osv])
|
||
assert document.document_type is DocumentType.OSV
|
||
assert len(await _issues(seeded, document)) == count
|