feat(proj): init
This commit is contained in:
@@ -0,0 +1,781 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command, CommandObject, CommandStart, StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
KeyboardButton,
|
||||
Message,
|
||||
ReplyKeyboardMarkup,
|
||||
ReplyKeyboardRemove,
|
||||
)
|
||||
|
||||
from bot.clients.budget_api import BudgetApiError
|
||||
from bot.handlers.yandex_gate import YandexLoginGateMiddleware
|
||||
from bot.handlers.states import BudgetSetup, DatedExpense
|
||||
from bot.services.budget import BudgetService, PeriodExpensesPage
|
||||
from bot.services.parsing import (
|
||||
format_date,
|
||||
format_money,
|
||||
format_status,
|
||||
parse_amount,
|
||||
parse_end_date,
|
||||
parse_expense_message,
|
||||
parse_spent_date,
|
||||
)
|
||||
|
||||
router = Router()
|
||||
router.message.middleware(YandexLoginGateMiddleware())
|
||||
router.callback_query.middleware(YandexLoginGateMiddleware())
|
||||
|
||||
HELP_TEXT = """\
|
||||
Я помогаю дотянуть до зарплаты без сюрпризов.
|
||||
|
||||
Команды:
|
||||
/budget — новый бюджет (имя · сумма · дата)
|
||||
/budgets — список бюджетов, выбрать / вкл-выкл / удалить
|
||||
/status — сколько можно тратить сегодня
|
||||
/today — траты за сегодня
|
||||
/history — траты за весь период (постранично)
|
||||
/day 12.09 — траты за дату
|
||||
/spend — трата за другую дату (диалог)
|
||||
/undo — отменить последнюю трату
|
||||
/cancel — отменить текущий диалог
|
||||
/help — эта справка
|
||||
|
||||
Быстрый ввод трат:
|
||||
• 250
|
||||
• 250 кофе
|
||||
• кофе 250
|
||||
• за 12.09 250 кофе
|
||||
• 250 кофе за 12.09
|
||||
• 12.09 250 кофе
|
||||
"""
|
||||
|
||||
MENU_STATUS = "📊 Статус"
|
||||
MENU_TODAY = "🧾 Сегодня"
|
||||
MENU_HISTORY = "📒 Период"
|
||||
MENU_DATED = "📅 За дату"
|
||||
MENU_UNDO = "↩️ Отмена траты"
|
||||
MENU_BUDGET = "💰 Новый бюджет"
|
||||
MENU_BUDGETS = "🗂 Бюджеты"
|
||||
|
||||
HISTORY_CB_PREFIX = "hist:"
|
||||
BUDGET_SELECT_PREFIX = "bsel:"
|
||||
BUDGET_TOGGLE_PREFIX = "btgl:"
|
||||
BUDGET_DELETE_PREFIX = "bdel:"
|
||||
|
||||
|
||||
def main_keyboard() -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text=MENU_STATUS), KeyboardButton(text=MENU_TODAY)],
|
||||
[KeyboardButton(text=MENU_HISTORY), KeyboardButton(text=MENU_DATED)],
|
||||
[KeyboardButton(text=MENU_BUDGETS), KeyboardButton(text=MENU_BUDGET)],
|
||||
[KeyboardButton(text=MENU_UNDO)],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
)
|
||||
|
||||
|
||||
def format_period_page(page_data: PeriodExpensesPage) -> str:
|
||||
budget = page_data.budget
|
||||
header = (
|
||||
f"📒 {budget.name}\n"
|
||||
f"Траты {format_date(budget.start_date)}–{format_date(budget.end_date)}\n"
|
||||
f"Всего записей: {page_data.total_count} · "
|
||||
f"сумма: {format_money(page_data.total_sum)}\n"
|
||||
f"Страница {page_data.page + 1}/{page_data.total_pages}"
|
||||
)
|
||||
if not page_data.items:
|
||||
return header + "\n\nПока нет трат за период."
|
||||
|
||||
lines = []
|
||||
for item in page_data.items:
|
||||
note = f" — {item.note}" if item.note else ""
|
||||
lines.append(
|
||||
f"• {format_date(item.spent_at)} · {format_money(item.amount)}{note}"
|
||||
)
|
||||
return header + "\n\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def format_budgets_list(items) -> str:
|
||||
if not items:
|
||||
return "Бюджетов пока нет. Создай: /budget"
|
||||
lines = ["Твои бюджеты:"]
|
||||
for status in items:
|
||||
b = status.budget
|
||||
marks = []
|
||||
if status.selected:
|
||||
marks.append("текущий")
|
||||
marks.append("активен" if b.is_active else "выкл")
|
||||
mark = ", ".join(marks)
|
||||
lines.append(
|
||||
f"• #{b.id} {b.name} — {format_money(b.total_amount)} "
|
||||
f"до {format_date(b.end_date)} ({mark})"
|
||||
)
|
||||
lines.append("\nКнопки: выбрать · вкл/выкл · удалить.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def budgets_keyboard(items) -> InlineKeyboardMarkup | None:
|
||||
if not items:
|
||||
return None
|
||||
rows: list[list[InlineKeyboardButton]] = []
|
||||
for status in items:
|
||||
b = status.budget
|
||||
select_label = f"{'✓ ' if status.selected else ''}{b.name}"[:28]
|
||||
toggle_label = "Выкл" if b.is_active else "Вкл"
|
||||
rows.append(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=select_label,
|
||||
callback_data=f"{BUDGET_SELECT_PREFIX}{b.id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text=toggle_label,
|
||||
callback_data=f"{BUDGET_TOGGLE_PREFIX}{b.id}",
|
||||
),
|
||||
InlineKeyboardButton(
|
||||
text="🗑",
|
||||
callback_data=f"{BUDGET_DELETE_PREFIX}{b.id}",
|
||||
),
|
||||
]
|
||||
)
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def history_keyboard(page_data: PeriodExpensesPage) -> InlineKeyboardMarkup | None:
|
||||
if page_data.total_pages <= 1:
|
||||
return None
|
||||
|
||||
buttons: list[InlineKeyboardButton] = []
|
||||
if page_data.page > 0:
|
||||
buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="‹ Назад",
|
||||
callback_data=f"{HISTORY_CB_PREFIX}{page_data.page - 1}",
|
||||
)
|
||||
)
|
||||
buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{page_data.page + 1}/{page_data.total_pages}",
|
||||
callback_data=f"{HISTORY_CB_PREFIX}nop",
|
||||
)
|
||||
)
|
||||
if page_data.page + 1 < page_data.total_pages:
|
||||
buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="Вперёд ›",
|
||||
callback_data=f"{HISTORY_CB_PREFIX}{page_data.page + 1}",
|
||||
)
|
||||
)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[buttons])
|
||||
|
||||
|
||||
async def _reply_expense_saved(
|
||||
message: Message,
|
||||
*,
|
||||
amount: float,
|
||||
note: str | None,
|
||||
spent_at: date | None,
|
||||
status,
|
||||
) -> None:
|
||||
note_part = f" ({note})" if note else ""
|
||||
date_part = f" за {format_date(spent_at)}" if spent_at else ""
|
||||
await message.answer(
|
||||
f"Записал {format_money(amount)}{note_part}{date_part}.\n\n"
|
||||
f"{format_status(status)}",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
async def _save_expense(
|
||||
message: Message,
|
||||
budget_service: BudgetService,
|
||||
*,
|
||||
amount: float,
|
||||
note: str | None,
|
||||
spent_at: date | None,
|
||||
state: FSMContext | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
status = await budget_service.add_expense(
|
||||
user_id=message.from_user.id,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
if state is not None:
|
||||
await state.clear()
|
||||
return False
|
||||
except BudgetApiError as exc:
|
||||
await message.answer(
|
||||
f"API недоступен: {exc}",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
if state is not None:
|
||||
await state.clear()
|
||||
return False
|
||||
|
||||
if state is not None:
|
||||
await state.clear()
|
||||
await _reply_expense_saved(
|
||||
message,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
status=status,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# --- Global commands / menu (always win over FSM) ---
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_start(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"Привет! Я бот «от зарплаты до зарплаты».\n\n"
|
||||
"1) Задай бюджет: /budget\n"
|
||||
"2) Пиши траты: 250 или кофе 250\n"
|
||||
"3) За другую дату: за 12.09 250 кофе\n"
|
||||
"4) Смотри лимит: /status",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("help"))
|
||||
async def cmd_help(message: Message, state: FSMContext) -> None:
|
||||
await state.clear()
|
||||
await message.answer(HELP_TEXT, reply_markup=main_keyboard())
|
||||
|
||||
|
||||
@router.message(Command("cancel"))
|
||||
@router.message(F.text.casefold() == "отмена")
|
||||
async def cmd_cancel(message: Message, state: FSMContext) -> None:
|
||||
current = await state.get_state()
|
||||
if current is None:
|
||||
await message.answer("Нечего отменять.", reply_markup=main_keyboard())
|
||||
return
|
||||
await state.clear()
|
||||
await message.answer("Ок, отменил.", reply_markup=main_keyboard())
|
||||
|
||||
|
||||
@router.message(Command("status"))
|
||||
@router.message(F.text == MENU_STATUS)
|
||||
async def cmd_status(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
try:
|
||||
status = await budget_service.get_status(message.from_user.id)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
return
|
||||
await message.answer(format_status(status), reply_markup=main_keyboard())
|
||||
|
||||
|
||||
@router.message(Command("today"))
|
||||
@router.message(F.text == MENU_TODAY)
|
||||
async def cmd_today(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
try:
|
||||
status = await budget_service.get_status(message.from_user.id)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
return
|
||||
|
||||
expenses = await budget_service.today_expenses(message.from_user.id)
|
||||
if not expenses:
|
||||
body = "Сегодня трат пока нет."
|
||||
else:
|
||||
lines = []
|
||||
for item in expenses:
|
||||
note = f" — {item.note}" if item.note else ""
|
||||
lines.append(f"• {format_money(item.amount)}{note}")
|
||||
body = "Траты сегодня:\n" + "\n".join(lines)
|
||||
|
||||
await message.answer(
|
||||
f"{body}\n\n{format_status(status)}",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("history"))
|
||||
@router.message(F.text == MENU_HISTORY)
|
||||
async def cmd_history(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
try:
|
||||
page_data = await budget_service.get_period_expenses_page(
|
||||
message.from_user.id,
|
||||
page=0,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
format_period_page(page_data),
|
||||
reply_markup=history_keyboard(page_data) or main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith(HISTORY_CB_PREFIX))
|
||||
async def cb_history_page(
|
||||
callback: CallbackQuery,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
raw = (callback.data or "")[len(HISTORY_CB_PREFIX) :]
|
||||
if raw == "nop":
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
try:
|
||||
page = int(raw)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректная страница", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
page_data = await budget_service.get_period_expenses_page(
|
||||
callback.from_user.id,
|
||||
page=page,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
|
||||
text = format_period_page(page_data)
|
||||
markup = history_keyboard(page_data)
|
||||
if callback.message:
|
||||
await callback.message.edit_text(text, reply_markup=markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(Command("day"))
|
||||
async def cmd_day(
|
||||
message: Message,
|
||||
command: CommandObject,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
args = (command.args or "").strip()
|
||||
if not args:
|
||||
await message.answer(
|
||||
"Укажи дату: /day 12.09\n"
|
||||
"Или список трат за сегодня: /today",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
day = parse_spent_date(args)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
return
|
||||
|
||||
try:
|
||||
status = await budget_service.get_status(message.from_user.id)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||
return
|
||||
|
||||
expenses = await budget_service.expenses_on_date(message.from_user.id, day)
|
||||
if not expenses:
|
||||
body = f"За {format_date(day)} трат нет."
|
||||
else:
|
||||
lines = []
|
||||
for item in expenses:
|
||||
note = f" — {item.note}" if item.note else ""
|
||||
lines.append(f"• {format_money(item.amount)}{note}")
|
||||
body = f"Траты за {format_date(day)}:\n" + "\n".join(lines)
|
||||
|
||||
await message.answer(
|
||||
f"{body}\n\n{format_status(status)}",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("undo"))
|
||||
@router.message(F.text == MENU_UNDO)
|
||||
async def cmd_undo(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
status, amount = await budget_service.undo_last_expense(message.from_user.id)
|
||||
if amount is None:
|
||||
await message.answer("Нечего отменять.", reply_markup=main_keyboard())
|
||||
return
|
||||
|
||||
text = f"Удалил последнюю трату: {format_money(amount)}."
|
||||
if status is not None:
|
||||
text += f"\n\n{format_status(status)}"
|
||||
await message.answer(text, reply_markup=main_keyboard())
|
||||
|
||||
|
||||
@router.message(Command("budget"))
|
||||
@router.message(F.text == MENU_BUDGET)
|
||||
async def cmd_budget(message: Message, state: FSMContext) -> None:
|
||||
await state.set_state(BudgetSetup.waiting_name)
|
||||
await message.answer(
|
||||
"Новый бюджет. Как назвать? (например: Зарплата, Отпуск)\n"
|
||||
"Или «-» чтобы оставить «Бюджет».\n"
|
||||
"Отмена: /cancel",
|
||||
reply_markup=ReplyKeyboardRemove(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("budgets"))
|
||||
@router.message(F.text == MENU_BUDGETS)
|
||||
async def cmd_budgets(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
await state.clear()
|
||||
try:
|
||||
items = await budget_service.list_user_statuses(message.from_user.id)
|
||||
except (ValueError, BudgetApiError) as exc:
|
||||
await message.answer(f"Не удалось загрузить: {exc}", reply_markup=main_keyboard())
|
||||
return
|
||||
await message.answer(
|
||||
format_budgets_list(items),
|
||||
reply_markup=budgets_keyboard(items) or main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith(BUDGET_SELECT_PREFIX))
|
||||
async def cb_budget_select(
|
||||
callback: CallbackQuery,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
raw = (callback.data or "")[len(BUDGET_SELECT_PREFIX) :]
|
||||
try:
|
||||
budget_id = int(raw)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректный id", show_alert=True)
|
||||
return
|
||||
try:
|
||||
status = await budget_service.select_budget(callback.from_user.id, budget_id)
|
||||
items = await budget_service.list_user_statuses(callback.from_user.id)
|
||||
except ValueError as exc:
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
if callback.message:
|
||||
await callback.message.edit_text(
|
||||
format_budgets_list(items) + f"\n\nТекущий: {status.budget.name}",
|
||||
reply_markup=budgets_keyboard(items),
|
||||
)
|
||||
await callback.answer(f"Выбран: {status.budget.name}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith(BUDGET_TOGGLE_PREFIX))
|
||||
async def cb_budget_toggle(
|
||||
callback: CallbackQuery,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
raw = (callback.data or "")[len(BUDGET_TOGGLE_PREFIX) :]
|
||||
try:
|
||||
budget_id = int(raw)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректный id", show_alert=True)
|
||||
return
|
||||
try:
|
||||
current = await budget_service.get_status(
|
||||
callback.from_user.id,
|
||||
budget_id=budget_id,
|
||||
)
|
||||
status = await budget_service.set_budget_active(
|
||||
callback.from_user.id,
|
||||
budget_id,
|
||||
not current.budget.is_active,
|
||||
)
|
||||
items = await budget_service.list_user_statuses(callback.from_user.id)
|
||||
except ValueError as exc:
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
if callback.message:
|
||||
await callback.message.edit_text(
|
||||
format_budgets_list(items),
|
||||
reply_markup=budgets_keyboard(items),
|
||||
)
|
||||
state_label = "включён" if status.budget.is_active else "выключен"
|
||||
await callback.answer(f"{status.budget.name}: {state_label}")
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith(BUDGET_DELETE_PREFIX))
|
||||
async def cb_budget_delete(
|
||||
callback: CallbackQuery,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
raw = (callback.data or "")[len(BUDGET_DELETE_PREFIX) :]
|
||||
try:
|
||||
budget_id = int(raw)
|
||||
except ValueError:
|
||||
await callback.answer("Некорректный id", show_alert=True)
|
||||
return
|
||||
try:
|
||||
current = await budget_service.get_status(
|
||||
callback.from_user.id,
|
||||
budget_id=budget_id,
|
||||
)
|
||||
name = current.budget.name
|
||||
await budget_service.delete_budget(callback.from_user.id, budget_id)
|
||||
items = await budget_service.list_user_statuses(callback.from_user.id)
|
||||
except (ValueError, BudgetApiError) as exc:
|
||||
await callback.answer(str(exc), show_alert=True)
|
||||
return
|
||||
if callback.message:
|
||||
await callback.message.edit_text(
|
||||
format_budgets_list(items),
|
||||
reply_markup=budgets_keyboard(items) or None,
|
||||
)
|
||||
await callback.answer(f"Удалён: {name}")
|
||||
|
||||
|
||||
@router.message(Command("spend"))
|
||||
@router.message(F.text == MENU_DATED)
|
||||
async def cmd_spend_dated(message: Message, state: FSMContext) -> None:
|
||||
await state.set_state(DatedExpense.waiting_date)
|
||||
await message.answer(
|
||||
"Трата за дату — одним сообщением или по шагам.\n\n"
|
||||
"Сразу: 12.09 250 кб\n"
|
||||
"Или только дата: 12.09\n"
|
||||
"Отмена: /cancel",
|
||||
reply_markup=ReplyKeyboardRemove(),
|
||||
)
|
||||
|
||||
|
||||
# --- FSM: budget ---
|
||||
|
||||
|
||||
@router.message(BudgetSetup.waiting_name)
|
||||
async def budget_name(message: Message, state: FSMContext) -> None:
|
||||
raw = (message.text or "").strip()
|
||||
name = "Бюджет" if raw in {"", "-", "—"} else raw[:64]
|
||||
await state.update_data(name=name)
|
||||
await state.set_state(BudgetSetup.waiting_amount)
|
||||
await message.answer(
|
||||
f"Название: {name}.\n"
|
||||
"Сколько денег в этом бюджете? (например: 25000)"
|
||||
)
|
||||
|
||||
|
||||
@router.message(BudgetSetup.waiting_amount)
|
||||
async def budget_amount(message: Message, state: FSMContext) -> None:
|
||||
try:
|
||||
amount = parse_amount(message.text or "")
|
||||
except ValueError as exc:
|
||||
await message.answer(f"{exc}\nПопробуй ещё раз, например: 25000")
|
||||
return
|
||||
|
||||
await state.update_data(amount=amount)
|
||||
await state.set_state(BudgetSetup.waiting_end_date)
|
||||
await message.answer(
|
||||
"До какой даты нужно протянуть?\n"
|
||||
"Форматы: 25.09 · 25.09.2026 · 2026-09-25"
|
||||
)
|
||||
|
||||
|
||||
@router.message(BudgetSetup.waiting_end_date)
|
||||
async def budget_end_date(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
try:
|
||||
end_date = parse_end_date(message.text or "")
|
||||
except ValueError as exc:
|
||||
await message.answer(f"{exc}\nПример: 25.09.2026")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
amount = float(data["amount"])
|
||||
name = str(data.get("name") or "Бюджет")
|
||||
|
||||
try:
|
||||
status = await budget_service.create_budget(
|
||||
user_id=message.from_user.id,
|
||||
total_amount=amount,
|
||||
end_date=end_date,
|
||||
name=name,
|
||||
)
|
||||
except ValueError as exc:
|
||||
await message.answer(str(exc))
|
||||
return
|
||||
except BudgetApiError as exc:
|
||||
await message.answer(f"API недоступен: {exc}")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
f"Бюджет «{name}» создан: {format_money(amount)} до {format_date(end_date)}.\n\n"
|
||||
f"{format_status(status)}",
|
||||
reply_markup=main_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
# --- FSM: dated expense ---
|
||||
|
||||
|
||||
@router.message(DatedExpense.waiting_date)
|
||||
async def dated_expense_date(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
text = (message.text or "").strip()
|
||||
if not text:
|
||||
await message.answer("Введи дату, например: 12.09")
|
||||
return
|
||||
|
||||
# One-shot: "12.09 250 кб" / "за 12.09 250 кб"
|
||||
try:
|
||||
amount, note, spent_at = parse_expense_message(text)
|
||||
except ValueError:
|
||||
amount, note, spent_at = None, None, None
|
||||
|
||||
if amount is not None and spent_at is not None:
|
||||
await _save_expense(
|
||||
message,
|
||||
budget_service,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
state=state,
|
||||
)
|
||||
return
|
||||
|
||||
# Date only, or "12.09 кб" (date + note without amount)
|
||||
tokens = text.split()
|
||||
try:
|
||||
spent_at = parse_spent_date(tokens[0].rstrip(":"))
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"Не понял дату.\n"
|
||||
"Примеры: 12.09 или 12.09 250 кб\n"
|
||||
"Отмена: /cancel"
|
||||
)
|
||||
return
|
||||
|
||||
if spent_at > date.today():
|
||||
await message.answer("Нельзя добавить трату на будущую дату. Введи другую:")
|
||||
return
|
||||
|
||||
rest = " ".join(tokens[1:]).strip()
|
||||
if rest:
|
||||
try:
|
||||
amount, note, nested = parse_expense_message(rest)
|
||||
except ValueError:
|
||||
# "12.09 кб" → дата есть, суммы нет: запомним заметку и спросим сумму
|
||||
await state.update_data(spent_at=spent_at.isoformat(), note_hint=rest)
|
||||
await state.set_state(DatedExpense.waiting_expense)
|
||||
await message.answer(
|
||||
f"Дата: {format_date(spent_at)}, заметка: {rest}.\n"
|
||||
"Теперь сумму, например: 250"
|
||||
)
|
||||
return
|
||||
|
||||
if nested is not None:
|
||||
spent_at = nested
|
||||
await _save_expense(
|
||||
message,
|
||||
budget_service,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
state=state,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(spent_at=spent_at.isoformat(), note_hint=None)
|
||||
await state.set_state(DatedExpense.waiting_expense)
|
||||
await message.answer(
|
||||
f"Дата: {format_date(spent_at)}.\n"
|
||||
"Теперь сумма (и комментарий): 250 или 250 кб"
|
||||
)
|
||||
|
||||
|
||||
@router.message(DatedExpense.waiting_expense)
|
||||
async def dated_expense_amount(
|
||||
message: Message,
|
||||
state: FSMContext,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
data = await state.get_data()
|
||||
spent_at = date.fromisoformat(data["spent_at"])
|
||||
note_hint = data.get("note_hint")
|
||||
|
||||
try:
|
||||
amount, note, nested_date = parse_expense_message(message.text or "")
|
||||
except ValueError as exc:
|
||||
await message.answer(f"{exc}")
|
||||
return
|
||||
|
||||
if nested_date is not None:
|
||||
spent_at = nested_date
|
||||
if note is None and note_hint:
|
||||
note = note_hint
|
||||
|
||||
await _save_expense(
|
||||
message,
|
||||
budget_service,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
# --- Free-text expense (no active dialog) ---
|
||||
|
||||
|
||||
@router.message(StateFilter(None), F.text)
|
||||
async def add_expense_from_text(
|
||||
message: Message,
|
||||
budget_service: BudgetService,
|
||||
) -> None:
|
||||
text = (message.text or "").strip()
|
||||
if text.startswith("/"):
|
||||
return
|
||||
|
||||
try:
|
||||
amount, note, spent_at = parse_expense_message(text)
|
||||
except ValueError:
|
||||
await message.answer(
|
||||
"Не понял. Примеры:\n"
|
||||
"• 250 / 250 кофе / кофе 250\n"
|
||||
"• за 12.09 250 кофе\n"
|
||||
"• /spend — диалог за дату"
|
||||
)
|
||||
return
|
||||
|
||||
await _save_expense(
|
||||
message,
|
||||
budget_service,
|
||||
amount=amount,
|
||||
note=note,
|
||||
spent_at=spent_at,
|
||||
)
|
||||
Reference in New Issue
Block a user