From c1288d08fbf9b90b62ff52fb0929b60b0605f48d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 03:35:10 +0400 Subject: [PATCH] =?UTF-8?q?fix(normalizer):=20=D0=B8=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=E2=80=94=20Money,=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D1=87=D0=BD=D0=BE=D0=BC=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=B5,=20=D0=BF=D1=80=D0=BE=D0=BF=D1=83?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=B4=D0=BB=D0=B8=D0=BD=D0=B0=20=D0=B4=D0=B0?= =?UTF-8?q?=D1=82,=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=20yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C-1 (№30): сумма с более чем двумя знаками после запятой не принимается (amount=null, string_value, is_recognized=false); confidence ячеек и шапки приводится к [0, 100], NaN и бесконечность дают 0 - F-A (№28): ошибочный файл набора при успешном понижает статус до needs_check - F-B: «ИНН не распознан» только когда ИНН не извлечён, иначе «ИНН распознан неуверенно» - №29: предупреждения «строк с нечисловым кодом пропущено: N» и «строк без кода с суммами пропущено: N» - I-3 (№31): тексты дат длиннее 200 символов не разбираются и не проходят fix_lookalikes - T1.3 (№33): загрузчик yaml отклоняет повторяющиеся ключи; тавтологичный тест дублей заменён настоящим - гигиена: докстринг amounts.py описывает фактическое поведение (все точки удаляются), мёртвые noqa в ocr/__init__.py Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/normalizer/ocr/__init__.py | 15 +- api/src/normalizer/ocr/amounts.py | 18 +- api/src/normalizer/ocr/dates.py | 20 ++- api/src/normalizer/ocr/document.py | 29 +++- api/src/normalizer/ocr/header.py | 13 +- api/src/normalizer/ocr/layout.py | 8 +- api/src/normalizer/ocr/rows.py | 40 ++++- api/src/normalizer/ocr/text.py | 16 ++ api/src/normalizer/rules.py | 25 ++- api/tests/integration/test_roundtrip.py | 2 +- api/tests/unit/normalizer/test_amounts.py | 13 ++ api/tests/unit/normalizer/test_dates.py | 35 +++- api/tests/unit/normalizer/test_document.py | 162 ++++++++++++++++++ api/tests/unit/normalizer/test_header.py | 24 +++ api/tests/unit/normalizer/test_layout.py | 16 +- .../unit/normalizer/test_ocr_normalizer.py | 34 +++- api/tests/unit/normalizer/test_rows.py | 84 +++++++++ api/tests/unit/normalizer/test_rules.py | 62 ++++++- 18 files changed, 574 insertions(+), 42 deletions(-) diff --git a/api/src/normalizer/ocr/__init__.py b/api/src/normalizer/ocr/__init__.py index f949e12..78b2d00 100644 --- a/api/src/normalizer/ocr/__init__.py +++ b/api/src/normalizer/ocr/__init__.py @@ -23,7 +23,7 @@ FULL_CONFIDENCE = 100.0 class OcrNormalizer: """Нормализатор ответа ИС Сканирование: ошибочные файлы — как в Срезе 0, из успешных собираются документы по - одному на таблицу; статус — `done` только когда всё распознано (D-6).""" + одному на таблицу; статус — `done` только когда всё распознано и ни один файл набора не ошибочен (D-6, №28).""" def __init__(self, rules: OcrRules) -> None: self._rules = rules @@ -43,15 +43,17 @@ class OcrNormalizer: documents: list[StatementDocument] = [] fully_recognized = True succeeded = 0 + failed = 0 first_error = None for index, entry in enumerate(files): error = file_error(index, entry) if error is not None: warnings.append(error.warning) first_error = first_error or error + failed += 1 continue succeeded += 1 - assert isinstance(entry, dict) # noqa: S101 — проверено _file_error + assert isinstance(entry, dict) # проверено _file_error file_documents, file_warnings, file_recognized = self._normalize_file(entry) documents.extend(file_documents) warnings.extend(file_warnings) @@ -60,7 +62,8 @@ class OcrNormalizer: return error_outcome(first_error.code, first_error.summary, warnings) if not documents: return error_outcome(ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE, warnings) - status = ResultStatus.DONE if fully_recognized else ResultStatus.NEEDS_CHECK + # №28: потерянный ошибочный файл (например, ОФР при успешном балансе) не остаётся под `done` + status = ResultStatus.DONE if fully_recognized and not failed else ResultStatus.NEEDS_CHECK rows = sum(len(document.lines) for document in documents) return outcome_cls(status=status, documents=documents, metrics=Metrics(rows=rows), warnings=warnings) @@ -73,8 +76,10 @@ class OcrNormalizer: return [], [f"{name}: data_ocr отсутствует или не объект"], False header = parse_file_header(data_ocr, self._rules.lookalikes) warnings: list[str] = [] - if header.inn is None or header.inn_confidence != FULL_CONFIDENCE: + if header.inn is None: warnings.append(f"{name}: ИНН не распознан") + elif header.inn_confidence != FULL_CONFIDENCE: + warnings.append(f"{name}: ИНН распознан неуверенно") if header.report_date is None: warnings.append(f"{name}: дата отчёта не разобрана") tables = data_ocr.get("table_structure") @@ -84,7 +89,7 @@ class OcrNormalizer: prefix = f"{name}: table:{index}: " try: built = build_document(table if isinstance(table, Mapping) else {}, index, name, header, self._rules) - except Exception as exc: # noqa: BLE001 — §11.1 п. 9: ошибка одной таблицы не роняет файл + except Exception as exc: # §11.1 п. 9: ошибка одной таблицы не роняет файл warnings.append(f"{prefix}BACKEND_PARSING_ERROR: {type(exc).__name__}: {exc}") recognized = False continue diff --git a/api/src/normalizer/ocr/amounts.py b/api/src/normalizer/ocr/amounts.py index c79d92d..2158a96 100644 --- a/api/src/normalizer/ocr/amounts.py +++ b/api/src/normalizer/ocr/amounts.py @@ -1,6 +1,14 @@ -"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра без изменений: -запятая → точка, пробелы долой, содержимое круглых скобок — отрицательное, прочие символы отбрасываются, несколько -точек — разделители тысяч (остаётся последняя). `verified` — разбор не отбрасывал символов.""" +"""Суммы из OCR-ячеек (§11.1 п. 7). Перенос `_parse_amount`, `_is_blank` и регулярок старого ядра с одним отличием — +порядком вычисления `verified` (решение №13 design.md, см. комментарий в `parse_amount`). + +Что делает разбор: запятая → точка, пробелы долой; если есть круглые скобки — берётся содержимое первой пары, и сумма +отрицательная; всё, кроме цифр, `+`, `-` и `.`, отбрасывается; если точек больше одной, удаляются ВСЕ точки +(`1.234,56` → `123456`, а не `1234.56`: последняя точка тоже теряется, такая сумма всегда `verified = False`). +`verified` — разбор не отбрасывал символов (скобки в счёт не идут). Побочный эффект №13: скобки без содержимого +(`()`, `( )`, `(.)`) дают `(None, verified = True)`, в источнике — `False`. + +Количество знаков после запятой здесь не проверяется: `1,234` → `1.234`; ограничение `Money` (не больше двух знаков) — +в `document._value` (решение №30).""" import re from dataclasses import dataclass @@ -23,8 +31,8 @@ def parse_amount(raw: str) -> ParsedAmount: """Парсит сумму из распознанного текста OCR-ячейки. Заменяет запятую на точку, удаляет пробелы, извлекает содержимое круглых скобок - (означает отрицательное число), удаляет непотребные символы, обрабатывает множественные - точки как разделители тысяч (оставляет последнюю). + (означает отрицательное число), удаляет непотребные символы; если точек больше одной — считает их разделителями + тысяч и удаляет все (десятичная часть при этом не сохраняется: `1.234,56` → 123456). verified=True только если в исходной строке не пришлось отбрасывать символы (после удаления скобок). diff --git a/api/src/normalizer/ocr/dates.py b/api/src/normalizer/ocr/dates.py index c72e44b..0ea1a29 100644 --- a/api/src/normalizer/ocr/dates.py +++ b/api/src/normalizer/ocr/dates.py @@ -1,12 +1,18 @@ """Разбор дат из OCR-текста (§11.1 п. 2). Дословный перенос `date_utils` старого ядра DKB_ARMKA (проверен на стенде ИС Сканирование): текстовые формы по приоритету, затем числовые. Вход — текст после -`text.fix_lookalikes`; замена двойников здесь не выполняется.""" +`text.fix_lookalikes`; замена двойников здесь не выполняется. + +Единственное отличие от источника (решение №31 design.md): тексты длиннее `MAX_DATE_TEXT_LENGTH` символов не +разбираются — регулярки квартала и полугодия дают кубическое backtracking на длинных строках (`"1" + " " * 4000 + "b"` — +десятки секунд при удерживаемом GIL), а дата занимает не более десятков символов.""" import re from collections.abc import Callable from datetime import date, timedelta -__all__ = ["parse_date"] +__all__ = ["MAX_DATE_TEXT_LENGTH", "is_date_sized", "parse_date"] + +MAX_DATE_TEXT_LENGTH = 200 _ROMAN = {"I": 1, "II": 2, "III": 3, "IV": 4, "1V": 4, "11": 2, "111": 3} @@ -184,12 +190,18 @@ def _year(y: int) -> date | None: return date(y, 12, 31) if 1990 <= y <= 2100 else None # noqa: PLR2004 +def is_date_sized(text: str) -> bool: + """Текст не длиннее `MAX_DATE_TEXT_LENGTH` (по краям пробелы не считаются): только такой разбирается как дата. + Вызывающие проверяют это ДО `fix_lookalikes`, чтобы не гонять замену двойников по длинному тексту.""" + return len(text.strip()) <= MAX_DATE_TEXT_LENGTH + + def parse_date(value: object) -> date | None: - """Вернуть дату или None, если это не дата (название компании и т.п.).""" + """Вернуть дату или None, если это не дата (название компании и т.п.) или текст длиннее `MAX_DATE_TEXT_LENGTH`.""" if not isinstance(value, str): return None s = value.strip() - if not s: + if not s or not is_date_sized(s): return None # текстовые формы ищем по всей строке, числовые — по первой (дальше мусор) return _text_date(s) or _numeric_date(s.split("\n", 1)[0]) or _numeric_date(s.replace("\n", " ")) diff --git a/api/src/normalizer/ocr/document.py b/api/src/normalizer/ocr/document.py index 7d3cc6b..b56e8c6 100644 --- a/api/src/normalizer/ocr/document.py +++ b/api/src/normalizer/ocr/document.py @@ -5,11 +5,12 @@ D-6, D-7): тип по label или по кодам, колонки из рас from collections.abc import Mapping from dataclasses import dataclass from datetime import date +from decimal import Decimal from typing import Literal from src.contract.result import StatementColumn, StatementDocument, StatementHeader, StatementLine, StatementValue from src.normalizer.ocr.amounts import is_blank, parse_amount -from src.normalizer.ocr.dates import parse_date +from src.normalizer.ocr.dates import is_date_sized, parse_date from src.normalizer.ocr.header import FileHeader from src.normalizer.ocr.layout import Cell, cell_text from src.normalizer.ocr.rows import ParsedRow, ParsedTable, RowKind, parse_table_rows, table_type_by_codes @@ -20,6 +21,7 @@ __all__ = ["BuiltDocument", "build_document"] FULL_CONFIDENCE = 100.0 CAPPED_CONFIDENCE = 99.0 +MONEY_DECIMAL_PLACES = 2 DocumentType = Literal["BALANCE", "INCOME", "OTHER"] @@ -78,7 +80,8 @@ def _columns(parsed: ParsedTable, lookalikes: Mapping[str, str], build: _Build) for position, source_idx in enumerate(header.layout.value_idxs): cell = header.cells.get(source_idx) label = collapse_whitespace(cell.value) if cell is not None else "" - period = parse_date(fix_lookalikes(label, lookalikes)) if label else None + # решение №31: подпись длиннее даты не разбирается и не проходит `fix_lookalikes` + period = parse_date(fix_lookalikes(label, lookalikes)) if label and is_date_sized(label) else None if period is None and not parsed.synthetic_header: build.warn(f"колонка {position} без даты: {label}") columns.append(StatementColumn(column_index=position, label=label, period_date=period)) @@ -109,6 +112,11 @@ def _choose_edition( return edition +def _decimal_places(amount: Decimal) -> int: + exponent = amount.as_tuple().exponent + return -exponent if isinstance(exponent, int) and exponent < 0 else 0 + + def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValue: if cell is None: build.fully_recognized = False @@ -117,14 +125,20 @@ def _value(cell: Cell | None, column_index: int, build: _Build) -> StatementValu ) parsed = parse_amount(cell.value) text = collapse_whitespace(cell.value) + amount = parsed.amount + verified = parsed.verified + if amount is not None and _decimal_places(amount) > MONEY_DECIMAL_PLACES: + # решение №30: `Money` контракта — не больше двух знаков; «1,234» (пробел тысяч как запятая) — артефакт OCR, + # сумма не принимается, текст остаётся в string_value, иначе ValidationError ронял бы всю таблицу + amount, verified = None, False # DBML: string_value — «как распознано, если не число» (решение №16): только когда сумма не разобрана - string_value = text if parsed.amount is None and text and not is_blank(text) else None - recognized = cell.confidence == FULL_CONFIDENCE and parsed.verified + string_value = text if amount is None and text and not is_blank(text) else None + recognized = cell.confidence == FULL_CONFIDENCE and verified if not recognized: build.fully_recognized = False return StatementValue( column_index=column_index, - amount=parsed.amount, + amount=amount, string_value=string_value, confidence=cell.confidence, is_recognized=recognized, @@ -205,6 +219,11 @@ def build_document( data_rows = [row for row in parsed.rows[1:] if row.kind is not RowKind.HEADER] if any(len(row.layout.value_idxs) != len(columns) for row in data_rows): build.warn("повторная шапка изменила число колонок значений") # одно предупреждение на таблицу + # №29: строки, отброшенные разбором молча (мусорный код; пустой код при наличии сумм) — по одному предупреждению + if parsed.skipped_non_numeric_code: + build.warn(f"строк с нечисловым кодом пропущено: {parsed.skipped_non_numeric_code}") + if parsed.skipped_no_code_with_amounts: + build.warn(f"строк без кода с суммами пропущено: {parsed.skipped_no_code_with_amounts}") codes = [clean_value(cell_text(row.cells, row.layout.code_idx)) for row in data_rows] edition = _choose_edition(header, columns, codes, rules, build) lines: list[StatementLine] = [] diff --git a/api/src/normalizer/ocr/header.py b/api/src/normalizer/ocr/header.py index 7bbfb6b..0c7a579 100644 --- a/api/src/normalizer/ocr/header.py +++ b/api/src/normalizer/ocr/header.py @@ -5,8 +5,8 @@ from collections.abc import Mapping from dataclasses import dataclass from datetime import date -from src.normalizer.ocr.dates import parse_date -from src.normalizer.ocr.text import clean_value, collapse_whitespace, digits_only, fix_lookalikes +from src.normalizer.ocr.dates import is_date_sized, parse_date +from src.normalizer.ocr.text import clamp_confidence, clean_value, collapse_whitespace, digits_only, fix_lookalikes __all__ = ["FileHeader", "parse_file_header"] @@ -31,10 +31,8 @@ def _field(data_ocr: Mapping[str, object], name: str) -> tuple[str, float | None if not isinstance(raw, Mapping): return "", None text = raw.get("form_name") - confidence = raw.get("confidence") text_str = str(text) if isinstance(text, str) else "" - conf_float = float(confidence) if isinstance(confidence, (int, float)) else None - return (text_str, conf_float) + return (text_str, clamp_confidence(raw.get("confidence"))) def parse_file_header(data_ocr: Mapping[str, object], lookalikes: Mapping[str, str]) -> FileHeader: @@ -49,7 +47,10 @@ def parse_file_header(data_ocr: Mapping[str, object], lookalikes: Mapping[str, s inn_confidence = None elif inn_confidence is not None and inn_raw.strip() != inn: inn_confidence = min(inn_confidence, CAPPED_CONFIDENCE) - report_date = parse_date(fix_lookalikes(date_raw, lookalikes)) if date_raw.strip() else None + # решение №31: длинный текст не разбирается и не проходит `fix_lookalikes` + report_date = ( + parse_date(fix_lookalikes(date_raw, lookalikes)) if date_raw.strip() and is_date_sized(date_raw) else None + ) return FileHeader( organization_name=organization_name, organization_confidence=organization_confidence if organization_name is not None else None, diff --git a/api/src/normalizer/ocr/layout.py b/api/src/normalizer/ocr/layout.py index 79eccbf..8476a9d 100644 --- a/api/src/normalizer/ocr/layout.py +++ b/api/src/normalizer/ocr/layout.py @@ -5,7 +5,7 @@ from collections.abc import Collection, Mapping, Sequence from dataclasses import dataclass from src.normalizer.ocr.amounts import is_blank, parse_amount -from src.normalizer.ocr.text import is_code_header, is_value_header, normalize_header +from src.normalizer.ocr.text import clamp_confidence, is_code_header, is_value_header, normalize_header __all__ = ["Cell", "Cells", "ColumnLayout", "build_layout", "cell_text", "cells_by_index", "infer_layout"] @@ -31,7 +31,7 @@ class ColumnLayout: def cells_by_index(row: Mapping[str, object]) -> Cells: """Нормализует набор ячеек из распознанной строки в словарь по индексам колонок. - value → str(value or ""), confidence → float(confidence or 0); + value → str(value or ""), confidence → число из [0, 100] (`clamp_confidence`; не число → 0); ячейка без column_index пропускается. """ cells: Cells = {} @@ -41,9 +41,9 @@ def cells_by_index(row: Mapping[str, object]) -> Cells: for raw in raw_cells: if not isinstance(raw, Mapping) or not isinstance(raw.get("column_index"), int): continue - confidence = raw.get("confidence") + confidence = clamp_confidence(raw.get("confidence")) cells[int(raw["column_index"])] = Cell( - str(raw.get("value") or ""), float(confidence) if isinstance(confidence, int | float) else 0.0 + str(raw.get("value") or ""), confidence if confidence is not None else 0.0 ) return cells diff --git a/api/src/normalizer/ocr/rows.py b/api/src/normalizer/ocr/rows.py index f684b04..0f97eee 100644 --- a/api/src/normalizer/ocr/rows.py +++ b/api/src/normalizer/ocr/rows.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Literal +from src.normalizer.ocr.amounts import parse_amount from src.normalizer.ocr.layout import Cell, Cells, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout __all__ = ["ParsedRow", "ParsedTable", "RowKind", "classify_row", "parse_table_rows", "table_type_by_codes"] @@ -37,6 +38,10 @@ class ParsedTable: rows: list[ParsedRow] synthetic_header: bool + # решение №29: строки, молча отбрасываемые разбором (сам разбор — дословный перенос, он их не меняет); + # шапки (в том числе повторные) в счёт не идут + skipped_non_numeric_code: int = 0 # код непустой и не целое число (`classify_row` → HEADER) + skipped_no_code_with_amounts: int = 0 # код пуст, но в колонках значений раскладки строки есть разобранная сумма def classify_row(cells: Cells, layout: ColumnLayout) -> RowKind | None: @@ -62,6 +67,22 @@ def _data_row(cells: Cells, layout: ColumnLayout, ordinal: int) -> ParsedRow | N return ParsedRow(cells, layout, kind, ordinal) +@dataclass +class _Skipped: + non_numeric_code: int = 0 + no_code_with_amounts: int = 0 + + +def _count_skipped(cells: Cells, layout: ColumnLayout, skipped: _Skipped) -> None: + """Строка отброшена `_data_row`: считаем её, если потеряна возможная строка данных (с кодом-мусором или с суммами); + пустой код без сумм — заголовок группы, «в том числе:» — не считается.""" + kind = classify_row(cells, layout) + if kind is RowKind.HEADER: + skipped.non_numeric_code += 1 + elif kind is None and any(parse_amount(cell_text(cells, idx)).amount is not None for idx in layout.value_idxs): + skipped.no_code_with_amounts += 1 + + def _synthetic_header_cells(layout: ColumnLayout) -> Cells: """Ячейки восстановленного заголовка: код и наименование, тексты периодов пустые (ячеек под значениями нет).""" cells: Cells = {layout.code_idx: Cell(HEADER_CODE, FULL_CONFIDENCE)} @@ -83,18 +104,26 @@ def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Co before_header = rows_cells[:first_header] inferred = infer_layout([cells for _, cells in before_header], known_codes) if before_header else None leading_rows: list[ParsedRow] = [] + skipped = _Skipped() if inferred is not None: for ordinal, cells in before_header: parsed = _data_row(cells, inferred, ordinal) if parsed is not None: leading_rows.append(parsed) + else: + _count_skipped(cells, inferred, skipped) if first_header == len(rows_cells): if not leading_rows or inferred is None: return None header_ordinal = min(p.ordinal for p in leading_rows) - 1 header = ParsedRow(_synthetic_header_cells(inferred), inferred, RowKind.HEADER, header_ordinal) - return ParsedTable([header, *leading_rows], synthetic_header=True) + return ParsedTable( + [header, *leading_rows], + synthetic_header=True, + skipped_non_numeric_code=skipped.non_numeric_code, + skipped_no_code_with_amounts=skipped.no_code_with_amounts, + ) header_row_index, header_cells = rows_cells[first_header] layout = build_layout(header_cells) @@ -110,7 +139,14 @@ def parse_table_rows(table_rows: Sequence[Mapping[str, object]], known_codes: Co parsed = _data_row(cells, layout, ordinal) if parsed is not None: parsed_rows.append(parsed) - return ParsedTable(parsed_rows, synthetic_header=False) + else: + _count_skipped(cells, layout, skipped) + return ParsedTable( + parsed_rows, + synthetic_header=False, + skipped_non_numeric_code=skipped.non_numeric_code, + skipped_no_code_with_amounts=skipped.no_code_with_amounts, + ) def table_type_by_codes(parsed: ParsedTable) -> Literal["BALANCE", "INCOME"] | None: diff --git a/api/src/normalizer/ocr/text.py b/api/src/normalizer/ocr/text.py index c4bc441..d396d02 100644 --- a/api/src/normalizer/ocr/text.py +++ b/api/src/normalizer/ocr/text.py @@ -2,10 +2,12 @@ `_keep_digits_only`, `_remove_spec_space`, `_normalize_header`, `_is_code_header`, `_is_value_header` старого ядра; алгоритмы без изменений.""" +import math import re from collections.abc import Mapping __all__ = [ + "clamp_confidence", "clean_value", "collapse_whitespace", "digits_only", @@ -38,6 +40,20 @@ def fix_lookalikes(text: str, lookalikes: Mapping[str, str]) -> str: return text +def clamp_confidence(raw: object) -> float | None: + """Уверенность OCR как число из [0, 100]: не число → `None`; NaN, бесконечность и целое вне диапазона float → 0; + остальное зажимается в [0, 100] (решение №30: `json.loads` принимает `NaN`/`Infinity`, контракт требует 0..100).""" + if not isinstance(raw, int | float): + return None + try: + value = float(raw) + except OverflowError: # целое больше диапазона float + return 0.0 + if not math.isfinite(value): + return 0.0 + return min(max(value, 0.0), 100.0) + + def clean_value(text: str) -> str: """Остаются буквы, цифры, пробел и точка; переносы строк и табуляции — пробел; края обрезаются.""" cleaned = "".join( diff --git a/api/src/normalizer/rules.py b/api/src/normalizer/rules.py index 9a12d23..27245b1 100644 --- a/api/src/normalizer/rules.py +++ b/api/src/normalizer/rules.py @@ -2,7 +2,8 @@ `statement_lines.yaml` — редакции форм (`66n` до 2024-12-31, `fsbu4` с 2025-01-01) с таблицей код → наименование; `ocr_lookalikes.yaml` — двойники букв и цифр; `statement_labels.yaml` — подстроки `label` таблицы → тип документа. -Битый файл — `ValueError` с именем файла (сервис не стартует). +Битый файл — `ValueError` с именем файла (сервис не стартует); повторяющиеся ключи yaml (код строки дважды) — тоже +ошибка (№33). """ from collections.abc import Iterable @@ -110,6 +111,26 @@ class OcrRules(BaseModel): return [edition.id for edition in self.editions if code in edition.lines] +class _UniqueKeyLoader(yaml.SafeLoader): + """`SafeLoader`, отклоняющий повторяющиеся ключи отображений (№33): `safe_load` молча оставляет последнее значение, + и повторный код в справочнике терялся бы без следа.""" + + def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[object, object]: + seen: set[object] = set() + for key_node, _ in node.value: + key = self.construct_object(key_node, deep=True) + try: + duplicate = key in seen + seen.add(key) + except TypeError: # нехешируемый ключ — его отклонит базовый `construct_mapping` + continue + if duplicate: + raise yaml.constructor.ConstructorError( + "при разборе отображения", node.start_mark, f"повторяющийся ключ {key!r}", key_node.start_mark + ) + return super().construct_mapping(node, deep=deep) + + def _read_yaml(directory: Path, name: str) -> object: path = directory / name try: @@ -117,7 +138,7 @@ def _read_yaml(directory: Path, name: str) -> object: except OSError as exc: raise ValueError(f"справочник {name} не прочитан ({path}): {exc}") from exc try: - return yaml.safe_load(text) + return yaml.load(text, Loader=_UniqueKeyLoader) # SafeLoader с проверкой дублей except yaml.YAMLError as exc: raise ValueError(f"справочник {name} не разобран как YAML: {exc}") from exc diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py index 0375d08..0761271 100644 --- a/api/tests/integration/test_roundtrip.py +++ b/api/tests/integration/test_roundtrip.py @@ -556,7 +556,7 @@ async def test_real_fixture_aep_3_two_files_merged_needs_check_with_documents(ro # успешный файл даёт предупреждения нормализации warnings = result.body["metrics"]["warnings"] assert warnings[-1] == f"{job_id}_ofr.json: INVALID_DOCUMENT: {ofr_error['message']}" - assert f"{job_id}.json: ИНН не распознан" in warnings + assert f"{job_id}.json: ИНН распознан неуверенно" in warnings # в aep_3 ИНН извлечён, но confidence < 100 assert f"{job_id}.json: table:0: шапка синтезирована: колонки без дат" in warnings expected = _golden("aep_3", job_id) if expected is not None: # эталон нормализатора (`make golden-update`) совпадает со сквозным результатом diff --git a/api/tests/unit/normalizer/test_amounts.py b/api/tests/unit/normalizer/test_amounts.py index 6f0e24f..5c6474c 100644 --- a/api/tests/unit/normalizer/test_amounts.py +++ b/api/tests/unit/normalizer/test_amounts.py @@ -21,6 +21,19 @@ from src.normalizer.ocr.amounts import ParsedAmount, is_blank, parse_amount ("", None, True), ("--", None, True), # прочерк: ничего не отброшено, суммы нет (NULL) (":", None, False), + # несколько точек: удаляются ВСЕ точки, десятичная часть теряется (`1.234,56` — не 1234.56), всегда не verified + ("1.234,56", Decimal("123456"), False), + ("1,234.56", Decimal("123456"), False), + ( + "1,234", + Decimal("1.234"), + True, + ), # знаков после запятой больше двух — `Money` отсекает `document._value` (№30) + # №13: скобки без содержимого — не сумма, но и не «неразобранный текст» + ("()", None, True), + ("( )", None, True), + ("(.)", None, True), + ("(+5)", Decimal("-5"), True), # в скобках знак заменяется минусом ], ) def test_parse_amount(raw: str, amount: Decimal | None, verified: bool) -> None: diff --git a/api/tests/unit/normalizer/test_dates.py b/api/tests/unit/normalizer/test_dates.py index f8ae5f6..fe659ce 100644 --- a/api/tests/unit/normalizer/test_dates.py +++ b/api/tests/unit/normalizer/test_dates.py @@ -1,10 +1,11 @@ """Разбор дат из OCR-текста (§11.1 п. 2): дословный перенос `date_utils` старого ядра, вход — после `fix_lookalikes`.""" +import time from datetime import date import pytest -from src.normalizer.ocr.dates import parse_date +from src.normalizer.ocr.dates import MAX_DATE_TEXT_LENGTH, is_date_sized, parse_date from src.normalizer.ocr.text import fix_lookalikes LOOKALIKES = {"О": "0", "о": "0", "З": "3", "l": "1", "I": "1", "|": "1", "б": "6", "S": "5", "Ч": "4"} @@ -68,3 +69,35 @@ def test_non_string_is_none() -> None: def test_year_out_of_range_is_none() -> None: assert parse_date("1 2 3 4") is None # 1234 вне 1990–2100 + + +class TestLengthCap: + """Решение №31: текст длиннее `MAX_DATE_TEXT_LENGTH` не разбирается; патологические строки не вешают процесс.""" + + PATHOLOGICAL = { + "spaces-then-letter": "1" + " " * 4000 + "b", + "newlines": "1" + "\n" * 4000, # после strip — «1»: короткий текст, отсекается не длиной + "newlines-inside": "1" + "\n" * 4000 + "b", + "lookalikes-after-digit": "1" + "О" * 5000, + } + + @pytest.mark.parametrize("raw", PATHOLOGICAL.values(), ids=PATHOLOGICAL.keys()) + def test_pathological_text_is_fast_and_none(self, raw: str) -> None: + start = time.perf_counter() + assert parse_date(raw) is None + assert parse_date(fix_lookalikes(raw, LOOKALIKES)) is None if is_date_sized(raw) else True + assert time.perf_counter() - start < 1.0 + + def test_long_text_is_not_date_sized(self) -> None: + assert not is_date_sized("1" + " " * 4000 + "b") and not is_date_sized("1" + "О" * 5000) + + def test_boundary(self) -> None: + date_text = "на 30 сентября 2025 г." + padded = date_text + " " * 300 # пробелы по краям не считаются + assert is_date_sized(padded) and parse_date(padded) == date(2025, 9, 30) + at_limit = date_text + "x" * (MAX_DATE_TEXT_LENGTH - len(date_text)) + assert len(at_limit) == MAX_DATE_TEXT_LENGTH and is_date_sized(at_limit) + assert parse_date(at_limit) == date(2025, 9, 30) # на пределе дата ещё разбирается + over = date_text + "x" * (MAX_DATE_TEXT_LENGTH + 1 - len(date_text)) + assert len(over) == MAX_DATE_TEXT_LENGTH + 1 and not is_date_sized(over) + assert parse_date(over) is None # одним символом больше — не разбирается, хотя дата в начале есть diff --git a/api/tests/unit/normalizer/test_document.py b/api/tests/unit/normalizer/test_document.py index 263f2e0..b16a205 100644 --- a/api/tests/unit/normalizer/test_document.py +++ b/api/tests/unit/normalizer/test_document.py @@ -1,5 +1,7 @@ """Сборка `statementDocument` из таблицы OCR (spec «Нормализация ответа ИС», D-6, D-7).""" +import json +import time from dataclasses import replace from datetime import date from decimal import Decimal @@ -422,3 +424,163 @@ class TestKnownCodeConfidence: assert line.line_code == "1150" and line.line_name == "Основные средства" assert line.line_code_confidence == 99 and line.line_name_confidence == 99 assert built.fully_recognized is False + + +class TestMoneyPrecision: + """Решение №30: `Money` контракта — не больше двух знаков; ячейка с большим числом знаков не валит таблицу.""" + + @pytest.mark.parametrize("text", ["1,234", "0,125", "12.3456", "(1,234)"]) + def test_more_than_two_decimals_is_not_an_amount(self, text: str) -> None: + table = _table( + "Бухгалтерский баланс", + CLEAN_BALANCE["rows"][0], + _row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)), + ) + built = _build(table) + value = built.document.lines[0].values[0] + assert value.amount is None and value.string_value == text and value.is_recognized is False + assert value.confidence == 100 + assert built.fully_recognized is False + + def test_neighbours_stay_intact_and_document_is_built(self) -> None: + table = _table( + "Бухгалтерский баланс", + CLEAN_BALANCE["rows"][0], + _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1,234", 100), (3, "56,78", 100)), + _row(2, (0, "НМА", 100), (1, "1110", 100), (2, "7", 100), (3, "8,5", 100)), + ) + built = _build(table) + first, second = built.document.lines + assert (first.values[0].amount, first.values[0].string_value) == (None, "1,234") + assert (first.values[1].amount, first.values[1].is_recognized) == (Decimal("56.78"), True) + assert (second.values[0].amount, second.values[0].is_recognized) == (Decimal("7"), True) + assert (second.values[1].amount, second.values[1].is_recognized) == (Decimal("8.5"), True) + built.document.to_wire() # контрактная модель сериализуется без ValidationError + + @pytest.mark.parametrize("text", ["1,23", "1,2", "0", "-5", "1 234,50"]) + def test_up_to_two_decimals_is_kept(self, text: str) -> None: + table = _table( + "Бухгалтерский баланс", + CLEAN_BALANCE["rows"][0], + _row(1, (0, "ОС", 100), (1, "1150", 100), (2, text, 100), (3, "2", 100)), + ) + value = _build(table).document.lines[0].values[0] + assert value.amount is not None and value.string_value is None and value.is_recognized is True + + +class TestConfidenceRange: + """Решение №30: confidence ячеек приводится к [0, 100], NaN — в 0 (контракт требует 0..100).""" + + @pytest.mark.parametrize( + ("raw", "expected"), [(float("nan"), 0), (150, 100), (-5, 0), (float("inf"), 0), (float("-inf"), 0)] + ) + def test_out_of_range_cell_confidence(self, raw: float, expected: float) -> None: + table = _table( + "Бухгалтерский баланс", + CLEAN_BALANCE["rows"][0], + _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "5", raw), (3, "2", 100)), + ) + built = _build(table) + assert built.document.lines[0].values[0].confidence == expected + built.document.to_wire() + + def test_header_with_nan_confidence_builds_document(self) -> None: + header = replace(HEADER_2024, inn_confidence=0.0, organization_confidence=0.0) + built = _build(CLEAN_BALANCE, header) + assert built.document.header.inn_confidence == 0 and built.fully_recognized is False + + +class TestSkippedRowsWarnings: + """Решение №29: отброшенные строки — по одному табличному предупреждению каждого вида, needs_check.""" + + def test_both_warnings_and_not_fully_recognized(self) -> None: + table = _table( + "Бухгалтерский баланс", + CLEAN_BALANCE["rows"][0], + _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), + _row(2, (0, "НМА", 100), (1, "1110 :", 100), (2, "3", 100), (3, "4", 100)), + _row(3, (0, "Мусор", 100), (1, "11x0", 100), (2, "3", 100), (3, "4", 100)), + _row(4, (0, "БАЛАНС", 100), (1, "", 100), (2, "5", 100), (3, "6", 100)), + _row(5, (0, "Итого", 100), (1, "", 100), (2, "7", 100), (3, "", 100)), + _row(6, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)), # без сумм — без счёта + ) + built = _build(table) + assert [line.line_code for line in built.document.lines] == ["1150"] + assert built.warnings == [ + "строк с нечисловым кодом пропущено: 2", + "строк без кода с суммами пропущено: 2", + ] + assert built.fully_recognized is False + + def test_group_titles_without_amounts_are_silent(self) -> None: + table = _table( + "Бухгалтерский баланс", + CLEAN_BALANCE["rows"][0], + _row(1, (0, "I. Внеоборотные активы", 100), (1, "", 100)), + _row(2, (0, "в том числе:", 100), (1, "", 100), (2, "", 100), (3, "-", 100)), + _row(3, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100), (3, "2", 100)), + ) + built = _build(table) + assert built.warnings == [] and built.fully_recognized is True + + def test_aep_like_table_with_synthetic_header(self) -> None: + """Как в aep_3: пустой код у «Выручка» (мусор «211(1 |» — в колонке значения) и мусорный код «2.10».""" + table = _table( + "Отчет о финансовых результатах", + _row(0, (0, "Выручка", 100), (1, "", 100), (2, "211(1 |", 71), (3, "8096738", 100)), + _row(1, (0, "Себестоимость продаж", 100), (1, "2120", 100), (2, "(5)", 100), (3, "(6)", 100)), + _row(2, (0, "Доходы от участия", 100), (1, "2.10", 100), (2, "5060", 100), (3, "*", 100)), + _row(3, (0, "Валовая прибыль", 100), (1, "2100", 100), (2, "1", 100), (3, "2", 100)), + ) + built = _build(table) + assert [line.line_code for line in built.document.lines] == ["2120", "2100"] + assert "строк с нечисловым кодом пропущено: 1" in built.warnings + assert "строк без кода с суммами пропущено: 1" in built.warnings + assert "шапка синтезирована: колонки без дат" in built.warnings + + +@pytest.mark.fixtures +class TestSkippedRowsRealFixtures: + @pytest.mark.parametrize( + ("name", "index", "expected"), + [ + ("aep_3.json", 0, ["строк с нечисловым кодом пропущено: 8", "строк без кода с суммами пропущено: 2"]), + ("aep_3.json", 1, ["строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 2"]), + ("aep_6.json", 0, ["строк с нечисловым кодом пропущено: 2", "строк без кода с суммами пропущено: 19"]), + ("aep_6.json", 1, ["строк с нечисловым кодом пропущено: 1", "строк без кода с суммами пропущено: 2"]), + ], + ) + def test_warnings_on_real_tables(self, name: str, index: int, expected: list[str]) -> None: + real = Path(API_DIR).parent.parent / "fixtures" / "ocr" / name + if not real.is_file(): + pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace") + data_ocr = json.loads(real.read_text(encoding="utf-8"))["data_ocr"] + built = build_document(data_ocr["table_structure"][index], index, "job.json", HEADER_2024, RULES) + assert built is not None + skipped = [w for w in built.warnings if w.startswith(("строк с нечисловым", "строк без кода"))] + assert skipped == expected + assert built.fully_recognized is False + + +@pytest.mark.parametrize( + "label", + [ + "на 2024" + " " * 4000 + "b", + "на 2024" + "\n" * 4000 + "b", + "на 2024" + "О" * 5000, + ], # «на » — признак колонки значений + ids=["spaces", "nl", "look"], +) +def test_long_column_label_is_fast_and_has_no_date(label: str) -> None: + """Решение №31: подпись колонки длиннее даты не разбирается и не проходит `fix_lookalikes` (нет квадратичного + роста через `_columns`).""" + table = _table( + "Бухгалтерский баланс", + _head(label), + _row(1, (0, "ОС", 100), (1, "1150", 100), (2, "1", 100)), + ) + start = time.perf_counter() + built = _build(table) + assert time.perf_counter() - start < 1.0 + assert built.document.columns[0].period_date is None and built.fully_recognized is False + assert any(w.startswith("колонка 0 без даты") for w in built.warnings) diff --git a/api/tests/unit/normalizer/test_header.py b/api/tests/unit/normalizer/test_header.py index 0551a44..494e042 100644 --- a/api/tests/unit/normalizer/test_header.py +++ b/api/tests/unit/normalizer/test_header.py @@ -1,6 +1,7 @@ """Шапка документа из `data_ocr` (§11.1 п. 8) на реальных `aep_3.json` (мусор) и `aep_6.json` (чисто).""" import json +import time from datetime import date from pathlib import Path @@ -52,6 +53,29 @@ def test_noisy_inn_caps_confidence() -> None: assert header.organization_name is None and header.organization_confidence is None +@pytest.mark.parametrize(("raw", "expected"), [(float("nan"), 0.0), (150, 100.0), (-5, 0.0), (float("inf"), 0.0)]) +def test_header_confidence_is_clamped(raw: float, expected: float) -> None: + """Решение №30: уверенность полей шапки — в [0, 100], NaN → 0 (контракт требует 0..100).""" + header = parse_file_header( + {"organization": _field("ООО Ромашка", raw), "inn": _field("7700000000", raw), "date": _field("2024", raw)}, + LOOKALIKES, + ) + assert header.organization_confidence == expected and header.inn_confidence == expected + assert header.report_date_recognized is (expected == 100.0) + + +@pytest.mark.parametrize( + "raw", ["1" + " " * 4000 + "b", "1" + "\n" * 4000, "1" + "О" * 5000], ids=["spaces", "nl", "look"] +) +def test_pathological_report_date_is_fast(raw: str) -> None: + """Решение №31: длинный текст даты не разбирается и не проходит `fix_lookalikes` (иначе — секунды и минуты).""" + start = time.perf_counter() + header = parse_file_header({"date": _field(raw, 100)}, LOOKALIKES) + assert time.perf_counter() - start < 1.0 + assert header.report_date is None and header.report_date_recognized is False + assert header.report_date_text == raw.strip() # исходный текст не теряется + + def test_missing_fields() -> None: header = parse_file_header({}, LOOKALIKES) assert header == FileHeader(None, None, None, None, None, None, None, False) diff --git a/api/tests/unit/normalizer/test_layout.py b/api/tests/unit/normalizer/test_layout.py index 91020d9..3eae623 100644 --- a/api/tests/unit/normalizer/test_layout.py +++ b/api/tests/unit/normalizer/test_layout.py @@ -2,6 +2,8 @@ Перенос `_build_layout`/`_infer_layout` из старого ядра.""" +import pytest + from src.normalizer.ocr.layout import Cell, ColumnLayout, build_layout, cell_text, cells_by_index, infer_layout KNOWN = frozenset({"1110", "1120", "1130", "1150", "2110", "2120"}) @@ -19,6 +21,16 @@ def test_cells_by_index_normalizes_types() -> None: assert cell_text(cells, None) == "" +@pytest.mark.parametrize( + ("raw", "expected"), + [(float("nan"), 0.0), (150, 100.0), (-5, 0.0), (float("inf"), 0.0), (10**400, 0.0), ("90", 0.0), (None, 0.0)], +) +def test_cells_by_index_clamps_confidence(raw: object, expected: float) -> None: + """Решение №30: `json.loads` принимает NaN/Infinity — уверенность ячейки приводится к [0, 100], NaN → 0.""" + row = {"cells": [{"column_index": 0, "value": "x", "confidence": raw}]} + assert cells_by_index(row)[0].confidence == expected + + class TestBuildLayout: def test_header_with_code_and_years(self) -> None: cells = cells_by_index( @@ -84,8 +96,8 @@ class TestInferLayout: assert layout is not None assert layout.code_idx == 1 assert layout.label_idx == 0 - # R-1: column 5 contains only garbage (empty, ':', '+ :'), no actual amounts - # column 4 has both garbage and amounts ('--', '3', '5 678') but amounts >= garbage + # R-1: в колонке 5 только мусор (пусто, ':', '+ :'), настоящих сумм нет + # в колонке 4 и мусор, и суммы ('--', '3', '5 678'), но сумм не меньше, чем мусора assert layout.value_idxs == (2, 4) def test_no_known_codes_is_none(self) -> None: diff --git a/api/tests/unit/normalizer/test_ocr_normalizer.py b/api/tests/unit/normalizer/test_ocr_normalizer.py index c3f0015..19e0a15 100644 --- a/api/tests/unit/normalizer/test_ocr_normalizer.py +++ b/api/tests/unit/normalizer/test_ocr_normalizer.py @@ -123,8 +123,31 @@ class TestStatus: noisy["data_ocr"]["date"] = {"form_name": "Общество", "confidence": 100} outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy))) assert f"{JOB}.json: ИНН не распознан" in outcome.warnings + assert not any("ИНН распознан неуверенно" in w for w in outcome.warnings) assert f"{JOB}.json: дата отчёта не разобрана" in outcome.warnings + def test_inn_found_but_unsure_is_worded_differently(self) -> None: + """F-B: «не распознан» — только когда ИНН не извлечён; извлечённый с confidence < 100 — «неуверенно».""" + unsure = copy.deepcopy(CLEAN) + unsure["data_ocr"]["inn"] = {"form_name": "7700000000", "confidence": 75} + outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", unsure))) + assert outcome.status is ResultStatus.NEEDS_CHECK + assert f"{JOB}.json: ИНН распознан неуверенно" in outcome.warnings + assert not any("ИНН не распознан" in w for w in outcome.warnings) + + def test_inn_with_noise_is_unsure(self) -> None: + """ИНН извлечён из мусора (confidence ограничивается 99) — тоже «неуверенно», а не «не распознан».""" + noisy = copy.deepcopy(CLEAN) + noisy["data_ocr"]["inn"] = {"form_name": "77 0000 0000 |", "confidence": 100} + outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", noisy))) + assert outcome.warnings == [f"{JOB}.json: ИНН распознан неуверенно"] + + def test_inn_missing_field_is_not_recognized(self) -> None: + missing = copy.deepcopy(CLEAN) + del missing["data_ocr"]["inn"] + outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", missing))) + assert outcome.warnings == [f"{JOB}.json: ИНН не распознан"] + class TestErrorFilesStillHandled: def test_all_files_failed_keeps_first_code(self) -> None: @@ -137,8 +160,17 @@ class TestErrorFilesStillHandled: def test_mixed_files(self) -> None: failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}} outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN), (f"{JOB}_ofr.json", failed))) - assert outcome.status is ResultStatus.DONE # ошибочный файл — предупреждение, не needs_check + # №28: потерянный ошибочный файл не остаётся под `done`, даже если успешный файл распознан полностью + assert outcome.status is ResultStatus.NEEDS_CHECK + assert outcome.error is None and len(outcome.documents) == 1 assert outcome.warnings == [f"{JOB}_ofr.json: INVALID_DOCUMENT: нет"] + assert OcrNormalizer(RULES).normalize(_merged((f"{JOB}.json", CLEAN))).status is ResultStatus.DONE + + def test_mixed_files_error_first_in_order(self) -> None: + """Порядок файлов не важен: ошибочный первым, успешный вторым — тоже needs_check с документом.""" + failed = {"file_uid": JOB, "error": {"code": "INVALID_DOCUMENT", "message": "нет"}, "data_ocr": {}} + outcome = OcrNormalizer(RULES).normalize(_merged((f"{JOB}_a.json", failed), (f"{JOB}_b.json", CLEAN))) + assert outcome.status is ResultStatus.NEEDS_CHECK and len(outcome.documents) == 1 def test_not_merged_structure(self) -> None: outcome = OcrNormalizer(RULES).normalize({"job_id": JOB}) diff --git a/api/tests/unit/normalizer/test_rows.py b/api/tests/unit/normalizer/test_rows.py index d8b8d6a..c2c2c5c 100644 --- a/api/tests/unit/normalizer/test_rows.py +++ b/api/tests/unit/normalizer/test_rows.py @@ -121,6 +121,90 @@ class TestParseTableRows: assert table_type_by_codes(parsed) == "BALANCE" +class TestSkippedRows: + """Решение №29: отброшенные строки считаются, не меняя сам разбор (строки данных те же, что и раньше).""" + + def test_clean_table_counts_nothing(self) -> None: + rows = [HEADER, _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "5", 100), (4, "6", 100))] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None + assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (0, 0) + + def test_non_numeric_code_is_counted(self) -> None: + rows = [ + HEADER, + _row(1, (0, "ОС", 100), (1, "1150 :", 100), (2, "5", 100), (4, "6", 100)), + _row(2, (0, "НМА", 100), (1, "1l10", 100), (2, "5", 100), (4, "6", 100)), # латинская l вместо единицы + _row(3, (0, "Выручка", 100), (1, "2110", 100), (2, "5", 100), (4, "6", 100)), + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None + assert [r.ordinal for r in parsed.rows] == [0, 3] # разбор прежний: строки с кодом-мусором отброшены + assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (2, 0) + + def test_empty_code_counted_only_with_amount_in_value_columns(self) -> None: + rows = [ + HEADER, + _row(1, (0, "БАЛАНС", 100), (1, "", 100), (2, "5 553 383", 100), (4, "", 100)), # сумма — считается + _row(2, (0, "Итого", 100), (4, "(77)", 100)), # ячейки кода нет вовсе, сумма в колонке 4 — считается + _row(3, (0, "в том числе:", 93), (1, "", 100), (2, "", 100), (4, "-", 100)), # прочерки — не сумма + _row(4, (0, "Раздел I", 100), (1, "", 100), (2, "текст", 100)), # текст — не сумма + _row(5, (0, "Справочно", 100), (1, "", 100), (3, "9", 100)), # сумма вне колонок значений — не считается + _row(6, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)), + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None and [r.ordinal for r in parsed.rows] == [0, 6] + assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (0, 2) + + def test_repeated_header_is_not_counted_and_later_rows_use_new_layout(self) -> None: + rows = [ + HEADER, + _row(1, (0, "Выручка", 100), (1, "2110", 100), (2, "1", 100)), + _row(2, (0, "Наименование показателя", 100), (1, "Код", 100), (3, "За 2024 г.", 100)), + _row(3, (0, "Мусор", 100), (1, "21x0", 100), (3, "7", 100)), + _row(4, (0, "БАЛАНС", 100), (1, "", 100), (3, "8", 100)), # сумма в колонке новой раскладки (3) + _row(5, (0, "Прочее", 100), (1, "", 100), (2, "8", 100)), # колонка 2 в новой раскладке — не значение + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None and [r.ordinal for r in parsed.rows] == [0, 1] + assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (1, 1) + + def test_leading_rows_before_header_are_counted(self) -> None: + rows = [ + _row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)), + _row(1, (1, "11x0", 100), (0, "Мусор", 100), (2, "5", 100)), + _row(2, (1, "", 100), (0, "БАЛАНС", 100), (2, "9", 100)), + _row(3, (1, "", 100), (0, "в том числе:", 100), (2, "", 100)), + _row(4, (0, "Наименование показателя", 100), (1, "Код", 100), (2, "2024", 100)), + _row(5, (0, "Основные средства", 100), (1, "1150", 100), (2, "7", 100)), + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None and not parsed.synthetic_header + assert [r.ordinal for r in parsed.rows] == [-1, 0, 5] # строка шапки — не в счёте + assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (1, 1) + + def test_synthetic_header_table_counts_leading_rows(self) -> None: + rows = [ + _row(0, (1, "1110", 100), (0, "Нематериальные активы", 100), (2, "5", 100)), + _row(1, (1, "1150 :", 100), (0, "ОС", 100), (2, "5", 100)), + _row(2, (1, "", 100), (0, "БАЛАНС", 100), (2, "9", 100)), + ] + parsed = parse_table_rows(rows, KNOWN) + assert parsed is not None and parsed.synthetic_header + assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (1, 1) + + @pytest.mark.fixtures + @pytest.mark.parametrize( + ("name", "index", "non_numeric", "no_code_amounts"), + [("aep_3.json", 0, 8, 2), ("aep_3.json", 1, 2, 2), ("aep_6.json", 0, 2, 19), ("aep_6.json", 1, 1, 2)], + ) + def test_real_tables(self, name: str, index: int, non_numeric: int, no_code_amounts: int) -> None: + """aep_3: «БАЛАНС» и «Выручка» с ПУСТЫМ кодом и суммами теряются; aep_6: «в том числе:» с суммами (OQ-141).""" + parsed = parse_table_rows(_real_table(name, index), KNOWN) + assert parsed is not None + assert (parsed.skipped_non_numeric_code, parsed.skipped_no_code_with_amounts) == (non_numeric, no_code_amounts) + + class TestTableType: def test_by_first_digit(self) -> None: rows = [ diff --git a/api/tests/unit/normalizer/test_rules.py b/api/tests/unit/normalizer/test_rules.py index 5e53938..a80fc87 100644 --- a/api/tests/unit/normalizer/test_rules.py +++ b/api/tests/unit/normalizer/test_rules.py @@ -45,12 +45,11 @@ class TestRealRules: assert rules.labels["бухгалтерский баланс"] == "BALANCE" assert rules.labels["отчет о финансовых результатах"] == "INCOME" - def test_codes_are_four_or_five_digits_without_duplicates(self) -> None: + def test_codes_are_four_or_five_digits_with_names(self) -> None: + """Дубли кодов проверяет загрузчик yaml (`TestDuplicateKeys`): после `safe_load` в словаре их уже не видно.""" rules = load_rules(RULES_DIR) for edition in rules.editions: - codes = list(edition.lines) - assert all(code.isdigit() and 4 <= len(code) <= 5 for code in codes), edition.id - assert len(codes) == len(set(codes)), edition.id + assert all(code.isdigit() and 4 <= len(code) <= 5 for code in edition.lines), edition.id assert all(name.strip() for name in edition.lines.values()), edition.id def test_fsbu4_edition_exists_even_if_empty(self) -> None: @@ -119,6 +118,61 @@ class TestValidation: load_rules(tmp_path) +class TestDuplicateKeys: + """Решение №33: повторяющийся ключ yaml — ошибка загрузки (иначе `safe_load` молча оставил бы последний).""" + + def test_duplicate_line_code_fails_with_file_and_key(self, tmp_path: Path) -> None: + _write_rules(tmp_path) + (tmp_path / "statement_lines.yaml").write_text( + "editions:\n" + " - id: 66n\n" + " lines:\n" + ' "1150": "Основные средства"\n' + ' "1110": "Нематериальные активы"\n' + ' "1150": "Другое наименование"\n', + encoding="utf-8", + ) + with pytest.raises(ValueError, match="statement_lines.yaml") as caught: + load_rules(tmp_path) + assert "повторяющийся ключ '1150'" in str(caught.value) and "line 6" in str(caught.value) + + def test_duplicate_edition_field_fails(self, tmp_path: Path) -> None: + _write_rules(tmp_path) + (tmp_path / "statement_lines.yaml").write_text( + "editions:\n - id: 66n\n id: other\n lines: {}\n", encoding="utf-8" + ) + with pytest.raises(ValueError, match="повторяющийся ключ 'id'"): + load_rules(tmp_path) + + def test_duplicate_lookalike_fails(self, tmp_path: Path) -> None: + _write_rules(tmp_path) + (tmp_path / "ocr_lookalikes.yaml").write_text('digits:\n "О": "0"\n "l": "1"\n "О": "6"\n', encoding="utf-8") + with pytest.raises(ValueError, match="ocr_lookalikes.yaml") as caught: + load_rules(tmp_path) + assert "повторяющийся ключ 'О'" in str(caught.value) + + def test_duplicate_label_fails(self, tmp_path: Path) -> None: + _write_rules(tmp_path) + (tmp_path / "statement_labels.yaml").write_text( + "labels:\n бухгалтерский баланс: BALANCE\n бухгалтерский баланс: INCOME\n", encoding="utf-8" + ) + with pytest.raises(ValueError, match="statement_labels.yaml") as caught: + load_rules(tmp_path) + assert "повторяющийся ключ 'бухгалтерский баланс'" in str(caught.value) + + def test_same_code_in_different_editions_is_fine(self, tmp_path: Path) -> None: + _write_rules( + tmp_path, + { + "editions": [ + {"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС"}}, + {"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС"}}, + ] + }, + ) + assert load_rules(tmp_path).editions_with("1150") == ["66n", "fsbu4"] + + def test_settings_defaults() -> None: settings = get_settings() assert settings.SCAN_CLEANUP is False