finance-telegram-bot/app/parser.py

101 lines
3.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from app.domain import ParsedTransaction, TransactionKind
class ParseError(ValueError):
pass
_TRANSACTION_RE = re.compile(
r"^\s*(?P<sign>[+-])?\s*"
r"(?:(?P<kind>доход|приход|расход|трата)\s+)?"
r"(?P<amount>\d[\d ]*(?:[,.]\d{1,2})?)\s*"
r"(?:₽|р\.?|руб(?:лей|ля|ль)?\.?)?\s+"
r"(?P<description>\S.*)\s*$",
flags=re.IGNORECASE,
)
_DESCRIPTION_FIRST_RE = re.compile(
r"^\s*(?P<sign>[+-])\s*(?P<description>зп|зарплата)\s+"
r"(?P<amount>\d[\d ]*(?:[,.]\d{1,2})?)\s*"
r"(?:₽|р\.?|руб(?:лей|ля|ль)?\.?)?\s*$",
flags=re.IGNORECASE,
)
_SALARY_RE = re.compile(r"(?:^|\s)(?:зп|зарплата)(?:$|\s)", flags=re.IGNORECASE)
def parse_transaction(text: str) -> ParsedTransaction:
match = _TRANSACTION_RE.match(text)
description_first = False
if not match:
match = _DESCRIPTION_FIRST_RE.match(text)
description_first = match is not None
if not match:
raise ParseError("Не удалось распознать сумму и описание")
raw_amount = match.group("amount").replace(" ", "").replace(",", ".")
try:
amount = Decimal(raw_amount)
except InvalidOperation as exc:
raise ParseError("Некорректная сумма") from exc
if amount <= 0:
raise ParseError("Сумма должна быть больше нуля")
amount_kopecks = int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
description = " ".join(match.group("description").split())
kind_word = "" if description_first else (match.group("kind") or "").casefold()
sign = match.group("sign")
if sign == "-" and kind_word in {"доход", "приход"}:
raise ParseError("Доход не может быть отрицательным")
is_salary = _SALARY_RE.search(description) is not None
kind = (
TransactionKind.INCOME
if is_salary or kind_word in {"доход", "приход"}
else TransactionKind.EXPENSE
)
debt_effect = -1 if sign == "-" or kind is TransactionKind.INCOME else 1
return ParsedTransaction(
amount_kopecks=amount_kopecks,
description=description,
kind=kind,
debt_effect=debt_effect,
debt_effect_override=-1 if sign == "-" else None,
)
def format_money(kopecks: int, *, signed: bool = False) -> str:
sign = ""
if signed:
sign = "+" if kopecks > 0 else ("" if kopecks < 0 else "")
absolute = abs(kopecks)
rubles, cents = divmod(absolute, 100)
grouped = f"{rubles:,}".replace(",", " ")
value = f"{grouped},{cents:02d}" if cents else grouped
return f"{sign}{value}"
_MONEY_ONLY_RE = re.compile(
r"^\s*(?P<amount>\d[\d ]*(?:[,.]\d{1,2})?)\s*"
r"(?:₽|р\.?|руб(?:лей|ля|ль)?\.?)?\s*$",
flags=re.IGNORECASE,
)
def parse_money_amount(text: str) -> int:
match = _MONEY_ONLY_RE.match(text)
if not match:
raise ParseError("Некорректная сумма")
raw_amount = match.group("amount").replace(" ", "").replace(",", ".")
try:
amount = Decimal(raw_amount)
except InvalidOperation as exc:
raise ParseError("Некорректная сумма") from exc
if amount < 0:
raise ParseError("Сумма не может быть отрицательной")
return int((amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP))