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:
parent
cb0bd062d4
commit
55b60f58b2
116
api/src/parser/statements/lines.py
Normal file
116
api/src/parser/statements/lines.py
Normal 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
|
||||
92
api/tests/unit/parser/statements/test_lines.py
Normal file
92
api/tests/unit/parser/statements/test_lines.py
Normal 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] == ["", "", ""]
|
||||
Loading…
x
Reference in New Issue
Block a user