Andrey Epifancev c780dfd858 fix(statements): округление итогов не портит статус (OQ-138), слабые продолжения только сразу после формы и той же формы, подстроки по порядку строк
- OQ-138 (решение владельца 06.10.2026): единицы шапки верны; жёсткое расхождение до rounding_tolerance_percent = 0,001 %
  от наибольшего по модулю числа соотношения — округление итогов: soft_relations_failed + предупреждение, статус не
  меняется. НПЗ, ОФР стр. 1 Унагранде Компани и Групп теперь done.
- N-1/N-2: лист с 1–2 кодами — продолжение только сразу после листа формы, коды из диапазона формы открытого документа;
  присоединение видно в предупреждениях. N-3: негативные тесты BookParser и StatementParser.
- M-9: пятизначная подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами (12310 под 1230).
- N-4: на продолжении без шапки колонка «Пояснения» узнаётся по коротким значениям и не даёт line_name.
- T7.1: ответ с ошибкой листа ОСВ называет неразобранные листы форм; T7.2: metrics.rows для форм — в спеке.
make check-full: 2217 passed, интеграция 10 passed; golden 138/138 (снимки — arm-ka-fixtures fix/20-golden).

Refs #20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-06 23:14:21 +04:00

201 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Строки с кодом и значения (design D-6; spec «Строки с кодом и значения»)."""
from decimal import Decimal
import pytest
from gridbuilder import sheet_from_rows
from src.parser.rules import Rules, load_rules
from src.parser.statements.columns import find_layout
from src.parser.statements.lines import collect_lines
_FULL_CONFIDENCE = 100
_FRACTIONAL_VALUES_COUNT = 4
_NUMERIC_VALUES_COUNT = 9
_SKIPPED_WITH_SUMS_COUNT = 0
_BOUNDARY_NUMERIC_VALUES = 20
_BOUNDARY_FRACTIONAL_VALUES = 2
_BELOW_THRESHOLD_NUMERIC_VALUES = 12
_BELOW_THRESHOLD_FRACTIONAL_VALUES = 1
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
ROWS = [
["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."],
[None, "АКТИВ"],
[None, "Нематериальные активы", "1110", Decimal("5971264.14"), 27405634],
[None, "Нематериальные активы\nв организации", "11101", Decimal("5852464.14"), "27 214 614,65"],
[None, None, None, 0, 0],
[None, "в том числе:"],
[None, "Финансовые вложения", "1170", None, 10],
[None, "Прочие", "1190", "–", "(2 100)"],
[None, "Запасы", "1210", "н/д", 5],
[None, "Запасы", "1210", 1, 1],
[None, "Итого по разделу I", "1100", Decimal("11823728.28"), 27405644],
]
def test_lines_values_and_skips(rules: Rules) -> None:
sheet = sheet_from_rows(ROWS)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows)
codes = [line.line_code for line in result.lines]
assert codes == ["1110", "11101", "1170", "1190", "1210", "1100"]
by_code = {line.line_code: line for line in result.lines}
assert by_code["11101"].line_name == "Нематериальные активы в организации"
assert [value.amount for value in by_code["11101"].values] == [Decimal("5852464.14"), Decimal("27214614.65")]
assert by_code["1170"].values[0].amount is None and by_code["1170"].values[0].is_recognized is True
assert by_code["1190"].values[0].amount is None and by_code["1190"].values[1].amount == Decimal("-2100.00")
unrecognised = by_code["1210"].values[0]
assert unrecognised.amount is None and unrecognised.string_value == "н/д" and unrecognised.is_recognized is False
assert all(value.confidence == _FULL_CONFIDENCE for line in result.lines for value in line.values)
assert all(
line.line_code_confidence == _FULL_CONFIDENCE and line.line_name_confidence == _FULL_CONFIDENCE
for line in result.lines
) # у всех строк название есть
assert [value.column_index for value in by_code["1110"].values] == [0, 1]
assert result.duplicates == ["1210"] and by_code["1210"].values[1].amount == Decimal("5.00")
assert result.skipped_with_sums == _SKIPPED_WITH_SUMS_COUNT # строка нулей без кода суммой не считается (M-8)
assert result.fractional_values == _FRACTIONAL_VALUES_COUNT and result.numeric_values == _NUMERIC_VALUES_COUNT
def test_line_without_name_column(rules: Rules) -> None:
sheet = sheet_from_rows([["Код", "На 31.12.2024"], ["1150", 5], ["1210", 6], ["1600", 11]])
layout = find_layout(sheet, rules, 1, 4)
result = collect_lines(sheet, rules, layout, layout.first_data_row, 4)
assert [line.line_name for line in result.lines] == ["", "", ""]
def test_fractional_warning_at_boundary_10_percent(rules: Rules) -> None:
"""Ровно 10% (2 из 20) → должно быть предупреждение."""
# With 2 columns: 10 data rows = 20 numeric values
# To get exactly 10%, need 2 fractional values
rows = [
[None, "Наименование показателя", "Код", "2024", "2023"],
[None, "Показатель 1", "1150", 100, 100],
[None, "Показатель 2", "1151", 200, 200],
[None, "Показатель 3", "1152", 300, 300],
[None, "Показатель 4", "1153", 400, 400],
[None, "Показатель 5", "1154", Decimal("500.50"), 500], # 1 fractional
[None, "Показатель 6", "1155", 600, Decimal("600.50")], # 1 fractional (2 из 20 = 10%)
[None, "Показатель 7", "1156", 700, 700],
[None, "Показатель 8", "1157", 800, 800],
[None, "Показатель 9", "1158", 900, 900],
[None, "Показатель 10", "1159", 1000, 1000],
]
sheet = sheet_from_rows(rows)
layout = find_layout(sheet, rules, 1, len(rows))
result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows), units="в тыс. рублей")
# Expect 20 numeric values and 2 fractional (10%)
assert result.numeric_values == _BOUNDARY_NUMERIC_VALUES and result.fractional_values == _BOUNDARY_FRACTIONAL_VALUES
assert not result.warnings # предупреждение о дробных — на уровне документа (statement.py), здесь только счётчики
def test_no_fractional_warning_below_10_percent(rules: Rules) -> None:
"""1 из 12 (8.33%) → предупреждения нет."""
# With 2 columns: 6 data rows = 12 numeric values
# 1 fractional = 8.33% < 10%
rows = [
[None, "Наименование показателя", "Код", "2024", "2023"],
[None, "Показатель 1", "1150", 100, 100],
[None, "Показатель 2", "1151", 200, 200],
[None, "Показатель 3", "1152", 300, 300],
[None, "Показатель 4", "1153", 400, 400],
[None, "Показатель 5", "1154", 500, 500],
[None, "Показатель 6", "1155", 600, Decimal("600.50")], # 1 из 12 = 8.33%
]
sheet = sheet_from_rows(rows)
layout = find_layout(sheet, rules, 1, len(rows))
result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows), units="в тыс. рублей")
# Expect 12 numeric values and 1 fractional (8.33% < 10%)
assert (
result.numeric_values == _BELOW_THRESHOLD_NUMERIC_VALUES
and result.fractional_values == _BELOW_THRESHOLD_FRACTIONAL_VALUES
)
def test_no_crash_with_zero_numeric_values(rules: Rules) -> None:
"""Ноль числовых значений — без падения."""
# No numeric values (only recognized None and unrecognized text)
rows = [
[None, "Наименование показателя", "Код", "2024", "2023"],
[None, "Показатель 1", "1150", None, None], # recognized None
[None, "Показатель 2", "1151", "–", "–"], # recognized empty (dash)
[None, "Показатель 3", "1152", "н/д", "н/д"], # unrecognized
]
sheet = sheet_from_rows(rows)
layout = find_layout(sheet, rules, 1, len(rows))
result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows), units="в тыс. рублей")
assert result.numeric_values == 0
assert result.fractional_values == 0
def test_numbering_row_and_zero_row_are_not_skipped_sums(rules: Rules) -> None:
"""M-8: «1 2 3 4 5» на разрыве страницы и строка нулей без кода не считаются «строкой без кода с суммами»."""
rows = [
["Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."],
["Основные средства", "1150", 100, 90],
["1", "2", "3", "4"],
["Раздел", None, 0, 0],
["Запасы", "1210", 50, 40],
["Итого", "1200", 50, 40],
["Без кода", None, 7, 8],
]
sheet = sheet_from_rows(rows)
layout = find_layout(sheet, rules, 1, len(rows))
result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows))
assert result.skipped_with_sums == 1 # только «Без кода»
assert result.warnings == ["строк без кода с суммами пропущено: 1"]
def test_empty_name_cell_takes_the_nearest_text_to_the_left_of_codes(rules: Rules) -> None:
"""I-3: название итога стоит в соседней колонке с отступом; пустое имя — не с уверенностью 100."""
rows = [
["Пояснения", "Наименование показателя", None, "Код", "На 31 декабря 2024 г."],
["1", "Основные средства", None, "1150", 100],
[None, None, "Итого по разделу I", "1100", 100],
[None, None, None, "1210", 5],
]
sheet = sheet_from_rows(rows)
layout = find_layout(sheet, rules, 1, len(rows))
result = collect_lines(sheet, rules, layout, layout.first_data_row, len(rows))
by_code = {line.line_code: line for line in result.lines}
assert by_code["1100"].line_name == "Итого по разделу I"
assert by_code["1100"].line_name_confidence == _FULL_CONFIDENCE
assert by_code["1210"].line_name == "" and by_code["1210"].line_name_confidence is None
def test_explanation_values_on_continuation_without_header_are_not_names(rules: Rules) -> None:
"""N-4: на продолжении без шапки «Пояснения» («5.1») узнаются по коротким значениям и не становятся названием."""
sheet = sheet_from_rows(
[
[None, "Запасы", "1210", 5],
["5.1", "Дебиторская задолженность", "1230", 7],
["5.2", None, "1200", 12],
]
)
layout = find_layout(sheet, rules, 1, sheet.n_rows)
result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows)
names = {line.line_code: line.line_name for line in result.lines}
assert names == {"1210": "Запасы", "1230": "Дебиторская задолженность", "1200": ""}