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 @@
+
+
+
| )} | |||||||||
|---|---|---|---|---|---|---|---|---|---|
| #{o.id} | +{o.product} | +{o.buyer} | +{o.shopName} | +{o.amount}₽ | +−{o.commission}₽ | +*{o.cardId} | +{o.date} | +
| )} | |||
|---|---|---|---|
| {tx.date} | +0?R.green:tx.amount<0?R.red:R.muted}>{tx.amount>0?"+":""}{tx.amount!==0?`${tx.amount.toLocaleString()} ₽`:"—"} | +{tx.note} | +
| )} | |||||||
|---|---|---|---|---|---|---|---|
{t.name} {t.email} |
+ {t.balance.toLocaleString()}₽ | +{t.alertThreshold.toLocaleString()}₽ | +{empty? |
+ {t.totalTurnover.toLocaleString()}₽ | ++{t.totalCommission.toLocaleString()}₽ | +
+ |
+