Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
353 lines
19 KiB
Python
353 lines
19 KiB
Python
"""`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:<id> -> 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
|