import asyncio import logging from datetime import date from decimal import Decimal import httpx from aiogram import Bot, Dispatcher, F from aiogram.filters import Command, CommandObject from aiogram.types import ( CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, Message, ReplyKeyboardMarkup, WebAppInfo, ) from app.core.config import settings from bot.api_client import ApiClient logging.basicConfig(level=logging.INFO) dp = Dispatcher() api = ApiClient() APPROVED_SERVICE_STATUSES = {"approved", "verified"} STO_WORKPLACE_ROLES = {"owner", "manager", "receptionist", "mechanic"} def webapp_url(path: str = "") -> str: base = settings.effective_webapp_url.rstrip("/") if not path: return base return f"{base}/{path.lstrip('/')}" def sto_workplace_url() -> str: return webapp_url("sto.html") async def sto_workplace_centers(telegram_id: int) -> list[dict]: try: centers = await api.my_service_centers(telegram_id) except httpx.HTTPStatusError: return [] return [ center for center in centers if center.get("verification_status") in APPROVED_SERVICE_STATUSES and (center.get("employee_role") or "owner") in STO_WORKPLACE_ROLES ] def main_keyboard() -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( keyboard=[ [KeyboardButton(text="Меню"), KeyboardButton(text="Мои авто")], [KeyboardButton(text="Помощь")], ], resize_keyboard=True, ) async def main_keyboard_for(telegram_id: int) -> ReplyKeyboardMarkup: rows = [ [KeyboardButton(text="Меню"), KeyboardButton(text="Мои авто")], ] if await sto_workplace_centers(telegram_id): rows.append([KeyboardButton(text="Панель СТО")]) rows.append([KeyboardButton(text="Помощь")]) return ReplyKeyboardMarkup(keyboard=rows, resize_keyboard=True) def webapp_inline_keyboard(text: str = "Открыть CarPass", path: str = "") -> InlineKeyboardMarkup: return InlineKeyboardMarkup( inline_keyboard=[ [InlineKeyboardButton(text=text, web_app=WebAppInfo(url=webapp_url(path)))], ] ) def menu_inline_keyboard(include_sto_workplace: bool = False) -> InlineKeyboardMarkup: rows = [ [InlineKeyboardButton(text="Открыть Mini App", web_app=WebAppInfo(url=webapp_url()))], [ InlineKeyboardButton(text="Мои авто", callback_data="menu:garage"), InlineKeyboardButton(text="Аналитика", callback_data="menu:analytics"), ], [ InlineKeyboardButton(text="Добавить запись", callback_data="menu:add_record"), InlineKeyboardButton(text="СТО", callback_data="menu:sto"), ], ] if include_sto_workplace: rows.append([InlineKeyboardButton(text="Панель СТО", web_app=WebAppInfo(url=sto_workplace_url()))]) return InlineKeyboardMarkup(inline_keyboard=rows) def admin_card_keyboard(center_id: int) -> InlineKeyboardMarkup: return InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton(text="Одобрить", callback_data=f"admin:approve:{center_id}"), InlineKeyboardButton(text="Правки", callback_data=f"admin:changes:{center_id}"), ], [ InlineKeyboardButton(text="Отклонить", callback_data=f"admin:reject:{center_id}"), InlineKeyboardButton(text="Заморозить", callback_data=f"admin:suspend:{center_id}"), ], ] ) async def safe_answer(message: Message, text: str, **kwargs) -> None: await message.answer(text, **kwargs) async def upsert(message: Message) -> dict: return await api.upsert_user(message.from_user) async def list_user_cars(message: Message) -> list[dict]: user = await upsert(message) return await api.list_cars(user["id"], message.from_user.id) async def require_one_car(message: Message) -> dict | None: cars = await list_user_cars(message) if not cars: await message.answer( "В гараже пока нет автомобиля. Добавь его командой /add_car Название или через Mini App.", reply_markup=webapp_inline_keyboard("Добавить авто"), ) return None if len(cars) > 1: await message.answer( "У тебя несколько авто. Для точной записи открой Mini App и выбери нужную машину.", reply_markup=webapp_inline_keyboard("Выбрать авто"), ) return None return cars[0] def money(value) -> str: return f"{Decimal(str(value or 0)).quantize(Decimal('0.01'))}" def parse_amount_arg(args: str | None) -> Decimal | None: if not args: return None import re matches = re.findall(r"\d+(?:[.,]\d+)?", args) if not matches: return None return Decimal(matches[-1].replace(",", ".")) @dp.message(Command("start")) async def start(message: Message) -> None: user = await upsert(message) text = ( f"Готово, {user.get('first_name') or 'водитель'}.\n\n" "CarPass ведет цифровой паспорт автомобиля: заправки, ТО, страховку, штрафы, стоимость владения и подтвержденную историю СТО.\n\n" "Mini App открывай кнопкой под сообщением: так Telegram передает защищенную авторизацию." ) centers = await sto_workplace_centers(message.from_user.id) await message.answer(text, reply_markup=menu_inline_keyboard(include_sto_workplace=bool(centers))) await message.answer("Клавиатура ниже открывает команды бота.", reply_markup=await main_keyboard_for(message.from_user.id)) @dp.message(F.text == "Меню") @dp.message(Command("menu")) async def menu(message: Message) -> None: await upsert(message) centers = await sto_workplace_centers(message.from_user.id) await message.answer( "Главное меню CarPass. Быстрые команды доступны здесь, а полный интерфейс работает в Mini App.", reply_markup=menu_inline_keyboard(include_sto_workplace=bool(centers)), ) @dp.message(Command("garage")) @dp.message(Command("cars")) @dp.message(F.text == "Мои авто") async def cars(message: Message) -> None: items = await list_user_cars(message) if not items: await message.answer("Автомобилей пока нет. Добавь через Mini App или /add_car Название.", reply_markup=webapp_inline_keyboard("Добавить авто")) return buttons = [[InlineKeyboardButton(text=car["name"], callback_data=f"stats:{car['id']}")] for car in items] buttons.append([InlineKeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.effective_webapp_url))]) await message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) @dp.message(Command("add_car")) async def add_car(message: Message, command: CommandObject) -> None: user = await upsert(message) name = command.args.strip() if command.args else "" if not name: await message.answer( "Напиши так: /add_car Toyota Camry\n\nVIN, госномер, кредит и параметры масла удобнее заполнить в Mini App.", reply_markup=webapp_inline_keyboard("Заполнить карточку"), ) return try: car = await api.create_car(user["id"], name, message.from_user.id) except httpx.HTTPStatusError as error: await message.answer(f"Не удалось добавить авто: {error.response.text}") return await message.answer(f"Добавил авто: {car['name']}", reply_markup=webapp_inline_keyboard("Открыть карточку")) @dp.message(Command("add_record")) async def add_record(message: Message) -> None: await upsert(message) await message.answer( "Добавить запись можно командой или через Mini App.\n\n" "/fuel заправил полный бак 43 литра на 72000, пробег 184230\n" "/service замена масла 70000 пробег 184900\n" "/insurance страховка 1200000 на 12 месяцев\n" "/tax налог 180000\n" "/fine штраф 40000\n\n" "При заправке бот распознает фразы «полный бак», «до полного», «full tank».", reply_markup=webapp_inline_keyboard("Добавить запись"), ) @dp.message(Command("fuel")) async def fuel(message: Message, command: CommandObject) -> None: args = command.args or "" if not args.strip(): await message.answer( "Напиши заправку текстом: /fuel заправил полный бак 43 литра на 72000, пробег 184230\n\n" "Бак был заправлен до полного? Укажи «полный бак», «нет» или «не знаю».", reply_markup=webapp_inline_keyboard("Заполнить заправку"), ) return await create_record_from_text(message, args, expected="fuel") @dp.message(Command("service")) async def service(message: Message, command: CommandObject) -> None: args = command.args or "" if not args.strip(): await message.answer( "Напиши сервисную запись: /service замена масла 70000 пробег 184900\n\n" "Для фото, следующего ТО и детальных работ удобнее Mini App.", reply_markup=webapp_inline_keyboard("Добавить ТО"), ) return await create_record_from_text(message, args, expected="service") @dp.message(Command("insurance")) async def insurance(message: Message, command: CommandObject) -> None: await create_expense_command(message, command, "insurance", "Страховка") @dp.message(Command("tax")) async def tax(message: Message, command: CommandObject) -> None: await create_expense_command(message, command, "tax", "Налог") @dp.message(Command("fine")) async def fine(message: Message, command: CommandObject) -> None: await create_expense_command(message, command, "fine", "Штраф") async def create_expense_command(message: Message, command: CommandObject, category: str, title: str) -> None: args = command.args or "" if not args.strip(): await message.answer(f"Напиши сумму: /{category if category != 'fine' else 'fine'} {title.lower()} 40000", reply_markup=webapp_inline_keyboard(f"Добавить {title.lower()}")) return car = await require_one_car(message) if not car: return parsed = await api.parse_record(message.from_user.id, f"{title} {args}") amount = parsed.get("data", {}).get("amount") or parse_amount_arg(args) if not amount: await message.answer("Не нашёл сумму. Проверь запись или открой форму в Mini App.", reply_markup=webapp_inline_keyboard("Открыть форму")) return payload = { "car_id": car["id"], "entry_date": date.today().isoformat(), "category": category, "title": title, "total_cost": float(amount), "currency": parsed.get("data", {}).get("currency") or car.get("currency") or "RUB", "is_recurring": category in {"insurance", "tax"}, } if category == "insurance": payload["period_months"] = 12 if "12" in args else None payload["payment_period_months"] = payload["period_months"] try: await api.create_expense(message.from_user.id, payload) except httpx.HTTPStatusError as error: await message.answer(f"Запись не сохранена: {error.response.text}") return await message.answer(f"{title} сохранен для {car['name']}.") @dp.message(Command("analytics")) async def analytics(message: Message) -> None: await cars(message) @dp.message(Command("sto")) async def sto(message: Message) -> None: await upsert(message) try: centers = await api.sto_catalog(message.from_user.id) except httpx.HTTPStatusError: centers = [] if not centers: await message.answer( "Проверенных СТО пока нет в каталоге. Можно зарегистрировать свое СТО через Mini App или командой /register_sto Название.", reply_markup=webapp_inline_keyboard("Открыть СТО"), ) return text = "Проверенные СТО:\n" + "\n".join( ( f"{item['id']}. {item.get('display_name') or item.get('name')} — {item.get('city') or 'город не указан'}" f"{' · ближайшее окно ' + item['nearest_slot_at'][:16].replace('T', ' ') if item.get('nearest_slot_at') else ''}" ) for item in centers[:10] ) await message.answer(text, reply_markup=webapp_inline_keyboard("Каталог СТО")) @dp.message(Command("appointments")) async def appointments(message: Message) -> None: await upsert(message) try: items = await api.my_appointments(message.from_user.id) except httpx.HTTPStatusError as error: await message.answer(f"Записи не загрузились: {error.response.text}") return if not items: await message.answer( "Активных записей пока нет. В Mini App можно выбрать авто, СТО и свободное окно.", reply_markup=webapp_inline_keyboard("Записаться в СТО"), ) return lines = ["Ваши записи:"] for item in items[:10]: lines.append( f"#{item['id']} {item['service_name']} — {item['requested_start_at'][:16].replace('T', ' ')} · {item['status']}" ) await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Мои записи")) @dp.message(Command("sto_bookings")) async def sto_bookings(message: Message) -> None: await upsert(message) centers = await sto_workplace_centers(message.from_user.id) if not centers: await message.answer("Панель СТО доступна только владельцу подтвержденного СТО и активным механикам.") return center = centers[0] try: dashboard = await api.sto_dashboard(message.from_user.id, center["id"]) pending = await api.sto_appointments(message.from_user.id, center["id"]) except httpx.HTTPStatusError as error: await message.answer(f"Заявки СТО не загрузились: {error.response.text}") return lines = [ f"Кабинет СТО: {center.get('display_name') or center.get('name')}", f"Авто: {dashboard['connected_vehicles']}", f"Новые заявки: {dashboard['pending_appointments']}", f"Подтверждено: {dashboard['confirmed_appointments']}", ] for item in pending[:8]: lines.append(f"#{item['id']} {item['service_name']} — {item['requested_start_at'][:16].replace('T', ' ')}") await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Открыть панель СТО", "sto.html")) @dp.message(Command("sto_workplace")) @dp.message(F.text == "Панель СТО") async def sto_workplace(message: Message) -> None: await upsert(message) centers = await sto_workplace_centers(message.from_user.id) if not centers: await message.answer("Панель СТО доступна только владельцу подтвержденного СТО и активным механикам.") return center = centers[0] await message.answer( f"Панель СТО: {center.get('display_name') or center.get('name')}", reply_markup=webapp_inline_keyboard("Открыть панель СТО", "sto.html"), ) @dp.message(Command("accept_sto_invite")) async def accept_sto_invite(message: Message, command: CommandObject) -> None: await upsert(message) token = command.args.strip() if command.args else "" if not token: await message.answer("Пришлите команду вместе с токеном: /accept_sto_invite ") return try: employee = await api.accept_sto_invite(message.from_user.id, token) except httpx.HTTPStatusError as error: await message.answer(f"Приглашение не принято: {error.response.text}") return await message.answer( f"Приглашение принято. Роль: {employee['role']}.", reply_markup=webapp_inline_keyboard("Открыть панель СТО", "sto.html"), ) @dp.message(Command("register_sto")) async def register_sto(message: Message, command: CommandObject) -> None: await upsert(message) name = command.args.strip() if command.args else "" if not name: await message.answer( "Для заявки СТО нужны название, адрес, телефон, специализация и фото документов. Открой форму в Mini App.\n\n" "Быстрый черновик можно создать так: /register_sto Smart Service", reply_markup=webapp_inline_keyboard("Зарегистрировать СТО"), ) return try: center = await api.register_service_center(message.from_user.id, {"display_name": name}) except httpx.HTTPStatusError as error: await message.answer(f"Не удалось отправить заявку: {error.response.text}") return await message.answer( f"Заявка СТО «{center['display_name'] or center['name']}» отправлена на модерацию. Статус: {center['verification_status']}.", reply_markup=webapp_inline_keyboard("Дополнить заявку"), ) @dp.message(Command("admin_sto_pending")) @dp.message(Command("admin_pending_sto")) async def admin_sto_pending(message: Message) -> None: await upsert(message) try: centers = await api.pending_service_centers(message.from_user.id) except httpx.HTTPStatusError as error: await message.answer(f"Нет доступа к модерации: {error.response.text}") return if not centers: await message.answer("Pending-заявок СТО нет.") return for center in centers[:20]: text = "\n".join( [ f"Заявка СТО #{center['id']}", center.get("display_name") or center.get("name") or "Без названия", f"Юр. название: {center.get('legal_name') or '-'}", f"Рег. номер: {center.get('business_registration_number') or '-'}", f"Адрес: {', '.join(x for x in [center.get('country'), center.get('city'), center.get('address')] if x) or '-'}", f"Телефон: {center.get('phone') or center.get('contact_phone') or '-'}", f"Контакт: {center.get('contact_person') or '-'}", f"Документы: {len(center.get('document_photo_urls') or [])}", ] ) await message.answer(text, reply_markup=admin_card_keyboard(center["id"])) @dp.message(Command("admin")) async def admin_home(message: Message) -> None: await upsert(message) try: await api.admin_dashboard(message.from_user.id) except httpx.HTTPStatusError as error: await message.answer(f"Админка недоступна: {error.response.text}") return await message.answer( "Admin Control Center: уведомления, пользователи, СТО, заявки, Data Explorer и Audit Log.", reply_markup=webapp_inline_keyboard("Открыть админку", "admin.html"), ) @dp.message(Command("admin_stats")) async def admin_stats(message: Message) -> None: await upsert(message) try: dashboard = await api.admin_dashboard(message.from_user.id) except httpx.HTTPStatusError as error: await message.answer(f"Нет доступа к admin stats: {error.response.text}") return await message.answer( "\n".join( [ "Admin stats", f"Users today: {dashboard['users_today']}", f"Users total: {dashboard['users_total']}", f"STO pending: {dashboard['pending_sto_applications']}", f"Appointments today: {dashboard['appointments_today']}", f"Work orders active: {dashboard['active_work_orders']}", f"Errors/security: {dashboard['system_errors']} / {dashboard['security_events']}", ] ), reply_markup=webapp_inline_keyboard("Admin dashboard", "admin.html"), ) @dp.message(Command("admin_users")) async def admin_users(message: Message) -> None: await upsert(message) try: data = await api.admin_users(message.from_user.id) except httpx.HTTPStatusError as error: await message.answer(f"Нет доступа к admin users: {error.response.text}") return lines = ["Последние пользователи:"] for row in data.get("rows", [])[:10]: lines.append(f"#{row.get('id')} {row.get('username') or '-'} · {row.get('platform_role')} · {row.get('created_at')}") await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Users", "admin.html?section=users")) @dp.message(Command("admin_sto")) async def admin_sto(message: Message) -> None: await admin_sto_pending(message) @dp.message(Command("admin_alerts")) async def admin_alerts(message: Message) -> None: await upsert(message) try: data = await api.admin_alerts(message.from_user.id) except httpx.HTTPStatusError as error: await message.answer(f"Нет доступа к admin alerts: {error.response.text}") return lines = ["Admin alerts:"] for row in data.get("rows", [])[:10]: lines.append(f"#{row.get('id')} {row.get('severity')} · {row.get('title')} · {row.get('status')}") await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Alerts", "admin.html?section=notifications")) async def admin_action(message: Message, command: CommandObject, action: str) -> None: args = (command.args or "").split(maxsplit=1) if not args: await message.answer("Укажи id заявки. Например: /admin_sto_approve 12") return try: center_id = int(args[0]) except ValueError: await message.answer("id заявки должен быть числом.") return comment = args[1] if len(args) > 1 else None try: center = await api.moderate_service_center( message.from_user.id, center_id, action, {"reason": comment, "comment": comment}, ) except httpx.HTTPStatusError as error: await message.answer(f"Не удалось выполнить действие: {error.response.text}") return await message.answer(f"Готово. СТО #{center['id']} теперь в статусе {center['verification_status']}.") @dp.message(Command("admin_sto_approve")) async def admin_sto_approve(message: Message, command: CommandObject) -> None: await admin_action(message, command, "approve") @dp.message(Command("admin_sto_reject")) async def admin_sto_reject(message: Message, command: CommandObject) -> None: await admin_action(message, command, "reject") @dp.message(Command("admin_sto_changes")) async def admin_sto_changes(message: Message, command: CommandObject) -> None: await admin_action(message, command, "changes") @dp.message(Command("admin_sto_suspend")) async def admin_sto_suspend(message: Message, command: CommandObject) -> None: await admin_action(message, command, "suspend") @dp.callback_query(F.data.startswith("stats:")) async def show_stats(callback: CallbackQuery) -> None: car_id = int(callback.data.split(":", 1)[1]) try: stats = await api.stats(car_id, callback.from_user.id) except httpx.HTTPStatusError as error: await callback.message.answer(f"Не удалось получить статистику: {error.response.text}") await callback.answer() return consumption = stats["avg_consumption_l_per_100km"] cost_per_km = stats["cost_per_km"] lines = [ "Статистика авто:", f"Расходы всего: {money(stats['total_cost'])}", f"Фиксированные: {money(stats.get('fixed_costs'))}", f"Переменные: {money(stats.get('variable_costs'))}", f"Топливо: {money(stats['fuel_cost'])}", f"Сервис и ремонты: {money(stats['service_cost'])}", f"Пробег по записям: {stats['distance_km']} км", f"Средний расход: {consumption:.2f} л/100 км" if consumption else "Средний расход: нет данных", f"Стоимость 1 км: {cost_per_km:.2f}" if cost_per_km else "Стоимость 1 км: нет данных", ] if stats.get("cost_warning"): lines.append(stats["cost_warning"]) await callback.message.answer("\n".join(lines)) await callback.answer() @dp.callback_query(F.data.startswith("menu:")) async def menu_callback(callback: CallbackQuery) -> None: action = callback.data.split(":", 1)[1] if action in {"garage", "analytics"}: user = await api.upsert_user(callback.from_user) items = await api.list_cars(user["id"], callback.from_user.id) if not items: await callback.message.answer("Автомобилей пока нет.", reply_markup=webapp_inline_keyboard("Добавить авто")) else: buttons = [[InlineKeyboardButton(text=car["name"], callback_data=f"stats:{car['id']}")] for car in items] await callback.message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) elif action == "sto": centers = await api.public_service_centers(callback.from_user.id) if not centers: await callback.message.answer("Проверенных СТО пока нет.", reply_markup=webapp_inline_keyboard("Каталог СТО")) else: await callback.message.answer( "Проверенные СТО:\n" + "\n".join(f"{item['id']}. {item.get('display_name') or item.get('name')}" for item in centers[:10]) ) else: await callback.message.answer("Открой Mini App для добавления записи.", reply_markup=webapp_inline_keyboard("Добавить запись")) await callback.answer() @dp.callback_query(F.data.startswith("admin:")) async def admin_callback(callback: CallbackQuery) -> None: _, action, center_id = callback.data.split(":", 2) try: center = await api.moderate_service_center( callback.from_user.id, int(center_id), action, {"reason": "Решение из Telegram-кнопки", "comment": "Решение из Telegram-кнопки"}, ) except httpx.HTTPStatusError as error: await callback.message.answer(f"Модерация не выполнена: {error.response.text}") await callback.answer() return await callback.message.answer(f"СТО #{center['id']} теперь в статусе {center['verification_status']}.") await callback.answer() @dp.message(F.text == "Помощь") @dp.message(Command("help")) async def help_message(message: Message) -> None: user = await api.upsert_user(message.from_user) centers = await sto_workplace_centers(message.from_user.id) admin_help = ( "Админ: /admin — панель, /admin_stats — метрики, /admin_users — последние пользователи, " "/admin_pending_sto — заявки СТО, /admin_alerts — события.\n" if user.get("platform_role") in {"admin", "super_admin", "moderator", "support", "analyst"} else "" ) sto_workplace_help = ( "• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n" "• /accept_sto_invite — принять приглашение сотрудника;\n" if centers else "" ) sto_business_help = ( "СТО: прими заявку, создай заказ-наряд, добавь работы/товары/жидкости, отправь владельцу на согласование и закрой работу после выполнения.\n" if centers else "" ) await message.answer( "CarPass помогает вести цифровой паспорт автомобиля.\n\n" "Главное:\n" "• /garage — список автомобилей;\n" "• /add_car Название — быстро добавить авто;\n" "• /fuel — заправка, включая полный бак;\n" "• /service — ТО и ремонт;\n" "• /insurance, /tax, /fine — регулярные и разовые расходы;\n" "• /analytics — стоимость владения и расход;\n" "• /sto — каталог проверенных СТО;\n" "• /appointments — мои записи в СТО;\n" f"{sto_workplace_help}" f"{admin_help}" "\n" "Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n" f"{sto_business_help}" "Безопасность: СТО видит автомобиль только после подтверждения владельца, а спорные изменения VIN, номера и пробега идут через согласование.\n\n" "Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.", reply_markup=menu_inline_keyboard(include_sto_workplace=bool(centers)), ) @dp.message(F.text == "Открыть CarPass") @dp.message(F.text == "Открыть гараж") async def old_open_buttons(message: Message) -> None: await message.answer( "Эта кнопка больше не используется как ReplyButton. Открой CarPass через защищенную кнопку ниже.", reply_markup=webapp_inline_keyboard(), ) @dp.message(F.text) async def parse_free_text(message: Message) -> None: if message.text.startswith("/"): return parsed = await api.parse_record(message.from_user.id, message.text) if parsed.get("event_type") == "unknown" or parsed.get("confidence", 0) < 0.55: await message.answer("Не понял запись. Открой /menu или Mini App, там все формы под рукой.", reply_markup=menu_inline_keyboard()) return await create_record_from_parsed(message, parsed) async def create_record_from_text(message: Message, text: str, expected: str | None = None) -> None: parsed = await api.parse_record(message.from_user.id, text) if expected and parsed.get("event_type") != expected: parsed["event_type"] = expected await create_record_from_parsed(message, parsed) async def create_record_from_parsed(message: Message, parsed: dict) -> None: car = await require_one_car(message) if not car: return data = parsed.get("data", {}) event_type = parsed.get("event_type") try: if event_type == "fuel": missing = [field for field in ("fuel_liters", "amount", "odometer_km") if not data.get(field)] if missing: await message.answer("Для заправки нужны литры, сумма и пробег. Открой форму, чтобы не ошибиться.", reply_markup=webapp_inline_keyboard("Добавить заправку")) return await api.create_fuel( message.from_user.id, { "car_id": car["id"], "entry_date": date.today().isoformat(), "odometer": int(data["odometer_km"]), "liters": float(data["fuel_liters"]), "price_per_liter": float(data["price_per_liter"] or Decimal(str(data["amount"])) / Decimal(str(data["fuel_liters"]))), "total_cost": float(data["amount"]), "is_full_tank": data.get("is_full_tank"), }, ) await message.answer("Заправка сохранена.") elif event_type == "service": await api.create_service( message.from_user.id, { "car_id": car["id"], "entry_date": date.today().isoformat(), "odometer": data.get("odometer_km"), "service_type": data.get("service_type") or "maintenance", "title": data.get("title") or "Сервисная запись", "total_cost": float(data.get("amount") or 0), }, ) await message.answer("Сервисная запись сохранена.") elif event_type in {"insurance", "tax", "fine"}: await api.create_expense( message.from_user.id, { "car_id": car["id"], "entry_date": date.today().isoformat(), "category": event_type, "title": {"insurance": "Страховка", "tax": "Налог", "fine": "Штраф"}[event_type], "total_cost": float(data.get("amount") or 0), "currency": data.get("currency") or "RUB", "is_recurring": event_type in {"insurance", "tax"}, }, ) await message.answer("Расход сохранен.") else: await message.answer("Эту запись лучше проверить в Mini App перед сохранением.", reply_markup=webapp_inline_keyboard("Открыть форму")) except httpx.HTTPStatusError as error: await message.answer(f"Запись не сохранена: {error.response.text}") async def main() -> None: if not settings.bot_token: raise RuntimeError("BOT_TOKEN is empty") if not settings.internal_api_token: raise RuntimeError("INTERNAL_API_TOKEN is empty") settings.validate_webapp_url_for_telegram() bot = Bot(settings.bot_token) try: await dp.start_polling(bot) finally: await api.close() await bot.session.close() if __name__ == "__main__": asyncio.run(main())