feat(statements): строки с кодом, значения Decimal, дубликаты кодов, предупреждение о единицах

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 16:59:02 +04:00
parent cb0bd062d4
commit 55b60f58b2
2 changed files with 208 additions and 0 deletions

View File

@ -0,0 +1,116 @@
"""Строки формы с кодом и их значения (design D-6).
Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них
есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money`
(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не
нормализуются: при «тыс.» в единицах и доле значений с дробной частью ≥ `FRACTIONAL_SHARE` — предупреждение.
"""
from dataclasses import dataclass, field
from src.contract.result import StatementLine, StatementValue
from src.parser.grid import Sheet
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"]
_FULL_CONFIDENCE = 100
FRACTIONAL_SHARE = 0.1
_THOUSANDS = "тыс"
@dataclass
class LinesResult:
lines: list[StatementLine] = field(default_factory=list)
duplicates: list[str] = field(default_factory=list)
skipped_with_sums: int = 0
fractional_values: int = 0
numeric_values: int = 0
warnings: list[str] = field(default_factory=list)
def _value(sheet: Sheet, row: int, col: int, column_index: int) -> tuple[StatementValue, bool]:
"""Значение ячейки и признак «число с дробной частью»."""
cell = sheet.cell(*sheet.merged_origin(row, col))
parsed = to_money(cell)
if parsed.value is not None:
has_fractional = parsed.value % 1 != 0
value = StatementValue(
column_index=column_index,
amount=parsed.value,
confidence=_FULL_CONFIDENCE,
is_recognized=True,
)
return value, has_fractional
if parsed.warning is None: # пусто или прочерк
value = StatementValue(
column_index=column_index,
amount=None,
confidence=_FULL_CONFIDENCE,
is_recognized=True,
)
return value, False
value = StatementValue(
column_index=column_index,
amount=None,
string_value=cell.text,
confidence=_FULL_CONFIDENCE,
is_recognized=False,
)
return value, False
def collect_lines( # noqa: PLR0913
sheet: Sheet,
rules: Rules,
layout: TableLayout,
first_row: int,
last_row: int,
*,
units: str | None = None,
) -> LinesResult:
result = LinesResult()
seen: set[str] = set()
for row in range(first_row, last_row + 1):
if sheet.is_row_empty(row):
continue
code = line_code(sheet.cell(row, layout.code_col), rules)
if code is None:
if any(to_money(sheet.cell(row, column.col)).value is not None for column in layout.periods):
result.skipped_with_sums += 1
continue
if code in seen:
result.duplicates.append(code)
continue
seen.add(code)
values: list[StatementValue] = []
for index, column in enumerate(layout.periods):
value, fractional = _value(sheet, row, column.col, index)
values.append(value)
if value.amount is not None:
result.numeric_values += 1
result.fractional_values += int(fractional)
name = cell_label(sheet, row, layout.name_col) if layout.name_col is not None else ""
result.lines.append(
StatementLine(
line_code=code,
line_code_confidence=_FULL_CONFIDENCE,
line_name=name,
line_name_confidence=_FULL_CONFIDENCE,
values=values,
)
)
if result.skipped_with_sums:
result.warnings.append(f"строк без кода с суммами пропущено: {result.skipped_with_sums}")
if (
units
and _THOUSANDS in normalize_text(units)
and result.numeric_values
and result.fractional_values / result.numeric_values >= FRACTIONAL_SHARE
):
result.warnings.append(
f"значений с дробной частью при единице тыс. руб.: {result.fractional_values} из {result.numeric_values}"
)
return result

View File

@ -0,0 +1,92 @@
"""Строки с кодом и значения (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 = 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 # строка нулей без кода
assert result.fractional_values == _FRACTIONAL_VALUES_COUNT and result.numeric_values == _NUMERIC_VALUES_COUNT
def test_fractional_warning_when_units_thousands(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, units="в тыс. рублей")
expected_warning = (
f"значений с дробной частью при единице тыс. руб.: {_FRACTIONAL_VALUES_COUNT} из {_NUMERIC_VALUES_COUNT}"
)
assert any(warning.startswith(expected_warning) for warning in result.warnings)
assert any("строк без кода с суммами пропущено: 1" in warning for warning in result.warnings)
def test_no_fractional_warning_without_thousands(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, units="руб.")
assert not any("дробной частью" in warning for warning in result.warnings)
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] == ["", "", ""]