Проверки §7 по колонкам, допуск, abs_terms, M-9, граница округления; граничные случаи маппинга колонок и уверенности; шапка, смешанные снимки, статус при задании needs_check, предупреждение о повторе формы и идемпотентный повторный маппинг форм; редакция формы, пара ББ/ОФР, 403 без права, подтверждение формы без шапки и без дат; комментарий без видимости ЮЛ; справочник пользователей; documents_count; CSV чек-листа. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
874 lines
46 KiB
Python
874 lines
46 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 CompanyGroup, LegalEntity, RegionalOffice
|
||
from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, PeriodQuarter, 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_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"
|
||
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
|
||
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
|