diff --git a/api/src/parser/numbers.py b/api/src/parser/numbers.py index 1162b06..a664ac0 100644 --- a/api/src/parser/numbers.py +++ b/api/src/parser/numbers.py @@ -24,6 +24,7 @@ __all__ = [ "inn_checksum_ok", "inn_digits", "inn_only", + "quantize_money", "row_inn", "split_inn_suffix", "to_money", @@ -79,8 +80,10 @@ def _shorten(raw: str) -> str: return raw if len(raw) <= _WARNING_VALUE_LIMIT else raw[:_WARNING_VALUE_LIMIT] + "…" -def _quantize(value: Decimal, shown: str) -> MoneyParse: - """Квантование до 0.01 с отсечением нечисел и значений вне numeric(18,2); отрицательный ноль -> 0.00.""" +def quantize_money(value: Decimal, shown: str) -> MoneyParse: + """Квантование до 0.01 с отсечением нечисел и значений вне numeric(18,2); отрицательный ноль -> 0.00. + + Публичная: ту же проверку применяет `statements.number_format` к отображаемому значению (#42, R-8).""" if not value.is_finite(): return _skip(f"нечисловое значение суммы «{_shorten(shown)}»") if value != 0 and not _MIN_ADJUSTED <= value.adjusted() < _MAX_INTEGER_DIGITS: @@ -104,7 +107,7 @@ def _parse_text(raw: str) -> MoneyParse: if not _NUMBER.match(compact): return _skip(f"нечисловое значение в колонке сумм: «{_shorten(raw.strip())}»") try: - return _quantize(Decimal(compact), raw.strip()) + return quantize_money(Decimal(compact), raw.strip()) except InvalidOperation: return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(raw.strip())}»") @@ -115,7 +118,7 @@ def to_money(cell: Cell) -> MoneyParse: return _skip() if cell.kind == "number": # str(): точная десятичная запись; Decimal(str(float)) — и для тестов с «сырыми» int/float в ячейке - return _quantize(Decimal(str(cell.value)), str(cell.value)) + return quantize_money(Decimal(str(cell.value)), str(cell.value)) if cell.kind == "text": return _parse_text(str(cell.value)) if cell.kind == "bool": diff --git a/api/src/parser/statements/number_format.py b/api/src/parser/statements/number_format.py new file mode 100644 index 0000000..f9920b1 --- /dev/null +++ b/api/src/parser/statements/number_format.py @@ -0,0 +1,253 @@ +"""Отображаемое значение числа по числовому формату ячейки Excel (#42, R-2…R-6, R-8). + +В выгрузках 1С суммы ББ/ОФР лежат рублями с копейками, а в тысячи их переводит формат ячейки (`#,##0,`, `0,`). +Значение документа — то, что показывает Excel, поэтому формат применяется к сырому числу. + +Семантика Excel, которую воспроизводит модуль: +- Секции через `;` (кавычки, `\\x` и `[...]` не разделяют). Без условий: 1 секция — всё; 2 — (>=0, <0); 3 — (>0, <0, + остальное); четвёртая (текст) игнорируется. С явными условиями `[=x] [x] [<=x] [>=x] [<>x]` секции проверяются + по порядку (R-2): неявное условие нефинальной секции — умолчание по номеру, финальной — «всё остальное»; ни одна не + подошла — формат не разобран. +- Знак показываемого значения — знак сырого значения, скобки/минус формата — оформление (R-3). Поэтому `#,##0;#,##0` + для -1500 даёт -1500: цифры в секции отрицательных показываются по модулю, а значение документа остаётся + отрицательным. +- Запятая между цифровыми плейсхолдерами — разделитель групп; запятые сразу после последнего плейсхолдера (и перед + точкой) — масштаб: каждая делит число на 1000. Округление — половина от нуля (ROUND_HALF_UP по модулю), как в Excel. +- Секция без цифровых плейсхолдеров показывает только текст; он разбирается тем же разбором, что текстовые ячейки + (`to_money`: «(872 207)» -> -872207, «-» и пусто -> нет значения). Не разобранный текст — формат не разобран (R-5). +- `General` -> сырое значение (R-4); проценты, экспонента, дроби, даты/время, `@`, неизвестные буквы -> не + разобран (R-6). +Разбор формата кэшируется: форматов в файле единицы, ячеек десятки тысяч. Арифметика — Decimal, без float. +""" + +import re +from dataclasses import dataclass +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation +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 + +__all__ = ["FormatResult", "display_amount"] + +_PLACEHOLDERS = "0#?" +_MAX_SECTIONS = 3 # четвёртая секция — текст, для чисел не используется +_MAX_DECIMALS = 2 # Money = numeric(18,2) +_SCALE_STEP = 3 # одна масштабирующая запятая = деление на 1000 +# Знаки, которые Excel показывает без кавычек как есть +_PLAIN_CHARS = frozenset(" $-+()<>=:!&~{}^' ") +_CONDITION = re.compile(r"^(<>|<=|>=|=|<|>)\s*([-+]?[0-9]*\.?[0-9]+)$") +_GENERAL = "general" + + +class FormatResult(NamedTuple): + """`raw` — General/пусто, действует прежняя логика; `value` — отображаемое значение (None — нет значения); + `unparsed` — формат не разобран.""" + + kind: Literal["raw", "value", "unparsed"] + value: Decimal | None = None + + +_RAW = FormatResult("raw") +_UNPARSED = FormatResult("unparsed") +_NO_VALUE = FormatResult("value", None) + + +@dataclass(frozen=True, slots=True) +class _Condition: + operator: str + operand: Decimal + + def holds(self, value: Decimal) -> bool: + match self.operator: + case "=": + return value == self.operand + case "<>": + return value != self.operand + case "<": + return value < self.operand + case "<=": + return value <= self.operand + case ">": + return value > self.operand + case _: + return value >= self.operand + + +@dataclass(frozen=True, slots=True) +class _Section: + condition: _Condition | None + general: bool + has_digits: bool + literal: str # показываемый текст (для секций без цифровых плейсхолдеров) + scale: int # число масштабирующих запятых + decimals: int # число плейсхолдеров после точки + + +def _split_sections(fmt: str) -> list[str] | None: + """Делит по `;` вне кавычек, `\\x` и `[...]`; незакрытая кавычка/скобка -> None.""" + sections: list[str] = [] + current: list[str] = [] + index = 0 + while index < len(fmt): + char = fmt[index] + if char in ('"', "["): + closing = '"' if char == '"' else "]" + end = fmt.find(closing, index + 1) + if end < 0: + return None + current.append(fmt[index : end + 1]) + index = end + 1 + continue + if char == "\\" and index + 1 < len(fmt): + current.append(fmt[index : index + 2]) + index += 2 + continue + if char == ";": + sections.append("".join(current)) + current = [] + else: + current.append(char) + index += 1 + sections.append("".join(current)) + return sections + + +def _parse_section(text: str) -> _Section | None: # noqa: C901, PLR0912 - линейный разбор символов формата + condition: _Condition | None = None + literal: 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]) + index = end + 1 + continue + if char == "[": + end = text.index("]", index + 1) + body = text[index + 1 : end].strip() + found = _CONDITION.match(body) + if found is not None: + if condition is not None: + return None + condition = _Condition(found.group(1), Decimal(found.group(2))) + index = end + 1 # цвет, [$-419] и прочее оформление игнорируются + continue + if char == "\\": + literal.append(text[index + 1 : index + 2]) + index += 2 + continue + if char in "_*": + index += 2 # отступ/заполнение: оформление + continue + if text[index : index + len(_GENERAL)].lower() == _GENERAL: + kinds.append("g") + index += len(_GENERAL) + continue + if char in _PLACEHOLDERS: + kinds.append("p") + elif char in ",.": + kinds.append(char) + elif char in _PLAIN_CHARS: + literal.append(char) + else: # %, E+, /, @, буквы дат и любые неизвестные знаки + return None + index += 1 + return _build_section(condition, literal, kinds) + + +def _build_section(condition: _Condition | None, literal: list[str], kinds: list[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 + position = 0 + while position < len(kinds): + if kinds[position] != ",": + position += 1 + continue + end = position + while end < len(kinds) and kinds[end] == ",": + end += 1 + follows_placeholder = position > 0 and kinds[position - 1] == "p" + if follows_placeholder and (end == len(kinds) or kinds[end] != "p"): + scale += end - position + 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) + + +@lru_cache(maxsize=256) +def _parse_format(fmt: str) -> tuple[_Section, ...] | None: + """Секции формата либо None, если формат не разобран.""" + parts = _split_sections(fmt) + if parts is None: + return None + sections: list[_Section] = [] + for part in parts[:_MAX_SECTIONS]: + section = _parse_section(part) + if section is None: + return None + sections.append(section) + return tuple(sections) + + +_DEFAULT_CONDITIONS: dict[int, tuple[_Condition, ...]] = { + 2: (_Condition(">=", Decimal(0)),), + 3: (_Condition(">", Decimal(0)), _Condition("<", Decimal(0))), +} + + +def _pick_section(sections: tuple[_Section, ...], raw: Decimal) -> _Section | None: + count = len(sections) + defaults = _DEFAULT_CONDITIONS.get(count, ()) + for position, section in enumerate(sections): + final = position == count - 1 + if section.condition is not None: + matched = section.condition.holds(raw) + elif final: + matched = True + else: + matched = defaults[position].holds(raw) + if matched: + return section + return None + + +def _scaled(raw: Decimal, section: _Section) -> FormatResult: + digits = min(section.decimals, _MAX_DECIMALS) + try: + magnitude = abs(raw).scaleb(-_SCALE_STEP * section.scale) + rounded = magnitude.quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP) + except InvalidOperation: # число длиннее точности контекста: заведомо вне numeric(18,2) + 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) + + +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) + + +def display_amount(raw: Decimal, fmt: str) -> FormatResult: + """Значение, которое Excel показывает для числа `raw` в формате `fmt` (см. описание модуля).""" + sections = _parse_format(fmt) if fmt.strip() else () + if sections is None: + return _UNPARSED + section = _pick_section(sections, raw) if sections else None + if not sections or (section is not None and section.general): + return _RAW + if section is None: + return _UNPARSED + return _scaled(raw, section) if section.has_digits else _literal_result(section) diff --git a/api/tests/unit/parser/statements/test_number_format.py b/api/tests/unit/parser/statements/test_number_format.py new file mode 100644 index 0000000..a00577d --- /dev/null +++ b/api/tests/unit/parser/statements/test_number_format.py @@ -0,0 +1,134 @@ +"""Отображаемое значение числа по числовому формату ячейки (#42, R-2…R-6): примеры из реальных выгрузок 1С.""" + +from decimal import Decimal + +import pytest + +from src.parser.statements.number_format import display_amount + + +def shown(raw: str, fmt: str) -> tuple[str, Decimal | None]: + result = display_amount(Decimal(raw), fmt) + return result.kind, result.value + + +@pytest.mark.parametrize( + ("raw", "fmt", "expected"), + [ + ("10741981390", "#,##0,", "10741981.00"), + ("586260.26", "0,", "586.00"), + ("898852.21795", "#,##0", "898852.00"), + ("898852.5", "#,##0", "898853.00"), + ("1234.5", "#,##0.00", "1234.50"), + ("1234.567", "#,##0.00", "1234.57"), + ("1500", "#,##0,", "2.00"), + ("-1500", "#,##0,", "-2.00"), + ("1499.99", "#,##0,", "1.00"), + ("-1499.99", "#,##0,", "-1.00"), + ("586260.26", "0.00,", "586.26"), + ("5000000", "#,##0,,", "5.00"), + ("1500", '#,##0,;(#,##0,);"-"', "2.00"), + ("-1500", '#,##0,;(#,##0,);"-"', "-2.00"), + ("1500", "#,##0;(#,##0)", "1500.00"), + ("-1500", "#,##0;(#,##0)", "-1500.00"), + ("-1500", "#,##0;#,##0", "-1500.00"), + ("1500", "[Red]#,##0", "1500.00"), + ("1500", "[$-419]#,##0", "1500.00"), + ("1500", "#,##0_);[Red](#,##0)", "1500.00"), + ("-1500", "#,##0_);[Red](#,##0)", "-1500.00"), + ("1500.126", "0.000", "1500.13"), + ("1500", '0 "тыс."', "1500.00"), + ("-872206880.07", '[=-872206880.07]"(872 207)";#,##0,', "-872207.00"), + ("-872206881", '[=-872206880.07]"(872 207)";#,##0,', "-872207.00"), + ("-646532", '[=-646532]"(647)"', "-647.00"), + ("12345678", '[=-872206880.07]"(872 207)";#,##0,', "12346.00"), + ], +) +def test_value(raw: str, fmt: str, expected: str) -> None: + kind, value = shown(raw, fmt) + assert kind == "value" + assert value is not None + assert str(value) == expected + + +@pytest.mark.parametrize( + ("raw", "fmt"), + [ + ("0", '[=0]"-"'), + ("0", '[=0]""'), + ("0", '[=0]"-";General'), + ("-0.79", '[=-0.79]"-"'), + ("0", '#,##0,;(#,##0,);"-"'), + ], +) +def test_no_value(raw: str, fmt: str) -> None: + assert shown(raw, fmt) == ("value", None) + + +@pytest.mark.parametrize( + ("raw", "fmt"), + [ + ("5", '[=0]"-"'), + ("5", "0%"), + ("5", "0.00E+00"), + ("5", "# ?/?"), + ("5", "@"), + ("5", "dd.mm.yyyy"), + ("5", "h:mm AM/PM"), + ("0", '[=0]"н/д"'), + ("5", '"н/д"'), + ("5", "0 руб"), + ], +) +def test_unparsed(raw: str, fmt: str) -> None: + assert shown(raw, fmt) == ("unparsed", None) + + +@pytest.mark.parametrize("fmt", ["General", "general", "", " "]) +def test_raw(fmt: str) -> None: + assert shown("5.5", fmt) == ("raw", None) + + +def test_general_section_after_condition() -> None: + assert shown("5.5", '[=0]"-";General') == ("raw", None) + + +@pytest.mark.parametrize( + ("raw", "fmt", "expected"), + [ + ("5", "[<10]0;[>=10]0,", "5.00"), + ("20000", "[<10]0;[>=10]0,", "20.00"), + ("5", "[<>5]0;0.0", "5.0"), + ], +) +def test_comparison_conditions(raw: str, fmt: str, expected: str) -> None: + kind, value = shown(raw, fmt) + assert kind == "value" + assert value is not None + assert Decimal(expected) == value + + +def test_fourth_section_ignored() -> None: + assert shown("1500", "#,##0,;(#,##0,);0;@") == ("value", Decimal("2.00")) + + +def test_semicolon_in_quotes_does_not_split() -> None: + assert shown("0", '[=0]"a;b"') == ("unparsed", None) + + +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_max_numeric_18_2_range() -> None: + assert shown("9999999999999999", "0") == ("value", Decimal("9999999999999999.00")) + assert shown("99999999999999999", "0") == ("unparsed", None) + + +def test_scaled_huge_value_fits() -> None: + assert shown("99999999999999999000", "0,") == ("unparsed", None) + assert shown("9999999999999999000", "0,") == ("value", Decimal("9999999999999999.00")) diff --git a/api/tests/unit/parser/test_numbers.py b/api/tests/unit/parser/test_numbers.py index 13ac112..33f3d68 100644 --- a/api/tests/unit/parser/test_numbers.py +++ b/api/tests/unit/parser/test_numbers.py @@ -7,7 +7,7 @@ import pytest from src.parser import detect from src.parser.grid import EMPTY_CELL, Cell -from src.parser.numbers import MONEY_Q, MoneyParse, inn_checksum_ok, inn_digits, row_inn, to_money +from src.parser.numbers import MONEY_Q, MoneyParse, inn_checksum_ok, inn_digits, quantize_money, row_inn, to_money from src.parser.rules import Rules, load_rules MAX_WARNING_LENGTH = 100 @@ -286,3 +286,18 @@ def rules() -> Rules: ) def test_row_inn(rules: Rules, cell: Cell, expected: str | None) -> None: assert row_inn(cell, rules) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [("1.005", "1.01"), ("-1.005", "-1.01"), ("0.004", "0.00"), ("-0.004", "0.00"), ("10741981", "10741981.00")], +) +def test_quantize_money_half_up_and_no_negative_zero(value: str, expected: str) -> None: + assert_money(quantize_money(Decimal(value), value), expected) + + +@pytest.mark.parametrize("value", ["1E+16", "NaN", "Infinity", "1E-2000"]) +def test_quantize_money_rejects_out_of_range(value: str) -> None: + parsed = quantize_money(Decimal(value), value) + assert parsed.value is None + assert parsed.warning is not None