diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index 62d0d1d..f42e592 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -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, ) diff --git a/api/tests/test_api_comments.py b/api/tests/test_api_comments.py index 7bece3d..e6f5e41 100644 --- a/api/tests/test_api_comments.py +++ b/api/tests/test_api_comments.py @@ -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() == [] diff --git a/api/tests/test_api_consolidation_checklist.py b/api/tests/test_api_consolidation_checklist.py index 5924a05..b69ed3e 100644 --- a/api/tests/test_api_consolidation_checklist.py +++ b/api/tests/test_api_consolidation_checklist.py @@ -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 diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py index 1de818d..f7edaff 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -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 - три документа ЮЛ diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 1d9ba08..2d7193e 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -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 diff --git a/api/tests/test_api_users_brief.py b/api/tests/test_api_users_brief.py index 794cbd6..5989410 100644 --- a/api/tests/test_api_users_brief.py +++ b/api/tests/test_api_users_brief.py @@ -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)] # дубли схлопываются, хвостовая запятая допустима diff --git a/api/tests/test_domain_checklist_csv.py b/api/tests/test_domain_checklist_csv.py index 6adef73..c7f7d31 100644 --- a/api/tests/test_domain_checklist_csv.py +++ b/api/tests/test_domain_checklist_csv.py @@ -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" diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py index e98a138..4d4a18b 100644 --- a/api/tests/test_domain_statement_checks.py +++ b/api/tests/test_domain_statement_checks.py @@ -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"), [ diff --git a/api/tests/test_domain_statement_mapping.py b/api/tests/test_domain_statement_mapping.py index 515ce1a..29df1be 100644 --- a/api/tests/test_domain_statement_mapping.py +++ b/api/tests/test_domain_statement_mapping.py @@ -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") diff --git a/api/tests/test_fixtures_marker.py b/api/tests/test_fixtures_marker.py index 88b03a4..f54eebd 100644 --- a/api/tests/test_fixtures_marker.py +++ b/api/tests/test_fixtures_marker.py @@ -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" diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py index 1c833ad..996ba8f 100644 --- a/api/tests/test_mapping_golden.py +++ b/api/tests/test_mapping_golden.py @@ -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)} не совпадают с листами снимка") diff --git a/api/tests/test_mapping_statements.py b/api/tests/test_mapping_statements.py index 54e5224..31da7a4 100644 --- a/api/tests/test_mapping_statements.py +++ b/api/tests/test_mapping_statements.py @@ -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])