from __future__ import annotations import re from datetime import date AMOUNT_RE = re.compile( r"^\s*(?P\d+(?:[.,]\d{1,2})?)\s*(?P.*)?$", re.UNICODE, ) NOTE_FIRST_RE = re.compile( r"^\s*(?P.+?)\s+(?P\d+(?:[.,]\d{1,2})?)\s*$", re.UNICODE, ) DATE_RE = re.compile( r"^(?P\d{1,2})[./](?P\d{1,2})(?:[./](?P\d{2,4}))?$" ) # Unambiguous date token: ISO, with year, slash, or zero-padded month (12.09) DATE_TOKEN_RE = re.compile( r"(?P" 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" 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)