710 lines
32 KiB
Python
710 lines
32 KiB
Python
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"))
|
||
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])
|
||
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:
|
||
centers = await sto_workplace_centers(message.from_user.id)
|
||
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}"
|
||
"\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)
|
||
await dp.start_polling(bot)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|