parser: отображаемое значение числа по числовому формату ячейки (#42)

Чистый модуль statements/number_format.py (display_amount) и публичная numbers.quantize_money.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 17:00:56 +04:00
parent c7c98150d5
commit 3eae1dcf70
4 changed files with 410 additions and 5 deletions

View File

@ -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":

View File

@ -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] [<>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)

View File

@ -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"))

View File

@ -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