Files
drivers_bot/bot/main.py
VPN SaaS Dev 8982299e71
Some checks failed
ci / test (pull_request) Has been cancelled
add admin data mutations and load check
2026-05-18 18:37:19 +09:00

794 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <token>")
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 <token> — принять приглашение сотрудника;\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())