I-1: `_editable` брал шапку без перечитывания, и после ожидания блокировки видел устаревший акцепт и значения шапки. `get_meta(..., refresh=True)` читает с populate_existing после lock_file и lock_document. T6.1: тесты — содержимое аудита правки значения и акцепта, исходное значение даты отчёта, дата не на конце квартала, сброс акцепта правкой только значения, неизменность формы после INVALID_INN, свежесть шапки под блокировкой. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
603 lines
30 KiB
Python
603 lines
30 KiB
Python
"""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
|
||
|
||
import pytest
|
||
from sqlalchemy import 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 LegalEntity, RegionalOffice
|
||
from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus
|
||
from src.models.files import Document, StoredFile
|
||
from src.models.statements import (
|
||
FinancialStatementColumn,
|
||
FinancialStatementLine,
|
||
FinancialStatementMeta,
|
||
FinancialStatementValue,
|
||
)
|
||
from tests.conftest import AuthClientFactory
|
||
from tests.factories import make_company_group, 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"
|
||
MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
|
||
MEZH_BALANCE_LINES = 37
|
||
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"
|
||
assert len(body["lines"]) == MEZH_BALANCE_LINES
|
||
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",
|
||
}
|
||
assert {"organization_name", "okud", "okpo", "okopf", "okfs", "address"} <= set(body["header"])
|
||
assert set(body["columns"][0]) == {"id", "column_index", "label", "period_date", "is_current"}
|
||
line = next(line for line in body["lines"] if any(v["amount"] is not None for v in line["values"]))
|
||
assert set(line) >= {"id", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "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",
|
||
}
|
||
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_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 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()
|
||
assert body["checks"]["cross_form"] == {
|
||
"column_index": -1,
|
||
"relation": CROSS_FORM_RELATION,
|
||
"expected": "200.00",
|
||
"actual": "150.00",
|
||
}, document.document_type
|
||
# соотношение выполнено: 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_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
|
||
|
||
|
||
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"
|