- OQ-138 (решение владельца 06.10.2026): единицы шапки верны; жёсткое расхождение до rounding_tolerance_percent = 0,001 % от наибольшего по модулю числа соотношения — округление итогов: soft_relations_failed + предупреждение, статус не меняется. НПЗ, ОФР стр. 1 Унагранде Компани и Групп теперь done. - N-1/N-2: лист с 1–2 кодами — продолжение только сразу после листа формы, коды из диапазона формы открытого документа; присоединение видно в предупреждениях. N-3: негативные тесты BookParser и StatementParser. - M-9: пятизначная подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами (12310 под 1230). - N-4: на продолжении без шапки колонка «Пояснения» узнаётся по коротким значениям и не даёт line_name. - T7.1: ответ с ошибкой листа ОСВ называет неразобранные листы форм; T7.2: metrics.rows для форм — в спеке. make check-full: 2217 passed, интеграция 10 passed; golden 138/138 (снимки — arm-ka-fixtures fix/20-golden). Refs #20 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
201 lines
10 KiB
Python
201 lines
10 KiB
Python
"""Строки с кодом и значения (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 = 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": ""}
|