arm-core/api/tests/test_mapping_statements_v2.py
Andrey Epifancev 2b8fafc3b3 feat(mapping): ОСВ по контракту 2.0 (Р-28) и выбор маппера по MAJOR
Приём файла результата выбирает модель по MAJOR (parse_result); нечитаемый JSON и неизвестный
MAJOR — неповторяемая ошибка схемы (T5.2). Статус ОСВ 2.0 — osv_status_v2, предупреждения листа
(OSV_*) — в document_issue. Решения P3-46…P3-49.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:57:38 +04:00

530 lines
30 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.

"""Маппер результата 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