fix(normalizer): итоги финального ревью — Money, статус при ошибочном файле, пропущенные строки, длина дат, дубли yaml

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 03:35:10 +04:00
parent 22025ecabe
commit c1288d08fb
18 changed files with 574 additions and 42 deletions

View File

@ -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

View File

@ -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 только если в исходной строке не пришлось отбрасывать символы
(после удаления скобок).

View File

@ -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", " "))

View File

@ -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] = []

View File

@ -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,

View File

@ -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

View File

@ -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:

View File

@ -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(

View File

@ -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

View File

@ -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`) совпадает со сквозным результатом

View File

@ -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:

View File

@ -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 # одним символом больше — не разбирается, хотя дата в начале есть

View File

@ -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)

View File

@ -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)

View File

@ -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:

View File

@ -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})

View File

@ -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 = [

View File

@ -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