fix: out-of-range для любых Decimal, знак в скобках недопустим, тесты округления

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:28:21 +04:00
parent 7b14276b1a
commit 5f259be9ac
2 changed files with 40 additions and 7 deletions

View File

@ -88,8 +88,6 @@ def _split_core(core: str) -> tuple[str, SignForm, bool, bool] | None:
fixed = core[-1] != ")" fixed = core[-1] != ")"
core = core[1:-1].strip() core = core[1:-1].strip()
sign_form, negative = SignForm.PARENTHESES, True sign_form, negative = SignForm.PARENTHESES, True
if core and core[0] in "-−+":
core = core[1:].strip()
elif core[0] in "-−+": elif core[0] in "-−+":
if core[0] in _MINUSES: if core[0] in _MINUSES:
sign_form, negative = SignForm.MINUS, True sign_form, negative = SignForm.MINUS, True
@ -118,8 +116,6 @@ def _number(body: str, source: Source, text: str, sign_form: SignForm, *, negati
return _guess(digits, text, sign_form, negative=negative) return _guess(digits, text, sign_form, negative=negative)
else: else:
integer, fraction = compact.replace(",", ".").split(".") integer, fraction = compact.replace(",", ".").split(".")
if not integer:
return AmountParse(None, text, False, sign_form)
if source is Source.OCR and len(fraction) >= 3: # noqa: PLR2004 — три знака: не копейки if source is Source.OCR and len(fraction) >= 3: # noqa: PLR2004 — три знака: не копейки
return AmountParse(None, text, False, sign_form) return AmountParse(None, text, False, sign_form)
if len(integer) > MAX_INTEGER_DIGITS: if len(integer) > MAX_INTEGER_DIGITS:
@ -181,9 +177,11 @@ def build_value(
string_text = text if text.strip() else None string_text = text if text.strip() else None
value = cell.value value = cell.value
if isinstance(value, Decimal): if isinstance(value, Decimal):
amount = _quantize(value) # длину целой части проверяем до квантования: для 1E+30 и нефинитных значений quantize падает
if _too_long(amount): amount = _quantize(value) if value.is_finite() and value.adjusted() < MAX_INTEGER_DIGITS else None
return ValueBuild(_plain(cell, column_index, AmountParse(None, text, False, None)), out_of_range=True) if amount is None or _too_long(amount):
overflow = AmountParse(None, string_text, False, None)
return ValueBuild(_plain(cell, column_index, overflow), out_of_range=True)
return ValueBuild( return ValueBuild(
_plain(cell, column_index, AmountParse(amount, None, True, None), recognized=not split_part), _plain(cell, column_index, AmountParse(amount, None, True, None), recognized=not split_part),
out_of_range=False, out_of_range=False,

View File

@ -82,3 +82,38 @@ def test_build_value_date_missing_multiline_split(rules: Rules) -> None:
assert (part.amount, part.is_recognized) == (D("917775"), False) assert (part.amount, part.is_recognized) == (D("917775"), False)
dash = build_value(Cell("—", None, 100.0, "row:3"), 2, OCR, rules).value dash = build_value(Cell("—", None, 100.0, "row:3"), 2, OCR, rules).value
assert (dash.amount, dash.string_value, dash.is_recognized, dash.text) == (None, None, True, "—") assert (dash.amount, dash.string_value, dash.is_recognized, dash.text) == (None, None, True, "—")
@pytest.mark.parametrize("raw", ["1E+30", "Infinity", "NaN", "-Infinity", "10000000000000000"])
def test_build_value_decimal_out_of_range(rules: Rules, raw: str) -> None:
built = build_value(Cell(raw, D(raw), 100.0, "Баланс!D1"), 3, EXCEL, rules)
assert (built.value.amount, built.value.string_value, built.value.is_recognized, built.out_of_range) == (
None,
raw,
False,
True,
)
def test_build_value_decimal_empty_text_and_boundary(rules: Rules) -> None:
empty = build_value(Cell("", D("1E+30"), 100.0, "Баланс!D1"), 3, EXCEL, rules)
assert (empty.value.string_value, empty.out_of_range) == (None, True)
edge = build_value(Cell("x", D("9999999999999999"), 100.0, "Баланс!D1"), 3, EXCEL, rules)
assert (edge.value.amount, edge.out_of_range) == (D("9999999999999999"), False)
carry = build_value(Cell("x", D("9999999999999999.999"), 100.0, "Баланс!D1"), 3, EXCEL, rules)
assert (carry.value.amount, carry.out_of_range) == (None, True)
def test_build_value_rounds_half_up(rules: Rules) -> None:
assert build_value(Cell("0,125", D("0.125"), 100.0, "Баланс!D1"), 3, EXCEL, rules).value.amount == D("0.13")
assert build_value(Cell("-0,125", D("-0.125"), 100.0, "Баланс!D1"), 3, EXCEL, rules).value.amount == D("-0.13")
def test_sign_inside_parentheses_is_invalid(rules: Rules) -> None:
parsed = parse_amount("(-5)", OCR, rules)
assert (parsed.amount, parsed.string_value, parsed.is_recognized, parsed.sign_form) == (None, "(-5)", False, None)
def test_leading_comma_is_edge_trash(rules: Rules) -> None:
parsed = parse_amount(",5", OCR, rules)
assert (parsed.amount, parsed.is_recognized) == (D("5"), False)