"""`PATCH …/statement`, часть `lines` (spec arm-core «PATCH формы — строки…»; P3-10, P3-16, P3-24; Review Focus 3, 4). Код 1215 — «только шаблонный» в ББ Межениновской (в файле такой строки нет), поэтому он годится для проверки шаблонных строк и повторов; в брифе вместо него 1250. """ import uuid 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.models.enums import DocumentIssueKind, IssueSeverity, IssueSource from src.models.files import Document from src.models.statements import DocumentIssue, FinancialStatementLine, FinancialStatementValue from src.repositories import statements as repo from tests.conftest import AuthClientFactory from tests.test_api_statements import ( KA_RF, NOW, _accept, _edit_events, _meta, form, insert_line, two_forms, ) pytestmark = [pytest.mark.integration, pytest.mark.fixtures] ROW_INDEX = 2 CODE = "1215" FULL = Decimal(100) async def _db(session: AsyncSession, line_id: uuid.UUID) -> FinancialStatementLine | None: return ( await session.execute( select(FinancialStatementLine) .where(FinancialStatementLine.id == line_id) .execution_options(populate_existing=True) ) ).scalar_one_or_none() async def _patch(client: Any, document: Document, body: dict[str, Any]) -> Any: return await client.patch(f"/documents/{document.id}/statement", json=body) async def _code_total(session: AsyncSession, document: Document, code: str) -> Decimal | None: meta = await _meta(session, document) columns, aggregate = await repo.form_aggregate(session, meta.id) current = next(c for c in columns if c.is_current) return aggregate[code].amounts[current.id] if code in aggregate else None async def test_assign_code_to_garbage_line( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) garbage = await insert_line( seeded, document, code=None, row_index=2, status="unreadable", amount=Decimal(5), code_text="ИЧ,215" ) client = await auth_client(KA_RF) response = await _patch(client, document, {"lines": [{"line_id": str(garbage.id), "line_code": CODE}]}) assert response.status_code == HTTPStatus.OK, response.text line = next(item for item in response.json()["lines"] if item["id"] == str(garbage.id)) assert line["line_status"] == "ok" and line["line_code"] == CODE and line["line_code_confidence"] == "100.00" assert line["line_code_modified"] is True and line["row_index"] == ROW_INDEX and line["origin"] == "source" assert line["needs_review"] is False stored = await _db(seeded, garbage.id) assert stored is not None and stored.line_code_original_value is None assert await _code_total(seeded, document, CODE) == Decimal(5) # сумма вошла в агрегат async def test_same_code_two_source_lines( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) first = await insert_line(seeded, document, code=None, row_index=7, status="unreadable") second = await insert_line(seeded, document, code=None, row_index=2, status="missing", amount=Decimal(3)) client = await auth_client(KA_RF) for line in (first, second): response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_code": CODE}]}) assert response.status_code == HTTPStatus.OK, response.text stored = [await _db(seeded, line.id) for line in (first, second)] assert [(s.line_status.value, s.line_code) for s in stored if s] == [("ok", CODE), ("ok", CODE)] assert await _code_total(seeded, document, CODE) == Decimal(3) async def test_code_taken_by_template_with_amount( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) template = await insert_line(seeded, document, code=CODE, row_index=None, amount=Decimal(9)) unreadable = await insert_line(seeded, document, code=None, row_index=5, status="unreadable", amount=Decimal(1)) other = await insert_line(seeded, document, code="1100", row_index=6, amount=Decimal(2)) await seeded.flush() meta = await _meta(seeded, document) units, review = meta.units, other.line_status client = await auth_client(KA_RF) columns = (await client.get(f"/documents/{document.id}/statement")).json()["columns"] response = await _patch( client, document, { "lines": [ {"line_id": str(unreadable.id), "line_code": CODE}, {"line_id": str(other.id), "line_status": "detail"}, ], "values": [{"line_id": str(template.id), "column_id": columns[0]["id"], "amount": "77"}], "header": {"units": "руб."}, }, ) assert response.status_code == HTTPStatus.CONFLICT, response.text detail = response.json()["detail"] assert detail["code"] == "LINE_CODE_TAKEN" and detail["line_id"] == str(unreadable.id) assert detail["line_code"] == CODE stored_unreadable, stored_other = await _db(seeded, unreadable.id), await _db(seeded, other.id) assert stored_unreadable is not None and stored_unreadable.line_status.value == "unreadable" assert stored_other is not None and stored_other.line_status == review assert (await _meta(seeded, document)).units == units assert await _code_total(seeded, document, CODE) == Decimal(9) assert await _edit_events(seeded, document) == [] async def test_code_taken_by_empty_template( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) template = await insert_line(seeded, document, code=CODE, row_index=None) other = await insert_line(seeded, document, code="1100", row_index=6, amount=Decimal(2)) meta = await _meta(seeded, document) column = (await repo.form_aggregate(seeded, meta.id))[0][0] seeded.add(FinancialStatementValue(line_id=template.id, column_id=column.id, amount=None)) seeded.add( DocumentIssue( document_id=document.id, kind=DocumentIssueKind.warning, code="X", severity=IssueSeverity.info, message="x", line_ids=[str(template.id), str(other.id)], source=IssueSource.core, created_at=NOW, ) ) source = await insert_line(seeded, document, code=None, row_index=3, status="unreadable", amount=Decimal(4)) client = await auth_client(KA_RF) response = await _patch(client, document, {"lines": [{"line_id": str(source.id), "line_code": CODE}]}) assert response.status_code == HTTPStatus.OK, response.text assert await _db(seeded, template.id) is None assert ( await seeded.execute(select(FinancialStatementValue).where(FinancialStatementValue.line_id == template.id)) ).first() is None issue = ( await seeded.execute( select(DocumentIssue).where(DocumentIssue.document_id == document.id, DocumentIssue.code == "X") ) ).scalar_one() await seeded.refresh(issue) assert issue.line_ids == [str(other.id)] stored = await _db(seeded, source.id) assert stored is not None and stored.line_status.value == "ok" and stored.line_code == CODE assert [item["line_code"] for item in response.json()["lines"] if item["line_code"] == CODE] == [CODE] # T13.2: удаление пустой шаблонной строки — в аудите `line: -> null` (event,) = await _edit_events(seeded, document) assert event.before_value[f"line:{template.id}"]["line_code"] == CODE assert event.after_value[f"line:{template.id}"] is None async def test_mark_detail(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) missing = await insert_line(seeded, document, code=None, row_index=8, status="missing", amount=Decimal(6)) client = await auth_client(KA_RF) unresolved = await client.post(f"/documents/{document.id}/confirm", json={}) assert unresolved.status_code == HTTPStatus.CONFLICT and unresolved.json()["detail"]["code"] == "LINES_UNRESOLVED" response = await _patch(client, document, {"lines": [{"line_id": str(missing.id), "line_status": "detail"}]}) assert response.status_code == HTTPStatus.OK, response.text stored = await _db(seeded, missing.id) assert stored is not None and stored.line_status.value == "detail" and stored.line_code is None again = await client.post(f"/documents/{document.id}/confirm", json={}) assert again.json().get("detail", {}).get("code") != "LINES_UNRESOLVED" async def test_status_ok_without_code(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) line = await insert_line(seeded, document, code=None, row_index=8, status="missing") client = await auth_client(KA_RF) response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_status": "ok"}]}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY detail = response.json()["detail"] assert detail["code"] == "LINE_CODE_REQUIRED" and detail["line_id"] == str(line.id) and detail["line_code"] is None async def test_invalid_line_patch(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) line = await insert_line(seeded, document, code=None, row_index=8, status="missing") client = await auth_client(KA_RF) lid = str(line.id) cases = [ ([{"line_id": lid}], None), ([{"line_id": lid, "line_code": "1215", "confirmed": True}], "1215"), ([{"line_id": lid, "line_code": "1215"}, {"line_id": lid, "line_status": "detail"}], None), ] for items, code in cases: response = await _patch(client, document, {"lines": items}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text detail = response.json()["detail"] assert detail["code"] == "INVALID_LINE_PATCH" and detail["line_id"] == lid and detail["line_code"] == code stored = await _db(seeded, line.id) assert stored is not None and stored.line_status.value == "missing" async def test_unknown_line(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document, foreign = await two_forms(seeded, fixtures_dir) other = await insert_line(seeded, foreign, code=None, row_index=8, status="missing") client = await auth_client(KA_RF) response = await _patch(client, document, {"lines": [{"line_id": str(other.id), "line_code": CODE}]}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text detail = response.json()["detail"] assert detail["code"] == "UNKNOWN_LINE" and detail["line_id"] == str(other.id) and detail["line_code"] == CODE async def test_invalid_line_code(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) line = await insert_line(seeded, document, code=None, row_index=8, status="missing") client = await auth_client(KA_RF) for bad in ("123", "123456", "12a4"): response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_code": bad}]}) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text detail = response.json()["detail"] assert set(detail) == {"code", "message", "line_id", "line_code"} assert detail["code"] == "INVALID_LINE_CODE" and detail["line_code"] == bad response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "line_code": "11101"}]}) assert response.status_code == HTTPStatus.OK, response.text async def test_confirm_code(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) line = await insert_line(seeded, document, code="1120", row_index=9, code_original="1120") line.line_code_confidence, line.needs_review = Decimal(50), True await _accept(seeded, document) client = await auth_client(KA_RF) response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "confirmed": True}]}) assert response.status_code == HTTPStatus.OK, response.text stored = await _db(seeded, line.id) assert stored is not None and stored.is_confirmed is True and stored.needs_review is False assert stored.line_code_confidence == Decimal(50) and not stored.line_code_modified assert response.json()["checks"]["accepted"] is not None response = await _patch(client, document, {"lines": [{"line_id": str(line.id), "confirmed": False}]}) stored = await _db(seeded, line.id) assert stored is not None and stored.is_confirmed is False and stored.needs_review is True async def test_parent_moves_up(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) meta = await _meta(seeded, document) for existing in ( await seeded.execute(select(FinancialStatementLine).where(FinancialStatementLine.meta_id == meta.id)) ).scalars(): existing.row_index = None if existing.row_index is None else existing.row_index + 100 top = await insert_line(seeded, document, code="1140", row_index=3) parent = await insert_line(seeded, document, code="1150", row_index=4) details = [await insert_line(seeded, document, code=None, row_index=index, status="detail") for index in (5, 6)] for item in details: item.parent_row_index = 4 first = await insert_line(seeded, document, code="1130", row_index=1) client = await auth_client(KA_RF) response = await _patch(client, document, {"lines": [{"line_id": str(parent.id), "line_status": "detail"}]}) assert response.status_code == HTTPStatus.OK, response.text stored = [await _db(seeded, line.id) for line in (parent, *details)] assert [s.parent_row_index for s in stored if s] == [3, 3, 3] # без строк `ok` выше — None response = await _patch(client, document, {"lines": [{"line_id": str(top.id), "line_status": "detail"}]}) assert response.status_code == HTTPStatus.OK stored = [await _db(seeded, line.id) for line in (top, parent, *details)] assert [s.parent_row_index for s in stored if s] == [1, 1, 1, 1] response = await _patch(client, document, {"lines": [{"line_id": str(first.id), "line_status": "detail"}]}) assert response.status_code == HTTPStatus.OK, response.text stored = [await _db(seeded, line.id) for line in (parent, *details)] assert all(s.parent_row_index is None for s in stored if s) async def test_line_edit_audit_and_accept( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) garbage = await insert_line(seeded, document, code=None, row_index=2, status="unreadable", amount=Decimal(5)) await _accept(seeded, document) client = await auth_client(KA_RF) response = await _patch(client, document, {"lines": [{"line_id": str(garbage.id), "line_code": CODE}]}) assert response.status_code == HTTPStatus.OK, response.text assert response.json()["checks"]["accepted"] is None # назначение кода — правка данных events = await _edit_events(seeded, document) assert len(events) == 1 key = f"line:{garbage.id}" assert events[0].before_value[key]["line_status"] == "unreadable" assert events[0].after_value[key]["line_status"] == "ok" and events[0].after_value[key]["line_code"] == CODE assert set(events[0].after_value[key]) == {"line_code", "line_status", "parent_row_index", "is_confirmed"} async def test_code_change_resets_confirmation_and_parent( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """M-4: смена кода и отметка расшифровкой снимают подтверждение; detail -> ok обнуляет родителя.""" document = await form(seeded, fixtures_dir) confirmed = await insert_line(seeded, document, code="1120", row_index=20, code_original="1120") confirmed.is_confirmed = True to_detail = await insert_line(seeded, document, code="1130", row_index=21, code_original="1130") to_detail.is_confirmed = True detail = await insert_line(seeded, document, code=None, row_index=22, status="detail") detail.parent_row_index = 21 client = await auth_client(KA_RF) body = { "lines": [ {"line_id": str(confirmed.id), "line_code": "1121"}, {"line_id": str(to_detail.id), "line_status": "detail"}, {"line_id": str(detail.id), "line_code": "1122"}, ] } response = await _patch(client, document, body) assert response.status_code == HTTPStatus.OK, response.text first, second, third = [await _db(seeded, line.id) for line in (confirmed, to_detail, detail)] assert first is not None and first.line_code == "1121" and first.is_confirmed is False assert second is not None and second.line_status.value == "detail" and second.is_confirmed is False assert third is not None and third.line_status.value == "ok" and third.parent_row_index is None async def test_values_error_after_lines_persists_nothing( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """M-10 (P3-10, отступление A13-9): 422 в `values` после применённых `lines` откатывает и строки, и аудит.""" document = await form(seeded, fixtures_dir) garbage = await insert_line(seeded, document, code=None, row_index=2, status="unreadable", amount=Decimal(5)) meta = await _meta(seeded, document) column_id, garbage_id = (await repo.form_aggregate(seeded, meta.id))[0][0].id, garbage.id # до отката сессии client = await auth_client(KA_RF) events_before = len(await _edit_events(seeded, document)) body = { "lines": [{"line_id": str(garbage_id), "line_code": CODE}], "values": [{"line_id": str(uuid.uuid4()), "column_id": str(column_id), "amount": "1"}], } response = await _patch(client, document, body) assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text stored = await _db(seeded, garbage_id) assert stored is not None and stored.line_status.value == "unreadable" and stored.line_code is None assert len(await _edit_events(seeded, document)) == events_before