214 lines
6.7 KiB
Python
214 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import date
|
|
|
|
AMOUNT_RE = re.compile(
|
|
r"^\s*(?P<amount>\d+(?:[.,]\d{1,2})?)\s*(?P<note>.*)?$",
|
|
re.UNICODE,
|
|
)
|
|
NOTE_FIRST_RE = re.compile(
|
|
r"^\s*(?P<note>.+?)\s+(?P<amount>\d+(?:[.,]\d{1,2})?)\s*$",
|
|
re.UNICODE,
|
|
)
|
|
DATE_RE = re.compile(
|
|
r"^(?P<d>\d{1,2})[./](?P<m>\d{1,2})(?:[./](?P<y>\d{2,4}))?$"
|
|
)
|
|
# Unambiguous date token: ISO, with year, slash, or zero-padded month (12.09)
|
|
DATE_TOKEN_RE = re.compile(
|
|
r"(?P<date>"
|
|
r"\d{4}-\d{2}-\d{2}"
|
|
r"|"
|
|
r"\d{1,2}[./]\d{1,2}[./]\d{2,4}"
|
|
r"|"
|
|
r"\d{1,2}/\d{1,2}"
|
|
r"|"
|
|
r"\d{1,2}\.(?:0[1-9]|1[0-2])"
|
|
r")"
|
|
)
|
|
ZA_DATE_RE = re.compile(
|
|
r"(?i)(?:^|\s)за\s+(?P<date>"
|
|
r"\d{4}-\d{2}-\d{2}"
|
|
r"|"
|
|
r"\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?"
|
|
r")"
|
|
r"(?=\s|$)"
|
|
)
|
|
|
|
|
|
def parse_amount(value: str) -> float:
|
|
normalized = value.strip().replace(",", ".").replace(" ", "")
|
|
amount = float(normalized)
|
|
if amount <= 0:
|
|
raise ValueError("Сумма должна быть больше нуля")
|
|
return round(amount, 2)
|
|
|
|
|
|
def parse_spent_date(value: str, today: date | None = None) -> date:
|
|
"""Parse expense date. Bare DD.MM prefers current/past year (not future)."""
|
|
day = today or date.today()
|
|
raw = value.strip()
|
|
|
|
try:
|
|
return date.fromisoformat(raw)
|
|
except ValueError:
|
|
pass
|
|
|
|
match = DATE_RE.match(raw)
|
|
if not match:
|
|
raise ValueError("Дата в формате ДД.ММ или ДД.ММ.ГГГГ")
|
|
|
|
d = int(match.group("d"))
|
|
m = int(match.group("m"))
|
|
y_raw = match.group("y")
|
|
if y_raw is None:
|
|
try:
|
|
candidate = date(day.year, m, d)
|
|
except ValueError as exc:
|
|
raise ValueError("Некорректная дата") from exc
|
|
if candidate > day:
|
|
try:
|
|
candidate = date(day.year - 1, m, d)
|
|
except ValueError as exc:
|
|
raise ValueError("Некорректная дата") from exc
|
|
return candidate
|
|
|
|
y = int(y_raw)
|
|
if y < 100:
|
|
y += 2000
|
|
try:
|
|
return date(y, m, d)
|
|
except ValueError as exc:
|
|
raise ValueError("Некорректная дата") from exc
|
|
|
|
|
|
def parse_end_date(value: str, today: date | None = None) -> date:
|
|
"""Parse DD.MM, DD.MM.YYYY, YYYY-MM-DD (for budget end — may roll to next year)."""
|
|
day = today or date.today()
|
|
raw = value.strip()
|
|
|
|
try:
|
|
return date.fromisoformat(raw)
|
|
except ValueError:
|
|
pass
|
|
|
|
match = DATE_RE.match(raw)
|
|
if not match:
|
|
raise ValueError("Дата в формате ДД.ММ или ДД.ММ.ГГГГ")
|
|
|
|
d = int(match.group("d"))
|
|
m = int(match.group("m"))
|
|
y_raw = match.group("y")
|
|
if y_raw is None:
|
|
y = day.year
|
|
candidate = date(y, m, d)
|
|
if candidate < day:
|
|
candidate = date(y + 1, m, d)
|
|
return candidate
|
|
|
|
y = int(y_raw)
|
|
if y < 100:
|
|
y += 2000
|
|
return date(y, m, d)
|
|
|
|
|
|
def _parse_amount_and_note(raw: str) -> tuple[float, str | None]:
|
|
text = raw.strip()
|
|
if not text:
|
|
raise ValueError("Не понял трату. Примеры: 250, 250 кофе, кофе 250")
|
|
|
|
first_token = text.split()[0]
|
|
if re.fullmatch(r"\d+(?:[.,]\d{1,2})?", first_token):
|
|
match = AMOUNT_RE.match(text)
|
|
if not match:
|
|
raise ValueError("Не понял трату. Примеры: 250, 250 кофе, кофе 250")
|
|
amount = parse_amount(match.group("amount"))
|
|
note = (match.group("note") or "").strip() or None
|
|
return amount, note
|
|
|
|
match = NOTE_FIRST_RE.match(text)
|
|
if match:
|
|
amount = parse_amount(match.group("amount"))
|
|
note = match.group("note").strip() or None
|
|
return amount, note
|
|
|
|
raise ValueError("Не понял трату. Примеры: 250, 250 кофе, кофе 250")
|
|
|
|
|
|
def parse_expense_message(
|
|
text: str,
|
|
today: date | None = None,
|
|
) -> tuple[float, str | None, date | None]:
|
|
"""Parse expense text.
|
|
|
|
Examples:
|
|
- 250 / 250 кофе / кофе 250
|
|
- за 12.09 250 кофе
|
|
- 250 кофе за 12.09
|
|
- 12.09 250 кофе
|
|
- 250 кофе 12.09
|
|
"""
|
|
raw = text.strip()
|
|
if not raw:
|
|
raise ValueError("Пустое сообщение")
|
|
|
|
spent_at: date | None = None
|
|
body = raw
|
|
|
|
za_match = ZA_DATE_RE.search(raw)
|
|
if za_match:
|
|
spent_at = parse_spent_date(za_match.group("date"), today=today)
|
|
body = (raw[: za_match.start()] + " " + raw[za_match.end() :]).strip()
|
|
else:
|
|
tokens = raw.split()
|
|
if len(tokens) >= 2:
|
|
first = tokens[0].rstrip(":")
|
|
last = tokens[-1]
|
|
if DATE_TOKEN_RE.fullmatch(first):
|
|
spent_at = parse_spent_date(first, today=today)
|
|
body = " ".join(tokens[1:])
|
|
elif DATE_TOKEN_RE.fullmatch(last):
|
|
spent_at = parse_spent_date(last, today=today)
|
|
body = " ".join(tokens[:-1])
|
|
|
|
amount, note = _parse_amount_and_note(body)
|
|
return amount, note, spent_at
|
|
|
|
|
|
def format_money(amount: float, currency: str = "₽") -> str:
|
|
sign = "-" if amount < 0 else ""
|
|
return f"{sign}{abs(amount):,.2f} {currency}".replace(",", " ")
|
|
|
|
|
|
def format_date(value: date) -> str:
|
|
return value.strftime("%d.%m.%Y")
|
|
|
|
|
|
def format_status(status) -> str:
|
|
from bot.services.budget import BudgetStatus
|
|
|
|
assert isinstance(status, BudgetStatus)
|
|
currency = "₽"
|
|
active = "активен" if status.budget.is_active else "неактивен"
|
|
selected = " · текущий" if status.selected else ""
|
|
lines = [
|
|
f"🏷 {status.budget.name} ({active}{selected})",
|
|
f"📅 До {format_date(status.budget.end_date)} · дней осталось: {status.days_left}",
|
|
f"💰 Бюджет: {format_money(status.budget.total_amount, currency)}",
|
|
f"🧾 Потрачено: {format_money(status.total_spent, currency)}",
|
|
f"🛡 Остаток: {format_money(status.remaining, currency)}",
|
|
"",
|
|
f"📊 Лимит на день: {format_money(status.daily_limit, currency)}",
|
|
f"🛒 Сегодня: {format_money(status.spent_today, currency)}",
|
|
f"✅ Ещё можно сегодня: {format_money(status.remaining_today, currency)}",
|
|
]
|
|
|
|
if status.is_expired:
|
|
lines.append("\n⚠️ Период бюджета закончился. Задай новый: /budget")
|
|
elif status.is_over_budget:
|
|
lines.append("\n🚨 Бюджет уже превышен.")
|
|
elif status.is_over_daily:
|
|
lines.append("\n⚠️ Сегодняшний лимит превышен — завтра лимит пересчитается по остатку.")
|
|
|
|
return "\n".join(lines)
|