Andrey Epifancev 388276c261 ББ/ОФР из Excel: значение ячейки — как показывает числовой формат (#42)
statement_money вместо to_money в разборе строк форм, предупреждения о неразобранном формате и об xlsb,
сравнение условий формата с точностью Excel (15 значащих цифр), тесты и тексты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:26:36 +04:00

272 lines
13 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, 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