import asyncio
import logging
from datetime import datetime, timedelta
from aiogram import Router, F
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.exceptions import TelegramBadRequest
from app.config import settings
from app.database.database import AsyncSessionLocal
from app.services.monitoring_service import monitoring_service
from app.services.nalogo_queue_service import nalogo_queue_service
from app.services.traffic_monitoring_service import (
traffic_monitoring_service,
traffic_monitoring_scheduler,
)
from app.utils.decorators import admin_required
from app.utils.pagination import paginate_list
from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard
from app.localization.texts import get_texts
from app.services.notification_settings_service import NotificationSettingsService
from app.states import AdminStates
logger = logging.getLogger(__name__)
router = Router()
def _format_toggle(enabled: bool) -> str:
return "🟢 Вкл" if enabled else "🔴 Выкл"
def _build_notification_settings_view(language: str):
texts = get_texts(language)
config = NotificationSettingsService.get_config()
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
second_hours = NotificationSettingsService.get_second_wave_valid_hours()
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
third_hours = NotificationSettingsService.get_third_wave_valid_hours()
third_days = NotificationSettingsService.get_third_wave_trigger_days()
trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True))
trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True))
trial_channel_status = _format_toggle(
config["trial_channel_unsubscribed"].get("enabled", True)
)
expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True))
second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True))
third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True))
summary_text = (
"🔔 Уведомления пользователям\n\n"
f"• 1 час после триала: {trial_1h_status}\n"
f"• 24 часа после триала: {trial_24h_status}\n"
f"• Отписка от канала: {trial_channel_status}\n"
f"• 1 день после истечения: {expired_1d_status}\n"
f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n"
f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}"
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")],
[InlineKeyboardButton(text="🧪 Тест: 1 час после триала", callback_data="admin_mon_notify_preview_trial_1h")],
[InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")],
[InlineKeyboardButton(text="🧪 Тест: 24 часа после триала", callback_data="admin_mon_notify_preview_trial_24h")],
[InlineKeyboardButton(text=f"{trial_channel_status} • Отписка от канала", callback_data="admin_mon_notify_toggle_trial_channel")],
[InlineKeyboardButton(text="🧪 Тест: отписка от канала", callback_data="admin_mon_notify_preview_trial_channel")],
[InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")],
[InlineKeyboardButton(text="🧪 Тест: 1 день после истечения", callback_data="admin_mon_notify_preview_expired_1d")],
[InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")],
[InlineKeyboardButton(text="🧪 Тест: скидка 2-3 день", callback_data="admin_mon_notify_preview_expired_2d")],
[InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")],
[InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")],
[InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")],
[InlineKeyboardButton(text="🧪 Тест: скидка спустя дни", callback_data="admin_mon_notify_preview_expired_nd")],
[InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")],
[InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")],
[InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")],
[InlineKeyboardButton(text="🧪 Отправить все тесты", callback_data="admin_mon_notify_preview_all")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")],
])
return summary_text, keyboard
def _build_notification_preview_message(language: str, notification_type: str):
texts = get_texts(language)
now = datetime.now()
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
header = "🧪 Тестовое уведомление мониторинга\n\n"
if notification_type == "trial_inactive_1h":
template = texts.get(
"TRIAL_INACTIVE_1H",
(
"⏳ Прошёл час, а подключения нет\n\n"
"Если возникли сложности с запуском — воспользуйтесь инструкциями."
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=settings.TRIAL_DURATION_DAYS)).strftime("%d.%m.%Y %H:%M"),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect",
)
],
[
InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)
],
[
InlineKeyboardButton(
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
callback_data="menu_support",
)
],
]
)
elif notification_type == "trial_inactive_24h":
template = texts.get(
"TRIAL_INACTIVE_24H",
(
"⏳ Вы ещё не подключились к VPN\n\n"
"Прошли сутки с активации тестового периода, но трафик не зафиксирован."
"\n\nНажмите кнопку ниже, чтобы подключиться."
),
)
message = template.format(
price=price_30_days,
end_date=(now + timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"),
callback_data="subscription_connect",
)
],
[
InlineKeyboardButton(
text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"),
callback_data="menu_subscription",
)
],
[
InlineKeyboardButton(
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
callback_data="menu_support",
)
],
]
)
elif notification_type == "trial_channel_unsubscribed":
template = texts.get(
"TRIAL_CHANNEL_UNSUBSCRIBED",
(
"🚫 Доступ приостановлен\n\n"
"Мы не нашли вашу подписку на наш канал, поэтому тестовая подписка отключена.\n\n"
"Подпишитесь на канал и нажмите «{check_button}», чтобы вернуть доступ."
),
)
check_button = texts.t("CHANNEL_CHECK_BUTTON", "✅ Я подписался")
message = template.format(check_button=check_button)
buttons: list[list[InlineKeyboardButton]] = []
if settings.CHANNEL_LINK:
buttons.append(
[
InlineKeyboardButton(
text=texts.t("CHANNEL_SUBSCRIBE_BUTTON", "🔗 Подписаться"),
url=settings.CHANNEL_LINK,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=check_button,
callback_data="sub_channel_check",
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
elif notification_type == "expired_1d":
template = texts.get(
"SUBSCRIPTION_EXPIRED_1D",
(
"⛔ Подписка закончилась\n\n"
"Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис."
),
)
message = template.format(
end_date=(now - timedelta(days=1)).strftime("%d.%m.%Y %H:%M"),
price=price_30_days,
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)
],
[
InlineKeyboardButton(
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
callback_data="balance_topup",
)
],
[
InlineKeyboardButton(
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
callback_data="menu_support",
)
],
]
)
elif notification_type == "expired_2d":
percent = NotificationSettingsService.get_second_wave_discount_percent()
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
template = texts.get(
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
(
"🔥 Скидка {percent}% на продление\n\n"
"Активируйте предложение, чтобы получить дополнительную скидку. "
"Она суммируется с вашей промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
trigger_days=3,
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="🎁 Получить скидку",
callback_data="claim_discount_preview",
)
],
[
InlineKeyboardButton(
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)
],
[
InlineKeyboardButton(
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
callback_data="balance_topup",
)
],
[
InlineKeyboardButton(
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
callback_data="menu_support",
)
],
]
)
elif notification_type == "expired_nd":
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
template = texts.get(
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 Индивидуальная скидка {percent}%\n\n"
"Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. "
"Она суммируется с промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
trigger_days=trigger_days,
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
)
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text="🎁 Получить скидку",
callback_data="claim_discount_preview",
)
],
[
InlineKeyboardButton(
text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"),
callback_data="subscription_extend",
)
],
[
InlineKeyboardButton(
text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"),
callback_data="balance_topup",
)
],
[
InlineKeyboardButton(
text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"),
callback_data="menu_support",
)
],
]
)
else:
raise ValueError(f"Unsupported notification type: {notification_type}")
footer = "\n\nСообщение отправлено только вам для проверки оформления."
return header + message + footer, keyboard
async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None:
message, keyboard = _build_notification_preview_message(language, notification_type)
await bot.send_message(
chat_id,
message,
parse_mode="HTML",
reply_markup=keyboard,
)
async def _render_notification_settings(callback: CallbackQuery) -> None:
language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE)
text, keyboard = _build_notification_settings_view(language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
async def _render_notification_settings_for_state(
bot,
chat_id: int,
message_id: int,
language: str,
business_connection_id: str | None = None,
) -> None:
text, keyboard = _build_notification_settings_view(language)
edit_kwargs = {
"text": text,
"chat_id": chat_id,
"message_id": message_id,
"parse_mode": "HTML",
"reply_markup": keyboard,
}
if business_connection_id:
edit_kwargs["business_connection_id"] = business_connection_id
try:
await bot.edit_message_text(**edit_kwargs)
except TelegramBadRequest as exc:
if "no text in the message to edit" in (exc.message or "").lower():
caption_kwargs = {
"chat_id": chat_id,
"message_id": message_id,
"caption": text,
"parse_mode": "HTML",
"reply_markup": keyboard,
}
if business_connection_id:
caption_kwargs["business_connection_id"] = business_connection_id
await bot.edit_message_caption(**caption_kwargs)
else:
raise
@router.callback_query(F.data == "admin_monitoring")
@admin_required
async def admin_monitoring_menu(callback: CallbackQuery):
try:
async with AsyncSessionLocal() as db:
status = await monitoring_service.get_monitoring_status(db)
running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен"
last_update = status['last_update'].strftime('%H:%M:%S') if status['last_update'] else "Никогда"
text = f"""
🔍 Система мониторинга
📊 Статус: {running_status}
🕐 Последнее обновление: {last_update}
⚙️ Интервал проверки: {settings.MONITORING_INTERVAL} мин
📈 Статистика за 24 часа:
• Всего событий: {status['stats_24h']['total_events']}
• Успешных: {status['stats_24h']['successful']}
• Ошибок: {status['stats_24h']['failed']}
• Успешность: {status['stats_24h']['success_rate']}%
🔧 Выберите действие:
"""
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
keyboard = get_monitoring_keyboard(language)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка в админ меню мониторинга: {e}")
await callback.answer("❌ Ошибка получения данных", show_alert=True)
@router.callback_query(F.data == "admin_mon_settings")
@admin_required
async def admin_monitoring_settings(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены"
second_percent = NotificationSettingsService.get_second_wave_discount_percent()
third_percent = NotificationSettingsService.get_third_wave_discount_percent()
third_days = NotificationSettingsService.get_third_wave_trigger_days()
text = (
"⚙️ Настройки мониторинга\n\n"
f"🔔 Уведомления пользователям: {global_status}\n"
f"• Скидка 2-3 дня: {second_percent}%\n"
f"• Скидка после {third_days} дней: {third_percent}%\n\n"
"Выберите раздел для настройки."
)
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")],
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка отображения настроек мониторинга: {e}")
await callback.answer("❌ Не удалось открыть настройки", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_settings")
@admin_required
async def admin_notify_settings(callback: CallbackQuery):
try:
await _render_notification_settings(callback)
except Exception as e:
logger.error(f"Ошибка отображения настроек уведомлений: {e}")
await callback.answer("❌ Не удалось загрузить настройки", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h")
@admin_required
async def toggle_trial_1h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_1h_enabled()
NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_trial_1h")
@admin_required
async def preview_trial_1h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_1h")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send trial 1h preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h")
@admin_required
async def toggle_trial_24h_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_inactive_24h_enabled()
NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_trial_24h")
@admin_required
async def preview_trial_24h_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_inactive_24h")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send trial 24h preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_trial_channel")
@admin_required
async def toggle_trial_channel_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
NotificationSettingsService.set_trial_channel_unsubscribed_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_trial_channel")
@admin_required
async def preview_trial_channel_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "trial_channel_unsubscribed")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send trial channel preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d")
@admin_required
async def toggle_expired_1d_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_expired_1d_enabled()
NotificationSettingsService.set_expired_1d_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_expired_1d")
@admin_required
async def preview_expired_1d_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_1d")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send expired 1d preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d")
@admin_required
async def toggle_second_wave_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_second_wave_enabled()
NotificationSettingsService.set_second_wave_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_expired_2d")
@admin_required
async def preview_second_wave_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_2d")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send second wave preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd")
@admin_required
async def toggle_third_wave_notification(callback: CallbackQuery):
enabled = NotificationSettingsService.is_third_wave_enabled()
NotificationSettingsService.set_third_wave_enabled(not enabled)
await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено")
await _render_notification_settings(callback)
@router.callback_query(F.data == "admin_mon_notify_preview_expired_nd")
@admin_required
async def preview_third_wave_notification(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await _send_notification_preview(callback.bot, callback.from_user.id, language, "expired_nd")
await callback.answer("✅ Пример отправлен")
except Exception as exc:
logger.error("Failed to send third wave preview: %s", exc)
await callback.answer("❌ Не удалось отправить тест", show_alert=True)
@router.callback_query(F.data == "admin_mon_notify_preview_all")
@admin_required
async def preview_all_notifications(callback: CallbackQuery):
try:
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
chat_id = callback.from_user.id
for notification_type in [
"trial_inactive_1h",
"trial_inactive_24h",
"trial_channel_unsubscribed",
"expired_1d",
"expired_2d",
"expired_nd",
]:
await _send_notification_preview(callback.bot, chat_id, language, notification_type)
await callback.answer("✅ Все тестовые уведомления отправлены")
except Exception as exc:
logger.error("Failed to send all notification previews: %s", exc)
await callback.answer("❌ Не удалось отправить тесты", show_alert=True)
async def _start_notification_value_edit(
callback: CallbackQuery,
state: FSMContext,
setting_key: str,
field: str,
prompt_key: str,
default_prompt: str,
):
language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE
await state.set_state(AdminStates.editing_notification_value)
await state.update_data(
notification_setting_key=setting_key,
notification_setting_field=field,
settings_message_chat=callback.message.chat.id,
settings_message_id=callback.message.message_id,
settings_business_connection_id=(
str(getattr(callback.message, "business_connection_id", None))
if getattr(callback.message, "business_connection_id", None) is not None
else None
),
settings_language=language,
)
texts = get_texts(language)
await callback.answer()
await callback.message.answer(texts.get(prompt_key, default_prompt))
@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent")
@admin_required
async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext):
await _start_notification_value_edit(
callback,
state,
"expired_second_wave",
"percent",
"NOTIFY_PROMPT_SECOND_PERCENT",
"Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
)
@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours")
@admin_required
async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext):
await _start_notification_value_edit(
callback,
state,
"expired_second_wave",
"hours",
"NOTIFY_PROMPT_SECOND_HOURS",
"Введите количество часов действия скидки (1-168):",
)
@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent")
@admin_required
async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext):
await _start_notification_value_edit(
callback,
state,
"expired_third_wave",
"percent",
"NOTIFY_PROMPT_THIRD_PERCENT",
"Введите новый процент скидки для позднего предложения (0-100):",
)
@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours")
@admin_required
async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext):
await _start_notification_value_edit(
callback,
state,
"expired_third_wave",
"hours",
"NOTIFY_PROMPT_THIRD_HOURS",
"Введите количество часов действия скидки (1-168):",
)
@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold")
@admin_required
async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext):
await _start_notification_value_edit(
callback,
state,
"expired_third_wave",
"trigger",
"NOTIFY_PROMPT_THIRD_DAYS",
"Через сколько дней после истечения отправлять предложение? (минимум 2):",
)
@router.callback_query(F.data == "admin_mon_start")
@admin_required
async def start_monitoring_callback(callback: CallbackQuery):
try:
if monitoring_service.is_running:
await callback.answer("ℹ️ Мониторинг уже запущен")
return
if not monitoring_service.bot:
monitoring_service.bot = callback.bot
asyncio.create_task(monitoring_service.start_monitoring())
await callback.answer("✅ Мониторинг запущен!")
await admin_monitoring_menu(callback)
except Exception as e:
logger.error(f"Ошибка запуска мониторинга: {e}")
await callback.answer(f"❌ Ошибка запуска: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_stop")
@admin_required
async def stop_monitoring_callback(callback: CallbackQuery):
try:
if not monitoring_service.is_running:
await callback.answer("ℹ️ Мониторинг уже остановлен")
return
monitoring_service.stop_monitoring()
await callback.answer("⏹️ Мониторинг остановлен!")
await admin_monitoring_menu(callback)
except Exception as e:
logger.error(f"Ошибка остановки мониторинга: {e}")
await callback.answer(f"❌ Ошибка остановки: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_force_check")
@admin_required
async def force_check_callback(callback: CallbackQuery):
try:
await callback.answer("⏳ Выполняем проверку подписок...")
async with AsyncSessionLocal() as db:
results = await monitoring_service.force_check_subscriptions(db)
text = f"""
✅ Принудительная проверка завершена
📊 Результаты проверки:
• Истекших подписок: {results['expired']}
• Истекающих подписок: {results['expiring']}
• Готовых к автооплате: {results['autopay_ready']}
🕐 Время проверки: {datetime.now().strftime('%H:%M:%S')}
Нажмите "Назад" для возврата в меню мониторинга.
"""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка принудительной проверки: {e}")
await callback.answer(f"❌ Ошибка проверки: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_traffic_check")
@admin_required
async def traffic_check_callback(callback: CallbackQuery):
"""Ручная проверка трафика — использует snapshot и дельту."""
try:
# Проверяем, включен ли мониторинг трафика
if not traffic_monitoring_scheduler.is_enabled():
await callback.answer(
"⚠️ Мониторинг трафика отключен в настройках\n"
"Включите TRAFFIC_FAST_CHECK_ENABLED=true в .env",
show_alert=True
)
return
await callback.answer("⏳ Запускаем проверку трафика (дельта)...")
# Используем run_fast_check — он сравнивает с snapshot и отправляет уведомления
from app.services.traffic_monitoring_service import traffic_monitoring_scheduler_v2
# Устанавливаем бота, если не установлен
if not traffic_monitoring_scheduler_v2.bot:
traffic_monitoring_scheduler_v2.set_bot(callback.bot)
violations = await traffic_monitoring_scheduler_v2.run_fast_check_now()
# Получаем информацию о snapshot
snapshot_age = await traffic_monitoring_scheduler_v2.service.get_snapshot_age_minutes()
threshold_gb = traffic_monitoring_scheduler_v2.service.get_fast_check_threshold_gb()
text = f"""
📊 Проверка трафика завершена
🔍 Результаты (дельта):
• Превышений за интервал: {len(violations)}
• Порог дельты: {threshold_gb} ГБ
• Возраст snapshot: {snapshot_age:.1f} мин
🕐 Время проверки: {datetime.now().strftime('%H:%M:%S')}
"""
if violations:
text += "\n⚠️ Превышения дельты:\n"
for v in violations[:10]:
name = v.full_name or v.user_uuid[:8]
text += f"• {name}: +{v.used_traffic_gb:.1f} ГБ\n"
if len(violations) > 10:
text += f"... и ещё {len(violations) - 10}\n"
text += "\n📨 Уведомления отправлены (с учётом кулдауна)"
else:
text += "\n✅ Превышений не обнаружено"
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Повторить", callback_data="admin_mon_traffic_check")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка проверки трафика: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data.startswith("admin_mon_logs"))
@admin_required
async def monitoring_logs_callback(callback: CallbackQuery):
try:
page = 1
if "_page_" in callback.data:
page = int(callback.data.split("_page_")[1])
async with AsyncSessionLocal() as db:
all_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
if not all_logs:
text = "📋 Логи мониторинга пусты\n\nСистема еще не выполнила проверки."
keyboard = get_monitoring_logs_back_keyboard()
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
return
per_page = 8
paginated_logs = paginate_list(all_logs, page=page, per_page=per_page)
text = f"📋 Логи мониторинга (стр. {page}/{paginated_logs.total_pages})\n\n"
for log in paginated_logs.items:
icon = "✅" if log['is_success'] else "❌"
time_str = log['created_at'].strftime('%m-%d %H:%M')
event_type = log['event_type'].replace('_', ' ').title()
message = log['message']
if len(message) > 45:
message = message[:45] + "..."
text += f"{icon} {time_str} {event_type}\n"
text += f" 📄 {message}\n\n"
total_success = sum(1 for log in all_logs if log['is_success'])
total_failed = len(all_logs) - total_success
success_rate = round(total_success / len(all_logs) * 100, 1) if all_logs else 0
text += f"📊 Общая статистика:\n"
text += f"• Всего событий: {len(all_logs)}\n"
text += f"• Успешных: {total_success}\n"
text += f"• Ошибок: {total_failed}\n"
text += f"• Успешность: {success_rate}%"
keyboard = get_monitoring_logs_keyboard(page, paginated_logs.total_pages)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка получения логов: {e}")
await callback.answer("❌ Ошибка получения логов", show_alert=True)
@router.callback_query(F.data == "admin_mon_clear_logs")
@admin_required
async def clear_logs_callback(callback: CallbackQuery):
try:
async with AsyncSessionLocal() as db:
deleted_count = await monitoring_service.cleanup_old_logs(db, days=0)
await db.commit()
if deleted_count > 0:
await callback.answer(f"🗑️ Удалено {deleted_count} записей логов")
else:
await callback.answer("ℹ️ Логи уже пусты")
await monitoring_logs_callback(callback)
except Exception as e:
logger.error(f"Ошибка очистки логов: {e}")
await callback.answer(f"❌ Ошибка очистки: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_test_notifications")
@admin_required
async def test_notifications_callback(callback: CallbackQuery):
try:
test_message = f"""
🧪 Тестовое уведомление системы мониторинга
Это тестовое сообщение для проверки работы системы уведомлений.
📊 Статус системы:
• Мониторинг: {'🟢 Работает' if monitoring_service.is_running else '🔴 Остановлен'}
• Уведомления: {'🟢 Включены' if settings.ENABLE_NOTIFICATIONS else '🔴 Отключены'}
• Время теста: {datetime.now().strftime('%H:%M:%S %d.%m.%Y')}
✅ Если вы получили это сообщение, система уведомлений работает корректно!
"""
await callback.bot.send_message(
callback.from_user.id,
test_message,
parse_mode="HTML"
)
await callback.answer("✅ Тестовое уведомление отправлено!")
except Exception as e:
logger.error(f"Ошибка отправки тестового уведомления: {e}")
await callback.answer(f"❌ Ошибка отправки: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_statistics")
@admin_required
async def monitoring_statistics_callback(callback: CallbackQuery):
try:
async with AsyncSessionLocal() as db:
from app.database.crud.subscription import get_subscriptions_statistics
sub_stats = await get_subscriptions_statistics(db)
mon_status = await monitoring_service.get_monitoring_status(db)
week_ago = datetime.now() - timedelta(days=7)
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
week_success = sum(1 for log in week_logs if log['is_success'])
week_errors = len(week_logs) - week_success
text = f"""
📊 Статистика мониторинга
📱 Подписки:
• Всего: {sub_stats['total_subscriptions']}
• Активных: {sub_stats['active_subscriptions']}
• Тестовых: {sub_stats['trial_subscriptions']}
• Платных: {sub_stats['paid_subscriptions']}
📈 За сегодня:
• Успешных операций: {mon_status['stats_24h']['successful']}
• Ошибок: {mon_status['stats_24h']['failed']}
• Успешность: {mon_status['stats_24h']['success_rate']}%
📊 За неделю:
• Всего событий: {len(week_logs)}
• Успешных: {week_success}
• Ошибок: {week_errors}
• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
🔧 Система:
• Интервал: {settings.MONITORING_INTERVAL} мин
• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
"""
# Добавляем информацию о чеках NaloGO
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
queue_len = nalogo_status.get("queue_length", 0)
total_amount = nalogo_status.get("total_amount", 0)
running = nalogo_status.get("running", False)
pending_count = nalogo_status.get("pending_verification_count", 0)
pending_amount = nalogo_status.get("pending_verification_amount", 0)
nalogo_section = f"""
🧾 Чеки NaloGO:
• Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
• В очереди: {queue_len} чек(ов)"""
if queue_len > 0:
nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽"
if pending_count > 0:
nalogo_section += f"\n⚠️ Требуют проверки: {pending_count} ({pending_amount:,.2f} ₽)"
text += nalogo_section
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
buttons = []
# Кнопки для работы с чеками NaloGO
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
nalogo_buttons = []
if nalogo_status.get("queue_length", 0) > 0:
nalogo_buttons.append(InlineKeyboardButton(
text=f"🧾 Отправить ({nalogo_status['queue_length']})",
callback_data="admin_mon_nalogo_force_process"
))
pending_count = nalogo_status.get("pending_verification_count", 0)
if pending_count > 0:
nalogo_buttons.append(InlineKeyboardButton(
text=f"⚠️ Проверить ({pending_count})",
callback_data="admin_mon_nalogo_pending"
))
nalogo_buttons.append(InlineKeyboardButton(
text="📊 Сверка чеков",
callback_data="admin_mon_receipts_missing"
))
buttons.append(nalogo_buttons)
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка получения статистики: {e}")
await callback.answer(f"❌ Ошибка получения статистики: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_nalogo_force_process")
@admin_required
async def nalogo_force_process_callback(callback: CallbackQuery):
"""Принудительная отправка чеков из очереди."""
try:
await callback.answer("🔄 Запускаю обработку очереди чеков...", show_alert=False)
result = await nalogo_queue_service.force_process()
if "error" in result:
await callback.answer(f"❌ {result['error']}", show_alert=True)
return
message = result.get("message", "Готово")
processed = result.get("processed", 0)
remaining = result.get("remaining", 0)
if processed > 0:
text = f"✅ Обработано: {processed} чек(ов)"
if remaining > 0:
text += f"\n⏳ Осталось в очереди: {remaining}"
else:
if remaining > 0:
text = f"⚠️ Сервис nalog.ru недоступен\n⏳ В очереди: {remaining} чек(ов)"
else:
text = "📭 Очередь пуста"
await callback.answer(text, show_alert=True)
# Обновляем страницу статистики
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
# Перезагружаем статистику
async with AsyncSessionLocal() as db:
from app.database.crud.subscription import get_subscriptions_statistics
sub_stats = await get_subscriptions_statistics(db)
mon_status = await monitoring_service.get_monitoring_status(db)
week_ago = datetime.now() - timedelta(days=7)
week_logs = await monitoring_service.get_monitoring_logs(db, limit=1000)
week_logs = [log for log in week_logs if log['created_at'] >= week_ago]
week_success = sum(1 for log in week_logs if log['is_success'])
week_errors = len(week_logs) - week_success
stats_text = f"""
📊 Статистика мониторинга
📱 Подписки:
• Всего: {sub_stats['total_subscriptions']}
• Активных: {sub_stats['active_subscriptions']}
• Тестовых: {sub_stats['trial_subscriptions']}
• Платных: {sub_stats['paid_subscriptions']}
📈 За сегодня:
• Успешных операций: {mon_status['stats_24h']['successful']}
• Ошибок: {mon_status['stats_24h']['failed']}
• Успешность: {mon_status['stats_24h']['success_rate']}%
📊 За неделю:
• Всего событий: {len(week_logs)}
• Успешных: {week_success}
• Ошибок: {week_errors}
• Успешность: {round(week_success/len(week_logs)*100, 1) if week_logs else 0}%
🔧 Система:
• Интервал: {settings.MONITORING_INTERVAL} мин
• Уведомления: {'🟢 Вкл' if getattr(settings, 'ENABLE_NOTIFICATIONS', True) else '🔴 Выкл'}
• Автооплата: {', '.join(map(str, settings.get_autopay_warning_days()))} дней
"""
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
queue_len = nalogo_status.get("queue_length", 0)
total_amount = nalogo_status.get("total_amount", 0)
running = nalogo_status.get("running", False)
nalogo_section = f"""
🧾 Чеки NaloGO:
• Сервис: {'🟢 Работает' if running else '🔴 Остановлен'}
• В очереди: {queue_len} чек(ов)"""
if queue_len > 0:
nalogo_section += f"\n• На сумму: {total_amount:,.2f} ₽"
stats_text += nalogo_section
buttons = []
# Кнопки для работы с чеками NaloGO
if settings.is_nalogo_enabled():
nalogo_status = await nalogo_queue_service.get_status()
nalogo_buttons = []
if nalogo_status.get("queue_length", 0) > 0:
nalogo_buttons.append(InlineKeyboardButton(
text=f"🧾 Отправить ({nalogo_status['queue_length']})",
callback_data="admin_mon_nalogo_force_process"
))
nalogo_buttons.append(InlineKeyboardButton(
text="📊 Сверка чеков",
callback_data="admin_mon_receipts_missing"
))
buttons.append(nalogo_buttons)
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(stats_text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка принудительной обработки чеков: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_nalogo_pending")
@admin_required
async def nalogo_pending_callback(callback: CallbackQuery):
"""Просмотр чеков ожидающих ручной проверки."""
try:
from app.services.nalogo_service import NaloGoService
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
nalogo_service = NaloGoService()
receipts = await nalogo_service.get_pending_verification_receipts()
if not receipts:
await callback.answer("✅ Нет чеков на проверку", show_alert=True)
return
text = f"⚠️ Чеки требующие проверки: {len(receipts)}\n\n"
text += "Проверьте в lknpd.nalog.ru созданы ли эти чеки.\n\n"
buttons = []
for i, receipt in enumerate(receipts[:10], 1):
payment_id = receipt.get("payment_id", "unknown")
amount = receipt.get("amount", 0)
created_at = receipt.get("created_at", "")[:16].replace("T", " ")
error = receipt.get("error", "")[:50]
text += f"{i}. {amount:,.2f} ₽\n"
text += f" 📅 {created_at}\n"
text += f" 🆔 {payment_id[:20]}...\n"
if error:
text += f" ❌ {error}\n"
text += "\n"
# Кнопки для каждого чека
buttons.append([
InlineKeyboardButton(
text=f"✅ Создан ({i})",
callback_data=f"admin_nalogo_verified:{payment_id[:30]}"
),
InlineKeyboardButton(
text=f"🔄 Отправить ({i})",
callback_data=f"admin_nalogo_retry:{payment_id[:30]}"
),
])
if len(receipts) > 10:
text += f"\n... и ещё {len(receipts) - 10} чек(ов)"
buttons.append([
InlineKeyboardButton(
text="🗑 Очистить всё (проверено)",
callback_data="admin_nalogo_clear_pending"
)
])
buttons.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")])
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка просмотра очереди проверки: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data.startswith("admin_nalogo_verified:"))
@admin_required
async def nalogo_mark_verified_callback(callback: CallbackQuery):
"""Пометить чек как созданный в налоговой."""
try:
from app.services.nalogo_service import NaloGoService
payment_id = callback.data.split(":", 1)[1]
nalogo_service = NaloGoService()
# Помечаем как проверенный (чек был создан)
removed = await nalogo_service.mark_pending_as_verified(
payment_id, receipt_uuid=None, was_created=True
)
if removed:
await callback.answer(f"✅ Чек помечен как созданный", show_alert=True)
# Обновляем список
await nalogo_pending_callback(callback)
else:
await callback.answer("❌ Чек не найден", show_alert=True)
except Exception as e:
logger.error(f"Ошибка пометки чека: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data.startswith("admin_nalogo_retry:"))
@admin_required
async def nalogo_retry_callback(callback: CallbackQuery):
"""Повторно отправить чек в налоговую."""
try:
from app.services.nalogo_service import NaloGoService
payment_id = callback.data.split(":", 1)[1]
nalogo_service = NaloGoService()
await callback.answer("🔄 Отправляю чек...", show_alert=False)
receipt_uuid = await nalogo_service.retry_pending_receipt(payment_id)
if receipt_uuid:
await callback.answer(f"✅ Чек создан: {receipt_uuid}", show_alert=True)
# Обновляем список
await nalogo_pending_callback(callback)
else:
await callback.answer("❌ Не удалось создать чек", show_alert=True)
except Exception as e:
logger.error(f"Ошибка повторной отправки чека: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_nalogo_clear_pending")
@admin_required
async def nalogo_clear_pending_callback(callback: CallbackQuery):
"""Очистить всю очередь проверки."""
try:
from app.services.nalogo_service import NaloGoService
nalogo_service = NaloGoService()
count = await nalogo_service.clear_pending_verification()
await callback.answer(f"✅ Очищено: {count} чек(ов)", show_alert=True)
# Возвращаемся на статистику
await callback.message.edit_text(
"✅ Очередь проверки очищена",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
])
)
except Exception as e:
logger.error(f"Ошибка очистки очереди: {e}")
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_receipts_missing")
@admin_required
async def receipts_missing_callback(callback: CallbackQuery):
"""Сверка чеков по логам."""
# Напрямую вызываем сверку по логам
await _do_reconcile_logs(callback)
@router.callback_query(F.data == "admin_mon_receipts_link_old")
@admin_required
async def receipts_link_old_callback(callback: CallbackQuery):
"""Привязать старые чеки из NaloGO к транзакциям по сумме и дате."""
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy import select, and_
from datetime import date, timedelta
from app.database.models import Transaction, PaymentMethod, TransactionType
from app.services.nalogo_service import NaloGoService
await callback.answer("🔄 Загружаю чеки из NaloGO...", show_alert=False)
TRACKING_START_DATE = datetime(2024, 12, 29, 0, 0, 0)
async with AsyncSessionLocal() as db:
# Получаем старые транзакции без чеков
query = select(Transaction).where(
and_(
Transaction.type == TransactionType.DEPOSIT.value,
Transaction.payment_method == PaymentMethod.YOOKASSA.value,
Transaction.receipt_uuid.is_(None),
Transaction.is_completed == True,
Transaction.created_at < TRACKING_START_DATE,
)
).order_by(Transaction.created_at.desc())
result = await db.execute(query)
transactions = result.scalars().all()
if not transactions:
await callback.answer("✅ Нет старых транзакций для привязки", show_alert=True)
return
# Получаем чеки из NaloGO за последние 60 дней
nalogo_service = NaloGoService()
to_date = date.today()
from_date = to_date - timedelta(days=60)
incomes = await nalogo_service.get_incomes(
from_date=from_date,
to_date=to_date,
limit=500,
)
if not incomes:
await callback.answer("❌ Не удалось получить чеки из NaloGO", show_alert=True)
return
# Создаём словарь чеков по сумме для быстрого поиска
# Ключ: сумма в копейках, значение: список чеков
incomes_by_amount = {}
for income in incomes:
amount = float(income.get("totalAmount", income.get("amount", 0)))
amount_kopeks = int(amount * 100)
if amount_kopeks not in incomes_by_amount:
incomes_by_amount[amount_kopeks] = []
incomes_by_amount[amount_kopeks].append(income)
linked = 0
for t in transactions:
if t.amount_kopeks in incomes_by_amount:
matching_incomes = incomes_by_amount[t.amount_kopeks]
if matching_incomes:
# Берём первый подходящий чек
income = matching_incomes.pop(0)
receipt_uuid = income.get("approvedReceiptUuid", income.get("receiptUuid"))
if receipt_uuid:
t.receipt_uuid = receipt_uuid
# Парсим дату чека
operation_time = income.get("operationTime")
if operation_time:
try:
from dateutil.parser import isoparse
t.receipt_created_at = isoparse(operation_time)
except Exception:
t.receipt_created_at = datetime.utcnow()
linked += 1
if linked > 0:
await db.commit()
text = f"🔗 Привязка завершена\n\n"
text += f"Всего транзакций: {len(transactions)}\n"
text += f"Чеков в NaloGO: {len(incomes)}\n"
text += f"Привязано: {linked}\n"
text += f"Не удалось привязать: {len(transactions) - linked}"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")],
])
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except Exception as e:
logger.error(f"Ошибка привязки старых чеков: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_receipts_reconcile")
@admin_required
async def receipts_reconcile_menu_callback(callback: CallbackQuery, state: FSMContext):
"""Меню выбора периода сверки."""
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
# Очищаем состояние на случай если остался ввод даты
await state.clear()
# Сразу показываем сверку по логам
await _do_reconcile_logs(callback)
async def _do_reconcile_logs(callback: CallbackQuery):
"""Внутренняя функция сверки по логам."""
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from pathlib import Path
import re
from collections import defaultdict
await callback.answer("🔄 Анализирую логи платежей...", show_alert=False)
# Путь к файлу логов платежей (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve()
log_dir = log_file_path.parent
current_dir = log_dir / "current"
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists():
try:
await callback.message.edit_text(
"❌ Файл логов не найден\n\n"
f"Путь: {payments_log}\n\n"
"Логи появятся после первого успешного платежа.",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_reconcile_logs")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
])
)
except TelegramBadRequest:
pass # Сообщение не изменилось
return
# Паттерны для парсинга логов
# Успешный платёж: "Успешно обработан платеж YooKassa 30e3c6fc-000f-5001-9000-1a9c8b242396: пользователь 1046 пополнил баланс на 200.0₽"
payment_pattern = re.compile(
r"(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Успешно обработан платеж YooKassa ([a-f0-9-]+).*на ([\d.]+)₽"
)
# Чек создан: "Чек NaloGO создан для платежа 30e3c6fc-000f-5001-9000-1a9c8b242396: 243udsqtik"
receipt_pattern = re.compile(
r"(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}.*Чек NaloGO создан для платежа ([a-f0-9-]+): (\w+)"
)
# Читаем и парсим логи
payments = {} # payment_id -> {date, amount}
receipts = {} # payment_id -> {date, receipt_uuid}
try:
with open(payments_log, "r", encoding="utf-8") as f:
for line in f:
# Проверяем платежи
match = payment_pattern.search(line)
if match:
date_str, payment_id, amount = match.groups()
payments[payment_id] = {
"date": date_str,
"amount": float(amount)
}
continue
# Проверяем чеки
match = receipt_pattern.search(line)
if match:
date_str, payment_id, receipt_uuid = match.groups()
receipts[payment_id] = {
"date": date_str,
"receipt_uuid": receipt_uuid
}
except Exception as e:
logger.error(f"Ошибка чтения логов: {e}")
await callback.message.edit_text(
f"❌ Ошибка чтения логов\n\n{str(e)}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")]
])
)
return
# Находим платежи без чеков
payments_without_receipts = []
for payment_id, payment_data in payments.items():
if payment_id not in receipts:
payments_without_receipts.append({
"payment_id": payment_id,
"date": payment_data["date"],
"amount": payment_data["amount"]
})
# Группируем по датам
by_date = defaultdict(list)
for p in payments_without_receipts:
by_date[p["date"]].append(p)
# Формируем отчёт
total_payments = len(payments)
total_receipts = len(receipts)
missing_count = len(payments_without_receipts)
missing_amount = sum(p["amount"] for p in payments_without_receipts)
text = "📋 Сверка по логам\n\n"
text += f"📦 Всего платежей: {total_payments}\n"
text += f"🧾 Чеков создано: {total_receipts}\n\n"
if missing_count == 0:
text += "✅ Все платежи имеют чеки!"
else:
text += f"⚠️ Без чеков: {missing_count} платежей на {missing_amount:,.2f} ₽\n\n"
# Показываем по датам (последние)
sorted_dates = sorted(by_date.keys(), reverse=True)
for date_str in sorted_dates[:7]:
date_payments = by_date[date_str]
date_amount = sum(p["amount"] for p in date_payments)
text += f"• {date_str}: {len(date_payments)} шт. на {date_amount:,.2f} ₽\n"
if len(sorted_dates) > 7:
text += f"\n...и ещё {len(sorted_dates) - 7} дней"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_reconcile_logs")],
[InlineKeyboardButton(text="📄 Детали", callback_data="admin_mon_reconcile_logs_details")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_statistics")],
])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except TelegramBadRequest:
pass # Сообщение не изменилось
except TelegramBadRequest:
pass # Игнорируем если сообщение не изменилось
except Exception as e:
logger.error(f"Ошибка сверки по логам: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@router.callback_query(F.data == "admin_mon_reconcile_logs")
@admin_required
async def receipts_reconcile_logs_refresh_callback(callback: CallbackQuery):
"""Обновить сверку по логам."""
await _do_reconcile_logs(callback)
@router.callback_query(F.data == "admin_mon_reconcile_logs_details")
@admin_required
async def receipts_reconcile_logs_details_callback(callback: CallbackQuery):
"""Детальный список платежей без чеков."""
try:
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from pathlib import Path
import re
await callback.answer("🔄 Загружаю детали...", show_alert=False)
# Путь к логам (logs/current/)
log_file_path = Path(settings.LOG_FILE).resolve()
log_dir = log_file_path.parent
current_dir = log_dir / "current"
payments_log = current_dir / settings.LOG_PAYMENTS_FILE
if not payments_log.exists():
await callback.answer("❌ Файл логов не найден", show_alert=True)
return
payment_pattern = re.compile(
r"(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2}).*Успешно обработан платеж YooKassa ([a-f0-9-]+).*пользователь (\d+).*на ([\d.]+)₽"
)
receipt_pattern = re.compile(
r"Чек NaloGO создан для платежа ([a-f0-9-]+)"
)
payments = {}
receipts = set()
with open(payments_log, "r", encoding="utf-8") as f:
for line in f:
match = payment_pattern.search(line)
if match:
date_str, time_str, payment_id, user_id, amount = match.groups()
payments[payment_id] = {
"date": date_str,
"time": time_str,
"user_id": user_id,
"amount": float(amount)
}
continue
match = receipt_pattern.search(line)
if match:
receipts.add(match.group(1))
# Платежи без чеков
missing = []
for payment_id, data in payments.items():
if payment_id not in receipts:
missing.append({"payment_id": payment_id, **data})
# Сортируем по дате (новые сверху)
missing.sort(key=lambda x: (x["date"], x["time"]), reverse=True)
if not missing:
text = "✅ Все платежи имеют чеки!"
else:
text = f"📄 Платежи без чеков ({len(missing)} шт.)\n\n"
for p in missing[:20]:
text += (
f"• {p['date']} {p['time']}\n"
f" User: {p['user_id']} | {p['amount']:.0f}₽\n"
f" {p['payment_id'][:18]}...\n\n"
)
if len(missing) > 20:
text += f"...и ещё {len(missing) - 20} платежей"
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_reconcile_logs")],
])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard)
except TelegramBadRequest:
pass
except TelegramBadRequest:
pass
except Exception as e:
logger.error(f"Ошибка детализации: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
def get_monitoring_logs_keyboard(current_page: int, total_pages: int):
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = []
if total_pages > 1:
nav_row = []
if current_page > 1:
nav_row.append(InlineKeyboardButton(
text="⬅️",
callback_data=f"admin_mon_logs_page_{current_page - 1}"
))
nav_row.append(InlineKeyboardButton(
text=f"{current_page}/{total_pages}",
callback_data="current_page"
))
if current_page < total_pages:
nav_row.append(InlineKeyboardButton(
text="➡️",
callback_data=f"admin_mon_logs_page_{current_page + 1}"
))
keyboard.append(nav_row)
keyboard.extend([
[
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
InlineKeyboardButton(text="🗑️ Очистить", callback_data="admin_mon_clear_logs")
],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_monitoring_logs_back_keyboard():
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🔄 Обновить", callback_data="admin_mon_logs"),
InlineKeyboardButton(text="🔍 Фильтры", callback_data="admin_mon_logs_filters")
],
[
InlineKeyboardButton(text="🗑️ Очистить логи", callback_data="admin_mon_clear_logs")
],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_monitoring")]
])
@router.message(Command("monitoring"))
@admin_required
async def monitoring_command(message: Message):
try:
async with AsyncSessionLocal() as db:
status = await monitoring_service.get_monitoring_status(db)
running_status = "🟢 Работает" if status['is_running'] else "🔴 Остановлен"
text = f"""
🔍 Быстрый статус мониторинга
📊 Статус: {running_status}
📈 События за 24ч: {status['stats_24h']['total_events']}
✅ Успешность: {status['stats_24h']['success_rate']}%
Для подробного управления используйте админ-панель.
"""
await message.answer(text, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка команды /monitoring: {e}")
await message.answer(f"❌ Ошибка: {str(e)}")
@router.message(AdminStates.editing_notification_value)
async def process_notification_value_input(message: Message, state: FSMContext):
data = await state.get_data()
if not data:
await state.clear()
await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.")
return
raw_value = (message.text or "").strip()
try:
value = int(raw_value)
except (TypeError, ValueError):
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число."))
return
key = data.get("notification_setting_key")
field = data.get("notification_setting_field")
language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE
texts = get_texts(language)
# Добавляем дополнительные проверки диапазона значений
if (key == "expired_second_wave" and field == "percent") or (key == "expired_third_wave" and field == "percent"):
if value < 0 or value > 100:
await message.answer("❌ Процент скидки должен быть от 0 до 100.")
return
elif (key == "expired_second_wave" and field == "hours") or (key == "expired_third_wave" and field == "hours"):
if value < 1 or value > 168: # Максимум 168 часов (7 дней)
await message.answer("❌ Количество часов должно быть от 1 до 168.")
return
elif key == "expired_third_wave" and field == "trigger":
if value < 2: # Минимум 2 дня
await message.answer("❌ Количество дней должно быть не менее 2.")
return
success = False
if key == "expired_second_wave" and field == "percent":
success = NotificationSettingsService.set_second_wave_discount_percent(value)
elif key == "expired_second_wave" and field == "hours":
success = NotificationSettingsService.set_second_wave_valid_hours(value)
elif key == "expired_third_wave" and field == "percent":
success = NotificationSettingsService.set_third_wave_discount_percent(value)
elif key == "expired_third_wave" and field == "hours":
success = NotificationSettingsService.set_third_wave_valid_hours(value)
elif key == "expired_third_wave" and field == "trigger":
success = NotificationSettingsService.set_third_wave_trigger_days(value)
if not success:
await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова."))
return
back_keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.get("BACK", "⬅️ Назад"),
callback_data="admin_mon_notify_settings",
)
]
]
)
await message.answer(
texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены."),
reply_markup=back_keyboard,
)
chat_id = data.get("settings_message_chat")
message_id = data.get("settings_message_id")
business_connection_id = data.get("settings_business_connection_id")
if chat_id and message_id:
await _render_notification_settings_for_state(
message.bot,
chat_id,
message_id,
language,
business_connection_id=business_connection_id,
)
await state.clear()
def register_handlers(dp):
dp.include_router(router)