Complete CarPass product flows
This commit is contained in:
529
bot/main.py
529
bot/main.py
@@ -1,6 +1,9 @@
|
||||
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 (
|
||||
@@ -25,78 +28,426 @@ api = ApiClient()
|
||||
def main_keyboard() -> ReplyKeyboardMarkup:
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="Открыть CarPass")],
|
||||
[KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")],
|
||||
[KeyboardButton(text="Меню"), KeyboardButton(text="Мои авто")],
|
||||
[KeyboardButton(text="Помощь")],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
)
|
||||
|
||||
|
||||
def webapp_inline_keyboard() -> InlineKeyboardMarkup:
|
||||
def webapp_inline_keyboard(text: str = "Открыть CarPass") -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Открыть CarPass", web_app=WebAppInfo(url=settings.effective_webapp_url))],
|
||||
[InlineKeyboardButton(text=text, web_app=WebAppInfo(url=settings.effective_webapp_url))],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def menu_inline_keyboard() -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="Открыть Mini App", web_app=WebAppInfo(url=settings.effective_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"),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
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 api.upsert_user(message.from_user)
|
||||
user = await upsert(message)
|
||||
text = (
|
||||
f"Готово, {user.get('first_name') or 'водитель'}.\n\n"
|
||||
"CarPass — цифровой паспорт автомобиля: заправки, обслуживание, напоминания, подтвержденная история и стоимость владения.\n\n"
|
||||
"Нажми «Открыть CarPass», чтобы перейти в приложение."
|
||||
"CarPass ведет цифровой паспорт автомобиля: заправки, ТО, страховку, штрафы, стоимость владения и подтвержденную историю СТО.\n\n"
|
||||
"Mini App открывай кнопкой под сообщением: так Telegram передает защищенную авторизацию."
|
||||
)
|
||||
await message.answer(text, reply_markup=webapp_inline_keyboard())
|
||||
await message.answer("Клавиатура ниже открывает меню бота. Сам Mini App запускается кнопкой в сообщении выше.", reply_markup=main_keyboard())
|
||||
await message.answer(text, reply_markup=menu_inline_keyboard())
|
||||
await message.answer("Клавиатура ниже открывает команды бота.", reply_markup=main_keyboard())
|
||||
|
||||
|
||||
@dp.message(F.text == "Меню")
|
||||
@dp.message(Command("menu"))
|
||||
async def menu(message: Message) -> None:
|
||||
await upsert(message)
|
||||
await message.answer(
|
||||
"Главное меню CarPass. Быстрые команды доступны здесь, а полный интерфейс работает в Mini App.",
|
||||
reply_markup=menu_inline_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@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 api.upsert_user(message.from_user)
|
||||
user = await upsert(message)
|
||||
name = command.args.strip() if command.args else ""
|
||||
if not name:
|
||||
await message.answer("Напиши так: /add_car Toyota Camry")
|
||||
await message.answer(
|
||||
"Напиши так: /add_car Toyota Camry\n\nVIN, госномер, кредит и параметры масла удобнее заполнить в Mini App.",
|
||||
reply_markup=webapp_inline_keyboard("Заполнить карточку"),
|
||||
)
|
||||
return
|
||||
car = await api.create_car(user["id"], name, message.from_user.id)
|
||||
await message.answer(f"Добавил авто: {car['name']}")
|
||||
|
||||
|
||||
@dp.message(Command("cars"))
|
||||
@dp.message(F.text == "Мои авто")
|
||||
async def cars(message: Message) -> None:
|
||||
user = await api.upsert_user(message.from_user)
|
||||
items = await api.list_cars(user["id"], message.from_user.id)
|
||||
if not items:
|
||||
await message.answer("Автомобилей пока нет. Добавь через mini app или командой /add_car Название.")
|
||||
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("Открыть карточку"))
|
||||
|
||||
buttons = [
|
||||
[InlineKeyboardButton(text=car["name"], callback_data=f"stats:{car['id']}")] for car in items
|
||||
]
|
||||
await message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons))
|
||||
|
||||
@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.public_service_centers(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 'город не указан'}"
|
||||
for item in centers[:10]
|
||||
)
|
||||
await message.answer(text, reply_markup=webapp_inline_keyboard("Каталог СТО"))
|
||||
|
||||
|
||||
@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"))
|
||||
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"]))
|
||||
|
||||
|
||||
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])
|
||||
stats = await api.stats(car_id, callback.from_user.id)
|
||||
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"]
|
||||
await callback.message.answer(
|
||||
"\n".join(
|
||||
[
|
||||
"Статистика авто:",
|
||||
f"Расходы всего: {stats['total_cost']}",
|
||||
f"Топливо: {stats['fuel_cost']}",
|
||||
f"Сервис и ремонты: {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 км: нет данных",
|
||||
]
|
||||
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()
|
||||
|
||||
|
||||
@@ -105,27 +456,105 @@ async def show_stats(callback: CallbackQuery) -> None:
|
||||
async def help_message(message: Message) -> None:
|
||||
await message.answer(
|
||||
"CarPass помогает вести цифровой паспорт автомобиля.\n\n"
|
||||
"Что можно делать:\n"
|
||||
"• добавлять автомобили и параметры обслуживания;\n"
|
||||
"• вести заправки, ТО, ремонт, страховку, налоги и штрафы;\n"
|
||||
"• видеть стоимость владения, стоимость 1 км и прогноз расходов;\n"
|
||||
"• загрузить чек, проверить распознанные данные и сохранить запись;\n"
|
||||
"• привязать авто к проверенному СТО и подтверждать сервисную историю;\n"
|
||||
"• зарегистрировать СТО и отправить заявку на проверку.\n\n"
|
||||
"Mini App нужно открывать кнопкой под этим сообщением: так Telegram передает защищенную авторизацию.",
|
||||
reply_markup=webapp_inline_keyboard(),
|
||||
"Главное:\n"
|
||||
"• /garage — список автомобилей;\n"
|
||||
"• /add_car Название — быстро добавить авто;\n"
|
||||
"• /fuel — заправка, включая полный бак;\n"
|
||||
"• /service — ТО и ремонт;\n"
|
||||
"• /insurance, /tax, /fine — регулярные и разовые расходы;\n"
|
||||
"• /analytics — стоимость владения и расход;\n"
|
||||
"• /sto — каталог проверенных СТО;\n"
|
||||
"• /register_sto — заявка на СТО.\n\n"
|
||||
"Mini App открывай только кнопкой под сообщением: Telegram передает initData, и авторизация проходит корректно.",
|
||||
reply_markup=menu_inline_keyboard(),
|
||||
)
|
||||
|
||||
|
||||
@dp.message(F.text == "Открыть CarPass")
|
||||
@dp.message(F.text == "Открыть гараж")
|
||||
async def open_carpass(message: Message) -> None:
|
||||
async def old_open_buttons(message: Message) -> None:
|
||||
await message.answer(
|
||||
"Открой CarPass кнопкой ниже. Это правильный Telegram Mini App вход с авторизацией.",
|
||||
"Эта кнопка больше не используется как 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")
|
||||
|
||||
Reference in New Issue
Block a user