From e68ac0dae95f564e8dfd188eddacd61d0b58b930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=92=D0=B8=D0=BA=D1=82=D0=BE=D1=80?= Date: Mon, 25 May 2026 02:44:22 +0900 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B0=D1=80=D0=B8=D1=84=D1=8B=20Bot?= =?UTF-8?q?Factory=20v2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 12 + VERSION | 1 + backend/billing.py | 103 +++ backend/config.py | 48 ++ backend/ctrl_bot.py | 613 +++++++++++++++++ backend/database.py | 53 ++ backend/main.py | 71 ++ backend/models.py | 160 +++++ backend/platform_bot.py | 698 +++++++++++++++++++ backend/requirements.txt | 18 + backend/shop_bot.py | 216 ++++++ backend/shop_bots_runner.py | 129 ++++ frontend/index.html | 14 + frontend/package.json | 21 + frontend/src/App.jsx | 1254 +++++++++++++++++++++++++++++++++++ frontend/src/main.jsx | 9 + frontend/vite.config.js | 8 + install.sh | 549 +++++++++++++++ 18 files changed, 3977 insertions(+) create mode 100644 .gitignore create mode 100644 VERSION create mode 100644 backend/billing.py create mode 100644 backend/config.py create mode 100644 backend/ctrl_bot.py create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 backend/models.py create mode 100644 backend/platform_bot.py create mode 100644 backend/requirements.txt create mode 100644 backend/shop_bot.py create mode 100644 backend/shop_bots_runner.py create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/vite.config.js create mode 100644 install.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b725c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +*.log +__pycache__/ +*.pyc +.venv/ +venv/ +node_modules/ +dist/ +.DS_Store +uploads/ +backups/ +install-info.txt diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..7ec1d6d --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +2.1.0 diff --git a/backend/billing.py b/backend/billing.py new file mode 100644 index 0000000..526676d --- /dev/null +++ b/backend/billing.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from calendar import monthrange +from sqlalchemy import func, select + +from config import settings +from models import Tenant, PlanEnum, Order, OrderStatus, Shop + + +PLAN_TITLES = { + "trial_week": "Тест 7 дней", + "trial": "Trial", + "basic": "Basic", + "pro": "Pro", + "enterprise": "Enterprise", + "postpaid_custom": "Индивидуальный постоплатный", +} + + +def plan_value(plan: PlanEnum | str | None) -> str: + if plan is None: + return "basic" + return getattr(plan, "value", str(plan)) + + +def plan_title(plan: PlanEnum | str | None) -> str: + return PLAN_TITLES.get(plan_value(plan), plan_value(plan)) + + +def is_postpaid(tenant: Tenant | None) -> bool: + return bool(tenant and plan_value(tenant.plan) == PlanEnum.postpaid_custom.value) + + +def is_trial_week_expired(tenant: Tenant | None) -> bool: + return bool( + tenant + and plan_value(tenant.plan) == PlanEnum.trial_week.value + and tenant.trial_ends_at + and tenant.trial_ends_at < datetime.utcnow() + ) + + +def tenant_can_sell(tenant: Tenant | None) -> tuple[bool, str]: + if not tenant: + return False, "Владелец магазина не найден." + if not tenant.is_active or tenant.is_blocked: + return False, "Магазин временно приостановлен." + if is_trial_week_expired(tenant): + return False, "Тестовый период закончился. Обратитесь к администрации." + if not is_postpaid(tenant) and tenant.balance <= 0: + return False, "Баланс BotFactory исчерпан. Магазин временно приостановлен." + return True, "" + + +def commission_rate(tenant: Tenant | None) -> float: + if not tenant: + return float(settings.COMMISSION_BASIC) + if is_postpaid(tenant): + return float(tenant.postpaid_commission_percent or settings.COMMISSION_POSTPAID_DEFAULT) + return float(settings.commission.get(plan_value(tenant.plan), settings.COMMISSION_BASIC)) + + +def previous_month_range(now: datetime | None = None) -> tuple[datetime, datetime]: + now = now or datetime.utcnow() + first_this_month = datetime(now.year, now.month, 1) + last_prev_day = first_this_month - timedelta(days=1) + start = datetime(last_prev_day.year, last_prev_day.month, 1) + end = first_this_month + return start, end + + +def current_due_date(tenant: Tenant, now: datetime | None = None) -> datetime: + now = now or datetime.utcnow() + day = int(tenant.postpaid_due_day or settings.POSTPAID_DEFAULT_DUE_DAY or 5) + day = max(1, min(28, day)) + last_day = monthrange(now.year, now.month)[1] + return datetime(now.year, now.month, min(day, last_day), 23, 59, 59) + + +async def postpaid_previous_month_totals(db, tenant_id: int) -> tuple[float, float]: + start, end = previous_month_range() + revenue = (await db.execute( + select(func.sum(Order.amount)) + .join(Shop, Order.shop_id == Shop.id) + .where( + Shop.tenant_id == tenant_id, + Order.status == OrderStatus.completed, + Order.created_at >= start, + Order.created_at < end, + ) + )).scalar() or 0 + commission = (await db.execute( + select(func.sum(Order.commission)) + .join(Shop, Order.shop_id == Shop.id) + .where( + Shop.tenant_id == tenant_id, + Order.status == OrderStatus.completed, + Order.created_at >= start, + Order.created_at < end, + ) + )).scalar() or 0 + return float(revenue), float(commission) diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..ef7f4cc --- /dev/null +++ b/backend/config.py @@ -0,0 +1,48 @@ +from pydantic_settings import BaseSettings +from typing import List + + +class Settings(BaseSettings): + DEBUG: bool = False + SECRET_KEY: str = "change-me" + DATABASE_URL: str = "" + DB_POOL_SIZE: int = 20 + REDIS_URL: str = "redis://127.0.0.1:6379/0" + PLATFORM_BOT_TOKEN: str = "" + PLATFORM_ADMIN_IDS: str = "" + API_HOST: str = "127.0.0.1" + API_PORT: int = 8000 + DOMAIN: str = "" + ALLOWED_ORIGINS: str = "http://localhost:3000" + UPLOAD_DIR: str = "/opt/botfactory/uploads" + MAX_UPLOAD_MB: int = 10 + COMMISSION_TRIAL_WEEK: int = 10 + COMMISSION_TRIAL: int = 10 + COMMISSION_BASIC: int = 7 + COMMISSION_PRO: int = 5 + COMMISSION_ENTERPRISE: int = 3 + COMMISSION_POSTPAID_DEFAULT: int = 5 + POSTPAID_DEFAULT_DUE_DAY: int = 5 + + @property + def admin_ids(self) -> List[int]: + return [int(i.strip()) for i in self.PLATFORM_ADMIN_IDS.split(",") + if i.strip().isdigit()] + + @property + def commission(self) -> dict: + return { + "trial_week": self.COMMISSION_TRIAL_WEEK, + "trial": self.COMMISSION_TRIAL, + "basic": self.COMMISSION_BASIC, + "pro": self.COMMISSION_PRO, + "enterprise": self.COMMISSION_ENTERPRISE, + "postpaid_custom": self.COMMISSION_POSTPAID_DEFAULT, + } + + class Config: + env_file = "/opt/botfactory/.env" + extra = "ignore" + + +settings = Settings() diff --git a/backend/ctrl_bot.py b/backend/ctrl_bot.py new file mode 100644 index 0000000..8d74be5 --- /dev/null +++ b/backend/ctrl_bot.py @@ -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"🏪 {self.name}\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"📊 {self.name} · {datetime.now():%d.%m.%Y %H:%M}\n{'─'*26}\n\n" + f"📦 Заказов: {ordn} | ⏳ Ждут: {pend}\n" + f"💰 Оборот: {fmt(rev)}\n" + f"📊 Комиссия: {fmt(comm)}\n" + f"💵 Мой доход: {fmt(rev-comm)}\n\n" + f"🛍 Товаров: {prods} | 💳 Карт: {cards}\n\n" + f"{be} Баланс BotFactory: {fmt(tenant.balance)}", + 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"📦 Заказы на проверке ({len(ords)}):\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"📦 Заказ #{o.id}\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"✅ Заказ #{oid} подтверждён. Товар выдан, {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 = "📋 Последние выполненные:\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"🛍 Товары ({len(prods)}):\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("🛍 Название товара:", 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"✅ Товар {data['name']} добавлен!", 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"🛍 {p.name}\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="💳 Пул карт:\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"💳 {c.bank}\nНомер: {c.number}\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="👥 Кассиры:\n\n" + bld=InlineKeyboardBuilder() + for m in members: + txt+=f"💼 {m.name} · {m.username}\nTG: {m.telegram_id}\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="⚙️ Токены бота магазина:\n\n" + bld=InlineKeyboardBuilder() + for t in toks: + em="🟢" if t.is_active else "⬛" + txt+=f"{em} {t.username} [{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"💰 Баланс BotFactory\n{be} {fmt(tenant.balance)}\n" + f"⚠️ Порог: {fmt(tenant.alert_threshold)}\n\n") + if tenant.balance<=0: txt+="🔴 Боты приостановлены!\n\n" + if txs: + txt+="Последние транзакции:\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( + "💳 Реквизиты для пополнения BotFactory\n\n" + "🏦 Тинькофф: 5536 **** **** 0001\n" + "👤 ООО БОТФАКТОРИ\n\n" + "📝 Назначение: Пополнение BotFactory\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"✅ Оплата подтверждена!\n\nЗаказ #{order_id}\n\n" + f"{content}", 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"🔔 Новый платёж!\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() diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..fca2406 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,53 @@ +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase +from config import settings + +engine = create_async_engine( + settings.DATABASE_URL, + pool_size=settings.DB_POOL_SIZE, + max_overflow=10, + echo=settings.DEBUG, +) +AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session + + +async def _run_lightweight_migrations(conn): + """Small safe migrations for the installer/update path. + + The project still uses SQLAlchemy create_all for first deployment. These + ALTER statements keep existing installs alive when new billing fields are + added before a full Alembic workflow appears. + """ + dialect = conn.dialect.name + if dialect != "postgresql": + return + + # PostgreSQL enum values must be added before columns can use them. + await conn.execute(text("ALTER TYPE planenum ADD VALUE IF NOT EXISTS 'trial_week'")) + await conn.execute(text("ALTER TYPE planenum ADD VALUE IF NOT EXISTS 'postpaid_custom'")) + + await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMP NULL")) + await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_commission_percent DOUBLE PRECISION DEFAULT 5")) + await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_due_day INTEGER DEFAULT 5")) + await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_note VARCHAR(512) DEFAULT ''")) + await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_enabled_at TIMESTAMP NULL")) + + +async def init_db(): + # Import models here so Base.metadata is populated even when init_db is + # called from worker entrypoints that did not import models yet. + import models # noqa: F401 + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await _run_lightweight_migrations(conn) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..bc9aedb --- /dev/null +++ b/backend/main.py @@ -0,0 +1,71 @@ +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from config import settings +from database import init_db + +logging.basicConfig( + level=logging.DEBUG if settings.DEBUG else logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger("botfactory") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Starting BotFactory API...") + await init_db() + logger.info("Database initialized") + yield + logger.info("Shutting down BotFactory API") + + +app = FastAPI( + title="BotFactory API", + version="2.1.0", + docs_url="/api/docs" if settings.DEBUG else None, + redoc_url=None, + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=[o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +upload_path = Path(settings.UPLOAD_DIR) +upload_path.mkdir(parents=True, exist_ok=True) +app.mount("/uploads", StaticFiles(directory=str(upload_path)), name="uploads") + + +@app.get("/api/health", tags=["System"]) +async def health(): + return {"status": "ok", "version": "2.1.0"} + + +@app.get("/api/version", tags=["System"]) +async def version(): + return { + "version": "2.1.0", + "debug": settings.DEBUG, + "domain": settings.DOMAIN or None, + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host=settings.API_HOST, + port=settings.API_PORT, + reload=settings.DEBUG, + loop="uvloop", + ) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..22c35a3 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,160 @@ +from datetime import datetime +from sqlalchemy import ( + String, Integer, Float, Boolean, DateTime, + ForeignKey, Text, BigInteger, Enum, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship +from database import Base +import enum + + +class PlanEnum(str, enum.Enum): + trial_week = "trial_week" + trial = "trial" + basic = "basic" + pro = "pro" + enterprise = "enterprise" + postpaid_custom = "postpaid_custom" + + +class OrderStatus(str, enum.Enum): + pending = "pending" + confirming = "confirming" + completed = "completed" + rejected = "rejected" + cancelled = "cancelled" + + +class Tenant(Base): + __tablename__ = "tenants" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(128)) + email: Mapped[str] = mapped_column(String(256), unique=True) + password_hash: Mapped[str] = mapped_column(String(256)) + telegram_id: Mapped[int|None] = mapped_column(BigInteger, nullable=True, index=True) + plan: Mapped[PlanEnum] = mapped_column(Enum(PlanEnum), default=PlanEnum.trial_week) + trial_ends_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True) + postpaid_commission_percent: Mapped[float] = mapped_column(Float, default=5.0) + postpaid_due_day: Mapped[int] = mapped_column(Integer, default=5) + postpaid_note: Mapped[str] = mapped_column(String(512), default="") + postpaid_enabled_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True) + balance: Mapped[float] = mapped_column(Float, default=0.0) + alert_threshold: Mapped[float] = mapped_column(Float, default=200.0) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + is_blocked: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + shops: Mapped[list["Shop"]] = relationship(back_populates="tenant") + transactions: Mapped[list["BalanceTransaction"]] = relationship(back_populates="tenant") + + +class Shop(Base): + __tablename__ = "shops" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True) + name: Mapped[str] = mapped_column(String(128)) + ctrl_bot_token: Mapped[str] = mapped_column(String(128)) + ctrl_bot_username:Mapped[str] = mapped_column(String(64), default="") + welcome_msg: Mapped[str] = mapped_column(Text, default="Добро пожаловать!") + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + tenant: Mapped["Tenant"] = relationship(back_populates="shops") + tokens: Mapped[list["ShopToken"]] = relationship(back_populates="shop") + products: Mapped[list["Product"]] = relationship(back_populates="shop") + cards: Mapped[list["PaymentCard"]] = relationship(back_populates="shop") + orders: Mapped[list["Order"]] = relationship(back_populates="shop") + team: Mapped[list["ShopMember"]] = relationship(back_populates="shop") + + +class ShopToken(Base): + __tablename__ = "shop_tokens" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True) + token: Mapped[str] = mapped_column(String(128)) + username: Mapped[str] = mapped_column(String(64), default="") + note: Mapped[str] = mapped_column(String(64), default="Основной") + is_active: Mapped[bool] = mapped_column(Boolean, default=False) + added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + shop: Mapped["Shop"] = relationship(back_populates="tokens") + + +class ShopMember(Base): + __tablename__ = "shop_members" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True) + telegram_id: Mapped[int] = mapped_column(BigInteger, index=True) + username: Mapped[str] = mapped_column(String(64), default="") + name: Mapped[str] = mapped_column(String(128)) + role: Mapped[str] = mapped_column(String(32), default="moderator") + added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + shop: Mapped["Shop"] = relationship(back_populates="team") + + +class Product(Base): + __tablename__ = "products" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True) + name: Mapped[str] = mapped_column(String(256)) + category: Mapped[str] = mapped_column(String(64), default="Общее") + description: Mapped[str] = mapped_column(Text, default="") + content: Mapped[str] = mapped_column(Text, default="") + photo_url: Mapped[str|None] = mapped_column(String(512), nullable=True) + price: Mapped[float] = mapped_column(Float) + stock: Mapped[int] = mapped_column(Integer, default=999) + sold: Mapped[int] = mapped_column(Integer, default=0) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + shop: Mapped["Shop"] = relationship(back_populates="products") + + +class PaymentCard(Base): + __tablename__ = "payment_cards" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True) + bank: Mapped[str] = mapped_column(String(64)) + number: Mapped[str] = mapped_column(String(32)) + holder: Mapped[str] = mapped_column(String(128)) + phone: Mapped[str] = mapped_column(String(32), default="") + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + orders_count: Mapped[int] = mapped_column(Integer, default=0) + received_total: Mapped[float] = mapped_column(Float, default=0.0) + + shop: Mapped["Shop"] = relationship(back_populates="cards") + + +class Order(Base): + __tablename__ = "orders" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id")) + card_id: Mapped[int|None] = mapped_column(ForeignKey("payment_cards.id"), nullable=True) + buyer_telegram_id: Mapped[int] = mapped_column(BigInteger, index=True) + buyer_username: Mapped[str] = mapped_column(String(64), default="") + amount: Mapped[float] = mapped_column(Float) + commission: Mapped[float] = mapped_column(Float, default=0.0) + status: Mapped[OrderStatus] = mapped_column(Enum(OrderStatus), default=OrderStatus.pending, index=True) + proof_file_id: Mapped[str|None] = mapped_column(String(256), nullable=True) + confirmed_by: Mapped[int|None] = mapped_column(BigInteger, nullable=True) + product_content: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + shop: Mapped["Shop"] = relationship(back_populates="orders") + + +class BalanceTransaction(Base): + __tablename__ = "balance_transactions" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True) + type: Mapped[str] = mapped_column(String(32)) # deposit / commission / refund + amount: Mapped[float] = mapped_column(Float) + balance_after: Mapped[float] = mapped_column(Float) + note: Mapped[str] = mapped_column(String(512), default="") + order_id: Mapped[int|None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + tenant: Mapped["Tenant"] = relationship(back_populates="transactions") diff --git a/backend/platform_bot.py b/backend/platform_bot.py new file mode 100644 index 0000000..e7dc0b5 --- /dev/null +++ b/backend/platform_bot.py @@ -0,0 +1,698 @@ +"""BotFactory — Platform Admin Bot (системный бот платформы)""" +import asyncio, logging +from datetime import datetime, timedelta +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, ReplyKeyboardMarkup, KeyboardButton, 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 Tenant, Shop, ShopToken, Product, PaymentCard, Order, BalanceTransaction, OrderStatus, PlanEnum +from billing import (current_due_date, is_postpaid, plan_title, + postpaid_previous_month_totals, tenant_can_sell) + +logger = logging.getLogger("platform_bot") +router = Router() + + +# ── Guards ──────────────────────────────────────────────────────────────────── +def is_admin(uid: int) -> bool: + return uid in settings.admin_ids + +def guard(fn): + async def w(event, *a, **kw): + uid = event.from_user.id + if not is_admin(uid): + t = event.answer if isinstance(event, Message) else event.answer + await t("⛔ Доступ запрещён.", show_alert=True) if isinstance(event, CallbackQuery) else await t("⛔ Доступ запрещён.") + return + return await fn(event, *a, **kw) + return w + + +# ── States ──────────────────────────────────────────────────────────────────── +class S(StatesGroup): + topup_amount = State() + topup_note = State() + broadcast = State() + bcast_ok = State() + plan_percent = State() + plan_due_day = State() + + +# ── Keyboards ───────────────────────────────────────────────────────────────── +def main_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 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() + +def fmt(v: float) -> str: + return f"{v:,.0f}".replace(",", " ") + " ₽" + +def bal_em(b: float, t: float) -> str: + return "🔴" if b <= 0 else ("🟡" if b <= t else "🟢") + + +# ── /start ──────────────────────────────────────────────────────────────────── +@router.message(Command("start")) +@guard +async def start(msg: Message, state: FSMContext): + await state.clear() + await msg.answer( + f"👋 BotFactory Admin\n{msg.from_user.full_name}\n" + f"ID: {msg.from_user.id}", + reply_markup=main_kb(), parse_mode="HTML") + + +# ── Статистика ──────────────────────────────────────────────────────────────── +@router.message(F.text == "📊 Статистика") +@guard +async def stats(msg: Message): + async with AsyncSessionLocal() as db: + n_all = (await db.execute(select(func.count(Tenant.id)))).scalar() + n_act = (await db.execute(select(func.count(Tenant.id)).where(Tenant.is_active==True))).scalar() + n_blk = (await db.execute(select(func.count(Tenant.id)).where(Tenant.is_blocked==True))).scalar() + n_low = (await db.execute(select(func.count(Tenant.id)).where(Tenant.balance>0, Tenant.balance<=Tenant.alert_threshold))).scalar() + n_zero = (await db.execute(select(func.count(Tenant.id)).where(Tenant.balance<=0, Tenant.is_active==True))).scalar() + turn = (await db.execute(select(func.sum(Order.amount)).where(Order.status==OrderStatus.completed))).scalar() or 0 + comm = (await db.execute(select(func.sum(Order.commission)).where(Order.status==OrderStatus.completed))).scalar() or 0 + pending = (await db.execute(select(func.count(Order.id)).where(Order.status.in_([OrderStatus.pending,OrderStatus.confirming])))).scalar() + today = datetime.utcnow().replace(hour=0,minute=0,second=0,microsecond=0) + orders_td = (await db.execute(select(func.count(Order.id)).where(Order.status==OrderStatus.completed, Order.created_at>=today))).scalar() + + txt = ( + f"📊 Статистика платформы\n{datetime.now():%d.%m.%Y %H:%M}\n{'─'*28}\n\n" + f"👥 Пользователей: {n_all} (активных {n_act})\n" + f"🔴 Заблокировано: {n_blk}\n🟡 Низкий баланс: {n_low}\n" + f"🔴 Баланс=0: {n_zero}\n\n" + f"💰 Оборот: {fmt(turn)}\n📊 Комиссия: {fmt(comm)}\n\n" + f"📦 Заказов сегодня: {orders_td}\n⏳ На подтверждении: {pending}" + ) + await msg.answer(txt, parse_mode="HTML", reply_markup=ib(("🔄 Обновить","stats_ref"))) + +@router.callback_query(F.data=="stats_ref") +@guard +async def stats_ref(cb: CallbackQuery): + await stats(cb.message); await cb.answer("Обновлено") + + +# ── Пользователи ────────────────────────────────────────────────────────────── +@router.message(F.text == "👥 Пользователи") +@guard +async def users(msg: Message): + async with AsyncSessionLocal() as db: + tenants = (await db.execute( + select(Tenant).order_by(desc(Tenant.created_at)).limit(20) + )).scalars().all() + if not tenants: + await msg.answer("Нет пользователей."); return + bld = InlineKeyboardBuilder() + txt = "👥 Пользователи\n\n" + for t in tenants: + em = "🔒" if t.is_blocked else ("🔴" if t.balance<=0 else "✅") + txt += f"{em} {t.name} · {t.email}\n 📦 {plan_title(t.plan)} · {bal_em(t.balance,t.alert_threshold)} {fmt(t.balance)}\n\n" + bld.add(InlineKeyboardButton(text=f"{em} {t.name[:22]}", callback_data=f"t_{t.id}")) + bld.adjust(2) + await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup()) + +@router.callback_query(F.data.startswith("t_")) +@guard +async def tenant_detail(cb: CallbackQuery, state: FSMContext): + data = cb.data or "" + if data.startswith("t_"): + tid = int(data[2:]) + elif data.startswith("blk_"): + tid = int(data[4:]) + elif data.startswith("setplan_"): + tid = int(data[len("setplan_"):].split("_", 1)[0]) + else: + await cb.answer("Не удалось определить пользователя", show_alert=True) + return + async with AsyncSessionLocal() as db: + t = await db.get(Tenant, tid) + if not t: + await cb.answer("Не найден", show_alert=True) + return + shops_n = (await db.execute(select(func.count(Shop.id)).where(Shop.tenant_id == tid))).scalar() + trial_txt = f"\n🧪 Тест до: {t.trial_ends_at:%d.%m.%Y}" if t.trial_ends_at else "" + postpaid_txt = "" + if is_postpaid(t): + revenue, due = await postpaid_previous_month_totals(db, tid) + due_date = current_due_date(t) + postpaid_txt = ( + f"\n🧾 Постоплата: {t.postpaid_commission_percent:g}% от выручки прошлого месяца" + f"\n📅 Оплата до: {due_date:%d.%m.%Y}" + f"\n📈 Прошлый месяц: {fmt(revenue)} · к оплате {fmt(due)}" + ) + txt = ( + f"👤 {t.name}\n{'─'*24}\n" + f"📧 {t.email}\n🆔 TG: {t.telegram_id or '—'}\n" + f"📦 Тариф: {plan_title(t.plan)}{trial_txt}{postpaid_txt}\n🏪 Магазинов: {shops_n}\n\n" + f"{bal_em(t.balance,t.alert_threshold)} Баланс: {fmt(t.balance)}\n" + f"⚠️ Порог: {fmt(t.alert_threshold)}\n" + f"Статус: {'🔒 Заблокирован' if t.is_blocked else '✅ Активен'}" + ) + bld = InlineKeyboardBuilder() + bld.row( + InlineKeyboardButton(text="💳 Пополнить", callback_data=f"topup_{tid}"), + InlineKeyboardButton(text="🧾 Тариф", callback_data=f"plan_{tid}"), + ) + bld.row(InlineKeyboardButton( + text="🔒 Блок" if not t.is_blocked else "🔓 Разблок", + callback_data=f"blk_{tid}", + )) + bld.row(InlineKeyboardButton(text="← Назад", callback_data="users_back")) + await cb.message.edit_text(txt, parse_mode="HTML", reply_markup=bld.as_markup()) + await cb.answer() + +@router.callback_query(F.data=="users_back") +@guard +async def users_back(cb: CallbackQuery): + await users(cb.message); await cb.answer() + +@router.callback_query(F.data.startswith("blk_")) +@guard +async def toggle_block(cb: CallbackQuery): + tid = int(cb.data[4:]) + async with AsyncSessionLocal() as db: + t = await db.get(Tenant, tid) + if not t: await cb.answer("Не найден"); return + t.is_blocked = not t.is_blocked + await db.commit() + await cb.answer("🔒 Заблокирован" if t.is_blocked else "🔓 Разблокирован", show_alert=True) + await tenant_detail(cb, None) + + + + +# ── Тарифы пользователей ───────────────────────────────────────────────────── +def plan_keyboard(tid: int): + bld = InlineKeyboardBuilder() + bld.row(InlineKeyboardButton(text="🧪 Тест 7 дней", callback_data=f"setplan_{tid}_trial_week")) + bld.row(InlineKeyboardButton(text="Trial", callback_data=f"setplan_{tid}_trial"), + InlineKeyboardButton(text="Basic", callback_data=f"setplan_{tid}_basic")) + bld.row(InlineKeyboardButton(text="Pro", callback_data=f"setplan_{tid}_pro"), + InlineKeyboardButton(text="Enterprise", callback_data=f"setplan_{tid}_enterprise")) + bld.row(InlineKeyboardButton(text="🧾 Индивидуальный постоплатный", callback_data=f"postpaid_{tid}")) + bld.row(InlineKeyboardButton(text="← Назад", callback_data=f"t_{tid}")) + return bld.as_markup() + + +@router.callback_query(F.data.startswith("plan_")) +@guard +async def plan_menu(cb: CallbackQuery): + tid = int(cb.data[5:]) + async with AsyncSessionLocal() as db: + t = await db.get(Tenant, tid) + if not t: + await cb.answer("Не найден", show_alert=True) + return + await cb.message.edit_text( + f"🧾 Тариф пользователя\n\n👤 {t.name}\nТекущий: {plan_title(t.plan)}\n\n" + "Постоплатный тариф применяется только вручную администратором.", + parse_mode="HTML", + reply_markup=plan_keyboard(tid), + ) + await cb.answer() + + +@router.callback_query(F.data.startswith("setplan_")) +@guard +async def set_regular_plan(cb: CallbackQuery): + raw = cb.data[len("setplan_"):] + tid_raw, plan_raw = raw.split("_", 1) + tid = int(tid_raw) + if plan_raw not in {p.value for p in PlanEnum} or plan_raw == PlanEnum.postpaid_custom.value: + await cb.answer("Некорректный тариф", show_alert=True) + return + async with AsyncSessionLocal() as db: + t = await db.get(Tenant, tid) + if not t: + await cb.answer("Не найден", show_alert=True) + return + t.plan = PlanEnum(plan_raw) + t.trial_ends_at = datetime.utcnow() + timedelta(days=7) if plan_raw == PlanEnum.trial_week.value else None + t.postpaid_enabled_at = None + t.postpaid_note = "" + if t.is_blocked and t.balance > 0: + t.is_blocked = False + await db.commit() + await cb.answer("Тариф применён", show_alert=True) + await tenant_detail(cb, None) + + +@router.callback_query(F.data.startswith("postpaid_")) +@guard +async def postpaid_start(cb: CallbackQuery, state: FSMContext): + tid = int(cb.data[len("postpaid_"):]) + async with AsyncSessionLocal() as db: + t = await db.get(Tenant, tid) + if not t: + await cb.answer("Не найден", show_alert=True) + return + await state.update_data(tid=tid, tname=t.name) + await state.set_state(S.plan_percent) + await cb.message.answer( + f"🧾 Индивидуальная постоплата для {t.name}\n\n" + "Введите процент комиссии от выручки предыдущего месяца, например: 5", + parse_mode="HTML", + reply_markup=ReplyKeyboardRemove(), + ) + await cb.answer() + + +@router.message(S.plan_percent) +@guard +async def postpaid_percent(msg: Message, state: FSMContext): + try: + percent = float(msg.text.replace(",", ".").replace(" ", "")) + assert 0 < percent <= 100 + except Exception: + await msg.answer("❌ Введите процент числом от 0.1 до 100, например: 5") + return + await state.update_data(percent=percent) + await state.set_state(S.plan_due_day) + await msg.answer("📅 Введите день месяца для оплаты комиссии: 1–28") + + +@router.message(S.plan_due_day) +@guard +async def postpaid_due_day(msg: Message, state: FSMContext): + try: + day = int(msg.text.strip()) + assert 1 <= day <= 28 + except Exception: + await msg.answer("❌ Введите день месяца числом от 1 до 28") + return + data = await state.get_data() + async with AsyncSessionLocal() as db: + t = await db.get(Tenant, data["tid"]) + if not t: + await state.clear() + await msg.answer("Пользователь не найден.", reply_markup=main_kb()) + return + t.plan = PlanEnum.postpaid_custom + t.trial_ends_at = None + t.postpaid_commission_percent = float(data["percent"]) + t.postpaid_due_day = day + t.postpaid_enabled_at = datetime.utcnow() + t.postpaid_note = f"Назначено администратором {msg.from_user.id}" + t.is_blocked = False + await db.commit() + await state.clear() + await msg.answer( + f"✅ Постоплатный тариф применён\n\n👤 {data['tname']}\n" + f"Комиссия: {data['percent']:g}% от выручки прошлого месяца\n" + f"Оплата: до {day} числа каждого месяца", + parse_mode="HTML", + reply_markup=main_kb(), + ) + + +@router.message(F.text == "🧾 Постоплата") +@guard +async def postpaid_report(msg: Message): + async with AsyncSessionLocal() as db: + tenants = (await db.execute( + select(Tenant).where(Tenant.plan == PlanEnum.postpaid_custom).order_by(Tenant.name.asc()) + )).scalars().all() + if not tenants: + await msg.answer("🧾 Постоплатных пользователей пока нет.") + return + lines = ["🧾 Индивидуальная постоплата\n"] + for t in tenants: + revenue, due = await postpaid_previous_month_totals(db, t.id) + due_date = current_due_date(t) + lines.append( + f"👤 {t.name} · {t.postpaid_commission_percent:g}%\n" + f" Выручка прошлого месяца: {fmt(revenue)}\n" + f" К оплате: {fmt(due)} до {due_date:%d.%m.%Y}\n" + ) + await msg.answer("\n".join(lines), parse_mode="HTML") + + +# ── Балансы ─────────────────────────────────────────────────────────────────── +@router.message(F.text == "💰 Балансы") +@guard +async def balances(msg: Message): + async with AsyncSessionLocal() as db: + tenants = (await db.execute( + select(Tenant).where(Tenant.is_active==True).order_by(Tenant.balance.asc()) + )).scalars().all() + crit, low, ok_list = [], [], [] + for t in tenants: + line = f"{bal_em(t.balance,t.alert_threshold)} {t.name[:20]}: {fmt(t.balance)}" + if t.balance <= 0: crit.append(line + " 🔴 СТОП") + elif t.balance <= t.alert_threshold: low.append(line) + else: ok_list.append(line) + txt = "💰 Балансы\n\n" + if crit: txt += "🔴 Критично:\n" + "\n".join(crit) + "\n\n" + if low: txt += "🟡 Низкий:\n" + "\n".join(low) + "\n\n" + txt += f"🟢 В норме: {len(ok_list)}" + await msg.answer(txt, parse_mode="HTML", + reply_markup=ib(("🔄 Обновить","bal_ref"))) + +@router.callback_query(F.data=="bal_ref") +@guard +async def bal_ref(cb: CallbackQuery): + await balances(cb.message); await cb.answer("Обновлено") + + +# ── Пополнение баланса ──────────────────────────────────────────────────────── +@router.callback_query(F.data.startswith("topup_")) +@guard +async def topup_start(cb: CallbackQuery, state: FSMContext): + tid = int(cb.data[6:]) + async with AsyncSessionLocal() as db: + t = await db.get(Tenant, tid) + await state.update_data(tid=tid, tname=t.name, tbal=t.balance) + await state.set_state(S.topup_amount) + await cb.message.answer( + f"💳 Пополнение: {t.name}\nБаланс: {fmt(t.balance)}\n\nВведите сумму (₽):", + parse_mode="HTML", reply_markup=ReplyKeyboardRemove()) + await cb.answer() + +@router.message(S.topup_amount) +@guard +async def topup_amount(msg: Message, state: FSMContext): + try: + amt = float(msg.text.replace(",",".").replace(" ","")) + assert amt > 0 + except: + await msg.answer("❌ Введите сумму, например: 1000"); return + await state.update_data(amt=amt) + await state.set_state(S.topup_note) + await msg.answer(f"Сумма: {fmt(amt)}\nКомментарий (или '-'):", parse_mode="HTML") + +@router.message(S.topup_note) +@guard +async def topup_note(msg: Message, state: FSMContext): + data = await state.get_data() + note = msg.text if msg.text != "-" else "Пополнение администратором" + amt = data["amt"] + async with AsyncSessionLocal() as db: + t = await db.get(Tenant, data["tid"]) + old = t.balance + t.balance += amt + if old <= 0 and t.balance > 0: + t.is_blocked = False + db.add(BalanceTransaction( + tenant_id=t.id, type="deposit", amount=amt, + balance_after=t.balance, note=f"[Admin] {note}")) + await db.commit() + await state.clear() + await msg.answer( + f"✅ Пополнено\n{data['tname']}\n+{fmt(amt)}\nБаланс: {fmt(t.balance)}\n{note}", + parse_mode="HTML", reply_markup=main_kb()) + # Уведомить владельца + if t.telegram_id: + try: + bot = Bot(token=settings.PLATFORM_BOT_TOKEN) + await bot.send_message(t.telegram_id, + f"💰 Баланс пополнен!\n+{fmt(amt)}\nВаш баланс: {fmt(t.balance)}\n{note}", + parse_mode="HTML") + await bot.session.close() + except Exception: pass + + + + +async def deliver_order(shop_id: int, buyer_tg_id: int, content: str, order_id: int) -> bool: + try: + async with AsyncSessionLocal() as db: + tok = (await db.execute(select(ShopToken).where( + ShopToken.shop_id == shop_id, + ShopToken.is_active == True, + ))).scalar_one_or_none() + if not tok: + logger.warning("order %s: active shop token not found", order_id) + return False + bot = Bot(token=tok.token) + try: + await bot.send_message( + buyer_tg_id, + f"✅ Оплата подтверждена!\n\nЗаказ #{order_id}\n\n{content}", + parse_mode="HTML", + ) + return True + finally: + await bot.session.close() + except Exception as e: + logger.error("deliver_order #%s failed: %s", order_id, e) + return False + + +# ── Заказы ──────────────────────────────────────────────────────────────────── +@router.message(F.text == "📦 Заказы") +@guard +async def orders_menu(msg: Message): + async with AsyncSessionLocal() as db: + pending = (await db.execute( + select(Order) + .where(Order.status.in_([OrderStatus.pending, OrderStatus.confirming])) + .order_by(Order.created_at.asc()).limit(10) + )).scalars().all() + if not pending: + await msg.answer("✅ Ожидающих заказов нет."); return + txt = f"📦 На подтверждении: {len(pending)}\n\n" + bld = InlineKeyboardBuilder() + for o in pending: + em = "🟡" if o.status==OrderStatus.pending else "🟠" + txt += f"{em} #{o.id} · {fmt(o.amount)} · {o.created_at:%d.%m %H:%M}\n" + bld.add(InlineKeyboardButton(text=f"{em}#{o.id}·{o.amount:.0f}₽", callback_data=f"ord_{o.id}")) + bld.adjust(3) + await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup()) + +@router.callback_query(F.data.startswith("ord_")) +@guard +async def order_detail(cb: CallbackQuery): + oid = int(cb.data[4:]) + async with AsyncSessionLocal() as db: + o = await db.get(Order, oid) + if not o: await cb.answer("Не найден", show_alert=True); return + shop = await db.get(Shop, o.shop_id) + txt = ( + f"📦 Заказ #{o.id}\n{'─'*24}\n" + f"🏪 {shop.name if shop else '—'}\n" + f"👤 {o.buyer_username or o.buyer_telegram_id}\n" + f"💰 {fmt(o.amount)} | Комиссия: {fmt(o.commission)}\n" + f"📅 {o.created_at:%d.%m.%Y %H:%M}" + ) + bld = InlineKeyboardBuilder() + if o.status in [OrderStatus.pending, OrderStatus.confirming]: + bld.row(InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"oc_{oid}"), + InlineKeyboardButton(text="❌ Отклонить", callback_data=f"or_{oid}")) + bld.row(InlineKeyboardButton(text="← Назад", callback_data="orders_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.edit_text(txt, parse_mode="HTML", reply_markup=bld.as_markup()) + await cb.answer() + +@router.callback_query(F.data.startswith("oc_")) +@guard +async def order_confirm(cb: CallbackQuery): + oid = int(cb.data[3:]) + charged_now = False + async with AsyncSessionLocal() as db: + o = await db.get(Order, oid) + if not o or o.status not in [OrderStatus.pending, OrderStatus.confirming]: + await cb.answer("Нельзя подтвердить", show_alert=True) + return + shop = await db.get(Shop, o.shop_id) + 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 + + if tenant and not is_postpaid(tenant): + old_balance = 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_balance > 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, + )) + + shop_id = o.shop_id + buyer_id = o.buyer_telegram_id + content = o.product_content + await db.commit() + + delivered = await deliver_order(shop_id, buyer_id, content, oid) + suffix = "комиссия списана" if charged_now else "комиссия начислена в постоплату" + delivery = "товар выдан" if delivered else "товар НЕ отправлен — нет активного токена магазина" + text = f"✅ Заказ #{oid} подтверждён: {delivery}, {suffix}." + if getattr(cb.message, "photo", None): + await cb.message.edit_caption(caption=text, parse_mode="HTML") + else: + await cb.message.edit_text(text, parse_mode="HTML") + await cb.answer("✅ Подтверждено!") + +@router.callback_query(F.data.startswith("or_")) +@guard +async def order_reject(cb: CallbackQuery): + oid = int(cb.data[3:]) + async with AsyncSessionLocal() as db: + o = await db.get(Order, oid) + if o: o.status = OrderStatus.rejected; await db.commit() + await cb.message.edit_text(f"❌ Заказ #{oid} отклонён.") + await cb.answer("Отклонено") + +@router.callback_query(F.data=="orders_back") +@guard +async def orders_back(cb: CallbackQuery): + await orders_menu(cb.message); await cb.answer() + + +# ── Рассылка ────────────────────────────────────────────────────────────────── +@router.message(F.text == "📢 Рассылка") +@guard +async def broadcast_start(msg: Message, state: FSMContext): + await state.set_state(S.broadcast) + b = ReplyKeyboardBuilder(); b.add(KeyboardButton(text="❌ Отмена")) + await msg.answer("📢 Введите текст рассылки (HTML):", reply_markup=b.as_markup(resize_keyboard=True)) + +@router.message(S.broadcast) +@guard +async def broadcast_text(msg: Message, state: FSMContext): + if msg.text == "❌ Отмена": + await state.clear(); await msg.answer("Отменено.", reply_markup=main_kb()); return + await state.update_data(text=msg.text) + await state.set_state(S.bcast_ok) + async with AsyncSessionLocal() as db: + n = (await db.execute(select(func.count(Tenant.id)) + .where(Tenant.telegram_id != None, Tenant.is_active==True))).scalar() + bld = InlineKeyboardBuilder() + bld.row(InlineKeyboardButton(text=f"✅ Отправить {n} польз.", callback_data="bcast_go"), + InlineKeyboardButton(text="❌ Отмена", callback_data="bcast_no")) + await msg.answer(f"Предпросмотр:\n\n{msg.text}\n\n👥 Получателей: {n}", + parse_mode="HTML", reply_markup=bld.as_markup()) + +@router.callback_query(F.data=="bcast_go", S.bcast_ok) +@guard +async def broadcast_go(cb: CallbackQuery, state: FSMContext): + data = await state.get_data(); await state.clear() + async with AsyncSessionLocal() as db: + tenants = (await db.execute( + select(Tenant).where(Tenant.telegram_id!=None, Tenant.is_active==True) + )).scalars().all() + bot = Bot(token=settings.PLATFORM_BOT_TOKEN) + sent = fail = 0 + for t in tenants: + try: + await bot.send_message(t.telegram_id, data["text"], parse_mode="HTML") + sent += 1; await asyncio.sleep(0.05) + except: fail += 1 + await bot.session.close() + await cb.message.edit_text(f"📢 Рассылка завершена\n✅ {sent} · ❌ {fail}") + await cb.answer() + +@router.callback_query(F.data=="bcast_no") +@guard +async def broadcast_no(cb: CallbackQuery, state: FSMContext): + await state.clear(); await cb.message.edit_text("Отменено."); await cb.answer() + + +# ── Настройки ───────────────────────────────────────────────────────────────── +@router.message(F.text == "⚙️ Настройки") +@guard +async def platform_settings(msg: Message): + c = settings.commission + txt = ( + f"⚙️ Настройки платформы\n\n" + f"Комиссии:\n Тест 7 дней: {c['trial_week']}% | Trial: {c['trial']}% | Basic: {c['basic']}%\n" + f" Pro: {c['pro']}% | Enterprise: {c['enterprise']}% | Постоплата default: {c['postpaid_custom']}%\n\n" + f"API: {settings.API_HOST}:{settings.API_PORT}\n" + f"Domain: {settings.DOMAIN or '—'}" + ) + await msg.answer(txt, parse_mode="HTML") + + +# ── Уведомления (вызываются из кода) ───────────────────────────────────────── +async def notify_admins(bot: Bot, text: str): + for aid in settings.admin_ids: + try: await bot.send_message(aid, text, parse_mode="HTML") + except Exception as e: logger.warning(f"notify {aid}: {e}") + +async def notify_zero_balance(bot: Bot, tenant: Tenant): + await notify_admins(bot, + f"🔴 Баланс исчерпан — боты остановлены!\n\n" + f"👤 {tenant.name} ({tenant.email})\n" + f"💰 Баланс: {fmt(tenant.balance)}\n\n/topup_{tenant.id}") + +async def notify_low_balance(bot: Bot, tenant: Tenant): + await notify_admins(bot, + f"🟡 Низкий баланс\n\n" + f"👤 {tenant.name}\n" + f"💰 Баланс: {fmt(tenant.balance)} (порог {fmt(tenant.alert_threshold)})") + + +# ── Запуск ──────────────────────────────────────────────────────────────────── +async def start_platform_bot(): + if not settings.PLATFORM_BOT_TOKEN: + logger.warning("PLATFORM_BOT_TOKEN не задан — платформенный бот отключён") + return + storage = RedisStorage.from_url(settings.REDIS_URL) + bot = Bot(token=settings.PLATFORM_BOT_TOKEN) + dp = Dispatcher(storage=storage) + dp.include_router(router) + logger.info("Platform admin bot started") + try: + await dp.start_polling(bot, allowed_updates=["message","callback_query"]) + finally: + await bot.session.close() + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.run(start_platform_bot()) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f31ceb2 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,18 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +aiogram==3.7.0 +sqlalchemy[asyncio]==2.0.30 +asyncpg==0.29.0 +alembic==1.13.1 +redis==5.0.4 +aioredis==2.0.1 +passlib[bcrypt]==1.7.4 +python-jose[cryptography]==3.3.0 +python-multipart==0.0.9 +httpx==0.27.0 +pydantic==2.7.1 +pydantic-settings==2.3.0 +python-dotenv==1.0.1 +aiofiles==23.2.1 +pillow==10.3.0 +uvloop==0.19.0 diff --git a/backend/shop_bot.py b/backend/shop_bot.py new file mode 100644 index 0000000..55f0acc --- /dev/null +++ b/backend/shop_bot.py @@ -0,0 +1,216 @@ +"""BotFactory — Shop Customer Bot (бот магазина для покупателей)""" +import asyncio, logging, random +from datetime import datetime +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, ReplyKeyboardRemove) +from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder +from sqlalchemy import select, desc +from config import settings +from database import AsyncSessionLocal +from models import Shop, ShopToken, Product, PaymentCard, Order, OrderStatus, Tenant +from billing import commission_rate, tenant_can_sell + +logger = logging.getLogger("shop_bot") + + +def fmt(v: float) -> str: + return f"{v:,.0f}".replace(",", " ") + " ₽" + + +class SS(StatesGroup): + waiting_proof = State() + + +class ShopCustomerBot: + def __init__(self, shop_id: int, shop_name: str): + self.shop_id = shop_id + self.name = shop_name + self.router = Router() + self._reg() + + def _reg(self): + r=self.router; sid=self.shop_id + + def main_kb(): + b=ReplyKeyboardBuilder() + b.row(KeyboardButton(text="🛍 Каталог")) + b.row(KeyboardButton(text="📦 Мои заказы"), KeyboardButton(text="💬 Поддержка")) + return b.as_markup(resize_keyboard=True) + + @r.message(Command("start")) + async def start(msg: Message, state: FSMContext): + await state.clear() + async with AsyncSessionLocal() as db: + 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 shop or not ok: + await msg.answer(f"⚠️ {reason or 'Магазин временно приостановлен.'}"); return + await msg.answer(f"{shop.welcome_msg}\n\n{shop.name}", + parse_mode="HTML", reply_markup=main_kb()) + + @r.message(F.text=="🛍 Каталог") + async def catalog(msg: Message): + async with AsyncSessionLocal() as db: + prods=(await db.execute( + select(Product).where(Product.shop_id==sid, Product.is_active==True, Product.stock>0) + .order_by(Product.category, Product.name) + )).scalars().all() + if not prods: + await msg.answer("🛍 Каталог пуст."); return + cats: dict = {} + for p in prods: cats.setdefault(p.category,[]).append(p) + bld=InlineKeyboardBuilder(); txt="🛍 Каталог\n\n" + for cat,ps in cats.items(): + txt+=f"📂 {cat}\n" + for p in ps: + txt+=f" • {p.name} — {fmt(p.price)}\n" + bld.add(InlineKeyboardButton(text=f"🛒 {p.name[:28]}", callback_data=f"buy_{p.id}")) + txt+="\n" + bld.adjust(1) + await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup()) + + @r.callback_query(F.data.startswith("buy_")) + async def product_card(cb: CallbackQuery): + pid=int(cb.data[4:]) + async with AsyncSessionLocal() as db: + p=await db.get(Product,pid) + if not p or not p.is_active or p.shop_id!=sid or p.stock <= 0: + await cb.answer("Товар недоступен",show_alert=True); return + bld=InlineKeyboardBuilder() + bld.row(InlineKeyboardButton(text=f"✅ Купить за {fmt(p.price)}", callback_data=f"order_{p.id}"), + InlineKeyboardButton(text="← Назад", callback_data="back_cat")) + txt=f"🛍 {p.name}\n{'─'*22}\n💰 {fmt(p.price)}\n📂 {p.category}\n\n{p.description}" + if p.photo_url: + await cb.message.answer_photo(photo=p.photo_url, 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=="back_cat") + async def back_cat(cb: CallbackQuery): + await catalog(cb.message); await cb.answer() + + @r.callback_query(F.data.startswith("order_")) + async def create_order(cb: CallbackQuery, state: FSMContext): + pid=int(cb.data[6:]) + async with AsyncSessionLocal() as db: + p=await db.get(Product,pid) + if not p or not p.is_active or p.shop_id!=sid or p.stock <= 0: + await cb.answer("Недоступен",show_alert=True); return + cards=(await db.execute( + select(PaymentCard).where(PaymentCard.shop_id==sid, PaymentCard.is_active==True) + )).scalars().all() + if not cards: + await cb.answer("Оплата временно недоступна.",show_alert=True); return + card=random.choice(cards) + shop=await db.get(Shop,sid); tenant=await db.get(Tenant,shop.tenant_id) + ok, reason = tenant_can_sell(tenant) + if not ok: + await cb.answer(reason, show_alert=True); return + rate=commission_rate(tenant) + commission=round(p.price*rate/100, 2) + order=Order(shop_id=sid, product_id=p.id, card_id=card.id, + buyer_telegram_id=cb.from_user.id, + buyer_username=cb.from_user.username or "", + amount=p.price, commission=commission, + status=OrderStatus.pending, product_content=p.content) + db.add(order); card.orders_count+=1; await db.flush() + oid=order.id; await db.commit() + await state.update_data(order_id=oid); await state.set_state(SS.waiting_proof) + pay_txt=( + f"💳 Заказ #{oid}\n{'─'*24}\n" + f"🛍 {p.name}\n💰 {fmt(p.price)}\n\n" + f"Переводите на карту:\n" + f"🏦 {card.bank}\n" + f"💳 {card.number}\n" + f"👤 {card.holder}\n" + +(f"📱 СБП: {card.phone}\n" if card.phone else "") + +f"\n📝 Назначение: Заказ #{oid}\n\n" + f"📸 После оплаты пришлите скриншот перевода.\n" + f"⏱ Товар выдаётся после проверки кассиром." + ) + bld=InlineKeyboardBuilder() + bld.add(InlineKeyboardButton(text="❌ Отменить заказ", callback_data=f"cancel_{oid}")) + await cb.message.answer(pay_txt, parse_mode="HTML", reply_markup=bld.as_markup()) + await cb.answer() + + @r.message(SS.waiting_proof, F.photo) + async def receive_proof(msg: Message, state: FSMContext): + data=await state.get_data(); oid=data.get("order_id") + if not oid: await state.clear(); return + file_id=msg.photo[-1].file_id + async with AsyncSessionLocal() as db: + o=await db.get(Order,oid) + if not o or o.buyer_telegram_id!=msg.from_user.id: + await msg.answer("❌ Заказ не найден."); await state.clear(); return + o.status=OrderStatus.confirming; o.proof_file_id=file_id + shop=await db.get(Shop,sid); await db.commit() + await state.clear() + await msg.answer( + f"✅ Скриншот получен!\nЗаказ #{oid} на проверке.\n⏱ До 15 минут.", + reply_markup=ReplyKeyboardRemove()) + # Уведомить кассиров + await self._notify(shop, oid, o.amount, msg.from_user.username or str(msg.from_user.id), file_id) + + @r.message(SS.waiting_proof) + async def proof_not_photo(msg: Message): + await msg.answer("📸 Пришлите скриншот перевода (фото).") + + @r.callback_query(F.data.startswith("cancel_")) + async def cancel(cb: CallbackQuery, state: FSMContext): + oid=int(cb.data[7:]) + async with AsyncSessionLocal() as db: + o=await db.get(Order,oid) + if o and o.buyer_telegram_id==cb.from_user.id and o.status==OrderStatus.pending: + o.status=OrderStatus.cancelled; await db.commit() + await state.clear(); await cb.message.edit_text("❌ Заказ отменён."); await cb.answer() + + @r.message(F.text=="📦 Мои заказы") + async def my_orders(msg: Message): + async with AsyncSessionLocal() as db: + ords=(await db.execute( + select(Order).where(Order.shop_id==sid, Order.buyer_telegram_id==msg.from_user.id) + .order_by(desc(Order.created_at)).limit(10) + )).scalars().all() + if not ords: await msg.answer("📦 Заказов нет."); return + em_map={OrderStatus.pending:"🟡",OrderStatus.confirming:"🟠", + OrderStatus.completed:"✅",OrderStatus.rejected:"❌",OrderStatus.cancelled:"🚫"} + txt="📦 Ваши заказы:\n\n" + for o in ords: + txt+=f"{em_map.get(o.status,'❓')} #{o.id} · {fmt(o.amount)} · {o.created_at:%d.%m %H:%M}\n" + await msg.answer(txt, parse_mode="HTML") + + @r.message(F.text=="💬 Поддержка") + async def support(msg: Message): + await msg.answer(f"💬 По вопросам — обращайтесь к администрации {self.name}.") + + async def _notify(self, shop, oid: int, amount: float, buyer: str, file_id: str): + if not shop or not shop.ctrl_bot_token: return + try: + from ctrl_bot import ShopControlBot + ctrl=ShopControlBot(self.shop_id, shop.ctrl_bot_token, self.name) + await ctrl.notify_cashiers(oid, amount, buyer, file_id) + except Exception as e: + logger.error(f"_notify: {e}") + + async def run(self): + 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: + logger.warning(f"shop_id={self.shop_id}: нет активного токена"); await asyncio.sleep(30); return + storage=RedisStorage.from_url(settings.REDIS_URL+f"?db={10+self.shop_id%4}") + bot=Bot(token=tok.token); dp=Dispatcher(storage=storage) + dp.include_router(self.router) + logger.info(f"Shop bot: {self.name} ({tok.username})") + try: + await dp.start_polling(bot, allowed_updates=["message","callback_query"]) + finally: + await bot.session.close() diff --git a/backend/shop_bots_runner.py b/backend/shop_bots_runner.py new file mode 100644 index 0000000..5f2b9ab --- /dev/null +++ b/backend/shop_bots_runner.py @@ -0,0 +1,129 @@ +""" +BotFactory — Bots Runner +Запускает платформенный бот, боты управления магазинами и клиентские боты. +Следит за сменой токенов и перезапускает конкретный бот без рестарта сервера. +""" +import asyncio +import logging +from sqlalchemy import select +from database import AsyncSessionLocal, init_db +from models import Shop, ShopToken + +logger = logging.getLogger("runner") + + +class BotManager: + def __init__(self): + self._tasks: dict[str, asyncio.Task] = {} + self._sigs: dict[str, str] = {} + + async def _safe_run(self, fn, key: str): + delay = 5 + while True: + try: + await fn() + except asyncio.CancelledError: + logger.info("[%s] stopped", key) + return + except Exception as e: + logger.error("[%s] crashed: %s — retry in %ss", key, e, delay) + await asyncio.sleep(delay) + delay = min(delay * 2, 120) + else: + await asyncio.sleep(5) + + async def _replace_task(self, key: str, signature: str, factory): + old_sig = self._sigs.get(key) + old_task = self._tasks.get(key) + if old_task and not old_task.done() and old_sig == signature: + return + + if old_task and not old_task.done(): + logger.info("[%s] signature changed, restarting", key) + old_task.cancel() + try: + await old_task + except asyncio.CancelledError: + pass + + bot_obj = factory() + self._sigs[key] = signature + self._tasks[key] = asyncio.create_task( + self._safe_run(bot_obj.run, key), name=key + ) + logger.info("[%s] started", key) + + async def start_platform(self): + key = "platform" + if key in self._tasks and not self._tasks[key].done(): + return + from platform_bot import start_platform_bot + self._sigs[key] = "platform" + self._tasks[key] = asyncio.create_task( + self._safe_run(start_platform_bot, key), name=key + ) + logger.info("[platform] started") + + async def start_shop(self, shop: Shop, active_token: ShopToken | None): + from ctrl_bot import ShopControlBot + from shop_bot import ShopCustomerBot + + await self._replace_task( + key=f"ctrl_{shop.id}", + signature=f"ctrl:{shop.id}:{shop.ctrl_bot_token}", + factory=lambda: ShopControlBot(shop.id, shop.ctrl_bot_token, shop.name), + ) + + if active_token: + await self._replace_task( + key=f"shop_{shop.id}", + signature=f"shop:{shop.id}:{active_token.token}", + factory=lambda: ShopCustomerBot(shop.id, shop.name), + ) + else: + key = f"shop_{shop.id}" + task = self._tasks.get(key) + if task and not task.done(): + task.cancel() + logger.info("[%s] stopped: active token missing", key) + + async def sync(self): + async with AsyncSessionLocal() as db: + shops = (await db.execute( + select(Shop).where(Shop.is_active == True) + )).scalars().all() + active_tokens = {} + for shop in shops: + active_tokens[shop.id] = (await db.execute( + select(ShopToken).where( + ShopToken.shop_id == shop.id, + ShopToken.is_active == True, + ) + )).scalar_one_or_none() + + active_keys = {"platform"} + for shop in shops: + active_keys.add(f"ctrl_{shop.id}") + if active_tokens.get(shop.id): + active_keys.add(f"shop_{shop.id}") + await self.start_shop(shop, active_tokens.get(shop.id)) + + for key, task in list(self._tasks.items()): + if key not in active_keys and not task.done(): + task.cancel() + logger.info("[%s] stopped: no longer active", key) + + async def run(self): + await init_db() + logger.info("BotManager starting...") + await self.start_platform() + await self.sync() + while True: + await asyncio.sleep(30) + await self.sync() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") + asyncio.run(BotManager().run()) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e5daeb0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + BotFactory + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1fba9b4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "botfactory-frontend", + "version": "2.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "build": "vite build", + "preview": "vite preview --port 3000" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "axios": "^1.7.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.2.12" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..5f166db --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,1254 @@ +import { useState, useEffect, useCallback } from "react"; + +// ─── HOOKS ──────────────────────────────────────────────────────────────────── +const useMobile = () => { + const [w, setW] = useState(typeof window !== "undefined" ? window.innerWidth : 1200); + useEffect(() => { + const fn = () => setW(window.innerWidth); + window.addEventListener("resize", fn); + return () => window.removeEventListener("resize", fn); + }, []); + return w < 768; +}; + +// ─── CONSTANTS ──────────────────────────────────────────────────────────────── +const R = { + accent:"#4f6ef7", orange:"#f7934f", green:"#4fd98e", + red:"#f75f4f", yellow:"#f7d64f", cyan:"#4fd9f7", + purple:"#b04ff7", + bg:"#07080a", surface:"#0e1014", card:"#13151a", + border:"#1c1f27", border2:"#252830", + text:"#e8eaed", muted:"#6b7280", +}; + +const PLANS = { + trial_week: { label:"Тест 7 дней", color:"#888", maxBots:1, rate:10, price:0 }, + trial: { label:"Trial", color:"#888", maxBots:1, rate:10, price:0 }, + basic: { label:"Basic", color:"#4ECDC4", maxBots:3, rate:7, price:990 }, + pro: { label:"Pro", color:"#f7d64f", maxBots:10, rate:5, price:2490 }, + enterprise: { label:"Enterprise", color:"#f7934f", maxBots:999,rate:3, price:7990 }, + postpaid_custom: { label:"Постоплата", color:"#b04ff7", maxBots:999, rate:5, price:null }, +}; + +// ─── MOCK DATA ──────────────────────────────────────────────────────────────── +const INIT_TENANT = { + id:1, name:"Алексей Громов", email:"alex@shop.ru", plan:"pro", + status:"active", balance:3450, alertThreshold:500, + totalOrders:142, totalTurnover:284500, totalCommission:14225, +}; + +// Each shop = one "магазин" with: +// controlBot – отдельный бот-оператора (не банится вместе с магазином) +// tokens – список токенов shop-бота (один активный, остальные резерв) +const INIT_SHOPS = [ + { + id:1, name:"ShopBot Alpha", revenue:178000, orders:89, products:12, + controlBot:{ token:"7100000001:CTRL_BOT_AAA", username:"@shopbot_alpha_ctrl", status:"active" }, + tokens:[ + { id:1, token:"7123456789:AAFxxx", username:"@shopbot_alpha", active:true, status:"active", note:"Основной", addedAt:"2026-02-01" }, + { id:2, token:"7987654321:BBByyy", username:"@shopbot_alpha_2", active:false, status:"standby", note:"Резервный", addedAt:"2026-04-10" }, + ], + admins:[{ id:1, name:"Алексей Громов", username:"@alex_gromov", role:"admin" }], + moderators:[ + { id:2, name:"Сергей Романов", username:"@sergey_mod", role:"moderator", addedAt:"2026-03-01" }, + { id:3, name:"Анна Белова", username:"@anna_cashier", role:"moderator", addedAt:"2026-03-15" }, + ], + }, + { + id:2, name:"Keys & Codes", revenue:106500, orders:53, products:28, + controlBot:{ token:"7200000002:CTRL_BOT_BBB", username:"@keys_codes_ctrl", status:"active" }, + tokens:[ + { id:1, token:"7111111111:CCCzzz", username:"@keys_codes_bot", active:true, status:"active", note:"Основной", addedAt:"2026-03-15" }, + ], + admins:[{ id:1, name:"Алексей Громов", username:"@alex_gromov", role:"admin" }], + moderators:[ + { id:4, name:"Дмитрий Петров", username:"@dmitry_mod", role:"moderator", addedAt:"2026-04-01" }, + ], + }, +]; + +const INIT_CARDS = [ + { id:1, shopId:1, bank:"Сбербанк", number:"4276 **** **** 4521", holder:"ALEKSEY GROMOV", phone:"+7 999 123-45-67", active:true, orders:34, received:68000 }, + { id:2, shopId:1, bank:"Тинькофф", number:"5536 **** **** 7832", holder:"ALEKSEY GROMOV", phone:"+7 988 987-65-43", active:true, orders:28, received:56000 }, + { id:3, shopId:2, bank:"ВТБ", number:"4272 **** **** 1203", holder:"ALEKSEY GROMOV", phone:"+7 977 555-11-22", active:true, orders:18, received:36000 }, + { id:4, shopId:1, bank:"Альфа", number:"5486 **** **** 9944", holder:"MARIA GROMOVA", phone:"", active:false, orders:6, received:12000 }, +]; + +const INIT_ORDERS = [ + { id:101, shopId:1, shopName:"ShopBot Alpha", product:"VPN Premium 3 мес", buyer:"@user_alex99", buyerId:9910001, amount:599, commission:30, cardId:1, status:"pending", date:"2026-05-24 14:52", proof:null }, + { id:102, shopId:1, shopName:"ShopBot Alpha", product:"Spotify Premium", buyer:"@music_lover", buyerId:9920002, amount:299, commission:15, cardId:2, status:"confirming", date:"2026-05-24 13:30", proof:"screenshot.jpg" }, + { id:103, shopId:2, shopName:"Keys & Codes", product:"Windows 11 Pro Key", buyer:"@techguy_77", buyerId:9930003, amount:890, commission:45, cardId:3, status:"completed", date:"2026-05-24 12:00", proof:"pay_proof.jpg" }, + { id:104, shopId:1, shopName:"ShopBot Alpha", product:"VPN Premium 3 мес", buyer:"@vpn_user22", buyerId:9940004, amount:599, commission:30, cardId:1, status:"completed", date:"2026-05-23 20:15", proof:"done.jpg" }, + { id:105, shopId:2, shopName:"Keys & Codes", product:"Office 2024 Key", buyer:"@office_pro", buyerId:9950005, amount:1290, commission:65, cardId:3, status:"rejected", date:"2026-05-23 18:00", proof:"fake.jpg" }, +]; + +const INIT_TX = [ + { id:1, type:"deposit", amount:+5000, date:"2026-05-20 10:00", note:"Пополнение баланса" }, + { id:2, type:"commission", amount:-30, date:"2026-05-24 12:00", note:"Комиссия: заказ #101 (599₽ × 5%)" }, + { id:3, type:"commission", amount:-15, date:"2026-05-24 13:30", note:"Комиссия: заказ #102 (299₽ × 5%)" }, + { id:4, type:"commission", amount:-45, date:"2026-05-24 12:00", note:"Комиссия: заказ #103 (890₽ × 5%)" }, + { id:5, type:"alert", amount:0, date:"2026-05-22 08:00", note:"⚠️ Уведомление: баланс ниже 500₽" }, + { id:6, type:"deposit", amount:+3000, date:"2026-05-15 14:00", note:"Пополнение баланса" }, +]; + +const MOCK_PLATFORM_TENANTS = [ + { id:1, name:"Алексей Громов", email:"alex@shop.ru", plan:"pro", status:"active", balance:3450, alertThreshold:500, totalOrders:142, totalTurnover:284500, totalCommission:14225 }, + { id:2, name:"Мария Селезнёва", email:"maria@digital.ru",plan:"basic", status:"active", balance:120, alertThreshold:200, totalOrders:87, totalTurnover:174000, totalCommission:12180 }, + { id:3, name:"Иван Кузнецов", email:"ivan@goods.ru", plan:"pro", status:"blocked", balance:0, alertThreshold:300, totalOrders:44, totalTurnover:98000, totalCommission:4900 }, + { id:4, name:"Дмитрий Попов", email:"dm@newshop.ru", plan:"trial", status:"active", balance:75, alertThreshold:50, totalOrders:5, totalTurnover:4500, totalCommission:450 }, +]; + +// ═══════════════════════════════════════════════════════════════════════════════ +// ICONS +// ═══════════════════════════════════════════════════════════════════════════════ +const P = { + dashboard:"M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z", + shops:"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z", + orders:"M19 3h-4.18C14.4 1.84 13.3 1 12 1c-1.3 0-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 0c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm2 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z", + cards:"M20 4H4c-1.11 0-2 .89-2 2v12c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z", + team:"M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z", + settings:"M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.57 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z", + plus:"M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z", + check:"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z", + close:"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z", + trash:"M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z", + eye:"M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z", + wallet:"M21 18v1c0 1.1-.9 2-2 2H5c-1.11 0-2-.9-2-2V5c0-1.1.89-2 2-2h14c1.1 0 2 .9 2 2v1h-9c-1.11 0-2 .9-2 2v8c0 1.1.89 2 2 2h9zm-9-2h10V8H12v8zm4-2.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z", + alert:"M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z", + lock:"M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z", + shuffle:"M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z", + swap:"M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z", + key:"M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z", + ctrl:"M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z", + shield:"M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z", + users:"M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z", + revenue:"M11.8 10.9c-2.27-.59-3-1.2-3-2.15 0-1.09 1.01-1.85 2.7-1.85 1.78 0 2.44.85 2.5 2.1h2.21c-.07-1.72-1.12-3.3-3.21-3.81V3h-3v2.16c-1.94.42-3.5 1.68-3.5 3.61 0 2.31 1.91 3.46 4.7 4.13 2.5.6 3 1.48 3 2.41 0 .69-.49 1.79-2.7 1.79-2.06 0-2.87-.92-2.98-2.1h-2.2c.12 2.19 1.76 3.42 3.68 3.83V21h3v-2.15c1.95-.37 3.5-1.5 3.5-3.55 0-2.84-2.43-3.81-4.7-4.4z", + history:"M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z", + menu:"M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z", + back:"M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z", + bell:"M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z", + edit:"M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z", + topup:"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z", + percent:"M7.5 11C9.43 11 11 9.43 11 7.5S9.43 4 7.5 4 4 5.57 4 7.5 5.57 11 7.5 11zm0-5C8.33 6 9 6.67 9 7.5S8.33 9 7.5 9 6 8.33 6 7.5 6.67 6 7.5 6zM4 18.5L18.5 4l1.5 1.5L5.5 20 4 18.5zm12-2.5c-1.93 0-3.5 1.57-3.5 3.5S14.07 23 16 23s3.5-1.57 3.5-3.5S17.93 16 16 16zm0 5c-.83 0-1.5-.67-1.5-1.5S15.17 18 16 18s1.5.67 1.5 1.5S16.83 21 16 21z", +}; + +const Ic = ({ n, s=16, c }) => ( + + + +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// ATOMS +// ═══════════════════════════════════════════════════════════════════════════════ +const css = ` + @import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;600&display=swap'); + *{box-sizing:border-box;margin:0;padding:0} + body{background:#07080a;color:#e8eaed;-webkit-font-smoothing:antialiased} + ::-webkit-scrollbar{width:4px;height:4px} + ::-webkit-scrollbar-track{background:#111} + ::-webkit-scrollbar-thumb{background:#2a2a2a;border-radius:2px} + select option{background:#161920} + input::placeholder,textarea::placeholder{color:#3a3f4a} + input:focus,textarea:focus,select:focus{outline:none!important;border-color:#4f6ef7!important} + tr:hover>td{background:#0f1118!important} + @media(max-width:767px){ + .hide-mobile{display:none!important} + .mob-full{width:100%!important} + .mob-stack{flex-direction:column!important} + .mob-p{padding:16px!important} + .mob-grid1{grid-template-columns:1fr!important} + .mob-grid2{grid-template-columns:1fr 1fr!important} + .mob-fs13{font-size:13px!important} + .mob-fs22{font-size:22px!important} + } +`; + +const Tag = ({label,color,bg})=>( + {label} +); +const PlanTag = ({plan})=>{const p=PLANS[plan]||PLANS.basic;return ;}; + +const Btn = ({children,onClick,v="primary",sm,full,disabled,style:s={}})=>{ + const st={ + primary:{bg:R.accent,color:"#fff",bo:R.accent}, + ghost:{bg:"transparent",color:R.muted,bo:R.border2}, + danger:{bg:`${R.red}15`,color:R.red,bo:`${R.red}40`}, + success:{bg:`${R.green}15`,color:R.green,bo:`${R.green}40`}, + warning:{bg:`${R.yellow}15`,color:R.yellow,bo:`${R.yellow}40`}, + orange:{bg:`${R.orange}15`,color:R.orange,bo:`${R.orange}40`}, + purple:{bg:`${R.purple}15`,color:R.purple,bo:`${R.purple}40`}, + cyan:{bg:`${R.cyan}15`,color:R.cyan,bo:`${R.cyan}40`}, + }[v]||{bg:R.surface,color:R.muted,bo:R.border2}; + return( + + ); +}; + +const F = ({label,children,style:s={}})=>( +
+ {label&&} + {children} +
+); +const baseInput = {width:"100%",background:R.surface,border:`1px solid ${R.border2}`,borderRadius:8,padding:"9px 12px",color:R.text,fontSize:13,fontFamily:"'JetBrains Mono'"}; +const Inp = ({label,...p})=>; +const Sel = ({label,children,...p})=>; +const Txa = ({label,...p})=>