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_checksum_ok",
|
||||||
"inn_digits",
|
"inn_digits",
|
||||||
"inn_only",
|
"inn_only",
|
||||||
|
"quantize_money",
|
||||||
"row_inn",
|
"row_inn",
|
||||||
"split_inn_suffix",
|
"split_inn_suffix",
|
||||||
"to_money",
|
"to_money",
|
||||||
@ -79,8 +80,10 @@ def _shorten(raw: str) -> str:
|
|||||||
return raw if len(raw) <= _WARNING_VALUE_LIMIT else raw[:_WARNING_VALUE_LIMIT] + "…"
|
return raw if len(raw) <= _WARNING_VALUE_LIMIT else raw[:_WARNING_VALUE_LIMIT] + "…"
|
||||||
|
|
||||||
|
|
||||||
def _quantize(value: Decimal, shown: str) -> MoneyParse:
|
def quantize_money(value: Decimal, shown: str) -> MoneyParse:
|
||||||
"""Квантование до 0.01 с отсечением нечисел и значений вне numeric(18,2); отрицательный ноль -> 0.00."""
|
"""Квантование до 0.01 с отсечением нечисел и значений вне numeric(18,2); отрицательный ноль -> 0.00.
|
||||||
|
|
||||||
|
Публичная: ту же проверку применяет `statements.number_format` к отображаемому значению (#42, R-8)."""
|
||||||
if not value.is_finite():
|
if not value.is_finite():
|
||||||
return _skip(f"нечисловое значение суммы «{_shorten(shown)}»")
|
return _skip(f"нечисловое значение суммы «{_shorten(shown)}»")
|
||||||
if value != 0 and not _MIN_ADJUSTED <= value.adjusted() < _MAX_INTEGER_DIGITS:
|
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):
|
if not _NUMBER.match(compact):
|
||||||
return _skip(f"нечисловое значение в колонке сумм: «{_shorten(raw.strip())}»")
|
return _skip(f"нечисловое значение в колонке сумм: «{_shorten(raw.strip())}»")
|
||||||
try:
|
try:
|
||||||
return _quantize(Decimal(compact), raw.strip())
|
return quantize_money(Decimal(compact), raw.strip())
|
||||||
except InvalidOperation:
|
except InvalidOperation:
|
||||||
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(raw.strip())}»")
|
return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(raw.strip())}»")
|
||||||
|
|
||||||
@ -115,7 +118,7 @@ def to_money(cell: Cell) -> MoneyParse:
|
|||||||
return _skip()
|
return _skip()
|
||||||
if cell.kind == "number":
|
if cell.kind == "number":
|
||||||
# str(): точная десятичная запись; Decimal(str(float)) — и для тестов с «сырыми» int/float в ячейке
|
# 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":
|
if cell.kind == "text":
|
||||||
return _parse_text(str(cell.value))
|
return _parse_text(str(cell.value))
|
||||||
if cell.kind == "bool":
|
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 import detect
|
||||||
from src.parser.grid import EMPTY_CELL, Cell
|
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
|
from src.parser.rules import Rules, load_rules
|
||||||
|
|
||||||
MAX_WARNING_LENGTH = 100
|
MAX_WARNING_LENGTH = 100
|
||||||
@ -286,3 +286,18 @@ def rules() -> Rules:
|
|||||||
)
|
)
|
||||||
def test_row_inn(rules: Rules, cell: Cell, expected: str | None) -> None:
|
def test_row_inn(rules: Rules, cell: Cell, expected: str | None) -> None:
|
||||||
assert row_inn(cell, rules) == expected
|
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