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
This commit is contained in:
Andrey Epifancev 2026-10-06 17:12:38 +04:00
parent 55b60f58b2
commit aad6310b97
4 changed files with 87 additions and 4 deletions

View File

@ -88,3 +88,6 @@ line_codes:
DISCLOSURE: [5000, 5999]
min_share: 0.6
min_codes: 3
# Порог для предупреждения о дробных значениях при единицах в тыс. руб. (процент; 1..100)
fractional_share_percent: 10

View File

@ -368,6 +368,7 @@ class StatementHeaderRules(_Model):
auditor: list[Pattern]
legal_form: list[Pattern]
line_codes: _LineCodeRules
fractional_share_percent: int
@model_validator(mode="after")
def _check_required_keys(self) -> "StatementHeaderRules":
@ -379,6 +380,10 @@ class StatementHeaderRules(_Model):
missing = expected - set(getattr(self, field_name))
if missing:
raise ValueError(f"{field_name}: нет обязательных ключей {sorted(missing)}")
if not 1 <= self.fractional_share_percent <= 100: # noqa: PLR2004
raise ValueError(
f"fractional_share_percent: должна быть в диапазоне [1, 100], получено {self.fractional_share_percent}"
)
return self
def form_type(self, text: str) -> FormType | None:

View File

@ -3,7 +3,7 @@
Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них
есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money`
(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не
нормализуются: при «тыс.» в единицах и доле значений с дробной частью ≥ `FRACTIONAL_SHARE` — предупреждение.
нормализуются: при «тыс.» в единицах и доле значений с дробной частью ≥ порогу (из словаря) — предупреждение.
"""
from dataclasses import dataclass, field
@ -14,10 +14,9 @@ from src.parser.numbers import to_money
from src.parser.rules import Rules, normalize_text
from src.parser.statements.columns import TableLayout, cell_label, line_code
__all__ = ["FRACTIONAL_SHARE", "LinesResult", "collect_lines"]
__all__ = ["LinesResult", "collect_lines"]
_FULL_CONFIDENCE = 100
FRACTIONAL_SHARE = 0.1
_THOUSANDS = "тыс"
@ -108,7 +107,7 @@ def collect_lines( # noqa: PLR0913
units
and _THOUSANDS in normalize_text(units)
and result.numeric_values
and result.fractional_values / result.numeric_values >= FRACTIONAL_SHARE
and result.fractional_values * 100 >= result.numeric_values * rules.statement_headers.fractional_share_percent
):
result.warnings.append(
f"значений с дробной частью при единице тыс. руб.: {result.fractional_values} из {result.numeric_values}"

View File

@ -13,6 +13,10 @@ _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")
@ -90,3 +94,75 @@ def test_line_without_name_column(rules: Rules) -> None:
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)