From 4acb0b83b86567f1cb107dcc31492d7c2dbf41c1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 19:58:40 +0400 Subject: [PATCH] =?UTF-8?q?feat(normalizer):=20=D0=BD=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=20json=20=D0=98=D0=A1=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20arm=5Fstatements=20=E2=80=94=20JSON=5FFAILED,=20TABLE=5FPARS?= =?UTF-8?q?E=5FERROR,=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=82=D0=B0=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/src/normalizer/__init__.py | 91 +++-------- api/src/normalizer/files.py | 84 +++++++++++ api/src/normalizer/ocr/answer_set.py | 121 +++++++++++++++ api/tests/unit/normalizer/ocr_builders.py | 49 ++++++ api/tests/unit/normalizer/test_answer_set.py | 151 +++++++++++++++++++ 5 files changed, 423 insertions(+), 73 deletions(-) create mode 100644 api/src/normalizer/files.py create mode 100644 api/src/normalizer/ocr/answer_set.py create mode 100644 api/tests/unit/normalizer/ocr_builders.py create mode 100644 api/tests/unit/normalizer/test_answer_set.py diff --git a/api/src/normalizer/__init__.py b/api/src/normalizer/__init__.py index 6287fb1..394dd1f 100644 --- a/api/src/normalizer/__init__.py +++ b/api/src/normalizer/__init__.py @@ -13,16 +13,28 @@ появится в Срезе 3 новой реализацией протокола `Normalizer`. """ -from dataclasses import dataclass from typing import Protocol, Self, runtime_checkable from pydantic import BaseModel, ConfigDict, Field, model_validator from src.contract import ErrorCode, MessageError, Metrics, ResultStatus, StatementDocument +from src.normalizer.files import ( + BAD_ENTRY_MESSAGE, + NO_FILES_MESSAGE, + NOT_JSON_MESSAGE, + NOT_MERGED_MESSAGE, + NOT_OBJECT_MESSAGE, + FileError, + file_error, +) __all__ = [ + "BAD_ENTRY_MESSAGE", "NOT_IMPLEMENTED_MESSAGE", + "NOT_JSON_MESSAGE", + "NOT_MERGED_MESSAGE", "NOT_OBJECT_MESSAGE", + "NO_FILES_MESSAGE", "NO_TABLES_MESSAGE", "NormalizeOutcome", "Normalizer", @@ -31,11 +43,6 @@ __all__ = [ ] NOT_IMPLEMENTED_MESSAGE = "нормализация ответа ИС Сканирование реализуется в Срезе 3" -NOT_OBJECT_MESSAGE = "ответ ИС не является JSON-объектом" -NOT_JSON_MESSAGE = "ответ ИС не разбирается как JSON" -NOT_MERGED_MESSAGE = "склеенный ответ ИС не соответствует структуре {job_id, files: [...]}" -NO_FILES_MESSAGE = "в ответе ИС нет ни одного файла" -BAD_ENTRY_MESSAGE = "элемент files не соответствует структуре {name, response, parse_error}" class NormalizeOutcome(BaseModel): @@ -82,71 +89,6 @@ class Normalizer(Protocol): def normalize(self, raw: object) -> NormalizeOutcome: ... -@dataclass(frozen=True) -class _FileError: - """Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для строки `warnings`.""" - - name: str - code: ErrorCode # error.code сводки, если этот файл — первый ошибочный - source_code: str # код как получен от ИС (для parse_error и не-объекта — INVALID_FORMAT) - message: str # сообщение файла: строка warnings - summary: str # error.message сводки, если этот файл — первый ошибочный - - @property - def warning(self) -> str: - return f"{self.name}: {self.source_code}: {self.message}" - - -def _invalid_file(name: str, message: str) -> _FileError: - code = ErrorCode.INVALID_FORMAT - return _FileError(name, code, code.value, message, message) - - -def _file_error(index: int, entry: object) -> _FileError | None: - """Ошибка файла склейки или `None`, если файл успешен. - - Успех — `response.error.code == ""` (реальный успех ИС: `error = {"code": "", "message": ""}`); отсутствующий, - `null` или не-объектный `error`, а также `error` без кода (`code` нет или `null`) ошибкой не считаются (№57). - Ошибочен файл с непустым `error.code`, с `parse_error`, с корнем ответа не-объектом (`INVALID_FORMAT`). - """ - if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not entry["name"]: - return _invalid_file(f"files[{index}]", BAD_ENTRY_MESSAGE) - name: str = entry["name"] - parse_error = entry.get("parse_error") - if parse_error is not None: - return _invalid_file(name, f"{NOT_JSON_MESSAGE}: {parse_error}") - response = entry.get("response") - if not isinstance(response, dict): - return _invalid_file(name, NOT_OBJECT_MESSAGE) - raw_error = response.get("error") - if not isinstance(raw_error, dict) or raw_error.get("code") in (None, ""): - return None - return _response_error(name, raw_error) - - -def _response_error(name: str, raw_error: dict[str, object]) -> _FileError: - """Ошибка ИС с непустым `error.code`: код контракта либо `UNKNOWN_ERROR` с исходным кодом в сообщении.""" - raw_code = raw_error.get("code") - source_code = raw_code if isinstance(raw_code, str) else repr(raw_code) - raw_message = raw_error.get("message") - if isinstance(raw_message, str) and raw_message: - message = raw_message - else: - # пустая строка и отсутствие поля — «нет сообщения»; значение иного типа показываем как есть - note = "" if raw_message in (None, "") else f" (поле message: {raw_message!r})" - message = f"ИС Сканирование вернула ошибку {source_code} без сообщения{note}" - - try: - code = ErrorCode(raw_code) if isinstance(raw_code, str) else None - except ValueError: - code = None - if code is not None: - return _FileError(name, code, source_code, message, message) - # код вне перечня контракта: UNKNOWN_ERROR, исходный код — в сообщении (R-12) - summary = f"ИС Сканирование вернула неизвестный код ошибки {raw_code!r}: {message}" - return _FileError(name, ErrorCode.UNKNOWN_ERROR, source_code, message, summary) - - class NullNormalizer: """Заглушка Среза 0 (D-5): сводка ошибок по файлам склейки, нормализации нет. @@ -161,10 +103,10 @@ class NullNormalizer: files: list[object] = raw["files"] if not files: return _error_outcome(ErrorCode.INVALID_FORMAT, NO_FILES_MESSAGE) - errors: list[_FileError] = [] + errors: list[FileError] = [] succeeded = 0 for index, entry in enumerate(files): - error = _file_error(index, entry) + error = file_error(index, entry) if error is None: succeeded += 1 else: @@ -182,5 +124,8 @@ def _error_outcome(code: ErrorCode, message: str, warnings: list[str] | None = N ) +_file_error = file_error # прежнее имя: его берёт старый OcrNormalizer до задачи 4 + + # в конце модуля: `OcrNormalizer` берёт `NormalizeOutcome` и помощники ошибок из этого модуля лениво (цикл импортов) from src.normalizer.ocr import NO_TABLES_MESSAGE, OcrNormalizer # noqa: E402 diff --git a/api/src/normalizer/files.py b/api/src/normalizer/files.py new file mode 100644 index 0000000..1bbdd65 --- /dev/null +++ b/api/src/normalizer/files.py @@ -0,0 +1,84 @@ +"""Ошибки файлов склейки ответа ИС (успех файла — `error.code == ""`, №57); общие для нормализаторов.""" + +from dataclasses import dataclass + +from arm_statements import Rules, StatementWarning, WarningCode, make_warning +from src.contract import ErrorCode + +NOT_OBJECT_MESSAGE = "ответ ИС не является JSON-объектом" +NOT_JSON_MESSAGE = "ответ ИС не разбирается как JSON" +NOT_MERGED_MESSAGE = "склеенный ответ ИС не соответствует структуре {job_id, files: [...]}" +NO_FILES_MESSAGE = "в ответе ИС нет ни одного файла" +BAD_ENTRY_MESSAGE = "элемент files не соответствует структуре {name, response, parse_error}" + + +@dataclass(frozen=True) +class FileError: + """Ошибочный файл склейки: код для сводки, исходный код ИС и сообщение для строки `warnings`.""" + + name: str + code: ErrorCode # error.code сводки, если этот файл — первый ошибочный + source_code: str # код как получен от ИС (для parse_error и не-объекта — INVALID_FORMAT) + message: str # сообщение файла: строка warnings + summary: str # error.message сводки, если этот файл — первый ошибочный + + @property + def warning(self) -> str: + """Строка `warnings` прежнего формата (до задачи 4).""" + return f"{self.name}: {self.source_code}: {self.message}" + + def to_warning(self, rules: Rules) -> StatementWarning: + """Предупреждение `JSON_FAILED` пакета: уровень задаёт словарь правил.""" + return make_warning( + WarningCode.JSON_FAILED, f"{self.source_code}: {self.message}", rules, locator=f"file:{self.name}" + ) + + +def invalid_file(name: str, message: str) -> FileError: + code = ErrorCode.INVALID_FORMAT + return FileError(name, code, code.value, message, message) + + +def file_error(index: int, entry: object) -> FileError | None: + """Ошибка файла склейки или `None`, если файл успешен. + + Успех — `response.error.code == ""` (реальный успех ИС: `error = {"code": "", "message": ""}`); отсутствующий, + `null` или не-объектный `error`, а также `error` без кода (`code` нет или `null`) ошибкой не считаются (№57). + Ошибочен файл с непустым `error.code`, с `parse_error`, с корнем ответа не-объектом (`INVALID_FORMAT`). + """ + if not isinstance(entry, dict) or not isinstance(entry.get("name"), str) or not entry["name"]: + return invalid_file(f"files[{index}]", BAD_ENTRY_MESSAGE) + name: str = entry["name"] + parse_error = entry.get("parse_error") + if parse_error is not None: + return invalid_file(name, f"{NOT_JSON_MESSAGE}: {parse_error}") + response = entry.get("response") + if not isinstance(response, dict): + return invalid_file(name, NOT_OBJECT_MESSAGE) + raw_error = response.get("error") + if not isinstance(raw_error, dict) or raw_error.get("code") in (None, ""): + return None + return response_error(name, raw_error) + + +def response_error(name: str, raw_error: dict[str, object]) -> FileError: + """Ошибка ИС с непустым `error.code`: код контракта либо `UNKNOWN_ERROR` с исходным кодом в сообщении.""" + raw_code = raw_error.get("code") + source_code = raw_code if isinstance(raw_code, str) else repr(raw_code) + raw_message = raw_error.get("message") + if isinstance(raw_message, str) and raw_message: + message = raw_message + else: + # пустая строка и отсутствие поля — «нет сообщения»; значение иного типа показываем как есть + note = "" if raw_message in (None, "") else f" (поле message: {raw_message!r})" + message = f"ИС Сканирование вернула ошибку {source_code} без сообщения{note}" + + try: + code = ErrorCode(raw_code) if isinstance(raw_code, str) else None + except ValueError: + code = None + if code is not None: + return FileError(name, code, source_code, message, message) + # код вне перечня контракта: UNKNOWN_ERROR, исходный код — в сообщении (R-12) + summary = f"ИС Сканирование вернула неизвестный код ошибки {raw_code!r}: {message}" + return FileError(name, ErrorCode.UNKNOWN_ERROR, source_code, message, summary) diff --git a/api/src/normalizer/ocr/answer_set.py b/api/src/normalizer/ocr/answer_set.py new file mode 100644 index 0000000..9aa6c19 --- /dev/null +++ b/api/src/normalizer/ocr/answer_set.py @@ -0,0 +1,121 @@ +"""Обход набора json ИС: документы по таблицам, предупреждения файла, статус и код ошибки набора. + +Спека scan-gateway «Ошибки набора json», statement-normalization «Шаг 8 — статус документа и конверта». +Решения Р-32/Р-33: json без таблиц и «все json ошибочны» — `JSON_FAILED` на каждый файл; код и сообщение ошибки +набора — от первого ошибочного файла. +""" + +from collections.abc import Mapping +from dataclasses import dataclass + +from arm_statements import ( + EnvelopeStatus, + Rules, + StatementDocument, + StatementWarning, + WarningCode, + envelope_status, + make_warning, + normalize_candidate, +) +from src.contract import ErrorCode +from src.normalizer.files import NO_FILES_MESSAGE, NOT_MERGED_MESSAGE, FileError, file_error +from src.normalizer.ocr.adapter import header_cells, table_candidate + +__all__ = ["NO_TABLES_MESSAGE", "AnswerSetResult", "SetError", "normalize_answer_set"] + +NO_TABLES_MESSAGE = "ни одной таблицы не разобрано" +NO_DATA_OCR_MESSAGE = "ответ ИС без data_ocr" +NO_TABLE_STRUCTURE_MESSAGE = "ответ ИС без table_structure" +EMPTY_TABLES_MESSAGE = "в ответе ИС нет таблиц" +EXCEPTION_TEXT_LIMIT = 300 # P5-9 + + +@dataclass(frozen=True) +class SetError: + code: ErrorCode + message: str + + +@dataclass(frozen=True) +class AnswerSetResult: + """`error is not None` тогда и только тогда, когда `status is ERROR`.""" + + status: EnvelopeStatus + documents: tuple[StatementDocument, ...] + file_warnings: tuple[StatementWarning, ...] + error: SetError | None + + +def _set_error(code: ErrorCode, message: str) -> AnswerSetResult: + return AnswerSetResult(EnvelopeStatus.ERROR, (), (), SetError(code, message)) + + +def normalize_answer_set(raw: object, rules: Rules) -> AnswerSetResult: + if not isinstance(raw, dict) or not isinstance(raw.get("files"), list): + return _set_error(ErrorCode.INVALID_FORMAT, NOT_MERGED_MESSAGE) + files: list[object] = raw["files"] + if not files: + return _set_error(ErrorCode.INVALID_FORMAT, NO_FILES_MESSAGE) + + documents: list[StatementDocument] = [] + warnings: list[StatementWarning] = [] + first_error: FileError | None = None + for index, entry in enumerate(files): + error = file_error(index, entry) + if error is not None: + warnings.append(error.to_warning(rules)) + first_error = first_error or error + continue + assert isinstance(entry, dict) # проверено file_error + _normalize_file(entry, rules, documents, warnings) + + status = envelope_status([d.status for d in documents], warnings) + if status is not EnvelopeStatus.ERROR: + return AnswerSetResult(status, tuple(documents), tuple(warnings), None) + if first_error is not None: + error_info = SetError(first_error.code, first_error.summary) + else: + error_info = SetError(ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE) + return AnswerSetResult(status, (), tuple(warnings), error_info) + + +def _json_failed(name: str, message: str, rules: Rules) -> StatementWarning: + return make_warning(WarningCode.JSON_FAILED, message, rules, locator=f"file:{name}") + + +def _normalize_file( + entry: Mapping[str, object], rules: Rules, documents: list[StatementDocument], warnings: list[StatementWarning] +) -> None: + name = str(entry["name"]) + response = entry["response"] + assert isinstance(response, dict) # проверено file_error + data_ocr = response.get("data_ocr") + if not isinstance(data_ocr, dict): + warnings.append(_json_failed(name, NO_DATA_OCR_MESSAGE, rules)) + return + tables = data_ocr.get("table_structure") + if not isinstance(tables, list): + warnings.append(_json_failed(name, NO_TABLE_STRUCTURE_MESSAGE, rules)) + return + if not tables: + warnings.append(_json_failed(name, EMPTY_TABLES_MESSAGE, rules)) + return + header = header_cells(data_ocr) + for i, table in enumerate(tables): + try: + result = normalize_candidate(table_candidate(name, i, table, header, rules), rules) + except Exception as exc: # noqa: BLE001 -- изоляция разбора одной таблицы (P5-9) + text = f"{type(exc).__name__}: {str(exc)[:EXCEPTION_TEXT_LIMIT]}" + warnings.append( + make_warning( + WarningCode.TABLE_PARSE_ERROR, + f"BACKEND_PARSING_ERROR: {text}", + rules, + locator=f"file:{name};table:{i}", + ) + ) + continue + if result.document is not None: + documents.append(result.document) + warnings.extend(result.file_warnings) diff --git a/api/tests/unit/normalizer/ocr_builders.py b/api/tests/unit/normalizer/ocr_builders.py new file mode 100644 index 0000000..08d2e66 --- /dev/null +++ b/api/tests/unit/normalizer/ocr_builders.py @@ -0,0 +1,49 @@ +"""Сборщики ответов ИС для тестов нормализатора (набор json, задачи 3, 4 и 6).""" + +from typing import Any + +OK = {"code": "", "message": ""} +BAD = {"code": "INVALID_DOCUMENT", "message": "Не удалось определить тип документа."} +HEAD = { + "organization": {"form_name": "ООО «Пример»", "confidence": 100.0}, + "inn": {"form_name": "7700000000", "confidence": 100.0}, + "units": {"form_name": "тыс. руб.", "confidence": 100.0}, + "date": {"form_name": "на 31 декабря 2024 г.", "confidence": 100.0}, +} +BALANCE = [ + ("Наименование показателя", "Код", "На 31 декабря 2024 г."), + ("Запасы", "1210", "60"), + ("Дебиторы", "1230", "40"), + ("Итого II", "1200", "100"), + ("НМА", "1110", "50"), + ("Итого I", "1100", "50"), + ("БАЛАНС", "1600", "150"), + ("Капитал", "1310", "150"), + ("Итого III", "1300", "150"), + ("БАЛАНС", "1700", "150"), +] +INCOME = [ + ("Наименование показателя", "Код", "За 2024 г."), + ("Выручка", "2110", "100"), + ("Себестоимость", "2120", "(60)"), + ("Валовая прибыль", "2100", "40"), + ("Прибыль от продаж", "2200", "40"), +] + + +def ocr_table(label: str, rows: list[tuple[str, ...]]) -> dict[str, Any]: + return { + "label": {"form_name": label, "confidence": 100.0}, + "rows": [ + {"row_index": i, "cells": [{"column_index": c, "value": v, "confidence": 100.0} for c, v in enumerate(r)]} + for i, r in enumerate(rows) + ], + } + + +def response(*tables: dict[str, Any], error: dict[str, str] = OK, head: dict[str, Any] = HEAD) -> dict[str, Any]: + return {"file_uid": "x", "error": error, "data_ocr": {**head, "table_structure": list(tables)}} + + +def merged(*files: tuple[str, Any]) -> dict[str, Any]: + return {"job_id": "j", "files": [{"name": n, "response": r, "parse_error": None} for n, r in files]} diff --git a/api/tests/unit/normalizer/test_answer_set.py b/api/tests/unit/normalizer/test_answer_set.py new file mode 100644 index 0000000..f9adbc2 --- /dev/null +++ b/api/tests/unit/normalizer/test_answer_set.py @@ -0,0 +1,151 @@ +from datetime import date +from typing import Any + +import pytest + +import src.normalizer.ocr.answer_set as answer_set_module +from arm_statements import DocStatus, DocumentType, EnvelopeStatus, PeriodKind, Severity, default_rules +from src.contract import ErrorCode +from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE, normalize_answer_set +from tests.unit.normalizer.ocr_builders import BAD, BALANCE, HEAD, INCOME, OK, merged, ocr_table, response + +RULES = default_rules() + + +def test_one_json_two_forms_copy_header() -> None: + res = normalize_answer_set( + merged( + ( + "j.json", + response( + ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME) + ), + ) + ), + RULES, + ) + assert [(d.locator, d.document_type, d.header.form_name) for d in res.documents] == [ + ("file:j.json;table:0", DocumentType.BALANCE, "Бухгалтерский баланс"), + ("file:j.json;table:1", DocumentType.INCOME, "Отчет о финансовых результатах"), + ] + assert {d.header.inn.value for d in res.documents} == {"7700000000"} + assert res.documents[0].status is DocStatus.DONE and res.error is None + + +def test_lost_ofr_json_is_file_error() -> None: + res = normalize_answer_set( + merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE))), ("j_ofr.json", response(error=BAD))), + RULES, + ) + assert res.status is EnvelopeStatus.NEEDS_CHECK and len(res.documents) == 1 + assert [(w.code, w.severity, w.locator, w.message) for w in res.file_warnings] == [ + ("JSON_FAILED", Severity.ERROR, "file:j_ofr.json", "INVALID_DOCUMENT: Не удалось определить тип документа.") + ] + + +def test_table_exception_is_file_warning(monkeypatch: pytest.MonkeyPatch) -> None: + real = answer_set_module.normalize_candidate + + def flaky(candidate: Any, rules: Any = None) -> Any: + if candidate.locator.endswith("table:1"): + raise RuntimeError("сломалось") + return real(candidate, rules) + + monkeypatch.setattr(answer_set_module, "normalize_candidate", flaky) + res = normalize_answer_set( + merged( + ( + "j.json", + response( + ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME) + ), + ) + ), + RULES, + ) + assert [d.document_type for d in res.documents] == [DocumentType.BALANCE] + [w] = res.file_warnings + assert (w.code, w.severity, w.locator) == ("TABLE_PARSE_ERROR", Severity.ERROR, "file:j.json;table:1") + assert w.message == "BACKEND_PARSING_ERROR: RuntimeError: сломалось" + assert res.status is EnvelopeStatus.NEEDS_CHECK + + +def test_broken_table_structure_is_table_parse_error() -> None: + res = normalize_answer_set( + merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), {"label": {}, "rows": "x"}))), RULES + ) + assert [w.code for w in res.file_warnings] == ["TABLE_PARSE_ERROR"] and len(res.documents) == 1 + + +def test_text_only_table_skipped_and_no_documents() -> None: + res = normalize_answer_set(merged(("j.json", response(ocr_table("", [("Руководитель", "Иванов")])))), RULES) + assert [(w.code, w.severity, w.locator) for w in res.file_warnings] == [ + ("TABLE_SKIPPED", Severity.INFO, "file:j.json;table:0") + ] + assert res.status is EnvelopeStatus.ERROR + assert (res.error.code, res.error.message) == (ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE) + + +def test_lost_table_with_numbers() -> None: + res = normalize_answer_set( + merged( + ("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("", [("Итого", "", "15 000")]))) + ), + RULES, + ) + assert [(w.code, w.severity, w.locator) for w in res.file_warnings] == [ + ("TABLE_LOST", Severity.ERROR, "file:j.json;table:1") + ] + assert res.status is EnvelopeStatus.NEEDS_CHECK + + +def test_all_json_failed_first_code_and_all_listed() -> None: + res = normalize_answer_set(merged(("j.json", response(error=BAD)), ("j_ofr.json", "не объект")), RULES) + assert res.status is EnvelopeStatus.ERROR and res.documents == () + assert (res.error.code, res.error.message) == (ErrorCode.INVALID_DOCUMENT, BAD["message"]) + assert [(w.code, w.locator) for w in res.file_warnings] == [ + ("JSON_FAILED", "file:j.json"), + ("JSON_FAILED", "file:j_ofr.json"), + ] + + +@pytest.mark.parametrize( + ("data_ocr", "message"), + [ + (None, "ответ ИС без data_ocr"), + ({**HEAD, "table_structure": None}, "ответ ИС без table_structure"), + ({**HEAD, "table_structure": []}, "в ответе ИС нет таблиц"), + ], +) +def test_successful_json_without_tables(data_ocr: object, message: str) -> None: + res = normalize_answer_set(merged(("j.json", {"error": OK, "data_ocr": data_ocr})), RULES) + assert [(w.code, w.severity, w.message) for w in res.file_warnings] == [("JSON_FAILED", Severity.ERROR, message)] + assert res.error is not None and res.error.code is ErrorCode.RECOGNITION_FAILED + + +@pytest.mark.parametrize("raw", [None, {"files": "x"}, {"job_id": "j"}]) +def test_not_merged(raw: object) -> None: + res = normalize_answer_set(raw, RULES) + assert res.status is EnvelopeStatus.ERROR and res.error is not None + assert res.error.code is ErrorCode.INVALID_FORMAT + + +# Сквозной тест дат шлюза: строки OCR из test_parses_real_forms (правило — в пакете, план 1, задача 5) +@pytest.mark.parametrize( + ("label", "expected"), + [ + ("На 30 июня\n2025 г", date(2025, 6, 30)), + ("За январь - июнь\n2025 г.", date(2025, 6, 30)), + ("30 сентября 2025 r.", date(2025, 9, 30)), + ("3l.12.2О24", date(2024, 12, 31)), + ("9 мес. 2025", date(2025, 9, 30)), + ("30.09.2025 0:00:00", date(2025, 9, 30)), + ], +) +def test_ocr_column_dates_end_to_end(label: str, expected: date) -> None: + rows = [("Наименование показателя", "Код", label), *BALANCE[1:]] + head = {**HEAD, "date": {"form_name": "2|0|2|4|", "confidence": 75.0}} + res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", rows), head=head))), RULES) + doc = res.documents[0] + assert [(c.period_kind, c.period_date) for c in doc.columns] == [(PeriodKind.ABSOLUTE, expected)] + assert doc.header.report_date.value == date(2024, 12, 31)