101 lines
3.5 KiB
Python
101 lines
3.5 KiB
Python
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))
|