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:
parent
c7c98150d5
commit
3eae1dcf70
@ -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":
|
||||
|
||||
253
api/src/parser/statements/number_format.py
Normal file
253
api/src/parser/statements/number_format.py
Normal 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)
|
||||
134
api/tests/unit/parser/statements/test_number_format.py
Normal file
134
api/tests/unit/parser/statements/test_number_format.py
Normal 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"))
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user