Andrey Epifancev aad6310b97 fix(statements/lines): T10.I1+I2 — порог дробных значений в словаре, тесты границ
Перенесено FRACTIONAL_SHARE в api/rules/statement_headers.yaml как fractional_share_percent (1..100),
добавлена валидация в StatementHeaderRules, целочисленное сравнение: fractional * 100 >= numeric * percent.
Добавлены тесты границ: ровно 10% (2 из 20), ниже 10% (1 из 12), без падения при нулевых числовых значениях.
Проверено RED мутацией: смена >= на > → падение теста граничного условия.

Refs #14

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 17:12:38 +04:00

169 lines
8.1 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 = 1
_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 # строка нулей без кода
assert result.fractional_values == _FRACTIONAL_VALUES_COUNT and result.numeric_values == _NUMERIC_VALUES_COUNT
def test_fractional_warning_when_units_thousands(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, units="в тыс. рублей")
expected_warning = (
f"значений с дробной частью при единице тыс. руб.: {_FRACTIONAL_VALUES_COUNT} из {_NUMERIC_VALUES_COUNT}"
)
assert any(warning.startswith(expected_warning) for warning in result.warnings)
assert any("строк без кода с суммами пропущено: 1" in warning for warning in result.warnings)
def test_no_fractional_warning_without_thousands(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, units="руб.")
assert not any("дробной частью" in warning for warning in result.warnings)
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 any("дробной частью" in warning for warning in result.warnings)
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
)
assert not any("дробной частью" in warning for warning in result.warnings)
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
assert not any("дробной частью" in warning for warning in result.warnings)