"""Строки с кодом и значения (design D-6; spec «Строки с кодом и значения»).""" from decimal import Decimal import pytest from gridbuilder import sheet_from_rows, with_formats 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": ""} # --- числовой формат ячейки (#42): значение — как его показывает документ --- FORMAT_ROWS = [ ["Наименование показателя", "Код", "На 31 декабря 2024 г."], ["Основные средства", "1150", Decimal("10741981390")], ["Запасы", "1210", Decimal("586260.26")], ["Прочие", "1190", Decimal("0")], ["Дебиторская", "1230", Decimal("-872206880.07")], ["Финансовые", "1240", Decimal("-646532")], ["Резерв", "1250", Decimal("0.35")], ["Итого по разделу I", "1100", Decimal("898852.21795")], ] def _formatted(rules: Rules, formats: dict[int, str]) -> tuple[dict[str, object], set[str]]: sheet = with_formats(sheet_from_rows(FORMAT_ROWS), {(row, 3): fmt for row, fmt in formats.items()}) layout = find_layout(sheet, rules, 1, sheet.n_rows) result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) return {line.line_code: line.values[0] for line in result.lines}, result.unparsed_formats def test_scaling_comma_and_literals_follow_number_format(rules: Rules) -> None: values, unparsed = _formatted( rules, { 2: "#,##0,", 3: "0,", 4: '[=0]"-"', 5: '[=-872206880.07]"(872\xa0207)";#,##0,', 6: '[=-646532]"(647)";#,##0,', 8: "#,##0", }, ) assert values["1150"].amount == Decimal("10741981.00") assert values["1210"].amount == Decimal("586.00") assert values["1190"].amount is None and values["1190"].is_recognized # прочерк без предупреждения assert values["1230"].amount == Decimal("-872207.00") # литерал со скобками assert values["1240"].amount == Decimal("-647.00") assert values["1100"].amount == Decimal("898852.00") assert unparsed == set() def test_unparsed_format_keeps_raw_value_and_is_collected(rules: Rules) -> None: values, unparsed = _formatted(rules, {7: "0%", 8: "0.00%"}) assert values["1250"].amount == Decimal("0.35") and values["1100"].amount == Decimal("898852.22") assert unparsed == {"0%", "0.00%"} def test_unparsed_format_in_row_without_code_is_collected(rules: Rules) -> None: rows = [*FORMAT_ROWS, ["Без кода", None, Decimal("5")]] sheet = with_formats(sheet_from_rows(rows), {(len(rows), 3): "0%"}) layout = find_layout(sheet, rules, 1, sheet.n_rows) result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) assert result.skipped_with_sums == 1 and result.unparsed_formats == {"0%"} def test_fractional_share_counts_displayed_values(rules: Rules) -> None: """Рубли с копейками, показанные в тысячах целыми (`#,##0,`), дробными не считаются.""" sheet = with_formats(sheet_from_rows(FORMAT_ROWS), {(2, 3): "#,##0,", (3, 3): "0,", (8, 3): "#,##0"}) layout = find_layout(sheet, rules, 1, sheet.n_rows) result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) # дробными остаются только значения без формата: 1190 = 0 (не считается), 1230, 1250 (-872206880.07 и 0.35) assert result.fractional_values == 2 # noqa: PLR2004