"""API формы ББ/ОФР (spec documents-api «Форма ББ/ОФР для экрана проверки», «Правка формы», «Акцепт проверок формы», «Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14).""" import datetime import json import uuid from decimal import Decimal from http import HTTPStatus from pathlib import Path from typing import Any import pytest from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_checks import CROSS_FORM_RELATION from src.models.access import AppUser from src.models.audit_ex import AuditEvent from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice from src.models.enums import ( AuditAction, DocumentIssueKind, DocumentStatus, DocumentType, FileStatus, IssueSeverity, IssueSource, PeriodQuarter, RecognitionStatus, StatementLineStatus, ) from src.models.files import Document, StoredFile from src.models.statements import ( DocumentIssue, FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue, ) from tests.conftest import AuthClientFactory from tests.factories import ( make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file, ) from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot pytestmark = [pytest.mark.integration, pytest.mark.fixtures] KA_RF = "ka_rf@armka.local" RKA = "rka@armka.local" # только DOCUMENT_VIEW ROSTOV = "Ростовский региональный филиал" MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" # ББ с пятизначными строками детализации UNAGRANDE_BALANCE = GOLDEN_STATEMENTS / "unagrande-group" / "2025-Q3" / "ББ. Лист 1 3кв.25.json" MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" MEZH_BALANCE_LINES = 37 MEZH_BALANCE_TEMPLATE_ONLY = {"1105", "1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н) MEZH_INCOME_TEMPLATE_ONLY = {"2420"} EVENTS_AFTER_BREAK_AND_ACCEPT = 2 async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity: office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() group = await make_company_group(session, name=f"ГК {name}", office=office) 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, group=group) async def form(session: AsyncSession, fixtures_dir: Path, path: Path = MEZH_BALANCE) -> Document: entity = await rostov_entity(session, fixtures_dir / path) documents = await map_golden_snapshot(session, entity=entity, path=fixtures_dir / path, uploaded_by=None) return next(d for d in documents if d.document_type in (DocumentType.BALANCE, DocumentType.INCOME)) async def two_forms(session: AsyncSession, fixtures_dir: Path) -> tuple[Document, Document]: """ББ и ОФР Межениновской за 2025 Q3 одного ЮЛ (два файла).""" entity = await rostov_entity(session, fixtures_dir / MEZH_BALANCE) balance = next( d for d in await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None) if d.document_type == DocumentType.BALANCE ) income = next( d for d in await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_INCOME, uploaded_by=None) if d.document_type == DocumentType.INCOME ) return balance, income async def set_amount( # noqa: PLR0913 - адресация ячейки: документ, строка, колонка session: AsyncSession, document: Document, line_code: str, amount: Decimal, *, current: bool = False, period_date: datetime.date | None = None, ) -> None: """Прямая правка суммы ячейки в БД теста (колонка `is_current` или с датой `period_date`).""" meta = ( await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) ).scalar_one() column_filter = ( FinancialStatementColumn.is_current.is_(True) if current else FinancialStatementColumn.period_date == period_date ) column_id = ( await session.execute( select(FinancialStatementColumn.id) .where(FinancialStatementColumn.meta_id == meta.id, column_filter) .order_by(FinancialStatementColumn.column_index) .limit(1) ) ).scalar_one() line_id = ( await session.execute( select(FinancialStatementLine.id).where( FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code ) ) ).scalar_one() await session.execute( update(FinancialStatementValue) .where(FinancialStatementValue.line_id == line_id, FinancialStatementValue.column_id == column_id) .values(amount=amount) ) await session.flush() async def test_get_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) response = await client.get(f"/documents/{document.id}/statement") assert response.status_code == HTTPStatus.OK, response.text body = response.json() assert body["edition"] == "fsbu4" assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3" source = [line for line in body["lines"] if line["origin"] == "source"] template = [line for line in body["lines"] if line["origin"] == "template"] assert len(source) == MEZH_BALANCE_LINES and all(line["id"] for line in source) assert {line["line_code"] for line in template} == MEZH_BALANCE_TEMPLATE_ONLY assert all(line["id"] is None for line in template) codes = [line["line_code"] for line in body["lines"]] assert codes == sorted(codes) columns = len(body["columns"]) assert all(len(line["values"]) == columns for line in body["lines"]) assert body["checks"]["failed"] == [] and body["checks"]["accepted"] is None assert set(body["header"]["inn"]) == {"value", "original_value", "confidence", "verified", "modified"} async def test_get_statement_shape_and_decimal_strings( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Документ, шапка и значения: поля спеки, суммы — строкой, `column_index` значения совпадает с колонкой.""" document = await form(seeded, fixtures_dir) client = await auth_client(RKA) # DOCUMENT_VIEW достаточно body = (await client.get(f"/documents/{document.id}/statement")).json() assert set(body["document"]) == { "id", "document_type", "status", "period_year", "period_quarter", "reporting_date", "counterparty_check", "locator", "file_id", "form_variant", } assert { "organization_name", "okud", "okpo", "okopf", "okfs", "address", "okei", "needs_review_fields", "source", "organization_name_modified", } <= set(body["header"]) assert set(body["columns"][0]) == { "id", "column_index", "label", "period_date", "is_current", "period_kind", "label_confidence", } line = next(line for line in body["lines"] if any(v["amount"] is not None for v in line["values"])) assert set(line) == { "id", "origin", "row_index", "source_ref", "line_status", "line_code", "line_code_text", "line_code_confidence", "line_code_modified", "reference_name", "parent_row_index", "needs_review", "is_confirmed", "line_name", "line_name_confidence", "parent_code", "values", } value = next(v for v in line["values"] if v["amount"] is not None) assert isinstance(value["amount"], str) assert set(value) == { "id", "column_id", "column_index", "amount", "string_value", "confidence", "is_recognized", "is_modified", "original_amount", "modified_by", "modified_at", "text", "needs_review", "is_confirmed", } index_by_id = {column["id"]: column["column_index"] for column in body["columns"]} assert all(index_by_id[v["column_id"]] == v["column_index"] for line in body["lines"] for v in line["values"]) assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"]) async def test_get_statement_parent_code_of_sublines( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """AURORA-1527: у пятизначной подстроки `parent_code` — её четырёхзначная строка, у остальных (в т. ч. шаблонных) — `null`; родитель тот же, что у проверки «сумма подстрок = родитель».""" document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() parents = {line["line_code"]: line["parent_code"] for line in body["lines"]} assert parents["11101"] == "1110" and parents["11201"] == "1120" and parents["11510"] == "1150" assert parents["11904"] == "1190" and parents["12101"] == "1210" assert all((parent is not None) == (len(code) == 5) for code, parent in parents.items()) # noqa: PLR2004 assert all(line["parent_code"] is None for line in body["lines"] if line["origin"] == "template") async def test_get_statement_parent_code_matches_subline_check( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Нарушенная «1110 = 11101 + …» строится по тем же подстрокам, что отдаёт `parent_code`.""" document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE) await set_amount(seeded, document, "1110", Decimal("999999"), current=True) await set_amount(seeded, document, "11101", Decimal("1"), current=True) # детализация есть — проверка работает client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() kids = sorted(line["line_code"] for line in body["lines"] if line["parent_code"] == "1110") assert kids == ["11101", "11102", "11103"] assert f"1110 = {' + '.join(kids)}" in {item["relation"] for item in body["checks"]["soft_failed"]} async def test_get_statement_no_subline_warnings_without_detail( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """AURORA-1527: все подстроки родителя пусты — «1150 = 11501 + …», «1170 = 11701», «1210 = 12101» не выдаются.""" document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() soft = [item["relation"] for item in body["checks"]["soft_failed"]] assert not any(relation.startswith(("1150 =", "1170 =", "1210 =")) for relation in soft), soft async def test_get_statement_orphan_subline_has_no_parent( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Подстрока без четырёхзначной строки в форме остаётся без родителя (и не попадает в проверку).""" document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE) await drop_line(seeded, document, "1170") client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() parents = {line["line_code"]: line["parent_code"] for line in body["lines"] if line["origin"] == "source"} assert parents["11701"] is None async def test_get_income_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir, MEZH_INCOME) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() assert body["document"]["document_type"] == "INCOME" and body["edition"] == "fsbu4" assert body["lines"] async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: return ( await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) ).scalar_one() async def drop_line(session: AsyncSession, document: Document, line_code: str) -> dict[int, Decimal | None]: """Удалить строку формы в БД теста (как будто источник её потерял); вернуть её суммы по `column_index`.""" meta = await _meta(session, document) line = ( await session.execute( select(FinancialStatementLine).where( FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code ) ) ).scalar_one() rows = ( await session.execute( select(FinancialStatementColumn.column_index, FinancialStatementValue.amount) .join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id) .where(FinancialStatementValue.line_id == line.id) ) ).all() await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line.id)) await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.id == line.id)) await session.flush() return dict(rows) async def drop_value(session: AsyncSession, document: Document, line_code: str, column_index: int) -> None: """Удалить одну ячейку строки в БД теста.""" meta = await _meta(session, document) line_id = ( await session.execute( select(FinancialStatementLine.id).where( FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code ) ) ).scalar_one() column_id = ( await session.execute( select(FinancialStatementColumn.id).where( FinancialStatementColumn.meta_id == meta.id, FinancialStatementColumn.column_index == column_index ) ) ).scalar_one() await session.execute( delete(FinancialStatementValue).where( FinancialStatementValue.line_id == line_id, FinancialStatementValue.column_id == column_id ) ) await session.flush() async def db_line_count(session: AsyncSession, document: Document) -> int: meta = await _meta(session, document) ids = await session.execute(select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta.id)) return len(ids.all()) async def test_get_income_statement_template_lines( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir, MEZH_INCOME) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() template = {line["line_code"] for line in body["lines"] if line["origin"] == "template"} assert template == MEZH_INCOME_TEMPLATE_ONLY async def test_get_completes_lost_line_without_writing( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Строка, потерянная источником (1700), достроена из справочника; статус и проверки — как без достройки.""" document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") lines_in_db = await db_line_count(seeded, document) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() line = next(line for line in body["lines"] if line["line_code"] == "1700") assert line["id"] is None and line["origin"] == "template" and line["line_name"] == "БАЛАНС" assert line["line_code_confidence"] is None and line["line_name_confidence"] is None assert len(line["values"]) == len(body["columns"]) assert all( v["id"] is None and v["amount"] is None and v["is_recognized"] is None and v["is_modified"] is False and v["original_amount"] is None and v["confidence"] is None for v in line["values"] ) assert [v["column_index"] for v in line["values"]] == sorted(c["column_index"] for c in body["columns"]) relations = {(f["relation"], f["column_index"]) for f in body["checks"]["failed"]} # цель 1700 не проверяется; «1600 = 1700» (цель 1600 есть, 1700 = 0) нарушено — как и без достройки assert not any(relation.startswith("1700 =") for relation, _ in relations) assert {index for relation, index in relations if relation == "1600 = 1700"} == {0, 1, 2} assert body["document"]["status"] == document.status.value # статус пересчитывается только правкой assert await db_line_count(seeded, document) == lines_in_db async def test_get_completes_missing_cell( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_value(seeded, document, "1150", 1) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() line = next(line for line in body["lines"] if line["line_code"] == "1150") assert line["origin"] == "source" and len(line["values"]) == len(body["columns"]) missing = next(v for v in line["values"] if v["column_index"] == 1) assert missing["id"] is None and missing["amount"] is None assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"]) async def test_get_without_edition_completes_cells_only( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_value(seeded, document, "1150", 1) document.reporting_date = None await seeded.flush() client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() assert body["edition"] is None assert len(body["lines"]) == MEZH_BALANCE_LINES assert all(line["origin"] == "source" for line in body["lines"]) assert all(len(line["values"]) == len(body["columns"]) for line in body["lines"]) async def test_statement_of_osv_is_conflict( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ") file, job = await make_stored_file( seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) osv = await make_document(seeded, file=file, job=job) client = await auth_client(KA_RF) response = await client.get(f"/documents/{osv.id}/statement") assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT" async def test_statement_hidden_is_404( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client("client_manager@armka.local") # другой филиал assert (await client.get(f"/documents/{document.id}/statement")).status_code == HTTPStatus.NOT_FOUND async def test_cross_form_violation_is_reported_on_both_forms( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Детерминированно (D-5): 1370 = 500 на 30.09.2025 и 300 на 31.12.2024, 2400 = 150 → expected 200, actual 150.""" balance, income = await two_forms(seeded, fixtures_dir) await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) await set_amount(seeded, income, "2400", Decimal("150"), current=True) client = await auth_client(KA_RF) for document in (balance, income): body = (await client.get(f"/documents/{document.id}/statement")).json() own = "1370" if document is balance else "2400" # line_ids — строки той формы, что читается own_ids = [line["id"] for line in body["lines"] if line["line_code"] == own and line["id"] is not None] assert body["checks"]["cross_form"] == { "column_index": -1, "relation": CROSS_FORM_RELATION, "expected": "200.00", "actual": "150.00", "codes": ["1370", "2400"], "line_ids": own_ids, }, document.document_type # мягкая межформенная проверка не блокирует подтверждение: подмена 1370 ломает и жёсткие проверки самого ББ — # после их акцепта форма с нарушением 1370↔2400 подтверждается failures = (await client.post(f"/documents/{balance.id}/confirm")).json()["detail"]["failures"] assert failures and all(item["relation"] != CROSS_FORM_RELATION for item in failures) accepted = await client.post(f"/documents/{balance.id}/statement/checks/accept", json={"comment": "тест"}) assert accepted.status_code == HTTPStatus.OK, accepted.text assert accepted.json()["checks"]["cross_form"] is not None confirmed = await client.post(f"/documents/{balance.id}/confirm") assert confirmed.status_code == HTTPStatus.OK, confirmed.text assert confirmed.json()["status"] == "CONFIRMED" # соотношение выполнено: 500 − 300 = 200 → проверка не нарушена await set_amount(seeded, income, "2400", Decimal("200"), current=True) body = (await client.get(f"/documents/{balance.id}/statement")).json() assert body["checks"]["cross_form"] is None async def test_cross_form_without_pair_is_null( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) assert (await client.get(f"/documents/{document.id}/statement")).json()["checks"]["cross_form"] is None async def test_cross_form_ignores_superseded_pair( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Пара из файла `SUPERSEDED` не участвует (D-5).""" balance, income = await two_forms(seeded, fixtures_dir) await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) await set_amount(seeded, income, "2400", Decimal("150"), current=True) await seeded.execute( update(StoredFile).where(StoredFile.id == income.stored_file_id).values(status=FileStatus.SUPERSEDED) ) await seeded.flush() client = await auth_client(KA_RF) assert (await client.get(f"/documents/{balance.id}/statement")).json()["checks"]["cross_form"] is None async def test_cross_form_pair_without_meta_does_not_shadow( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """T5.1: новее подтверждённый документ того же типа без шапки формы не мешает найти настоящую пару.""" balance, income = await two_forms(seeded, fixtures_dir) await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) await set_amount(seeded, income, "2400", Decimal("150"), current=True) entity = await seeded.get(LegalEntity, balance.legal_entity_id) file, job = await make_stored_file( seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) ghost = await make_document(seeded, file=file, job=job, year=2025, quarter=balance.period_quarter) ghost.document_type = DocumentType.INCOME ghost.osv_account_code = None ghost.status = DocumentStatus.CONFIRMED ghost.created_at = income.created_at + datetime.timedelta(days=1) await seeded.flush() client = await auth_client(KA_RF) body = (await client.get(f"/documents/{balance.id}/statement")).json() assert body["checks"]["cross_form"]["actual"] == "150.00" async def _first_cell(client, document_id) -> tuple[dict, dict]: body = (await client.get(f"/documents/{document_id}/statement")).json() line = next(line for line in body["lines"] if line["values"][0]["amount"] is not None) return line, line["values"][0] async def test_patch_value_and_revert(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) line, cell = await _first_cell(client, document.id) original = cell["amount"] new = str(Decimal(original) + 20) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": new}]}, ) assert response.status_code == HTTPStatus.OK, response.text edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0] assert Decimal(edited["amount"]) == Decimal(new) and Decimal(edited["original_amount"]) == Decimal(original) assert edited["is_modified"] is True and edited["modified_by"] is not None event = ( ( await seeded.execute( select(AuditEvent).where( AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT ) ) ) .scalars() .one() ) key = f"value:{edited['id']}" assert set(event.before_value) == {key} == set(event.after_value) assert Decimal(event.before_value[key]) == Decimal(original) and Decimal(event.after_value[key]) == Decimal(new) user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() assert edited["modified_by"] == str(user_id) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]}, ) reverted = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0] assert reverted["is_modified"] is False and Decimal(reverted["original_amount"]) == Decimal(original) async def test_patch_report_date_recomputes_period( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) url = f"/documents/{document.id}/statement" original = (await client.get(url)).json()["header"]["report_date"]["original_value"] assert original is not None response = await client.patch(url, json={"header": {"report_date": "2025-06-30"}}) body = response.json() assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True assert body["header"]["report_date"]["value"] == "2025-06-30" assert body["header"]["report_date"]["original_value"] == original # исходник не тронут async def test_patch_report_date_not_quarter_end( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 5 (PATCH): дата не на конце квартала — квартал пуст, статус `NEEDS_CHECK`.""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}}) assert response.status_code == HTTPStatus.OK, response.text body = response.json()["document"] assert body["period_quarter"] is None and body["status"] == "NEEDS_CHECK" async def test_patch_inn_validates_and_rechecks( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) bad = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "12345"}}) assert bad.status_code == HTTPStatus.UNPROCESSABLE_ENTITY and bad.json()["detail"]["code"] == "INVALID_INN" other = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "7700000000"}}) assert other.json()["document"]["counterparty_check"] == "MISMATCH" assert other.json()["document"]["status"] == "NEEDS_CHECK" async def test_patch_inn_null_is_invalid_and_keeps_inn( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """M-5: `inn: null` — 422 `INVALID_INN` (ИНН не очищается, MISMATCH не обходится), данные не меняются.""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) url = f"/documents/{document.id}/statement" before = (await client.get(url)).json() response = await client.patch(url, json={"header": {"inn": None}}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "INVALID_INN" assert (await client.get(url)).json() == before assert (await client.patch(url, json={"header": {"units": "тыс. руб."}})).status_code == HTTPStatus.OK # без inn async def test_patch_invalid_inn_changes_nothing( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """После `INVALID_INN` значения из того же тела не применяются, аудита нет.""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) url = f"/documents/{document.id}/statement" line, cell = await _first_cell(client, document.id) before = (await client.get(url)).json() body = { "header": {"inn": "12345"}, "values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}], } response = await client.patch(url, json=body) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "INVALID_INN" assert (await client.get(url)).json() == before events = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all() assert events == [] async def test_patch_rereads_meta_under_lock( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Шапка перечитывается под блокировкой: акцепт, поставленный параллельным запросом после загрузки шапки в сессию, виден правке и сбрасывается ею (иначе акцепт остался бы поверх изменённых данных).""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) url = f"/documents/{document.id}/statement" line, cell = await _first_cell(client, document.id) # карта идентичности слабая: удерживаем шапку, как это делает запрос между `_visible_statement` и блокировкой held = ( await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) ).scalar_one() user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() await seeded.execute( update(FinancialStatementMeta) .where(FinancialStatementMeta.document_id == document.id) .values( checks_accepted_by=user_id, checks_accepted_at=datetime.datetime.now(datetime.UTC), checks_accepted_comment="x", ) .execution_options(synchronize_session=False) # объект в сессии остаётся устаревшим, как у ждущего запроса ) item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)} response = await client.patch(url, json={"values": [item]}) assert response.status_code == HTTPStatus.OK, response.text assert response.json()["checks"]["accepted"] is None await seeded.refresh(held) assert held.checks_accepted_at is None and held.checks_accepted_comment is None event = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().one() assert event.before_value["checks_accepted"]["by"] == str(user_id) and event.after_value["checks_accepted"] is None assert event.before_value["checks_accepted"]["comment"] == "x" # T6.4: сброшенный комментарий акцепта сохранён async def test_patch_duplicate_cell_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 1.""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) line, cell = await _first_cell(client, document.id) item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"} response = await client.patch( f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]} ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "DUPLICATE_CELL" async def test_patch_amount_precision_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 2: больше двух знаков или больше 16 цифр до запятой — 422 схемы, данные не меняются.""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) line, cell = await _first_cell(client, document.id) for amount in ("1.234", "12345678901234567"): response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": amount}]}, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, amount _, after = await _first_cell(client, document.id) assert after["amount"] == cell["amount"] and after["is_modified"] is False async def test_patch_unknown_cell(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_id": str(uuid.uuid4()), "column_id": str(uuid.uuid4()), "amount": "1"}]}, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "UNKNOWN_CELL" async def test_patch_cell_of_other_form_is_unknown( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Ячейка другой формы того же ЮЛ — не ячейка этого документа.""" balance, income = await two_forms(seeded, fixtures_dir) client = await auth_client(KA_RF) line, cell = await _first_cell(client, income.id) response = await client.patch( f"/documents/{balance.id}/statement", json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"}]}, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "UNKNOWN_CELL" async def test_patch_requires_edit_right( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(RKA) response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) assert response.status_code == HTTPStatus.FORBIDDEN async def test_empty_patch_no_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) assert (await client.patch(f"/documents/{document.id}/statement", json={})).status_code == HTTPStatus.OK events = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all() assert events == [] async def _break_balance(client, document_id) -> None: body = (await client.get(f"/documents/{document_id}/statement")).json() line_1600 = next(line for line in body["lines"] if line["line_code"] == "1600") cell = line_1600["values"][0] amount = str(Decimal(cell["amount"] or 0) + 1000) await client.patch( f"/documents/{document_id}/statement", json={"values": [{"line_id": line_1600["id"], "column_id": cell["column_id"], "amount": amount}]}, ) async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) url = f"/documents/{document.id}/statement" nothing = await client.post(f"{url}/checks/accept", json={"comment": "ок"}) assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT" await _break_balance(client, document.id) broken = (await client.get(url)).json() assert any(item["relation"].startswith("1600") for item in broken["checks"]["failed"]) assert broken["document"]["status"] == "NEEDS_CHECK" accepted = await client.post(f"{url}/checks/accept", json={"comment": "округление в форме клиента"}) assert accepted.status_code == HTTPStatus.OK assert accepted.json()["checks"]["accepted"]["comment"] == "округление в форме клиента" assert accepted.json()["document"]["status"] == "RECOGNIZED" # других причин NEEDS_CHECK нет (D-6) await client.patch(url, json={"header": {"units": "руб."}}) after = (await client.get(url)).json() assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK" async def test_accept_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) await _break_balance(client, document.id) response = await client.post( f"/documents/{document.id}/statement/checks/accept", json={"comment": " округление "} ) assert response.status_code == HTTPStatus.OK, response.text user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() events = ( ( await seeded.execute( select(AuditEvent).where( AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT ) ) ) .scalars() .all() ) assert len(events) == EVENTS_AFTER_BREAK_AND_ACCEPT # правка значения в `_break_balance` и акцепт (accept,) = [event for event in events if "checks_accepted" in event.after_value] assert accept.before_value == {"checks_accepted": None} assert set(accept.after_value) == {"checks_accepted"} assert set(accept.after_value["checks_accepted"]) == {"by", "at", "comment"} assert accept.after_value["checks_accepted"]["by"] == str(user_id) assert accept.after_value["checks_accepted"]["comment"] == "округление" # без пробелов по краям async def test_value_patch_resets_accept( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Сбрасывает акцепт и правка одного только значения (не только шапки).""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) url = f"/documents/{document.id}/statement" await _break_balance(client, document.id) assert (await client.post(f"{url}/checks/accept", json={"comment": "ок"})).status_code == HTTPStatus.OK line, cell = await _first_cell(client, document.id) item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)} response = await client.patch(url, json={"values": [item]}) assert response.status_code == HTTPStatus.OK, response.text assert response.json()["checks"]["accepted"] is None async def test_accept_blank_comment_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) response = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": " "}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY async def test_patch_confirmed_readonly( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK url = f"/documents/{document.id}/statement" response = await client.patch(url, json={"header": {"units": "руб."}}) assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_READONLY" response = await client.post(f"{url}/checks/accept", json={"comment": "ок"}) assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_READONLY" async def test_patch_superseded_file(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) await seeded.execute( update(StoredFile).where(StoredFile.id == document.stored_file_id).values(status=FileStatus.SUPERSEDED) ) await seeded.flush() client = await auth_client(KA_RF) response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "FILE_SUPERSEDED" async def test_patch_osv_is_not_statement( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ 2") file, job = await make_stored_file( seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) osv = await make_document(seeded, file=file, job=job) client = await auth_client(KA_RF) response = await client.patch(f"/documents/{osv.id}/statement", json={"header": {"units": "руб."}}) assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT" async def test_confirm_requires_checks( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) await _break_balance(client, document.id) blocked = await client.post(f"/documents/{document.id}/confirm") assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" failures = blocked.json()["detail"]["failures"] assert failures and set(failures[0]) == {"column_index", "relation", "expected", "actual"} assert all(isinstance(failure["expected"], str) and isinstance(failure["actual"], str) for failure in failures) status = await seeded.scalar(select(Document.status).where(Document.id == document.id)) assert status is not DocumentStatus.CONFIRMED await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "округление"}) confirmed = await client.post(f"/documents/{document.id}/confirm") assert confirmed.status_code == HTTPStatus.OK and confirmed.json()["status"] == "CONFIRMED" meta = ( await seeded.execute( select(FinancialStatementMeta) .where(FinancialStatementMeta.document_id == document.id) .execution_options(populate_existing=True) ) ).scalar_one() assert meta.reviewed_by is not None and meta.reviewed_at is not None async def test_confirm_after_non_quarter_date_edit( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 5: дата не конец квартала → квартал NULL, NEEDS_CHECK, PERIOD_REQUIRED; исправление даты снимает.""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) edit = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}}) body = edit.json() assert body["document"]["period_quarter"] is None and body["document"]["status"] == "NEEDS_CHECK" blocked = await client.post(f"/documents/{document.id}/confirm") assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED" await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-09-30"}}) assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK async def test_period_required_precedes_checks_not_passed( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Порядок D-9: при пустом периоде и нарушенной проверке — `PERIOD_REQUIRED`.""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) await _break_balance(client, document.id) await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}}) blocked = await client.post(f"/documents/{document.id}/confirm") assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED" async def test_confirm_form_rereads_accept_under_lock( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Акцепт, поставленный параллельным запросом после загрузки шапки в сессию, виден подтверждению (T6.I1).""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) await _break_balance(client, document.id) held = ( # карта идентичности слабая: удерживаем шапку без акцепта, как это делает ждущий запрос await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) ).scalar_one() assert held.checks_accepted_at is None user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() await seeded.execute( update(FinancialStatementMeta) .where(FinancialStatementMeta.document_id == document.id) .values( checks_accepted_by=user_id, checks_accepted_at=datetime.datetime.now(datetime.UTC), checks_accepted_comment="x", ) .execution_options(synchronize_session=False) # объект в сессии остаётся устаревшим ) confirmed = await client.post(f"/documents/{document.id}/confirm") assert confirmed.status_code == HTTPStatus.OK, confirmed.text async def test_confirmed_form_in_checklist( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) entity = await seeded.get(LegalEntity, document.legal_entity_id) assert entity is not None group = await seeded.get(CompanyGroup, entity.company_group_id) assert group is not None user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() _, version = await make_consolidation( seeded, group=group, year=2025, quarter=PeriodQuarter.Q3, members=[entity], initiator=user ) client = await auth_client(KA_RF) assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK checklist = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() balance_index = next(i for i, rule in enumerate(checklist["rules"]) if rule["document_type"] == "BALANCE") row = next(row for row in checklist["rows"] if row["legal_entity_id"] == str(entity.id)) assert row["cells"][balance_index]["status"] == "CONFIRMED" meta = ( await seeded.execute( select(FinancialStatementMeta) .where(FinancialStatementMeta.document_id == document.id) .execution_options(populate_existing=True) ) ).scalar_one() assert meta.reviewed_by == user.id @pytest.mark.parametrize("units", [" ", "", "я" * 51]) async def test_patch_units_invalid_is_422( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, units: str ) -> None: """T6.2: пробельная, пустая и длиннее 50 символов единицы измерения отклоняются; `null` — «не менять».""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) url = f"/documents/{document.id}/statement" response = await client.patch(url, json={"header": {"units": units}}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text unchanged = await client.patch(url, json={"header": {"units": None}}) assert unchanged.status_code == HTTPStatus.OK async def test_patch_units_are_stripped( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": " тыс. руб. "}}) assert response.status_code == HTTPStatus.OK, response.text assert response.json()["header"]["units"]["value"] == "тыс. руб." async def undated_form(session: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> Document: """Форма без дат (снимок `st03_no_codes` без `report_date`; колонок в нём нет): период не определяется.""" source = fixtures_dir / GOLDEN_STATEMENTS / "synthetic" / "st03_no_codes.json" data = json.loads(source.read_text("utf-8")) for item in data["documents"]: item["header"]["report_date"] = item["header"]["report_date_text"] = None path = tmp_path / "st03_undated.json" path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") entity = await rostov_entity(session, path, name="ООО Без дат") (document,) = await map_golden_snapshot(session, entity=entity, path=path, uploaded_by=None) return document async def test_edition_66n_for_date_up_to_2024_12_31( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """D-14: редакция 66н — по дате формы ≤ 31.12.2024 (правка даты отчёта меняет редакцию и в ответе, и в чтении).""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) url = f"/documents/{document.id}/statement" assert (await client.get(url)).json()["edition"] == "fsbu4" edited = await client.patch(url, json={"header": {"report_date": "2024-12-31"}}) assert edited.status_code == HTTPStatus.OK, edited.text assert edited.json()["edition"] == "66n" and edited.json()["document"]["period_quarter"] == "Q4" assert (await client.get(url)).json()["edition"] == "66n" await client.patch(url, json={"header": {"report_date": "2025-03-31"}}) assert (await client.get(url)).json()["edition"] == "fsbu4" # граница: первый день после 31.12.2024 уже ФСБУ 4 async def test_edition_null_without_dates( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path ) -> None: document = await undated_form(seeded, fixtures_dir, tmp_path) assert document.reporting_date is None and document.period_year is None client = await auth_client(KA_RF) response = await client.get(f"/documents/{document.id}/statement") assert response.status_code == HTTPStatus.OK, response.text body = response.json() assert body["edition"] is None and body["document"]["period_quarter"] is None assert body["checks"]["cross_form"] is None async def test_cross_form_null_when_quarter_not_defined( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Документ без квартала: межформенная проверка не выполняется (без пары по периоду) и не ломает чтение.""" balance, income = await two_forms(seeded, fixtures_dir) await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) await set_amount(seeded, income, "2400", Decimal("150"), current=True) client = await auth_client(KA_RF) url = f"/documents/{balance.id}/statement" assert (await client.get(url)).json()["checks"]["cross_form"] is not None # контроль: пара и нарушение есть edited = await client.patch(url, json={"header": {"report_date": "2025-10-31"}}) assert edited.status_code == HTTPStatus.OK, edited.text assert edited.json()["document"]["period_quarter"] is None and edited.json()["checks"]["cross_form"] is None response = await client.get(url) assert response.status_code == HTTPStatus.OK and response.json()["checks"]["cross_form"] is None async def test_cross_form_prefers_confirmed_pair_over_newer( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """D-5: подтверждённая форма-пара выигрывает у более новой неподтверждённой; без подтверждения — самая новая.""" balance, old_income = await two_forms(seeded, fixtures_dir) entity = await seeded.get(LegalEntity, balance.legal_entity_id) assert entity is not None newer_income = next( d for d in await map_golden_snapshot(seeded, entity=entity, path=fixtures_dir / MEZH_INCOME, uploaded_by=None) if d.document_type == DocumentType.INCOME ) # повторная загрузка заменяет прежний файл; возвращаем его в обработанные — оба претендуют на роль пары await seeded.execute( update(StoredFile).where(StoredFile.id == old_income.stored_file_id).values(status=FileStatus.PROCESSED) ) assert newer_income.created_at > old_income.created_at await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) await set_amount(seeded, old_income, "2400", Decimal("150"), current=True) # нарушает 500 − 300 = 200 await set_amount(seeded, newer_income, "2400", Decimal("200"), current=True) # выполняет client = await auth_client(KA_RF) url = f"/documents/{balance.id}/statement" assert (await client.get(url)).json()["checks"]["cross_form"] is None # без подтверждения — более новая пара await seeded.execute(update(Document).where(Document.id == old_income.id).values(status=DocumentStatus.CONFIRMED)) cross = (await client.get(url)).json()["checks"]["cross_form"] assert cross is not None and cross["actual"] == "150.00" # подтверждённая пара, хотя она старее async def test_statement_requires_document_view( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Роль без права `DOCUMENT_VIEW` (ADMIN) получает 403 на чтение формы, правку и акцепт.""" document = await form(seeded, fixtures_dir) client = await auth_client("admin@armka.local") url = f"/documents/{document.id}/statement" assert (await client.get(url)).status_code == HTTPStatus.FORBIDDEN assert (await client.patch(url, json={"header": {"units": "руб."}})).status_code == HTTPStatus.FORBIDDEN assert (await client.post(f"{url}/checks/accept", json={"comment": "ок"})).status_code == HTTPStatus.FORBIDDEN async def test_confirm_form_without_meta_has_no_conditions( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Решение №16: документ типа BALANCE без шапки формы (старые данные) подтверждается без условий D-9.""" entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО Без шапки") file, job = await make_stored_file( seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) document = await make_document(seeded, file=file, job=job, status=DocumentStatus.NEEDS_CHECK) document.document_type, document.osv_account_code = DocumentType.BALANCE, None await seeded.flush() client = await auth_client(KA_RF) response = await client.post(f"/documents/{document.id}/confirm") assert response.status_code == HTTPStatus.OK, response.text assert response.json()["status"] == "CONFIRMED" async def test_confirm_undated_form_requires_period( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path ) -> None: """Форма без дат (`period_year` NULL): подтверждение — 409 `PERIOD_REQUIRED`; после правки даты — проходит.""" document = await undated_form(seeded, fixtures_dir, tmp_path) assert document.period_year is None client = await auth_client(KA_RF) blocked = await client.post(f"/documents/{document.id}/confirm") assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED" edit = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2024-12-31"}}) assert edit.status_code == HTTPStatus.OK, edit.text assert edit.json()["document"]["period_year"] == 2024 # noqa: PLR2004 - год даты из правки assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK async def test_status_with_lines_but_no_columns_is_stable_after_edit( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path ) -> None: """M-2: «есть строки» — наличие строк формы (как при маппинге), а не наличие сумм: форма со строками без колонок остаётся `RECOGNIZED` и после правки шапки.""" data = json.loads((fixtures_dir / MEZH_BALANCE).read_text("utf-8")) data["documents"][0]["columns"] = [] for line in data["documents"][0]["lines"]: line["values"] = [] path = tmp_path / "no_columns.json" path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") entity = await rostov_entity(seeded, path) (document,) = await map_golden_snapshot(seeded, entity=entity, path=path, uploaded_by=None) assert document.status is DocumentStatus.RECOGNIZED client = await auth_client(KA_RF) response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) assert response.status_code == HTTPStatus.OK, response.text assert response.json()["lines"] and response.json()["document"]["status"] == "RECOGNIZED" async def _column(client, document_id, index: int) -> str: body = (await client.get(f"/documents/{document_id}/statement")).json() return next(c["id"] for c in body["columns"] if c["column_index"] == index) async def _db_line(session: AsyncSession, document: Document, code: str) -> FinancialStatementLine | None: meta = await _meta(session, document) return ( await session.execute( select(FinancialStatementLine) .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == code) .execution_options(populate_existing=True) ) ).scalar_one_or_none() async def _edit_events(session: AsyncSession, document: Document) -> list[AuditEvent]: return list( ( await session.execute( select(AuditEvent).where( AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT ) ) ).scalars() ) async def test_patch_creates_template_line( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": "1700", "column_id": col0, "amount": "2371333"}]}, ) assert response.status_code == HTTPStatus.OK, response.text line = next(item for item in response.json()["lines"] if item["line_code"] == "1700") assert line["id"] is not None and line["origin"] == "template" and line["line_name"] == "БАЛАНС" cell = next(v for v in line["values"] if v["column_id"] == col0) assert Decimal(cell["amount"]) == Decimal("2371333") and cell["original_amount"] is None assert cell["is_modified"] is True and cell["is_recognized"] is None and cell["modified_by"] is not None other = [v for v in line["values"] if v["column_id"] != col0] assert other and all(v["id"] is None for v in other) # остальные колонки не созданы db_line = await _db_line(seeded, document, "1700") assert db_line is not None and db_line.line_code_original_value is None and db_line.line_name_original_value is None (event,) = await _edit_events(seeded, document) key = f"value:{cell['id']}" assert event.before_value == {key: None} and Decimal(event.after_value[key]) == Decimal("2371333") async def test_patch_two_cells_of_new_line_create_one_line( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 2.""" document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") before = await db_line_count(seeded, document) client = await auth_client(KA_RF) col0, col1 = await _column(client, document.id, 0), await _column(client, document.id, 1) response = await client.patch( f"/documents/{document.id}/statement", json={ "values": [ {"line_code": "1700", "column_id": col0, "amount": "1"}, {"line_code": "1700", "column_id": col1, "amount": "2"}, ] }, ) assert response.status_code == HTTPStatus.OK, response.text assert await db_line_count(seeded, document) == before + 1 line = next(item for item in response.json()["lines"] if item["line_code"] == "1700") assert sum(1 for v in line["values"] if v["id"] is not None) == 2 # noqa: PLR2004 async def test_patch_repeat_line_code_after_creation_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 3: второй «Сохранить» со старым черновиком по коду — 422, строка не задвоена.""" document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) item = {"line_code": "1700", "column_id": col0, "amount": "1"} assert ( await client.patch(f"/documents/{document.id}/statement", json={"values": [item]}) ).status_code == HTTPStatus.OK count = await db_line_count(seeded, document) response = await client.patch(f"/documents/{document.id}/statement", json={"values": [{**item, "amount": "5"}]}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "UNKNOWN_CELL" assert "line_id" in response.json()["detail"]["message"] assert await db_line_count(seeded, document) == count async def test_patch_line_code_existing_or_unknown_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) count = await db_line_count(seeded, document) for code in ("1150", "1106"): # 1150 есть в форме; 1106 нет в справочнике fsbu4 response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": code, "column_id": col0, "amount": "1"}]}, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, code assert response.json()["detail"]["code"] == "UNKNOWN_CELL" assert await db_line_count(seeded, document) == count assert await _edit_events(seeded, document) == [] async def test_patch_line_code_without_edition_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") document.reporting_date = None await seeded.flush() client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": "1700", "column_id": col0, "amount": "1"}]}, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "UNKNOWN_CELL" async def test_patch_null_into_missing_cell_is_noop( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") count = await db_line_count(seeded, document) client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": "1700", "column_id": col0, "amount": None}]}, ) assert response.status_code == HTTPStatus.OK, response.text assert await db_line_count(seeded, document) == count assert await _edit_events(seeded, document) == [] async def test_patch_creates_missing_cell_of_existing_line( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_value(seeded, document, "1150", 1) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() line = next(item for item in body["lines"] if item["line_code"] == "1150") col1 = next(c["id"] for c in body["columns"] if c["column_index"] == 1) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_id": line["id"], "column_id": col1, "amount": "77"}]}, ) assert response.status_code == HTTPStatus.OK, response.text cell = next( v for v in next(i for i in response.json()["lines"] if i["id"] == line["id"])["values"] if v["column_id"] == col1 ) assert cell["id"] is not None and Decimal(cell["amount"]) == Decimal("77") and cell["original_amount"] is None assert cell["is_modified"] is True async def test_patch_bad_column_rolls_back_new_line( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 1: одна ячейка корректна (новая строка), другая — чужая колонка: 422, строка не создана.""" document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") count = await db_line_count(seeded, document) client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) response = await client.patch( f"/documents/{document.id}/statement", json={ "values": [ {"line_code": "1700", "column_id": col0, "amount": "1"}, {"line_code": "1700", "column_id": str(uuid.uuid4()), "amount": "2"}, ] }, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "UNKNOWN_CELL" assert await db_line_count(seeded, document) == count async def test_patch_cell_address_must_be_exactly_one( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) line, cell = await _first_cell(client, document.id) for item in ( {"line_id": line["id"], "line_code": "1700", "column_id": cell["column_id"], "amount": "1"}, {"column_id": cell["column_id"], "amount": "1"}, ): response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert isinstance(response.json()["detail"], list) # 422 валидации тела — массив async def test_patch_duplicate_line_code_rejected( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) item = {"line_code": "1700", "column_id": col0, "amount": "1"} response = await client.patch( f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]} ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "DUPLICATE_CELL" async def test_template_line_checks_follow_filled_amounts( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 4 (spec statement-checks): заполненная в одной колонке строка проверяется по всем колонкам, очищенная — снова не участвует.""" document = await form(seeded, fixtures_dir) sums = await drop_line(seeded, document, "1700") assert sums[1] not in (None, Decimal(0)) and sums[2] not in (None, Decimal(0)) # колонки 1, 2 непустые client = await auth_client(KA_RF) col0 = await _column(client, document.id, 0) response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": "1700", "column_id": col0, "amount": str(sums[0])}]}, ) failed = response.json()["checks"]["failed"] relation = "1700 = 1300 + 1400 + 1500" assert sorted(f["column_index"] for f in failed if f["relation"] == relation) == [1, 2] assert all(f["column_index"] != 0 for f in failed) response = await client.patch( f"/documents/{document.id}/statement", json={ "values": [ { "line_id": next(i["id"] for i in response.json()["lines"] if i["line_code"] == "1700"), "column_id": col0, "amount": None, } ] }, ) assert response.status_code == HTTPStatus.OK, response.text after_clear = response.json()["checks"]["failed"] assert not any(f["relation"].startswith("1700 =") for f in after_clear) # очищенная 1700 снова не цель assert {f["column_index"] for f in after_clear if f["relation"] == "1600 = 1700"} == {0, 1, 2} # --- агрегат формы по коду (v5.19, A7) --- async def insert_line( # noqa: PLR0913 - строка формы теста: код, статус, индекс, сумма session: AsyncSession, document: Document, *, code: str | None, row_index: int | None, status: str = "ok", amount: Decimal | None = None, current: bool = True, code_original: str | None = None, code_text: str | None = None, ) -> FinancialStatementLine: """Строка формы напрямую в БД; ячейка с `amount` — в колонке отчётной даты (`current`) или нулевой.""" meta = await _meta(session, document) columns = ( ( await session.execute( select(FinancialStatementColumn) .where(FinancialStatementColumn.meta_id == meta.id) .order_by(FinancialStatementColumn.column_index) ) ) .scalars() .all() ) column = next(c for c in columns if c.is_current) if current else columns[0] line = FinancialStatementLine( id=uuid.uuid4(), meta_id=meta.id, line_code=code, line_code_original_value=code_original, line_status=status, line_code_text=code_text, line_name="тест", row_index=row_index, ) session.add(line) await session.flush() if amount is not None: session.add( FinancialStatementValue( id=uuid.uuid4(), line_id=line.id, column_id=column.id, amount=amount, is_modified=False ) ) await session.flush() return line async def test_cross_form_uses_aggregate( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Review Focus 2: две строки 1370 в колонке отчётной даты — 500 + 300 = 800.""" balance, income = await two_forms(seeded, fixtures_dir) await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("0"), period_date=datetime.date(2024, 12, 31)) await insert_line(seeded, balance, code="1370", row_index=99, amount=Decimal("300"), code_original="1370") client = await auth_client(KA_RF) await set_amount(seeded, income, "2400", Decimal("800"), current=True) assert (await client.get(f"/documents/{income.id}/statement")).json()["checks"]["cross_form"] is None await set_amount(seeded, income, "2400", Decimal("500"), current=True) cross = (await client.get(f"/documents/{income.id}/statement")).json()["checks"]["cross_form"] assert cross is not None and cross["expected"] == "800.00" and cross["actual"] == "500.00" async def test_parent_code_with_repeated_parent( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE) await insert_line(seeded, document, code="1150", row_index=98, amount=Decimal("1"), code_original="1150") await insert_line(seeded, document, code="11501", row_index=99, amount=Decimal("1"), code_original="11501") client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() sub = [line for line in body["lines"] if line["line_code"] == "11501"] assert [line["parent_code"] for line in sub] == ["1150", "1150"][: len(sub)] and sub assert len([line for line in body["lines"] if line["line_code"] == "1150"]) == 2 # noqa: PLR2004 async def test_template_not_completed_for_code_with_ok_line( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await insert_line(seeded, document, code="1215", row_index=90, amount=Decimal("5"), code_original="1215") client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() lines = [line for line in body["lines"] if line["line_code"] == "1215"] assert [(line["origin"], line["id"] is None) for line in lines] == [("source", False)] async def test_template_completed_for_code_only_unreadable( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) bad = await insert_line( seeded, document, code=None, row_index=91, status="unreadable", amount=Decimal("5"), code_text="ИЧ,215" ) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() template = [line for line in body["lines"] if line["line_code"] == "1215"] assert [(line["origin"], line["id"]) for line in template] == [("template", None)] assert any(line["id"] == str(bad.id) for line in body["lines"]) async def test_patch_by_line_code_with_ok_line_is_unknown_cell( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await insert_line(seeded, document, code="1215", row_index=90, amount=Decimal("5"), code_original="1215") client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() column_id = body["columns"][0]["id"] response = await client.patch( f"/documents/{document.id}/statement", json={"values": [{"line_code": "1215", "column_id": column_id, "amount": "1"}]}, ) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY assert response.json()["detail"]["code"] == "UNKNOWN_CELL" async def test_origin_by_row_index(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) line = await insert_line(seeded, document, code="1250", row_index=7, amount=Decimal("5"), code_original=None) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() assert next(item for item in body["lines"] if item["id"] == str(line.id))["origin"] == "source" # --- v5.19: поля ответа, порядок строк, issues, справочник строк ----------------------------------------------------- async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: return ( await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) ).scalar_one() async def _statement(client: Any, document: Document) -> dict[str, Any]: response = await client.get(f"/documents/{document.id}/statement") assert response.status_code == HTTPStatus.OK, response.text return response.json() async def test_get_statement_v5_19_fields_on_1x_form( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) body = await _statement(await auth_client(KA_RF), document) source = [line for line in body["lines"] if line["origin"] == "source"] assert all(line["line_status"] == "ok" and line["source_ref"] is None for line in source) assert sorted(line["row_index"] for line in source) == list(range(len(source))) assert all(not line["needs_review"] and not line["is_confirmed"] for line in body["lines"]) assert all( line["row_index"] is None and line["line_status"] == "ok" for line in body["lines"] if line["id"] is None ) cells = [value for line in source for value in line["values"] if value["id"] is not None] assert all(value["text"] == value["string_value"] for value in cells) assert body["document"]["form_variant"] is None assert body["header"]["needs_review_fields"] == [] and body["header"]["organization_name_modified"] is False assert {column["period_kind"] for column in body["columns"]} <= {"absolute", "none"} assert body["issues"] == [] async def test_reference_name_first_name( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) body = await _statement(await auth_client(KA_RF), document) assert body["edition"] == "fsbu4" line = next(line for line in body["lines"] if line["line_code"] == "1520") assert line["reference_name"] == "Кредиторская задолженность" async def test_issues_in_statement_and_document( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) seeded.add( DocumentIssue( document_id=document.id, kind=DocumentIssueKind.warning, code="COLUMN_SKIPPED", severity=IssueSeverity.info, message="колонка пропущена", column_id=None, column_index=5, source=IssueSource.service, created_at=datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC), ) ) await seeded.flush() client = await auth_client(KA_RF) expected = { "kind": "warning", "code": "COLUMN_SKIPPED", "severity": "info", "message": "колонка пропущена", "line_ids": [], "column_id": None, "column_index": 5, "field": None, "source": "service", "relation": None, "codes": [], "expected": None, "actual": None, } [issue] = (await _statement(client, document))["issues"] assert {key: value for key, value in issue.items() if key != "id"} == expected card = (await client.get(f"/documents/{document.id}")).json() assert [{key: value for key, value in item.items() if key != "id"} for item in card["issues"]] == [expected] assert card["issue_counts"] == {"error": 0, "info": 1} async def test_line_code_confidence_and_okei( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) meta = await _meta(seeded, document) meta.okei = "384" line = ( await seeded.execute( select(FinancialStatementLine).where( FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "1210" ) ) ).scalar_one() line.line_code_confidence = Decimal("67.50") await seeded.flush() body = await _statement(await auth_client(KA_RF), document) assert body["header"]["okei"] == "384" assert next(item for item in body["lines"] if item["line_code"] == "1210")["line_code_confidence"] == "67.50" async def test_header_source_confidence_is_decimal_string( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) meta = await _meta(seeded, document) meta.header_source = { "inn": {"text": "x", "confidence": 61.538464}, "units": {"text": "тыс. руб.", "confidence": 100}, "okpo": {"text": None, "confidence": None}, } meta.needs_review_fields = ["inn"] await seeded.flush() header = (await _statement(await auth_client(KA_RF), document))["header"] assert header["source"]["inn"] == {"text": "x", "confidence": "61.54"} assert header["source"]["units"]["confidence"] == "100.00" assert header["source"]["okpo"] == {"text": None, "confidence": None} assert header["needs_review_fields"] == ["inn"] async def test_edition_lines(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) body = await _statement(client, document) codes = [item["code"] for item in body["edition_lines"]] assert len(codes) == len(set(codes)) and "1120" not in codes and "1215" in codes assert {item["code"]: item["name"] for item in body["edition_lines"]}["1520"] == "Кредиторская задолженность" document.reporting_date = None # форма без даты — редакции нет await seeded.flush() codes = [item["code"] for item in (await _statement(client, document))["edition_lines"]] assert len(codes) == len(set(codes)) and "1120" in codes and "1215" in codes async def test_failed_check_has_codes_and_line_ids( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) await set_amount(seeded, document, "1600", Decimal("1"), current=True) body = await _statement(await auth_client(KA_RF), document) failed = body["checks"]["failed"] assert failed, body["checks"] by_code = {line["line_code"]: line["id"] for line in body["lines"] if line["line_status"] == "ok"} for item in failed: assert "1600" in item["codes"] or item["codes"] assert item["line_ids"] and {by_code[code] for code in item["codes"] if code in by_code} >= set( item["line_ids"] ) async def test_lines_order_follows_layout( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Строки `ok` — по коду; нечитаемая строка источника идёт сразу за предшествующей строкой `ok`.""" document = await form(seeded, fixtures_dir) meta = await _meta(seeded, document) anchor = ( await seeded.execute( select(FinancialStatementLine).where( FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "1210" ) ) ).scalar_one() seeded.add( FinancialStatementLine( meta_id=meta.id, line_code=None, row_index=(anchor.row_index or 0) * 1000 + 1, line_status=StatementLineStatus.unreadable, line_code_text="12?0", ) ) await seeded.flush() await seeded.execute( update(FinancialStatementLine) .where( FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_status == StatementLineStatus.unreadable, ) .values(row_index=(anchor.row_index or 0) * 1000 + 1) ) lines = (await _statement(await auth_client(KA_RF), document))["lines"] ok_codes = [line["line_code"] for line in lines if line["line_status"] == "ok"] assert ok_codes == sorted(ok_codes) unreadable = next(i for i, line in enumerate(lines) if line["line_status"] == "unreadable") assert unreadable > 0 assert lines[unreadable]["line_code_text"] == "12?0"