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:
parent
308fbc4fb4
commit
234e830476
@ -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,
|
||||
)
|
||||
|
||||
@ -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() == []
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 - три документа ЮЛ
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)] # дубли схлопываются, хвостовая запятая допустима
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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"),
|
||||
[
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)} не совпадают с листами снимка")
|
||||
|
||||
@ -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])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user