Complete CarPass product flows

This commit is contained in:
VPN SaaS Dev
2026-05-14 21:19:37 +09:00
parent a83f55c646
commit c0014ab4ea
28 changed files with 3006 additions and 159 deletions

View File

@@ -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")