From ce8372d7549d825c3f85f5e708dcd0c2187a1929 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:07:02 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20=D1=8F=D1=87=D0=B5=D0=B9?= =?UTF-8?q?=D0=BA=D0=B0=20Excel=20=E2=86=92=20Cell:=20=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=81=D1=82=20=D0=BF=D0=BE=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=BC=D1=83=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D1=83,=20value,=20ref=20<=D0=BB=D0=B8=D1=81=D1=82>!=20(#54?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/src/parser/grid.py | 12 ++- api/src/parser/statements/cells.py | 70 +++++++++++++++ api/src/parser/statements/number_format.py | 89 ++++++++++++++----- .../unit/parser/statements/test_cells.py | 71 +++++++++++++++ .../parser/statements/test_number_format.py | 46 +++++++--- api/tests/unit/parser/test_grid.py | 10 ++- 6 files changed, 265 insertions(+), 33 deletions(-) create mode 100644 api/src/parser/statements/cells.py create mode 100644 api/tests/unit/parser/statements/test_cells.py diff --git a/api/src/parser/grid.py b/api/src/parser/grid.py index 9918f3d..747e240 100644 --- a/api/src/parser/grid.py +++ b/api/src/parser/grid.py @@ -11,7 +11,7 @@ from decimal import Decimal from functools import cached_property from typing import Literal -__all__ = ["EMPTY_CELL", "MAX_INDEXED_AREA", "Cell", "CellKind", "MergedRange", "Sheet", "column_letter"] +__all__ = ["EMPTY_CELL", "MAX_INDEXED_AREA", "Cell", "CellKind", "MergedRange", "Sheet", "cell_ref", "column_letter"] CellKind = Literal["empty", "text", "number", "date", "bool"] CellValue = str | Decimal | datetime.date | datetime.datetime | bool | None @@ -139,3 +139,13 @@ def column_letter(index: int) -> str: index, remainder = divmod(index - 1, _ALPHABET_SIZE) letters.append(chr(ord("A") + remainder)) return "".join(reversed(letters)) + + +def cell_ref(sheet_name: str, row: int, col: int) -> str: + """Адрес ячейки `<лист>!`; имя листа в одинарных кавычках по правилу Excel (апостроф внутри удваивается). + + Кавычки нужны, если в имени есть что-то кроме букв, цифр и `_` или оно начинается с цифры.""" + plain = sheet_name != "" and all(c.isalpha() or c.isdigit() or c == "_" for c in sheet_name) + if not plain or sheet_name[0].isdigit(): + sheet_name = "'" + sheet_name.replace("'", "''") + "'" + return f"{sheet_name}!{column_letter(col)}{row}" diff --git a/api/src/parser/statements/cells.py b/api/src/parser/statements/cells.py new file mode 100644 index 0000000..2e27332 --- /dev/null +++ b/api/src/parser/statements/cells.py @@ -0,0 +1,70 @@ +"""Ячейка Excel -> `arm_statements.Cell` (план 4, P4-3…P4-5): текст как показывает Excel, значение, ссылка. + +Адаптер не нормализует: `value` числа — показываемое число (диапазон `numeric(18,2)` проверяет пакет), `text` — то, что +видит аналитик. Неразобранные числовые форматы собираются в `unparsed_formats` для предупреждения. +""" + +import datetime +from collections.abc import Sequence + +from arm_statements import Cell as StatementCell +from src.parser.grid import Cell, Sheet, cell_ref +from src.parser.statements.number_format import display_amount, general_text + +__all__ = ["excel_cell", "grid_rows"] + +_CONFIDENCE = 100.0 +_TRUE_TEXT = "ИСТИНА" +_FALSE_TEXT = "ЛОЖЬ" + + +def _number_cell(cell: Cell, unparsed_formats: set[str]) -> tuple[str, object]: + raw = cell.value + assert raw is not None and not isinstance(raw, str | bool | datetime.date) # noqa: S101 - kind == "number" + if cell.number_format is not None: + shown = display_amount(raw, cell.number_format) + if shown.kind == "value": + return str(shown.text), shown.value + if shown.kind == "unparsed": + unparsed_formats.add(cell.number_format) + return general_text(raw), raw + + +def _text_and_value(cell: Cell, unparsed_formats: set[str]) -> tuple[str, object] | None: + match cell.kind: + case "number": + return _number_cell(cell, unparsed_formats) + case "date": + value = cell.value + assert isinstance(value, datetime.date) # noqa: S101 - kind == "date" + return value.strftime("%d.%m.%Y"), value.date() if isinstance(value, datetime.datetime) else value + case "bool": + return (_TRUE_TEXT if cell.value else _FALSE_TEXT), bool(cell.value) + case "text": + return cell.text, None + case _: + return None + + +def excel_cell(sheet: Sheet, row: int, col: int, unparsed_formats: set[str]) -> StatementCell | None: + """Ячейка листа для пакета; пустая и не-левая-верхняя позиция объединённого диапазона -> `None`.""" + if sheet.merged_origin(row, col) != (row, col): + return None + converted = _text_and_value(sheet.cell(row, col), unparsed_formats) + if converted is None: + return None + text, value = converted + return StatementCell(text=text, value=value, confidence=_CONFIDENCE, ref=cell_ref(sheet.name, row, col)) # type: ignore[arg-type] + + +def grid_rows( + sheet: Sheet, first_row: int, last_row: int, width: int, unparsed_formats: set[str] +) -> list[tuple[StatementCell | None, ...]]: + """Плотная сетка строк `first_row..last_row` включительно; позиция = `col - 1`, длина строки = `width`.""" + rows: list[tuple[StatementCell | None, ...]] = [] + for row in range(first_row, last_row + 1): + cells: Sequence[StatementCell | None] = [ + excel_cell(sheet, row, col, unparsed_formats) for col in range(1, width + 1) + ] + rows.append(tuple(cells)) + return rows diff --git a/api/src/parser/statements/number_format.py b/api/src/parser/statements/number_format.py index fe1367d..80c18b3 100644 --- a/api/src/parser/statements/number_format.py +++ b/api/src/parser/statements/number_format.py @@ -27,9 +27,9 @@ from functools import lru_cache from typing import Literal, NamedTuple from src.parser.grid import Cell -from src.parser.numbers import quantize_money, to_money +from src.parser.numbers import to_money -__all__ = ["FormatResult", "display_amount"] +__all__ = ["FormatResult", "display_amount", "general_text"] _PLACEHOLDERS = "0#?" _MAX_PARTS = 4 @@ -43,16 +43,16 @@ _GENERAL = "general" class FormatResult(NamedTuple): - """`raw` — General/пусто, действует прежняя логика; `value` — отображаемое значение (None — нет значения); - `unparsed` — формат не разобран.""" + """`raw` — General/пусто, действует прежняя логика; `value` — отображаемое значение (None — нет значения) и его + текст `text`, как показывает Excel; `unparsed` — формат не разобран.""" kind: Literal["raw", "value", "unparsed"] value: Decimal | None = None + text: str | None = None _RAW = FormatResult("raw") _UNPARSED = FormatResult("unparsed") -_NO_VALUE = FormatResult("value", None) _EXCEL_DIGITS = 15 # Excel сравнивает числа с точностью 15 значащих цифр @@ -99,6 +99,9 @@ class _Section: literal: str # показываемый текст (для секций без цифровых плейсхолдеров) scale: int # число масштабирующих запятых decimals: int # число плейсхолдеров после точки + grouping: bool = False # есть разделитель групп разрядов + prefix: str = "" # литералы до первого плейсхолдера + suffix: str = "" # литералы после первого плейсхолдера def _split_sections(fmt: str) -> list[str] | None: @@ -130,16 +133,24 @@ def _split_sections(fmt: str) -> list[str] | None: return sections +def _put(chars: str, literal: list[str], prefix: list[str], suffix: list[str], kinds: list[str]) -> None: + """Литерал секции: общий текст и его положение относительно первого цифрового плейсхолдера.""" + literal.append(chars) + (suffix if "p" in kinds else prefix).append(chars) + + def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - линейный разбор символов формата condition: _Condition | None = None literal: list[str] = [] + prefix: list[str] = [] + suffix: list[str] = [] kinds: list[str] = [] # "p" — плейсхолдер, "," — запятая, "." — точка, прочее не фиксируем index = 0 while index < len(text): char = text[index] if char == '"': end = text.index('"', index + 1) - literal.append(text[index + 1 : end]) + _put(text[index + 1 : end], literal, prefix, suffix, kinds) index = end + 1 continue if char == "[": @@ -155,7 +166,7 @@ def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - ли index = end + 1 # цвет, [$-419] и прочее оформление игнорируются continue if char == "\\": - literal.append(text[index + 1 : index + 2]) + _put(text[index + 1 : index + 2], literal, prefix, suffix, kinds) index += 2 continue if char in "_*": @@ -170,20 +181,23 @@ def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - ли elif char in ",.": kinds.append(char) elif char in _PLAIN_CHARS: - literal.append(char) + _put(char, literal, prefix, suffix, kinds) else: # %, E+, /, @, буквы дат и любые неизвестные знаки return None index += 1 - return _build_section(condition, literal, kinds) + return _build_section(condition, literal, kinds, "".join(prefix), "".join(suffix)) -def _build_section(condition: _Condition | None, literal: list[str], kinds: list[str]) -> _Section | None: +def _build_section( + condition: _Condition | None, literal: list[str], kinds: list[str], prefix: str, suffix: str +) -> _Section | None: if "g" in kinds: return _Section(condition, True, False, "", 0, 0) if kinds == ["g"] else None last = max((i for i, kind in enumerate(kinds) if kind == "p"), default=-1) if last < 0: return _Section(condition, False, False, "".join(literal).strip(), 0, 0) scale = 0 + grouping = False position = 0 while position < len(kinds): if kinds[position] != ",": @@ -195,11 +209,13 @@ def _build_section(condition: _Condition | None, literal: list[str], kinds: list follows_placeholder = position > 0 and kinds[position - 1] == "p" if follows_placeholder and (end == len(kinds) or kinds[end] != "p"): scale += end - position + elif follows_placeholder: + grouping = True position = end decimals = 0 if "." in kinds: decimals = sum(1 for kind in kinds[kinds.index(".") :] if kind == "p") - return _Section(condition, False, True, "", scale, decimals) + return _Section(condition, False, True, "", scale, decimals, grouping, prefix, suffix) @lru_cache(maxsize=256) @@ -239,23 +255,53 @@ def _pick_section(sections: tuple[_Section, ...], raw: Decimal) -> _Section | No return None -def _scaled(raw: Decimal, section: _Section) -> FormatResult: +_WIDE = Context(prec=60, rounding=ROUND_HALF_UP) # без потери цифр у чисел длиннее 15 знаков; диапазон — дело пакета +_GROUP_SIZE = 3 +_PRECISION_LIMIT = Decimal(10) ** _EXCEL_DIGITS + + +def _group_digits(digits: str) -> str: + head = len(digits) % _GROUP_SIZE or _GROUP_SIZE + parts = [digits[:head], *(digits[i : i + _GROUP_SIZE] for i in range(head, len(digits), _GROUP_SIZE))] + return " ".join(parts) + + +def general_text(raw: Decimal) -> str: + """Текст числа без формата: целое без дробной части, иначе без хвостовых нулей; запятая, без групп и экспоненты.""" + if raw == raw.to_integral_value(): + return format(int(raw), "d") + return format(raw.normalize(), "f").replace(".", ",") + + +def _section_text(magnitude: Decimal, raw_negative: bool, section: _Section, sign: bool) -> str: + rounded = magnitude.quantize(Decimal(1).scaleb(-section.decimals), context=_WIDE) + whole, _, fraction = format(rounded, "f").partition(".") + body = _group_digits(whole) if section.grouping else whole + if section.decimals: + body = f"{body},{fraction}" + minus = "-" if raw_negative and sign else "" + return f"{minus}{section.prefix}{body}{section.suffix}" + + +def _scaled(raw: Decimal, section: _Section, sign: bool) -> FormatResult: digits = min(section.decimals, _MAX_DECIMALS) + # 15 значащих цифр, как в Excel, но длинные целые (16+ цифр) показываем без потери цифр + precise = _excel_precision(raw) if abs(raw) < _PRECISION_LIMIT else raw try: - magnitude = abs(_excel_precision(raw)).scaleb(-_SCALE_STEP * section.scale) - rounded = magnitude.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP) - except InvalidOperation: # число длиннее точности контекста: заведомо вне numeric(18,2) + magnitude = abs(precise).scaleb(-_SCALE_STEP * section.scale, context=_WIDE) + rounded = magnitude.quantize(Decimal(1).scaleb(-digits), context=_WIDE) + text = _section_text(magnitude, raw < 0, section, sign) + except InvalidOperation: return _UNPARSED - signed = -rounded if raw < 0 else rounded # знак от сырого значения (R-3) - money = quantize_money(signed, str(raw)) - return _UNPARSED if money.warning is not None else FormatResult("value", money.value) + value = (-rounded if raw < 0 else rounded).quantize(Decimal("0.01"), context=_WIDE) # знак от сырого (R-3) + return FormatResult("value", value, text) def _literal_result(section: _Section) -> FormatResult: parsed = to_money(Cell(section.literal, "text")) if parsed.warning is not None: return _UNPARSED - return _NO_VALUE if parsed.value is None else FormatResult("value", parsed.value) + return FormatResult("value", parsed.value, section.literal) def display_amount(raw: Decimal, fmt: str) -> FormatResult: @@ -268,4 +314,7 @@ def display_amount(raw: Decimal, fmt: str) -> FormatResult: return _RAW if section is None: return _UNPARSED - return _scaled(raw, section) if section.has_digits else _literal_result(section) + if not section.has_digits: + return _literal_result(section) + single = len(sections) == 1 and section.condition is None + return _scaled(raw, section, single) diff --git a/api/tests/unit/parser/statements/test_cells.py b/api/tests/unit/parser/statements/test_cells.py new file mode 100644 index 0000000..bcca02e --- /dev/null +++ b/api/tests/unit/parser/statements/test_cells.py @@ -0,0 +1,71 @@ +"""Ячейка Excel -> `Cell` пакета: текст по формату, значение, ссылка, сетка (план 4, задача 3).""" + +import datetime +from decimal import Decimal + +from gridbuilder import M, at, sheet_from_rows, with_formats + +from arm_statements import Cell +from src.parser.statements.cells import excel_cell, grid_rows + + +def _balance(row: int, col: int, value: Decimal): # noqa: ANN202 + rows: list[list[object]] = [[] for _ in range(row - 1)] + rows.append(at({col: value})) + return sheet_from_rows(rows, name="Баланс") + + +def test_excel_cell_by_number_format() -> None: + sheet = with_formats(_balance(27, 4, Decimal("106311116.46")), {(27, 4): "#,##0,"}) + cell = excel_cell(sheet, 27, 4, set()) + assert cell == Cell(text="106 311", value=Decimal("106311"), confidence=100.0, ref="Баланс!D27") + + +def test_unparsed_format_keeps_raw_and_is_recorded() -> None: + formats: set[str] = set() + sheet = with_formats(sheet_from_rows([[Decimal("0.15")]]), {(1, 1): "0.0%"}) + cell = excel_cell(sheet, 1, 1, formats) + assert cell is not None + assert (cell.value, cell.text, formats) == (Decimal("0.15"), "0,15", {"0.0%"}) + + +def test_general_and_no_format_give_raw_value() -> None: + cell = excel_cell(sheet_from_rows([[Decimal("7016006635.0")]]), 1, 1, set()) + assert cell is not None + assert (cell.value, cell.text) == (Decimal("7016006635.0"), "7016006635") + + +def test_date_text_bool_and_merged() -> None: + sheet = sheet_from_rows( + [ + [datetime.date(2025, 9, 30), "Итого", True], + [M("Шапка", 2), False], + ] + ) + date_cell = excel_cell(sheet, 1, 1, set()) + assert date_cell is not None + assert (date_cell.text, date_cell.value) == ("30.09.2025", datetime.date(2025, 9, 30)) + text_cell = excel_cell(sheet, 1, 2, set()) + assert text_cell is not None + assert (text_cell.text, text_cell.value) == ("Итого", None) + yes, no = excel_cell(sheet, 1, 3, set()), excel_cell(sheet, 2, 3, set()) + assert yes is not None and no is not None + assert [(yes.text, yes.value), (no.text, no.value)] == [("ИСТИНА", True), ("ЛОЖЬ", False)] + origin = excel_cell(sheet, 2, 1, set()) + assert origin is not None + assert origin.ref == "Лист_1!A2" + assert excel_cell(sheet, 2, 2, set()) is None # не левая верхняя позиция merged + assert excel_cell(sheet, 5, 5, set()) is None # пустая + + +def test_grid_rows_dense_width() -> None: + sheet = sheet_from_rows([["a"], [None, "b", None, Decimal(5)], [], ["z"]]) + width, count = 6, 3 + rows = grid_rows(sheet, 1, count, width, set()) + assert len(rows) == count + assert all(len(row) == width for row in rows) + assert rows[0][0] is not None and rows[0][0].text == "a" + assert rows[1][1] is not None and rows[1][1].ref == "Лист_1!B2" + assert rows[1][3] is not None and rows[1][3].value == Decimal(5) + assert rows[2] == (None,) * 6 + assert rows[0][1:] == (None,) * 5 diff --git a/api/tests/unit/parser/statements/test_number_format.py b/api/tests/unit/parser/statements/test_number_format.py index 52c33f9..a0ec920 100644 --- a/api/tests/unit/parser/statements/test_number_format.py +++ b/api/tests/unit/parser/statements/test_number_format.py @@ -4,7 +4,7 @@ from decimal import Decimal import pytest -from src.parser.statements.number_format import FormatResult, display_amount +from src.parser.statements.number_format import FormatResult, display_amount, general_text def shown(raw: str, fmt: str) -> tuple[str, Decimal | None]: @@ -131,19 +131,20 @@ def test_escaped_char_is_decoration() -> None: assert shown("1500", "#,##0\\ \\р") == ("value", Decimal("1500.00")) -def test_huge_value_out_of_range_is_unparsed() -> None: - assert shown("1" + "0" * 40, "#,##0") == ("unparsed", None) +def test_huge_value_is_returned_without_range_check() -> None: + """P4-4: диапазон numeric(18,2) проверяет пакет (VALUE_OUT_OF_RANGE), адаптер отдаёт показываемое число.""" + assert shown("1" + "0" * 40, "#,##0") == ("value", Decimal("1" + "0" * 40 + ".00")) -def test_max_numeric_18_2_range() -> None: - """Excel показывает 15 значащих цифр: 16-значное целое округляется до 1E+16, что уже вне numeric(18,2).""" +def test_long_integers_keep_all_digits() -> None: + """P4-4: длинные целые показываются без потери цифр, диапазон проверяет пакет.""" assert shown("999999999999999", "0") == ("value", Decimal("999999999999999.00")) - assert shown("9999999999999999", "0") == ("unparsed", None) - assert shown("99999999999999999", "0") == ("unparsed", None) + assert shown("9999999999999999", "0") == ("value", Decimal("9999999999999999.00")) + assert shown("99999999999999999", "0") == ("value", Decimal("99999999999999999.00")) -def test_scaled_huge_value_fits() -> None: - assert shown("99999999999999999000", "0,") == ("unparsed", None) +def test_scaled_huge_value_is_not_range_checked() -> None: + assert shown("99999999999999999000", "0,") == ("value", Decimal("99999999999999999.00")) assert shown("999999999999999000", "0,") == ("value", Decimal("999999999999999.00")) @@ -153,7 +154,7 @@ def test_condition_compares_with_excel_15_digit_precision() -> None: result = display_amount(Decimal("-1486533582.3500001"), fmt) - assert result == FormatResult("value", Decimal("-1486534")) + assert result == FormatResult("value", Decimal("-1486534"), "(1\u00a0486\u00a0534)") def test_value_is_rounded_to_15_digits_half_up_before_scaling() -> None: @@ -165,8 +166,31 @@ def test_condition_operand_is_rounded_to_15_digits() -> None: """Литерал «-» — «нет значения»; «x» дал бы unparsed по R-5 независимо от условия, поэтому берём прочерк.""" result = display_amount(Decimal("0.1234567890123456"), '[=0.1234567890123456]"-"') - assert result == FormatResult("value", None) + assert result == FormatResult("value", None, "-") def test_condition_that_does_not_match_the_pattern_is_unparsed() -> None: assert shown("0", '[=1E-5]"x"') == ("unparsed", None) + + +@pytest.mark.parametrize( + ("raw", "fmt", "value", "text"), + [ + ("106311116.46", "#,##0,", "106311", "106 311"), + ("-646532.4", "#,##0;(#,##0)", "-646532", "(646 532)"), + ("-1500", "#,##0", "-1500", "-1 500"), + ("-1500", "#,##0;#,##0", "-1500", "1 500"), + ("1.5", "0.00", "1.50", "1,50"), + ("0", '#,##0;-#,##0;"-"', None, "-"), + ("12345678901234567", "0", "12345678901234567", "12345678901234567"), + ], +) +def test_display_text(raw: str, fmt: str, value: str | None, text: str) -> None: + shown = display_amount(Decimal(raw), fmt) + expected = None if value is None else Decimal(value) + assert (shown.kind, shown.value, shown.text) == ("value", expected, text) + + +def test_general_text() -> None: + texts = [general_text(Decimal(x)) for x in ("7016006635.0", "106311116.460", "-0.5")] + assert texts == ["7016006635", "106311116,46", "-0,5"] diff --git a/api/tests/unit/parser/test_grid.py b/api/tests/unit/parser/test_grid.py index baccddc..c5797b3 100644 --- a/api/tests/unit/parser/test_grid.py +++ b/api/tests/unit/parser/test_grid.py @@ -5,7 +5,7 @@ from decimal import Decimal import pytest -from src.parser.grid import EMPTY_CELL, Cell, MergedRange, Sheet, column_letter +from src.parser.grid import EMPTY_CELL, Cell, MergedRange, Sheet, cell_ref, column_letter def _sheet( @@ -91,3 +91,11 @@ def test_column_letter(index: int, letter: str) -> None: def test_column_letter_rejects_non_positive_index() -> None: with pytest.raises(ValueError, match="1-based"): column_letter(0) + + +def test_cell_ref_quotes_sheet_name_by_excel_rule() -> None: + assert cell_ref("Баланс", 27, 3) == "Баланс!C27" + assert cell_ref("Лист_1", 2, 2) == "Лист_1!B2" + assert cell_ref("Бухгалтерский баланс. Лист 1", 14, 3) == "'Бухгалтерский баланс. Лист 1'!C14" + assert cell_ref("2024", 1, 1) == "'2024'!A1" + assert cell_ref("Отчёт д'Артаньяна", 1, 28) == "'Отчёт д''Артаньяна'!AB1"