Исправлен установщик и добавлены тарифы BotFactory v2.1
Этот коммит содержится в:
@@ -0,0 +1,613 @@
|
||||
"""BotFactory — Shop Control Bot (бот управления магазином)"""
|
||||
import asyncio, logging, random
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from aiogram import Bot, Dispatcher, F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.fsm.storage.redis import RedisStorage
|
||||
from aiogram.types import (Message, CallbackQuery, InlineKeyboardButton,
|
||||
KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||
from sqlalchemy import select, func, desc
|
||||
from config import settings
|
||||
from database import AsyncSessionLocal
|
||||
from models import Shop, ShopMember, ShopToken, Product, PaymentCard, Order, OrderStatus, BalanceTransaction, Tenant
|
||||
from billing import is_postpaid, tenant_can_sell
|
||||
|
||||
logger = logging.getLogger("ctrl_bot")
|
||||
|
||||
|
||||
def fmt(v: float) -> str:
|
||||
return f"{v:,.0f}".replace(",", " ") + " ₽"
|
||||
|
||||
def dt(d: datetime) -> str:
|
||||
return d.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
class CS(StatesGroup):
|
||||
prod_name = State(); prod_price = State(); prod_cat = State()
|
||||
prod_desc = State(); prod_content = State()
|
||||
card_bank = State(); card_num = State(); card_holder = State(); card_phone = State()
|
||||
mbr_id = State(); mbr_name = State()
|
||||
tok_val = State(); tok_user = State(); tok_note = State()
|
||||
|
||||
|
||||
async def get_role(shop_id: int, tg_id: int) -> Optional[str]:
|
||||
async with AsyncSessionLocal() as db:
|
||||
shop = await db.get(Shop, shop_id)
|
||||
if not shop: return None
|
||||
tenant = await db.get(Tenant, shop.tenant_id)
|
||||
if tenant and tenant.telegram_id == tg_id:
|
||||
return "admin"
|
||||
m = (await db.execute(
|
||||
select(ShopMember).where(ShopMember.shop_id==shop_id, ShopMember.telegram_id==tg_id)
|
||||
)).scalar_one_or_none()
|
||||
return m.role if m else None
|
||||
|
||||
|
||||
def admin_kb():
|
||||
b = ReplyKeyboardBuilder()
|
||||
b.row(KeyboardButton(text="📊 Статистика"), KeyboardButton(text="📦 Заказы"))
|
||||
b.row(KeyboardButton(text="🛍 Товары"), KeyboardButton(text="💳 Карты"))
|
||||
b.row(KeyboardButton(text="👥 Команда"), KeyboardButton(text="⚙️ Токены"))
|
||||
b.row(KeyboardButton(text="💰 Баланс"))
|
||||
return b.as_markup(resize_keyboard=True)
|
||||
|
||||
def cashier_kb():
|
||||
b = ReplyKeyboardBuilder()
|
||||
b.row(KeyboardButton(text="📦 Заказы на проверке"))
|
||||
b.row(KeyboardButton(text="📋 История платежей"))
|
||||
return b.as_markup(resize_keyboard=True)
|
||||
|
||||
def ib(*rows):
|
||||
bld = InlineKeyboardBuilder()
|
||||
for row in rows:
|
||||
if isinstance(row, list):
|
||||
bld.row(*[InlineKeyboardButton(text=t, callback_data=d) for t, d in row])
|
||||
else:
|
||||
bld.add(InlineKeyboardButton(text=row[0], callback_data=row[1]))
|
||||
return bld.as_markup()
|
||||
|
||||
|
||||
class ShopControlBot:
|
||||
def __init__(self, shop_id: int, token: str, shop_name: str):
|
||||
self.shop_id = shop_id
|
||||
self.token = token
|
||||
self.name = shop_name
|
||||
self.router = Router()
|
||||
self._reg()
|
||||
|
||||
def _reg(self):
|
||||
r = self.router; sid = self.shop_id
|
||||
|
||||
async def role(event) -> Optional[str]:
|
||||
uid = event.from_user.id
|
||||
r = await get_role(sid, uid)
|
||||
if not r:
|
||||
deny = "⛔ Вы не в команде магазина. Обратитесь к владельцу."
|
||||
if isinstance(event, Message): await event.answer(deny)
|
||||
else: await event.answer(deny, show_alert=True)
|
||||
return r
|
||||
|
||||
# /start
|
||||
@r.message(Command("start"))
|
||||
async def start(msg: Message, state: FSMContext):
|
||||
await state.clear()
|
||||
rl = await role(msg)
|
||||
if not rl: return
|
||||
await msg.answer(
|
||||
f"🏪 <b>{self.name}</b>\n{'Администратор' if rl=='admin' else 'Кассир'}",
|
||||
parse_mode="HTML",
|
||||
reply_markup=admin_kb() if rl=="admin" else cashier_kb())
|
||||
|
||||
# ── СТАТИСТИКА ────────────────────────────────────────────────────────
|
||||
@r.message(F.text=="📊 Статистика")
|
||||
async def stats(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
rev = (await db.execute(select(func.sum(Order.amount)).where(Order.shop_id==sid, Order.status==OrderStatus.completed))).scalar() or 0
|
||||
comm = (await db.execute(select(func.sum(Order.commission)).where(Order.shop_id==sid, Order.status==OrderStatus.completed))).scalar() or 0
|
||||
ordn = (await db.execute(select(func.count(Order.id)).where(Order.shop_id==sid, Order.status==OrderStatus.completed))).scalar()
|
||||
pend = (await db.execute(select(func.count(Order.id)).where(Order.shop_id==sid, Order.status.in_([OrderStatus.pending,OrderStatus.confirming])))).scalar()
|
||||
prods= (await db.execute(select(func.count(Product.id)).where(Product.shop_id==sid, Product.is_active==True))).scalar()
|
||||
cards= (await db.execute(select(func.count(PaymentCard.id)).where(PaymentCard.shop_id==sid, PaymentCard.is_active==True))).scalar()
|
||||
shop = await db.get(Shop, sid)
|
||||
tenant = await db.get(Tenant, shop.tenant_id)
|
||||
be = "🔴" if tenant.balance<=0 else ("🟡" if tenant.balance<=tenant.alert_threshold else "🟢")
|
||||
await msg.answer(
|
||||
f"📊 <b>{self.name}</b> · {datetime.now():%d.%m.%Y %H:%M}\n{'─'*26}\n\n"
|
||||
f"📦 Заказов: <b>{ordn}</b> | ⏳ Ждут: <b>{pend}</b>\n"
|
||||
f"💰 Оборот: <b>{fmt(rev)}</b>\n"
|
||||
f"📊 Комиссия: <b>{fmt(comm)}</b>\n"
|
||||
f"💵 Мой доход: <b>{fmt(rev-comm)}</b>\n\n"
|
||||
f"🛍 Товаров: {prods} | 💳 Карт: {cards}\n\n"
|
||||
f"{be} Баланс BotFactory: <b>{fmt(tenant.balance)}</b>",
|
||||
parse_mode="HTML")
|
||||
|
||||
# ── ЗАКАЗЫ ────────────────────────────────────────────────────────────
|
||||
@r.message(F.text.in_(["📦 Заказы","📦 Заказы на проверке"]))
|
||||
async def orders(msg: Message):
|
||||
if not await role(msg): return
|
||||
async with AsyncSessionLocal() as db:
|
||||
ords = (await db.execute(
|
||||
select(Order).where(Order.shop_id==sid,
|
||||
Order.status.in_([OrderStatus.pending,OrderStatus.confirming]))
|
||||
.order_by(Order.created_at.asc()).limit(20)
|
||||
)).scalars().all()
|
||||
if not ords:
|
||||
await msg.answer("✅ Ожидающих заказов нет."); return
|
||||
txt = f"📦 <b>Заказы на проверке ({len(ords)}):</b>\n\n"
|
||||
bld = InlineKeyboardBuilder()
|
||||
for o in ords:
|
||||
em = "🟡" if o.status==OrderStatus.pending else "🟠"
|
||||
txt += f"{em} #{o.id} · {fmt(o.amount)} · {o.buyer_username or o.buyer_telegram_id}\n"
|
||||
bld.add(InlineKeyboardButton(text=f"{em}#{o.id}·{o.amount:.0f}₽", callback_data=f"co_{o.id}"))
|
||||
bld.adjust(2)
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data.startswith("co_"))
|
||||
async def order_detail(cb: CallbackQuery):
|
||||
if not await role(cb): return
|
||||
oid = int(cb.data[3:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
o = await db.get(Order, oid)
|
||||
if not o or o.shop_id!=sid: await cb.answer("Не найден",show_alert=True); return
|
||||
card = await db.get(PaymentCard, o.card_id) if o.card_id else None
|
||||
txt = (f"📦 <b>Заказ #{o.id}</b>\n{'─'*22}\n"
|
||||
f"👤 {o.buyer_username or o.buyer_telegram_id}\n"
|
||||
f"💰 {fmt(o.amount)} | Комиссия: {fmt(o.commission)}\n"
|
||||
+ (f"💳 {card.bank} · {card.number}\n" if card else "")
|
||||
+ f"📅 {dt(o.created_at)}")
|
||||
bld = InlineKeyboardBuilder()
|
||||
if o.status in [OrderStatus.pending, OrderStatus.confirming]:
|
||||
bld.row(InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"cc_{oid}"),
|
||||
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"cr_{oid}"))
|
||||
bld.row(InlineKeyboardButton(text="← Назад", callback_data="cord_back"))
|
||||
if o.proof_file_id:
|
||||
await cb.message.answer_photo(photo=o.proof_file_id, caption=txt,
|
||||
parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
else:
|
||||
await cb.message.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
await cb.answer()
|
||||
|
||||
@r.callback_query(F.data.startswith("cc_"))
|
||||
async def confirm_order(cb: CallbackQuery):
|
||||
if not await role(cb): return
|
||||
oid = int(cb.data[3:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
o = await db.get(Order, oid)
|
||||
if not o or o.shop_id!=sid or o.status not in [OrderStatus.pending,OrderStatus.confirming]:
|
||||
await cb.answer("Нельзя подтвердить", show_alert=True); return
|
||||
shop = await db.get(Shop, sid)
|
||||
tenant = await db.get(Tenant, shop.tenant_id) if shop else None
|
||||
ok, reason = tenant_can_sell(tenant)
|
||||
if not ok:
|
||||
await cb.answer(f"❌ {reason}", show_alert=True); return
|
||||
|
||||
product = await db.get(Product, o.product_id)
|
||||
card = await db.get(PaymentCard, o.card_id) if o.card_id else None
|
||||
|
||||
o.status = OrderStatus.completed
|
||||
o.confirmed_by = cb.from_user.id
|
||||
|
||||
if product:
|
||||
product.sold += 1
|
||||
if product.stock > 0:
|
||||
product.stock -= 1
|
||||
if product.stock <= 0:
|
||||
product.is_active = False
|
||||
|
||||
if card:
|
||||
card.received_total += o.amount
|
||||
|
||||
charged_now = False
|
||||
if tenant and not is_postpaid(tenant):
|
||||
old = tenant.balance
|
||||
tenant.balance -= o.commission
|
||||
charged_now = True
|
||||
db.add(BalanceTransaction(tenant_id=tenant.id, type="commission",
|
||||
amount=-o.commission, balance_after=tenant.balance,
|
||||
note=f"Заказ #{oid}", order_id=oid))
|
||||
if old > 0 and tenant.balance <= 0:
|
||||
tenant.is_blocked = True
|
||||
elif tenant:
|
||||
db.add(BalanceTransaction(tenant_id=tenant.id, type="postpaid_accrual",
|
||||
amount=0, balance_after=tenant.balance,
|
||||
note=f"Начислена постоплатная комиссия {fmt(o.commission)} за заказ #{oid}", order_id=oid))
|
||||
|
||||
content = o.product_content; buyer_id = o.buyer_telegram_id
|
||||
await db.commit()
|
||||
await self._deliver(buyer_id, content, oid)
|
||||
suffix = "комиссия списана" if charged_now else "комиссия начислена в постоплату"
|
||||
txt = f"✅ <b>Заказ #{oid} подтверждён.</b> Товар выдан, {suffix}."
|
||||
if cb.message.photo:
|
||||
await cb.message.edit_caption(caption=txt, parse_mode="HTML")
|
||||
else:
|
||||
await cb.message.edit_text(txt, parse_mode="HTML")
|
||||
await cb.answer("✅")
|
||||
|
||||
@r.callback_query(F.data.startswith("cr_"))
|
||||
async def reject_order(cb: CallbackQuery):
|
||||
if not await role(cb): return
|
||||
oid = int(cb.data[3:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
o = await db.get(Order, oid); o.status = OrderStatus.rejected; await db.commit()
|
||||
await cb.message.edit_text(f"❌ Заказ #{oid} отклонён."); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data=="cord_back")
|
||||
async def cord_back(cb: CallbackQuery):
|
||||
await orders(cb.message); await cb.answer()
|
||||
|
||||
@r.message(F.text=="📋 История платежей")
|
||||
async def history(msg: Message):
|
||||
if not await role(msg): return
|
||||
async with AsyncSessionLocal() as db:
|
||||
ords = (await db.execute(
|
||||
select(Order).where(Order.shop_id==sid, Order.status==OrderStatus.completed)
|
||||
.order_by(desc(Order.created_at)).limit(15)
|
||||
)).scalars().all()
|
||||
if not ords: await msg.answer("Нет выполненных заказов."); return
|
||||
txt = "📋 <b>Последние выполненные:</b>\n\n"
|
||||
for o in ords:
|
||||
txt += f"✅ #{o.id} · {fmt(o.amount)} · {dt(o.created_at)}\n"
|
||||
await msg.answer(txt, parse_mode="HTML")
|
||||
|
||||
# ── ТОВАРЫ (admin) ────────────────────────────────────────────────────
|
||||
@r.message(F.text=="🛍 Товары")
|
||||
async def products_menu(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
prods = (await db.execute(
|
||||
select(Product).where(Product.shop_id==sid).order_by(desc(Product.created_at)).limit(20)
|
||||
)).scalars().all()
|
||||
if not prods:
|
||||
await msg.answer("Товаров нет.", reply_markup=ib(("➕ Добавить","cp_add"))); return
|
||||
bld = InlineKeyboardBuilder(); txt = f"🛍 <b>Товары ({len(prods)}):</b>\n\n"
|
||||
for p in prods:
|
||||
em = "✅" if p.is_active else "🔴"
|
||||
txt += f"{em} {p.name} · {fmt(p.price)}\n"
|
||||
bld.add(InlineKeyboardButton(text=f"{em}{p.name[:22]}", callback_data=f"cp_{p.id}"))
|
||||
bld.adjust(1); bld.row(InlineKeyboardButton(text="➕ Добавить", callback_data="cp_add"))
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data=="cp_add")
|
||||
async def add_prod(cb: CallbackQuery, state: FSMContext):
|
||||
if await role(cb) != "admin": return
|
||||
await state.set_state(CS.prod_name)
|
||||
await cb.message.answer("🛍 <b>Название товара:</b>", parse_mode="HTML", reply_markup=ReplyKeyboardRemove())
|
||||
await cb.answer()
|
||||
|
||||
@r.message(CS.prod_name)
|
||||
async def p_name(msg: Message, state: FSMContext):
|
||||
await state.update_data(name=msg.text); await state.set_state(CS.prod_price)
|
||||
await msg.answer("Цена (₽):")
|
||||
|
||||
@r.message(CS.prod_price)
|
||||
async def p_price(msg: Message, state: FSMContext):
|
||||
try: price=float(msg.text.replace(",",".")); assert price>0
|
||||
except: await msg.answer("❌ Введите цену, например 599"); return
|
||||
await state.update_data(price=price); await state.set_state(CS.prod_cat)
|
||||
await msg.answer("Категория (или '-'):")
|
||||
|
||||
@r.message(CS.prod_cat)
|
||||
async def p_cat(msg: Message, state: FSMContext):
|
||||
await state.update_data(category=msg.text if msg.text!="-" else "Общее")
|
||||
await state.set_state(CS.prod_desc)
|
||||
await msg.answer("Описание для покупателя:")
|
||||
|
||||
@r.message(CS.prod_desc)
|
||||
async def p_desc(msg: Message, state: FSMContext):
|
||||
await state.update_data(description=msg.text); await state.set_state(CS.prod_content)
|
||||
await msg.answer("Содержимое товара (выдаётся после оплаты):")
|
||||
|
||||
@r.message(CS.prod_content)
|
||||
async def p_content(msg: Message, state: FSMContext):
|
||||
data = await state.get_data(); await state.clear()
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(Product(shop_id=sid, name=data["name"], price=data["price"],
|
||||
category=data.get("category","Общее"), description=data.get("description",""),
|
||||
content=msg.text))
|
||||
await db.commit()
|
||||
await msg.answer(f"✅ Товар <b>{data['name']}</b> добавлен!", parse_mode="HTML", reply_markup=admin_kb())
|
||||
|
||||
@r.callback_query(F.data.startswith("cp_") & ~F.data.in_({"cp_add"}))
|
||||
async def prod_detail(cb: CallbackQuery):
|
||||
if await role(cb) != "admin": return
|
||||
pid = int(cb.data[3:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
p = await db.get(Product, pid)
|
||||
if not p or p.shop_id!=sid: await cb.answer("Не найден"); return
|
||||
bld = InlineKeyboardBuilder()
|
||||
bld.row(InlineKeyboardButton(text="🔄 Скрыть/Показать", callback_data=f"cpt_{pid}"),
|
||||
InlineKeyboardButton(text="🗑 Удалить", callback_data=f"cpd_{pid}"))
|
||||
bld.row(InlineKeyboardButton(text="← Назад", callback_data="cpback"))
|
||||
await cb.message.edit_text(
|
||||
f"🛍 <b>{p.name}</b>\n💰 {fmt(p.price)}\n📂 {p.category}\n"
|
||||
f"Продано: {p.sold}\n{'✅' if p.is_active else '🔴'} {'Активен' if p.is_active else 'Скрыт'}",
|
||||
parse_mode="HTML", reply_markup=bld.as_markup()); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data.startswith("cpt_"))
|
||||
async def prod_toggle(cb: CallbackQuery):
|
||||
pid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
p=await db.get(Product,pid)
|
||||
if p and p.shop_id==sid: p.is_active=not p.is_active; await db.commit()
|
||||
await prod_detail(cb)
|
||||
|
||||
@r.callback_query(F.data.startswith("cpd_"))
|
||||
async def prod_del(cb: CallbackQuery):
|
||||
pid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
p=await db.get(Product,pid)
|
||||
if p and p.shop_id==sid: await db.delete(p); await db.commit()
|
||||
await cb.message.edit_text("🗑 Товар удалён."); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data=="cpback")
|
||||
async def cpback(cb: CallbackQuery):
|
||||
await products_menu(cb.message); await cb.answer()
|
||||
|
||||
# ── КАРТЫ (admin) ─────────────────────────────────────────────────────
|
||||
@r.message(F.text=="💳 Карты")
|
||||
async def cards_menu(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
cards = (await db.execute(select(PaymentCard).where(PaymentCard.shop_id==sid))).scalars().all()
|
||||
if not cards:
|
||||
await msg.answer("Карт нет.", reply_markup=ib(("➕ Добавить карту","ccard_add"))); return
|
||||
bld=InlineKeyboardBuilder(); txt="💳 <b>Пул карт:</b>\n\n"
|
||||
for c in cards:
|
||||
em="🟢" if c.is_active else "🔴"
|
||||
txt+=f"{em} {c.bank} · {c.number}\n Получено: {fmt(c.received_total)}\n\n"
|
||||
bld.add(InlineKeyboardButton(text=f"{em}{c.bank}···{c.number[-4:]}", callback_data=f"ccd_{c.id}"))
|
||||
bld.adjust(2); bld.row(InlineKeyboardButton(text="➕ Добавить карту", callback_data="ccard_add"))
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data=="ccard_add")
|
||||
async def add_card(cb: CallbackQuery, state: FSMContext):
|
||||
if await role(cb) != "admin": return
|
||||
await state.set_state(CS.card_bank)
|
||||
await cb.message.answer("🏦 Введите название банка:", reply_markup=ReplyKeyboardRemove())
|
||||
await cb.answer()
|
||||
|
||||
@r.message(CS.card_bank)
|
||||
async def cb_bank(msg: Message, state: FSMContext):
|
||||
await state.update_data(bank=msg.text); await state.set_state(CS.card_num)
|
||||
await msg.answer("Номер карты или номер для СБП:")
|
||||
|
||||
@r.message(CS.card_num)
|
||||
async def cb_num(msg: Message, state: FSMContext):
|
||||
await state.update_data(number=msg.text); await state.set_state(CS.card_holder)
|
||||
await msg.answer("Имя владельца (ЛАТИНИЦА, CAPS):")
|
||||
|
||||
@r.message(CS.card_holder)
|
||||
async def cb_holder(msg: Message, state: FSMContext):
|
||||
await state.update_data(holder=msg.text.upper()); await state.set_state(CS.card_phone)
|
||||
await msg.answer("Телефон для СБП (или '-'):")
|
||||
|
||||
@r.message(CS.card_phone)
|
||||
async def cb_phone(msg: Message, state: FSMContext):
|
||||
data=await state.get_data(); await state.clear()
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(PaymentCard(shop_id=sid, bank=data["bank"], number=data["number"],
|
||||
holder=data["holder"], phone=msg.text if msg.text!="-" else "", is_active=True))
|
||||
await db.commit()
|
||||
await msg.answer(f"✅ Карта {data['bank']} добавлена в пул!", reply_markup=admin_kb())
|
||||
|
||||
@r.callback_query(F.data.startswith("ccd_"))
|
||||
async def card_detail(cb: CallbackQuery):
|
||||
if await role(cb) != "admin": return
|
||||
cid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
c=await db.get(PaymentCard,cid)
|
||||
if not c or c.shop_id!=sid: await cb.answer("Не найдена"); return
|
||||
bld=InlineKeyboardBuilder()
|
||||
bld.row(InlineKeyboardButton(text="🔄 Вкл/Откл", callback_data=f"cct_{cid}"),
|
||||
InlineKeyboardButton(text="🗑 Удалить", callback_data=f"ccr_{cid}"))
|
||||
bld.row(InlineKeyboardButton(text="← Назад", callback_data="ccback"))
|
||||
await cb.message.edit_text(
|
||||
f"💳 <b>{c.bank}</b>\nНомер: <code>{c.number}</code>\n{c.holder}\n"
|
||||
f"{'СБП: '+c.phone+chr(10) if c.phone else ''}"
|
||||
f"Заказов: {c.orders_count} | Получено: {fmt(c.received_total)}\n"
|
||||
f"{'🟢 В пуле' if c.is_active else '🔴 Отключена'}",
|
||||
parse_mode="HTML", reply_markup=bld.as_markup()); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data.startswith("cct_"))
|
||||
async def card_toggle(cb: CallbackQuery):
|
||||
cid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
c=await db.get(PaymentCard,cid)
|
||||
if c and c.shop_id==sid: c.is_active=not c.is_active; await db.commit()
|
||||
await card_detail(cb)
|
||||
|
||||
@r.callback_query(F.data.startswith("ccr_"))
|
||||
async def card_del(cb: CallbackQuery):
|
||||
cid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
c=await db.get(PaymentCard,cid)
|
||||
if c and c.shop_id==sid: await db.delete(c); await db.commit()
|
||||
await cb.message.edit_text("🗑 Карта удалена."); await cb.answer()
|
||||
|
||||
@r.callback_query(F.data=="ccback")
|
||||
async def ccback(cb: CallbackQuery):
|
||||
await cards_menu(cb.message); await cb.answer()
|
||||
|
||||
# ── КОМАНДА (admin) ───────────────────────────────────────────────────
|
||||
@r.message(F.text=="👥 Команда")
|
||||
async def team_menu(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
members=(await db.execute(select(ShopMember).where(ShopMember.shop_id==sid))).scalars().all()
|
||||
txt="👥 <b>Кассиры:</b>\n\n"
|
||||
bld=InlineKeyboardBuilder()
|
||||
for m in members:
|
||||
txt+=f"💼 {m.name} · {m.username}\nTG: <code>{m.telegram_id}</code>\n\n"
|
||||
bld.add(InlineKeyboardButton(text=f"🗑{m.name[:20]}", callback_data=f"cdm_{m.id}"))
|
||||
bld.adjust(2); bld.row(InlineKeyboardButton(text="➕ Добавить кассира", callback_data="cadd_mbr"))
|
||||
if not members: txt+="Кассиров нет."
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data=="cadd_mbr")
|
||||
async def add_mbr(cb: CallbackQuery, state: FSMContext):
|
||||
if await role(cb) != "admin": return
|
||||
await state.set_state(CS.mbr_id)
|
||||
await cb.message.answer("Введите Telegram ID кассира\n(узнать у @userinfobot):", reply_markup=ReplyKeyboardRemove())
|
||||
await cb.answer()
|
||||
|
||||
@r.message(CS.mbr_id)
|
||||
async def mbr_id(msg: Message, state: FSMContext):
|
||||
try: tid=int(msg.text.strip())
|
||||
except: await msg.answer("❌ Только число"); return
|
||||
await state.update_data(tid=tid); await state.set_state(CS.mbr_name)
|
||||
await msg.answer("Имя кассира:")
|
||||
|
||||
@r.message(CS.mbr_name)
|
||||
async def mbr_name(msg: Message, state: FSMContext):
|
||||
data=await state.get_data(); await state.clear()
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(ShopMember(shop_id=sid, telegram_id=data["tid"], name=msg.text, role="moderator"))
|
||||
await db.commit()
|
||||
await msg.answer(f"✅ Кассир {msg.text} добавлен!\nПусть напишет /start боту управления.", reply_markup=admin_kb())
|
||||
|
||||
@r.callback_query(F.data.startswith("cdm_"))
|
||||
async def del_mbr(cb: CallbackQuery):
|
||||
mid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
m=await db.get(ShopMember,mid)
|
||||
if m and m.shop_id==sid: await db.delete(m); await db.commit()
|
||||
await cb.message.edit_text("🗑 Кассир удалён."); await cb.answer()
|
||||
|
||||
# ── ТОКЕНЫ (admin) ────────────────────────────────────────────────────
|
||||
@r.message(F.text=="⚙️ Токены")
|
||||
async def tokens_menu(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
toks=(await db.execute(select(ShopToken).where(ShopToken.shop_id==sid))).scalars().all()
|
||||
txt="⚙️ <b>Токены бота магазина:</b>\n\n"
|
||||
bld=InlineKeyboardBuilder()
|
||||
for t in toks:
|
||||
em="🟢" if t.is_active else "⬛"
|
||||
txt+=f"{em} <b>{t.username}</b> [{t.note}]\n{t.token[:20]}…\n\n"
|
||||
if not t.is_active:
|
||||
bld.add(InlineKeyboardButton(text=f"🔀 Переключить на {t.username[:16]}", callback_data=f"tsw_{t.id}"))
|
||||
bld.adjust(1); bld.row(InlineKeyboardButton(text="➕ Добавить токен", callback_data="tadd"))
|
||||
txt+="⚠️ При блокировке бота — добавьте новый токен и переключитесь."
|
||||
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
|
||||
@r.callback_query(F.data.startswith("tsw_"))
|
||||
async def token_switch(cb: CallbackQuery):
|
||||
if await role(cb) != "admin": return
|
||||
tid=int(cb.data[4:])
|
||||
async with AsyncSessionLocal() as db:
|
||||
toks=(await db.execute(select(ShopToken).where(ShopToken.shop_id==sid))).scalars().all()
|
||||
for t in toks: t.is_active=(t.id==tid)
|
||||
await db.commit()
|
||||
await cb.answer("✅ Токен переключён! Бот магазина перезапустится.", show_alert=True)
|
||||
await tokens_menu(cb.message)
|
||||
|
||||
@r.callback_query(F.data=="tadd")
|
||||
async def token_add(cb: CallbackQuery, state: FSMContext):
|
||||
if await role(cb) != "admin": return
|
||||
await state.set_state(CS.tok_val)
|
||||
await cb.message.answer("Вставьте токен нового бота (@BotFather):", reply_markup=ReplyKeyboardRemove())
|
||||
await cb.answer()
|
||||
|
||||
@r.message(CS.tok_val)
|
||||
async def tok_val(msg: Message, state: FSMContext):
|
||||
await state.update_data(token=msg.text.strip()); await state.set_state(CS.tok_user)
|
||||
await msg.answer("Username бота (например @myshop2_bot):")
|
||||
|
||||
@r.message(CS.tok_user)
|
||||
async def tok_user(msg: Message, state: FSMContext):
|
||||
await state.update_data(username=msg.text.strip()); await state.set_state(CS.tok_note)
|
||||
await msg.answer("Метка токена (например 'Резервный #2'):")
|
||||
|
||||
@r.message(CS.tok_note)
|
||||
async def tok_note(msg: Message, state: FSMContext):
|
||||
data=await state.get_data(); await state.clear()
|
||||
async with AsyncSessionLocal() as db:
|
||||
db.add(ShopToken(shop_id=sid, token=data["token"], username=data["username"], note=msg.text, is_active=False))
|
||||
await db.commit()
|
||||
await msg.answer(f"✅ Токен {data['username']} добавлен!\nДля переключения — раздел «⚙️ Токены»", reply_markup=admin_kb())
|
||||
|
||||
# ── БАЛАНС (admin) ────────────────────────────────────────────────────
|
||||
@r.message(F.text=="💰 Баланс")
|
||||
async def balance(msg: Message):
|
||||
if await role(msg) != "admin": return
|
||||
async with AsyncSessionLocal() as db:
|
||||
shop=await db.get(Shop,sid); tenant=await db.get(Tenant,shop.tenant_id)
|
||||
txs=(await db.execute(select(BalanceTransaction)
|
||||
.where(BalanceTransaction.tenant_id==tenant.id)
|
||||
.order_by(desc(BalanceTransaction.created_at)).limit(5))).scalars().all()
|
||||
be="🔴" if tenant.balance<=0 else ("🟡" if tenant.balance<=tenant.alert_threshold else "🟢")
|
||||
txt=(f"💰 <b>Баланс BotFactory</b>\n{be} <b>{fmt(tenant.balance)}</b>\n"
|
||||
f"⚠️ Порог: {fmt(tenant.alert_threshold)}\n\n")
|
||||
if tenant.balance<=0: txt+="🔴 Боты приостановлены!\n\n"
|
||||
if txs:
|
||||
txt+="<b>Последние транзакции:</b>\n"
|
||||
for tx in txs:
|
||||
s="+" if tx.amount>0 else ""; em="💚" if tx.amount>0 else "📊"
|
||||
txt+=f"{em} {s}{fmt(tx.amount)} — {tx.note[:40]}\n"
|
||||
await msg.answer(txt, parse_mode="HTML",
|
||||
reply_markup=ib(("💳 Реквизиты для пополнения","topup_req")))
|
||||
|
||||
@r.callback_query(F.data=="topup_req")
|
||||
async def topup_req(cb: CallbackQuery):
|
||||
await cb.message.answer(
|
||||
"💳 <b>Реквизиты для пополнения BotFactory</b>\n\n"
|
||||
"🏦 Тинькофф: <code>5536 **** **** 0001</code>\n"
|
||||
"👤 ООО БОТФАКТОРИ\n\n"
|
||||
"📝 Назначение: <code>Пополнение BotFactory</code>\n"
|
||||
"⚠️ Обязательно укажите назначение!", parse_mode="HTML")
|
||||
await cb.answer()
|
||||
|
||||
# ── Deliver product ────────────────────────────────────────────────────────
|
||||
async def _deliver(self, buyer_tg_id: int, content: str, order_id: int):
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
tok=(await db.execute(select(ShopToken).where(
|
||||
ShopToken.shop_id==self.shop_id, ShopToken.is_active==True)
|
||||
)).scalar_one_or_none()
|
||||
if not tok: return
|
||||
bot=Bot(token=tok.token)
|
||||
await bot.send_message(buyer_tg_id,
|
||||
f"✅ <b>Оплата подтверждена!</b>\n\nЗаказ #{order_id}\n\n"
|
||||
f"<code>{content}</code>", parse_mode="HTML")
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f"_deliver: {e}")
|
||||
|
||||
# ── Notify cashiers (вызывается из shop_bot) ───────────────────────────────
|
||||
async def notify_cashiers(self, order_id: int, amount: float, buyer: str, file_id: str):
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
members=(await db.execute(select(ShopMember).where(ShopMember.shop_id==self.shop_id))).scalars().all()
|
||||
shop=await db.get(Shop,self.shop_id); tenant=await db.get(Tenant,shop.tenant_id)
|
||||
recipients=[m.telegram_id for m in members]
|
||||
if tenant and tenant.telegram_id: recipients.append(tenant.telegram_id)
|
||||
if not recipients: return
|
||||
bot=Bot(token=self.token)
|
||||
bld=InlineKeyboardBuilder()
|
||||
bld.row(InlineKeyboardButton(text=f"✅ Подтвердить #{order_id}", callback_data=f"cc_{order_id}"),
|
||||
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"cr_{order_id}"))
|
||||
caption=(f"🔔 <b>Новый платёж!</b>\n#{order_id} · {fmt(amount)}\n👤 @{buyer}")
|
||||
for uid in set(recipients):
|
||||
try:
|
||||
await bot.send_photo(uid, photo=file_id, caption=caption,
|
||||
parse_mode="HTML", reply_markup=bld.as_markup())
|
||||
except Exception as e:
|
||||
logger.warning(f"notify uid={uid}: {e}")
|
||||
await bot.session.close()
|
||||
except Exception as e:
|
||||
logger.error(f"notify_cashiers: {e}")
|
||||
|
||||
async def run(self):
|
||||
storage=RedisStorage.from_url(settings.REDIS_URL+f"?db={self.shop_id%13+1}")
|
||||
bot=Bot(token=self.token); dp=Dispatcher(storage=storage)
|
||||
dp.include_router(self.router)
|
||||
logger.info(f"Ctrl bot: {self.name} (shop={self.shop_id})")
|
||||
try:
|
||||
await dp.start_polling(bot, allowed_updates=["message","callback_query"])
|
||||
finally:
|
||||
await bot.session.close()
|
||||
Ссылка в новой задаче
Block a user