diff --git a/api/rules/statement_headers.yaml b/api/rules/statement_headers.yaml index 801e0a9..d3231a1 100644 --- a/api/rules/statement_headers.yaml +++ b/api/rules/statement_headers.yaml @@ -88,3 +88,6 @@ line_codes: DISCLOSURE: [5000, 5999] min_share: 0.6 min_codes: 3 + +# Порог для предупреждения о дробных значениях при единицах в тыс. руб. (процент; 1..100) +fractional_share_percent: 10 diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 7062b58..b0ab56e 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -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: diff --git a/api/src/parser/statements/lines.py b/api/src/parser/statements/lines.py index 589fd4a..c1a4fdd 100644 --- a/api/src/parser/statements/lines.py +++ b/api/src/parser/statements/lines.py @@ -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}" diff --git a/api/tests/unit/parser/statements/test_lines.py b/api/tests/unit/parser/statements/test_lines.py index 7456135..34d6f7f 100644 --- a/api/tests/unit/parser/statements/test_lines.py +++ b/api/tests/unit/parser/statements/test_lines.py @@ -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)