test(statements): закрыты отложенные minor-находки ревью задач 1-12 (T1.1, T2.1, T3.1, T3.3, T4.x, T5.5, T7.2, T7.3, T8.4, T10.1, T11.3, T12.4)

Проверки §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
This commit is contained in:
Andrey Epifancev 2026-10-07 14:00:09 +04:00
parent 308fbc4fb4
commit 234e830476
12 changed files with 657 additions and 8 deletions

View File

@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
if TYPE_CHECKING:
from src.contract.result import RecognitionResult
from src.models.directories import LegalEntity
from src.models.enums import RecognitionStatus
from src.models.files import Document
FIXTURES_ENV = "ARM_KA_FIXTURES"
@ -58,9 +59,9 @@ def ocr_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "Recogni
"status": data["status"],
"documents": data["documents"],
}
if data.get("error"):
if data.get("error") is not None:
payload["error"] = data["error"]
if data.get("metrics"):
if data.get("metrics") is not None:
payload["metrics"] = data["metrics"]
return RecognitionResult.model_validate(payload)
@ -74,20 +75,22 @@ def golden_result(path: Path, *, job_id: uuid.UUID, file_id: uuid.UUID) -> "Reco
return RecognitionResult.model_validate(data | {"job_id": str(job_id), "file_id": str(file_id)})
async def map_golden_snapshot(
async def map_golden_snapshot( # noqa: PLR0913 - все входы именованные
session: AsyncSession,
*,
entity: "LegalEntity",
path: Path,
uploaded_by: uuid.UUID | None,
result_builder: Callable[..., "RecognitionResult"] | None = None,
job_status: "RecognitionStatus | None" = None,
) -> "list[Document]":
"""Golden-снимок `path` → файл ЮЛ `entity` и его документы в БД теста (`flush`, без commit).
Как маппер: файл `PROCESSING` с текущим заданием `done`, затем боевой `map_file` с планом счетов seed и
настоящим `CounterpartyIndex` (справочник читается на момент вызова — ЮЛ и алиасы должны быть созданы
до маппинга). Возвращает документы файла отсортированные по (document_type, osv_account_code).
`result_builder` — функция создания RecognitionResult (по умолчанию golden_result).
`result_builder` — функция создания RecognitionResult (по умолчанию golden_result); `job_status` — статус
текущего задания (по умолчанию `done`; `needs_check` — как после результата с `status = needs_check`).
"""
# ленивые импорты: `conftest` импортирует этот модуль раньше `tests.factories` (цикл)
from src.models.accounting import ChartOfAccount # noqa: PLC0415
@ -101,7 +104,7 @@ async def map_golden_snapshot(
session,
entity=entity,
status=FileStatus.PROCESSING,
job_status=RecognitionStatus.done,
job_status=job_status or RecognitionStatus.done,
original_name=path.name,
uploaded_by=uploaded_by,
)

View File

@ -65,3 +65,17 @@ async def test_comment_on_superseded_file(seeded: AsyncSession, auth_client: Aut
assert created.status_code == HTTP_CREATED, created.text
listing = (await client.get(f"/legal-entities/{entity.id}/files", params={"include_superseded": "true"})).json()
assert listing["items"][0]["documents"][0]["comments_count"] == 1
async def test_post_by_editor_without_entity_visibility_is_404(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Право `DOCUMENT_EDIT` есть, но ЮЛ чужого филиала не видно: POST — 404 (как для чужого документа), комментарий не
создаётся."""
_, document = await _doc(seeded)
url = f"/documents/{document.id}/comments"
stranger = await auth_client("client_manager@armka.local") # DOCUMENT_EDIT, другой филиал
response = await stranger.post(url, json={"text": "чужой комментарий"})
assert response.status_code == HTTP_NOT_FOUND, response.text
owner = await auth_client(KA_RF)
assert (await owner.get(url)).json() == []

View File

@ -3,6 +3,7 @@
import uuid
from typing import Any
from urllib.parse import unquote
import pytest
from sqlalchemy import select
@ -25,6 +26,7 @@ from tests.factories import (
HTTP_OK = 200
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_UNPROCESSABLE = 422
KA_RF = "ka_rf@armka.local"
CURATOR = "curator_go@armka.local"
FOREIGN = "client_manager@armka.local" # Краснодарский филиал
@ -260,3 +262,60 @@ async def test_checklist_csv_format(auth_client: AuthClientFactory, seeded: Asyn
disposition = response.headers.get("content-disposition", "")
assert "filename*=UTF-8''" in disposition
assert "checklist-" in disposition
@pytest.mark.integration
async def test_checklist_csv_exact_headers_and_full_row(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""CSV: тип с `charset=utf-8`, имя файла `checklist-<ГК>-<год>-<квартал>.csv` (percent-кодировано в `filename*`,
ASCII-запасной вариант в `filename`) и строка данных целиком — все 10 ячеек по правилам комплекта."""
version, (first, second) = await _bound_world(seeded)
client = await auth_client(KA_RF)
response = await client.get(f"/consolidations/versions/{version.id}/checklist", params={"format": "csv"})
assert response.status_code == HTTP_OK, response.text
assert response.headers["content-type"] == "text/csv; charset=utf-8"
disposition = response.headers["content-disposition"]
assert disposition.startswith("attachment; filename=\"checklist.csv\"; filename*=UTF-8''")
assert unquote(disposition.split("filename*=UTF-8''")[1]) == "checklist-ГК Русский бисквит-2024-Q3.csv"
assert disposition.isascii()
lines = response.content.decode("utf-8-sig").splitlines()
cells = ["Отсутствует"] * len(EXPECTED_RULES)
cells[OSV_60] = "Загружен"
assert lines[1] == ";".join([first.name, first.inn or "", *cells])
assert lines[2] == ";".join([second.name, second.inn or "", *["Отсутствует"] * len(EXPECTED_RULES)])
assert len(lines) == 3 # noqa: PLR2004 - заголовок и два ЮЛ
@pytest.mark.integration
async def test_checklist_csv_lists_several_missing_quarters(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Подтверждён только Q3: ячейка ОСВ 60 — «Отсутствует (нет: Q1, Q2)»."""
version, (first, _) = await _world(seeded)
await _bound_osv(seeded, version, first, "60", Q3)
client = await auth_client(KA_RF)
response = await client.get(f"/consolidations/versions/{version.id}/checklist", params={"format": "csv"})
row = response.content.decode("utf-8-sig").splitlines()[1].split(";")
assert row[2 + OSV_60] == "Отсутствует (нет: Q1, Q2)"
@pytest.mark.integration
async def test_checklist_unknown_format_is_422(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
version, _ = await _world(seeded)
client = await auth_client(KA_RF)
response = await client.get(f"/consolidations/versions/{version.id}/checklist", params={"format": "xml"})
assert response.status_code == HTTP_UNPROCESSABLE
@pytest.mark.integration
async def test_checklist_csv_of_invisible_version_is_404(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Чужая (невидимая) версия: CSV — тот же 404, что и JSON, без файла."""
version, _ = await _bound_world(seeded)
foreign = await auth_client(FOREIGN) # CONSOLIDATION_VIEW есть, но Краснодарский филиал
for fmt in ("csv", "json"):
response = await foreign.get(f"/consolidations/versions/{version.id}/checklist", params={"format": fmt})
assert response.status_code == HTTP_NOT_FOUND, fmt
assert "content-disposition" not in response.headers

View File

@ -618,3 +618,59 @@ async def test_documents_count(seeded: AsyncSession, auth_client: AuthClientFact
patched = await client.patch(f"/directories/legal-entities/{entity.id}", json={"name": "ООО Счётчик плюс"})
assert patched.status_code == HTTP_OK, patched.text
assert patched.json()["documents_count"] == expected_count # T11.2: тот же подсчёт в PATCH
async def _with_documents(session: AsyncSession, entity: Any, accounts: Sequence[str]) -> None:
"""Один обработанный файл ЮЛ с документами по перечисленным счетам."""
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
for account in accounts:
await make_document(session, file=file, job=job, account=account)
@pytest.mark.integration
async def test_documents_count_zero_and_grouped_per_entity(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""D-13: у ЮЛ без документов счётчик 0 (а не пропуск), в списке из нескольких ЮЛ счёт идёт по каждому ЮЛ."""
group = await make_company_group(seeded, name="ГК Счётчики")
empty = await make_legal_entity(seeded, name="ООО Пустое", group=group)
single = await make_legal_entity(seeded, name="ООО Одиночное", group=group)
triple = await make_legal_entity(seeded, name="ООО Тройное", group=group)
await _with_documents(seeded, single, ["60"])
await _with_documents(seeded, triple, ["60", "62", "66"])
client = await auth_client("ka_go@armka.local")
listed = (await client.get(ENTITIES, params={"company_group_id": str(group.id)})).json()
counts = {item["id"]: item["documents_count"] for item in listed["items"]}
assert counts == {str(empty.id): 0, str(single.id): 1, str(triple.id): 3}
card = (await client.get(f"{ENTITIES}/{empty.id}")).json()
assert card["documents_count"] == 0
@pytest.mark.integration
async def test_documents_count_in_move_and_withdraw_responses(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Ответы move/withdraw (как PATCH) несут реальный `documents_count` (PATCH покрыт `test_documents_count`)."""
rostov = await _office(seeded, ROSTOV)
old_group = await make_company_group(seeded, name="ГК Откуда", office=rostov)
new_group = await make_company_group(seeded, name="ГК Куда", office=rostov)
moved = await make_legal_entity(seeded, name="ООО Переезд", group=old_group)
closed = await make_legal_entity(seeded, name="ООО Закрытие", group=old_group)
await _with_documents(seeded, moved, ["60", "62"])
await _with_documents(seeded, closed, ["60", "62", "66"])
client = await auth_client("ka_rf@armka.local")
today = _today().isoformat()
move = await client.post(
f"{ENTITIES}/{moved.id}/move", json={"company_group_id": str(new_group.id), "effective_date": today}
)
assert move.status_code == HTTP_OK, move.text
assert move.json()["documents_count"] == 2 # noqa: PLR2004 - два документа ЮЛ
withdraw = await client.post(
f"{ENTITIES}/{closed.id}/withdraw", json={"event": "CLOSE", "effective_date": today, "comment": "Ликвидация"}
)
assert withdraw.status_code == HTTP_OK, withdraw.text
assert withdraw.json()["documents_count"] == 3 # noqa: PLR2004 - три документа ЮЛ

View File

@ -738,3 +738,136 @@ async def test_patch_units_are_stripped(
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

View File

@ -2,17 +2,22 @@
import uuid
import httpx
import pytest
from fastapi import FastAPI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.endpoints.users import MAX_BRIEF_IDS
from src.models.access import AppUser
from src.models.directories import RegionalOffice
from tests.conftest import AuthClientFactory
from tests.factories import make_user
pytestmark = pytest.mark.integration
HTTP_OK = 200
HTTP_UNAUTHORIZED = 401
HTTP_UNPROCESSABLE = 422
KA_RF = "ka_rf@armka.local"
@ -35,3 +40,47 @@ async def test_brief_limits(seeded: AsyncSession, auth_client: AuthClientFactory
response = await client.get("/users/brief", params={"ids": too_many})
assert response.status_code == HTTP_UNPROCESSABLE and response.json()["detail"]["code"] == "TOO_MANY_IDS"
assert (await client.get("/users/brief", params={"ids": "not-a-uuid"})).status_code == HTTP_UNPROCESSABLE
async def test_brief_without_token_is_401(app: FastAPI, seeded: AsyncSession) -> None: # noqa: ARG001
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test/api/v1") as anonymous:
response = await anonymous.get("/users/brief", params={"ids": str(uuid.uuid4())})
assert response.status_code == HTTP_UNAUTHORIZED
async def test_brief_includes_inactive_user(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
office = (await seeded.execute(select(RegionalOffice).limit(1))).scalar_one()
gone = await make_user(
seeded, email="gone@armka.local", office=office, full_name="Уволенный Сотрудник", is_active=False
)
client = await auth_client(KA_RF)
response = await client.get("/users/brief", params={"ids": str(gone.id)})
assert response.status_code == HTTP_OK
assert response.json() == [{"id": str(gone.id), "full_name": "Уволенный Сотрудник", "is_active": False}]
async def test_brief_invalid_uuid_code(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ARG001
client = await auth_client(KA_RF)
response = await client.get("/users/brief", params={"ids": f"{uuid.uuid4()},not-a-uuid"})
assert response.status_code == HTTP_UNPROCESSABLE
assert response.json()["detail"]["code"] == "INVALID_IDS"
@pytest.mark.parametrize("ids", ["", " ", ",", " , ,"])
async def test_brief_empty_ids_is_empty_list(
seeded: AsyncSession,
auth_client: AuthClientFactory,
ids: str, # noqa: ARG001
) -> None:
client = await auth_client(KA_RF)
response = await client.get("/users/brief", params={"ids": ids})
assert response.status_code == HTTP_OK and response.json() == []
async def test_brief_duplicates_and_trailing_comma(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
client = await auth_client(KA_RF)
response = await client.get("/users/brief", params={"ids": f" {user.id} ,{user.id},{str(user.id).upper()},"})
assert response.status_code == HTTP_OK
assert [item["id"] for item in response.json()] == [str(user.id)] # дубли схлопываются, хвостовая запятая допустима

View File

@ -33,6 +33,54 @@ def test_render() -> None:
assert lines[1] == '"ООО ""Альфа; Бета""";7700000000;Загружен;Отсутствует (нет: Q2)'
def _cell(status: CellStatus, *quarters: PeriodQuarter) -> ChecklistCellOut:
return ChecklistCellOut(status=status, missing_quarters=list(quarters), document_ids=[])
def test_missing_quarters_listed_in_order() -> None:
checklist = ChecklistOut(
rules=[ChecklistRuleOut(document_type=DocumentType.OSV, account="60", is_mandatory=True)],
rows=[
ChecklistRowOut(
legal_entity_id=uuid.uuid4(),
legal_entity_name="ООО Альфа",
legal_entity_inn="7700000001",
cells=[_cell(CellStatus.MISSING, PeriodQuarter.Q1, PeriodQuarter.Q2)],
)
],
)
lines = render_checklist_csv(checklist).decode("utf-8-sig").splitlines()
assert lines == ["Организация;ИНН;ОСВ 60", "ООО Альфа;7700000001;Отсутствует (нет: Q1, Q2)"]
def test_all_statuses_rendered_with_their_labels() -> None:
"""Каждый статус ячейки получает свою подпись: обработка, проверка (`NEEDS_CHECK` и `RECOGNIZED`), ошибка."""
statuses = [
CellStatus.CONFIRMED,
CellStatus.PROCESSING,
CellStatus.NEEDS_CHECK,
CellStatus.RECOGNIZED,
CellStatus.ERROR,
CellStatus.MISSING,
]
rules = [ChecklistRuleOut(document_type=DocumentType.INCOME, account=None, is_mandatory=True)] * len(statuses)
checklist = ChecklistOut(
rules=rules,
rows=[
ChecklistRowOut(
legal_entity_id=uuid.uuid4(),
legal_entity_name="ООО Бета",
legal_entity_inn="7700000000",
cells=[_cell(status) for status in statuses],
)
],
)
lines = render_checklist_csv(checklist).decode("utf-8-sig").splitlines()
assert lines[1] == (
"ООО Бета;7700000000;Загружен;Обработка;Требуется проверка;Требуется проверка;Ошибка загрузки;Отсутствует"
)
def test_filename() -> None:
assert checklist_filename("Русский бисквит", 2025, "Q3") == "checklist-Русский бисквит-2025-Q3.csv"

View File

@ -93,6 +93,84 @@ def test_target_without_self_sum_hard_failure() -> None:
)
def _by_relation(items: tuple[FailedCheck, ...], relation: str) -> list[FailedCheck]:
return [item for item in items if item.relation == relation]
def test_relation_checked_per_column_and_none_is_zero() -> None:
# колонка 0: 900 = 300 + 600; колонка 1: 1000 ≠ 900; колонка 2: 300 = 300 + None(=0)
lines = _lines(
("1100", {0: "300", 1: "300", 2: "300"}),
("1200", {0: "600", 1: "600", 2: None}),
("1600", {0: "900", 1: "1000", 2: "300"}),
)
report = run_checks("BALANCE", lines, [0, 1, 2], RULES)
assert _by_relation(report.failed, "1600 = 1100 + 1200") == [
FailedCheck(1, "1600 = 1100 + 1200", D("1000"), D("900"))
]
assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == []
def test_tolerance_exceeded_by_one_unit_is_violation() -> None:
# допуск 1: 902 при 300 + 600 — расхождение 2 > 1, число мало, округление (0,001 %) не спасает
lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "902"}))
report = run_checks("BALANCE", lines, [0], RULES)
assert _by_relation(report.failed, "1600 = 1100 + 1200") == [
FailedCheck(0, "1600 = 1100 + 1200", D("902"), D("900"))
]
def test_tolerance_boundary_is_inclusive() -> None:
lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"}))
report = run_checks("BALANCE", lines, [0], RULES)
assert _by_relation(report.failed, "1600 = 1100 + 1200") == []
assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == []
def test_abs_terms_positive_expense_holds_and_mismatch_fails() -> None:
# 66н пишет расход в скобках, 1С — со знаком минус; здесь положительный 2120: 100 − 60 = 40 выполнено по модулю
holds = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "40"})), [0], RULES)
assert _by_relation(holds.failed, "2100 = 2110 - 2120") == []
broken = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "50"})), [0], RULES)
failed = _by_relation(broken.failed, "2100 = 2110 - 2120")
assert len(failed) == 1
assert (failed[0].column_index, failed[0].expected, failed[0].actual) == (0, D("50"), D("40"))
def test_subline_attached_to_last_parent_by_row_order() -> None:
# M-9: 12310 и 12320 идут под строкой 1230, хотя префикса 1231/1232 среди четырёхзначных кодов нет
lines = _lines(("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"}))
report = run_checks("BALANCE", lines, [0], RULES)
assert [item.relation for item in _by_relation(report.soft_failed, "1230 = 12310 + 12320")] == [
"1230 = 12310 + 12320"
]
def test_subline_prefers_last_parent_over_prefix() -> None:
# 12310 стоит под 1230 (ближайшая строка выше с теми же тремя цифрами), а не под 1231, хотя префикс 1231 есть
lines = _lines(("1231", {0: "5"}), ("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"}))
report = run_checks("BALANCE", lines, [0], RULES)
relations = {item.relation for item in report.soft_failed}
assert "1230 = 12310 + 12320" in relations
assert not any(relation.startswith("1231 =") for relation in relations)
def test_rounding_threshold_boundary() -> None:
# порог = 0,001 % наибольшего по модулю числа соотношения; при итоге 10 000 000 он ровно 100
assert D("10000000") * RULES.rounding_tolerance_percent / D("100") == D("100")
halves = {0: "4999950"}
at_threshold = run_checks(
"BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000000"})), [0], RULES
)
assert _by_relation(at_threshold.failed, "1600 = 1100 + 1200") == []
assert [item.expected - item.actual for item in _by_relation(at_threshold.soft_failed, "1600 = 1100 + 1200")] == [
D("100")
]
above = run_checks("BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000001"})), [0], RULES)
assert [item.expected - item.actual for item in _by_relation(above.failed, "1600 = 1100 + 1200")] == [D("101")]
assert _by_relation(above.soft_failed, "1600 = 1100 + 1200") == []
@pytest.mark.parametrize(
("b1370", "i2400", "expected"),
[

View File

@ -153,3 +153,59 @@ def test_values_ok() -> None:
assert not values_ok([(False, False, hundred)])
assert not values_ok([(False, True, Decimal("99"))])
assert values_ok([(True, False, Decimal("10"))]) # правленое аналитиком значение не тянет статус вниз
def test_equal_column_dates_current_is_lowest_index_only() -> None:
columns = [
{"column_index": 2, "label": "б", "period_date": "2025-09-30"},
{"column_index": 1, "label": "а", "period_date": "2025-09-30"},
{"column_index": 3, "label": "в", "period_date": "2024-12-31"},
]
draft = build_statement(_doc(columns=columns, lines=[]))
assert {index: current for index, _, _, current in draft.columns} == {1: True, 2: False, 3: False}
def test_column_without_date_next_to_dated_is_not_current() -> None:
columns = [
{"column_index": 0, "label": "примечание"},
{"column_index": 1, "label": "На 30.09.2025", "period_date": "2025-09-30"},
]
draft = build_statement(_doc(columns=columns, lines=[]))
assert {index: current for index, _, _, current in draft.columns} == {0: False, 1: True}
def test_duplicate_code_values_belong_to_first_line() -> None:
lines = [
{
"line_code": "1150",
"line_name": "первая",
"values": [{"column_index": 0, "amount": "10", "confidence": 100, "is_recognized": True}],
},
{
"line_code": "1150",
"line_name": "дубль",
"values": [{"column_index": 0, "amount": "999", "confidence": 100, "is_recognized": True}],
},
]
draft = build_statement(_doc(lines=lines))
assert [line.line_name for line in draft.lines] == ["первая"]
assert [(v.line_code, v.column_index, v.amount) for v in draft.values] == [
("1150", 0, Decimal("10")),
("1150", 1, None),
]
def test_fractional_confidence_kept_exactly() -> None:
lines = [
{
"line_code": "1150",
"line_code_confidence": 99.5,
"line_name": "ОС",
"line_name_confidence": 98.25,
"values": [{"column_index": 0, "amount": "10", "confidence": 99.5, "is_recognized": True}],
}
]
draft = build_statement(_doc(lines=lines))
assert draft.lines[0].line_code_confidence == Decimal("99.5")
assert draft.lines[0].line_name_confidence == Decimal("98.25")
assert draft.values[0].confidence == Decimal("99.5")

View File

@ -1,5 +1,6 @@
"""Маркер `fixtures`: путь к реальным данным и пропуск с причиной (spec quality-checks, «Фикстуры … опциональны»)."""
import json
import uuid
from pathlib import Path
@ -53,3 +54,29 @@ def test_ocr_result_wraps_normalizer_golden(fixtures_dir: Path) -> None:
assert result.job_id == job_id and result.file_id == file_id
assert result.service.name == "scan-gateway"
assert [doc.document_type for doc in result.documents] == ["BALANCE", "INCOME"]
assert result.status == "needs_check" and result.schema_version == "1.2"
assert result.error is None
assert result.metrics is not None and result.metrics.rows == 60 # noqa: PLR2004 - эталон aep_6
@pytest.mark.fixtures
def test_ocr_result_keeps_error_of_failed_golden(fixtures_dir: Path) -> None:
"""Эталон с ошибкой: код и сообщение переносятся в результат, метрик нет."""
result = ocr_result(fixtures_dir / OCR_EXPECTED / "aep_5.json", job_id=uuid.uuid4(), file_id=uuid.uuid4())
assert result.status == "error" and result.documents == []
assert result.error is not None and result.error.code == "INVALID_DOCUMENT"
assert result.metrics is None
def test_ocr_result_keeps_empty_metrics(tmp_path: Path) -> None:
"""Пустой объект `metrics` передаётся как есть (сравнение `is not None`, а не по истинности)."""
snapshot = tmp_path / "empty.json"
snapshot.write_text(
json.dumps(
{"status": "error", "error": {"code": "UNREADABLE", "message": "m"}, "metrics": {}, "documents": []}
),
"utf-8",
)
result = ocr_result(snapshot, job_id=uuid.uuid4(), file_id=uuid.uuid4())
assert result.metrics is not None and result.metrics.rows is None
assert result.error is not None and result.error.code == "UNREADABLE"

View File

@ -109,6 +109,28 @@ def _check_sheet( # noqa: PLR0912 - все инварианты одного л
return problems, missing
async def _form_problems(session: AsyncSession, file_id: uuid.UUID, raw: dict[str, Any], name: str) -> list[str]:
"""Формы смешанных снимков (s12 и др.): по документу на каждый тип ББ/ОФР снимка (повтор формы пропускается),
`DISCLOSURE`/`OTHER` — по одному документу на тип."""
types = Counter(
document["document_type"] for document in raw["documents"] if document["document_type"] != DocumentType.OSV
)
stored = Counter(
(
await session.execute(
select(Document.document_type).where(
Document.stored_file_id == file_id, Document.document_type != DocumentType.OSV
)
)
).scalars()
)
return [
f"{name}: документов {kind} {stored.get(kind, 0)}, ожидалось {1 if types[kind] else 0}"
for kind in sorted({*types, *stored})
if stored.get(kind, 0) != (1 if types[kind] else 0)
]
async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: Path) -> None: # noqa: PLR0914
"""«Все golden-снимки парсера» (не меньше 113) маппятся в БД, инварианты спеки на каждом документе, ≤ 60 с."""
session = seeded
@ -156,6 +178,7 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P
)
).scalars()
}
problems.extend(await _form_problems(session, file_id, raw, name))
sheets = [sheet for sheet in raw["documents"] if sheet["document_type"] == "OSV"]
if set(documents) != {sheet["locator"] for sheet in sheets}:
problems.append(f"{name}: документы {sorted(documents)} не совпадают с листами снимка")

View File

@ -3,23 +3,34 @@
import json
import uuid
from datetime import UTC, datetime
from pathlib import Path
import pytest
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.accounting import ChartOfAccount
from src.models.directories import LegalEntity
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, FileStatus, PeriodQuarter
from src.models.files import Document, StoredFile
from src.models.enums import (
DocumentCheckResult,
DocumentStatus,
DocumentType,
FileStatus,
PeriodQuarter,
RecognitionStatus,
)
from src.models.files import Document, RecognitionJob, StoredFile
from src.models.statements import (
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
from src.repositories.counterparties import CounterpartyIndex
from src.services.result_mapping import MAPPING_WARNINGS_KEY, map_file
from tests.factories import make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, map_golden_snapshot, ocr_result
from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, golden_result, map_golden_snapshot, ocr_result
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
@ -117,6 +128,7 @@ async def test_form_without_codes(seeded: AsyncSession, fixtures_dir: Path) -> N
(balance,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
assert balance.status is DocumentStatus.NEEDS_CHECK
meta = await _meta(seeded, balance)
assert meta.inn == "7700000000" and meta.organization_name == 'ООО "Тестовая организация"'
assert await _count(seeded, FinancialStatementLine, meta.id) == 0
# в снимке st03_no_codes колонок нет (`columns = []`): только шапка (расхождение со spec «и колонками»)
assert await _count(seeded, FinancialStatementColumn, meta.id) == 0
@ -129,6 +141,23 @@ async def test_mixed_book_osv_and_balance(seeded: AsyncSession, fixtures_dir: Pa
assert {d.document_type for d in documents} == {DocumentType.OSV, DocumentType.BALANCE}
async def test_mixed_osv_snapshot_with_balance(seeded: AsyncSession, fixtures_dir: Path) -> None:
"""Снимок парсера ОСВ `s12` с листом ББ: лист ОСВ и по одной форме на лист ББ снимка."""
owner = await make_legal_entity(seeded, name="ООО Смешанная ОСВ")
path = fixtures_dir / "golden" / "expected_osv_parse" / "synthetic" / "s12_statement_and_osv.json"
raw = json.loads(path.read_text("utf-8"))
forms = [d for d in raw["documents"] if d["document_type"] in ("BALANCE", "INCOME")]
assert forms, "в снимке s12 больше нет формы: тест вырожден"
documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
stored_forms = [d for d in documents if d.document_type in (DocumentType.BALANCE, DocumentType.INCOME)]
assert sorted(d.document_type for d in stored_forms) == sorted(f["document_type"] for f in forms)
assert [d.document_type for d in documents].count(DocumentType.OSV) == sum(
1 for d in raw["documents"] if d["document_type"] == "OSV"
)
for document in stored_forms:
assert (await _meta(seeded, document)).id is not None
async def test_ocr_form_without_report_date(seeded: AsyncSession, fixtures_dir: Path) -> None:
owner = await make_legal_entity(seeded, name="ООО Дорстроймонтаж")
documents = await map_golden_snapshot(
@ -202,3 +231,77 @@ async def test_duplicate_form_in_file_skipped(seeded: AsyncSession, fixtures_dir
documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
assert [d.document_type for d in documents] == [DocumentType.INCOME]
assert documents[0].status is DocumentStatus.NEEDS_CHECK # без повтора тот же снимок — RECOGNIZED
assert await _mapping_warnings(seeded, documents[0]) == [
"sheet:повтор: вторая форма INCOME в файле пропущена (OQ-77)"
]
async def test_form_with_needs_check_job_is_needs_check(seeded: AsyncSession, fixtures_dir: Path) -> None:
"""D-6: условие «задание done» — форма чистого снимка при задании `needs_check` не `RECOGNIZED`."""
path = fixtures_dir / MEZH / "1_Форма2_бух_09.json"
owner = await _owner(seeded, path)
(income,) = await map_golden_snapshot(
seeded, entity=owner, path=path, uploaded_by=None, job_status=RecognitionStatus.needs_check
)
assert income.counterparty_check is DocumentCheckResult.MATCH
assert (
income.status is DocumentStatus.NEEDS_CHECK
) # то же при задании done — RECOGNIZED (test_clean_income_recognized)
async def test_repeated_form_mapping_is_idempotent(seeded: AsyncSession, fixtures_dir: Path) -> None:
"""Spec «Повторный маппинг идемпотентен»: файл возвращён в `PROCESSING` и смаплен снова — один набор шапок, колонок,
строк и значений, никаких остатков; формы другого файла не тронуты."""
path = next((fixtures_dir / NPZ).glob("*.json"))
owner = await _owner(seeded, path)
bystander = await make_legal_entity(seeded, name="ООО Сосед")
other = await map_golden_snapshot(
seeded, entity=bystander, path=fixtures_dir / MEZH / "1_Форма1_бух_09.json", uploaded_by=None
)
documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None)
before = await _statement_totals(seeded)
file_id = documents[0].stored_file_id
file = await seeded.get(StoredFile, file_id)
assert file is not None and file.current_recognition_job_id is not None
job = await seeded.get(RecognitionJob, file.current_recognition_job_id)
assert job is not None
file.status = FileStatus.PROCESSING
await seeded.flush()
chart = {code: key for code, key in await seeded.execute(select(ChartOfAccount.code, ChartOfAccount.id))}
result = golden_result(path, job_id=job.id, file_id=file.id)
await map_file(
seeded,
job=job,
file=file,
result=result,
chart=chart,
index=await CounterpartyIndex.load(seeded),
now=datetime.now(UTC),
)
assert await _statement_totals(seeded) == before
again = (await seeded.execute(select(Document).where(Document.stored_file_id == file_id))).scalars().all()
assert {d.document_type for d in again} == {DocumentType.BALANCE, DocumentType.INCOME}
assert {d.id for d in again}.isdisjoint({d.id for d in documents}) # документы пересозданы, не дублированы
assert file.status is FileStatus.PROCESSED
kept = (
await seeded.execute(select(Document).where(Document.stored_file_id == other[0].stored_file_id))
).scalar_one()
assert kept.id == other[0].id
async def _statement_totals(session: AsyncSession) -> dict[str, int]:
"""Число строк во всех таблицах формы: любой остаток или дубль после повторного маппинга меняет итог."""
counts: dict[str, int] = {}
for model in (FinancialStatementMeta, FinancialStatementColumn, FinancialStatementLine, FinancialStatementValue):
counts[model.__tablename__] = (await session.execute(select(func.count()).select_from(model))).scalar_one()
return counts
async def _mapping_warnings(session: AsyncSession, document: Document) -> list[str]:
file = await session.get(StoredFile, document.stored_file_id)
assert file is not None and file.current_recognition_job_id is not None
job = await session.get(RecognitionJob, file.current_recognition_job_id)
assert job is not None
return list(job.metrics[MAPPING_WARNINGS_KEY])