Compare commits

...

26 Commits

Author SHA1 Message Date
f72d538344 14 2026-05-16 03:15:27 +03:00
d89261808b 13 2026-05-16 03:10:50 +03:00
772e88bb30 12 2026-05-16 03:05:59 +03:00
100b50003f 11 2026-05-16 02:58:06 +03:00
eeac56190c 10 2026-05-16 02:48:18 +03:00
22576c0c40 9 2026-05-16 02:22:14 +03:00
88e4ab62be 8 2026-05-16 02:14:57 +03:00
91a5283a2f 7 2026-05-16 02:09:36 +03:00
be2a17c36b 6 2026-05-16 01:59:23 +03:00
77d396b842 5 2026-05-16 01:49:57 +03:00
22fa2fadf8 4 2026-05-16 01:48:30 +03:00
f9fd42e8e3 3 2026-05-15 23:29:01 +03:00
302bb191a3 2 2026-05-15 20:41:36 +03:00
41dd63655d 1 2026-05-15 20:26:18 +03:00
6fc7072639 new file: frontend/nginx.conf 2026-05-15 20:19:47 +03:00
641f9e1d8c modified: backend/app.py
modified:   frontend/index.html
	modified:   frontend/js/app.js
2026-05-15 19:02:24 +03:00
2df4f52adb modified: backend/Dockerfile 2026-05-15 18:58:12 +03:00
9152ecc4fc chore: update service configurations in docker-compose.yml 2026-05-15 18:48:39 +03:00
d9aae5b85e feat: implement backend core with repricing logic, case management, and multi-service architecture 2026-05-13 00:28:13 +03:00
38f3199c33 feat: implement core frontend dashboard, auth flow, and AI-driven case management system 2026-05-13 00:16:58 +03:00
0732acbda5 feat: initialize full-stack architecture with backend routes, database schema, and glassmorphic frontend UI 2026-05-13 00:12:19 +03:00
bd73f18d5c feat: implement core backend API structure and frontend service layer for case management and authentication 2026-05-12 15:41:57 +03:00
ee39a3d83b feat: implement backend API clients for multi-marketplace integration and frontend service layer 2026-05-12 13:16:14 +03:00
6974af486a Changes to be committed:
deleted:    backend/.env
2026-05-12 11:23:54 +03:00
5149ec0250 chore: add environment variables for Ozon API and database connection 2026-05-12 11:22:50 +03:00
6c79b11b59 Changes to be committed:
deleted:    backend/.env
2026-05-12 11:22:28 +03:00
86 changed files with 5705 additions and 187 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
backend/.env

View File

@@ -1,40 +1,65 @@
# OzonGuard (AutoMarket)
# 🚀 AutoMarket Ultimate Marketplace SaaS Platform
**OzonGuard** — это веб-приложение для быстрой подготовки, управления и отправки жалоб в службу поддержки маркетплейса Ozon на нарушителей (например, на копирование карточек товаров, нарушения интеллектуальной собственности и т.д.).
**AutoMarket** — это ультимативная экосистема для профессиональных селлеров, которая автоматизирует 90% рутины: от генерации контента и защиты авторских прав до динамического ценообразования и глубокой аналитики конкурентов.
## Основной функционал
---
- **Управление кейсами:** Создание новых жалоб (кейсов) с указанием ссылок на карточку нарушителя, его артикула и вашего артикула.
- **Прикрепление доказательств:** Возможность загрузки файлов (изображений и PDF-документов) в качестве доказательной базы.
- **Статусы кейсов:** Отслеживание процесса работы над жалобой по статусам: *Новый*, *Подготовлен*, *Отправлен*, *Закрыт*.
- **Поиск и фильтрация:** Удобный поиск по артикулу или ссылке, а также фильтрация списка по текущему статусу жалобы.
## 💎 Ключевые возможности
## Технологический стек
### 1. 🤖 AI Assistant & Content Factory
* **Умный генератор карточек:** Создание контента (описание, ТТХ, SEO) по названию товара.
* **Deep Insight:** ИИ-анализ конкурентов. Сравнение карточек, поиск слабых мест и разработка стратегии захвата рынка.
* **ИИ-Фото Промпты:** Задания для нейросетей (Midjourney/DALL-E) для создания идеального фотоконтента.
* **Авто-ответчик и Претензии:** Генерация ответов на отзывы и юридических жалоб на нарушителей.
- **Frontend:** HTML5, CSS3, Vanilla JavaScript (без использования тяжеловесных фреймворков).
- **Backend:** Python 3.9+, Flask, Werkzeug.
- **База данных:** PostgreSQL.
### 2. 🏷️ Авто-репрайзер (Dynamic Pricing)
* **Борьба за BuyBox:** Автоматическое изменение цены для удержания лидерства в поиске.
* **Защита маржи:** Установка Min/Max цены, ниже которой система не опустится.
* **Стратегии:** "На 1₽ дешевле конкурента" или "Следование за минимальной ценой".
## Установка и запуск (Linux/macOS)
### 3. 📉 Умная аналитика и Прогноз
* **Inventory Forecasting:** Прогноз обнуления остатков (Out-of-Stock) с цветовой индикацией критических уровней.
* **SEO Tracker:** Мониторинг позиций в поиске по ключевым словам с историческими графиками.
* **P&L Калькулятор:** Детальный расчет чистой прибыли, ROI и маржинальности.
В проекте предусмотрен bash-скрипт `init.sh` для автоматической настройки окружения.
### 4. 🛡️ Защита контента (Image Matching)
* **Visual Fingerprinting:** Поиск визуальных клонов ваших фото с помощью алгоритма pHash.
* **Авто-жалобы:** Подача претензий нарушителям в один клик.
1. Убедитесь, что у вас установлены Python 3.9+ и PostgreSQL (`psql`).
2. При необходимости настройте подключение к БД в файле `backend/.env` (переменная `DATABASE_URL=postgresql://user:password@localhost:5432/dbname`).
3. Выполните скрипт инициализации из корневой директории:
```bash
bash init.sh
```
### 5. 👥 Командный доступ (Enterprise Ready)
* **Ролевая модель:** Admin, Manager, Viewer.
* **Multi-user:** Возможность добавления сотрудников в общую компанию с ограничением прав доступа.
**Что делает скрипт `init.sh`:**
- Проверяет наличие Python 3 и создает виртуальное окружение `venv`.
- Устанавливает Python-зависимости из файла `backend/requirements.txt`.
- Инициализирует базу данных с помощью файла `db/init.sql` (если настроен `.env`).
- Запускает локальный Flask-сервер на порту `5000`.
### 6. 🤖 Telegram-бот "Директор магазина"
* **Мобильный офис:** Команды `/stats`, `/oos`, `/reviews` и `/cases` для управления бизнесом прямо из Telegram.
* **Real-time Push:** Мгновенные уведомления о новых событиях и критических остатках.
## Структура репозитория
---
- `/frontend/` — статические файлы пользовательского интерфейса (страницы, стили, логика на JS).
- `/backend/` — серверная часть на Flask (точка входа `app.py`, обработка маршрутов API в `routes/`).
- `/db/` — SQL-скрипты для инициализации таблиц базы данных.
- `init.sh` — стартовый скрипт для быстрого развертывания проекта.
## 🛠 Технологический стек
* **Backend:** Python (Flask), Socket.io, telebot (Telegram API).
* **Frontend:** Vanilla JS + Chart.js + Lucide Icons + Glassmorphism UI.
* **Database:** PostgreSQL (с поддержкой RBAC и Multi-tenancy).
* **AI:** Google Gemini 1.5 Flash.
* **Security:** JWT (с ролью и ID компании), Bcrypt, Audit Logging.
---
## 🚀 Быстрый запуск
### 1. Настройка .env
```env
DATABASE_URL=postgresql://user:pass@host.docker.internal:5432/automarket
JWT_SECRET=your_secret
GEMINI_API_KEY=your_gemini_key
TELEGRAM_BOT_TOKEN=your_bot_token
```
### 2. Запуск
```bash
docker-compose up --build
python backend/bot.py # Запуск Telegram-бота
```
* **Web UI:** `http://localhost:8080`
* **API Docs:** `http://localhost:5000/api/docs`

View File

@@ -1,3 +0,0 @@
OZON_CLIENT_ID=898289
OZON_API_KEY=4467d825-d7aa-4804-abd0-9f9d2edef88e
DATABASE_URL=postgresql://devops_portal:ADzXo0Gm!@10.10.10.167:5432/devops_portal

28
backend/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# backend/Dockerfile
FROM python:3.9-slim
WORKDIR /app
# Установка системных зависимостей для psycopg2 и др.
RUN apt-get update && apt-get install -y \
libpq-dev \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Копирование и установка зависимостей
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копирование кода бэкенда
COPY backend/ ./backend/
# Создание __init__.py если их нет (для корректной работы относительных импортов)
RUN touch backend/__init__.py backend/routes/__init__.py
# Переменная окружения для корректных импортов
ENV PYTHONPATH=/app
EXPOSE 5000
# Запуск приложения как модуля (обязательно для относительных импортов)
CMD ["python", "-m", "backend.app"]

View File

@@ -0,0 +1,15 @@
from .client import AEClient
from .services.transactions import TransactionsService
from .services.products import ProductsService
class AliExpressAPI:
"""
Main entry point for AE Platform (AliExpress Russia) SDK.
Instantiates all services with a shared client.
"""
def __init__(self, api_token: str = None, user_id: str = None):
self.client = AEClient(api_token, user_id)
# Initialize services
self.transactions = TransactionsService(self.client)
self.products = ProductsService(self.client)

76
backend/ae_api/client.py Normal file
View File

@@ -0,0 +1,76 @@
import os
import requests
import time
from typing import Dict, Any, Optional
from .exceptions import AEAPIError, AEAuthError, AERateLimitError, AEBadRequestError, AENotFoundError
class AEClient:
"""Base client for interacting with AE Platform API."""
BASE_URL = "https://api2.aeplatform.ru/api/v1"
def __init__(self, api_token: Optional[str] = None, user_id: Optional[str] = None):
# Fetch from env if not provided
self.api_token = api_token or os.getenv('AE_API_TOKEN')
self.user_id = user_id or os.getenv('AE_USER_ID')
if not self.api_token:
raise ValueError("AE_API_TOKEN must be provided")
self.session = requests.Session()
# AE Platform API requires JWT token and locale
self.session.headers.update({
"Authorization": self.api_token, # Token from documentation is passed directly in Authorization header
"x-request-locale": "ru_RU",
"Content-Type": "application/json"
})
def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]:
"""Perform HTTP request to AE Platform API with automatic retries."""
url = f"{self.BASE_URL}{path}"
for attempt in range(retries):
try:
response = self.session.request(method, url, json=data, params=params)
# Check rate limiting
if response.status_code == 429:
if attempt < retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
raise AERateLimitError("Rate limit exceeded")
if response.status_code in (401, 403):
raise AEAuthError(f"Authentication failed: {response.text}")
if response.status_code in (400, 422):
raise AEBadRequestError(f"Bad Request / Unprocessable Entity: {response.text}")
if response.status_code == 404:
raise AENotFoundError(f"Not Found: {response.text}")
response.raise_for_status()
if response.content:
try:
return response.json()
except ValueError:
return {"status": "success", "text": response.text}
return {}
except requests.exceptions.RequestException as e:
if attempt < retries - 1:
time.sleep(2 ** attempt)
continue
raise AEAPIError(f"Request failed: {str(e)}")
raise AEAPIError("Request failed after max retries")
def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("POST", path, data=data)
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("GET", path, params=params)
def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("PUT", path, data=data)

View File

@@ -0,0 +1,19 @@
class AEAPIError(Exception):
"""Base exception for all AE Platform API errors."""
pass
class AEAuthError(AEAPIError):
"""Raised when authentication fails (HTTP 401 or 403)."""
pass
class AERateLimitError(AEAPIError):
"""Raised when API rate limits are exceeded (HTTP 429)."""
pass
class AEBadRequestError(AEAPIError):
"""Raised when API returns HTTP 400 or 422."""
pass
class AENotFoundError(AEAPIError):
"""Raised when API returns HTTP 404."""
pass

View File

@@ -0,0 +1,30 @@
from typing import Dict, Any, Optional
from ..client import AEClient
class ProductsService:
"""Service to handle AE Platform Products and Links."""
def __init__(self, client: AEClient):
self.client = client
def get_link_info(self, link: str, user_id: Optional[str] = None, user_sub_id: Optional[str] = None) -> Dict[str, Any]:
"""
Get information about a product/link including commission rates.
Endpoint: POST /users/{userId}/link/info
"""
uid = user_id or self.client.user_id
if not uid:
raise ValueError("user_id must be provided either in the method call or client initialization.")
payload = {"link": link}
if user_sub_id:
payload["userSubId"] = user_sub_id
return self.client.post(f"/users/{uid}/link/info", data=payload)
def get_products_feeds(self, limit: int = 30) -> Dict[str, Any]:
"""
Get product feeds.
Endpoint: GET /productsfeeds/feeds
"""
return self.client.get("/productsfeeds/feeds", params={"limit": limit})

View File

@@ -0,0 +1,19 @@
from typing import Dict, Any, Optional
from ..client import AEClient
class TransactionsService:
"""Service to handle AE Platform Transactions (Orders)."""
def __init__(self, client: AEClient):
self.client = client
def get_list(self, user_id: Optional[str] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get list of transactions (orders) for a user.
Endpoint: GET /users/{user_id}/transactions
"""
uid = user_id or self.client.user_id
if not uid:
raise ValueError("user_id must be provided either in the method call or client initialization.")
return self.client.get(f"/users/{uid}/transactions", params=params)

View File

@@ -2,22 +2,170 @@ import os
from flask import Flask
from flask_cors import CORS
from dotenv import load_dotenv
from flasgger import Swagger
from flask_socketio import SocketIO, emit
# Load environment variables from .env
load_dotenv()
app = Flask(__name__)
CORS(app)
socketio = SocketIO(app, cors_allowed_origins="*")
# Swagger setup
# ... (keeping existing swagger config)
app.config['SWAGGER'] = {
'title': 'AutoMarket API',
'uiversion': 3,
'specs_route': '/api/docs/',
'securityDefinitions': {
'APIKeyHeader': {
'type': 'apiKey',
'name': 'Authorization',
'in': 'header',
'description': 'Введите токен в формате: Bearer <ваш_токен>'
}
}
}
swagger = Swagger(app)
# Ensure upload folder exists
# ...
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
# Register blueprints (routes)
# Автоматическая инициализация таблиц при старте
def init_db():
try:
from .db import get_db_connection, release_db_connection
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL,
password_hash TEXT NOT NULL, role VARCHAR(20) DEFAULT 'admin',
parent_id INT REFERENCES users(id) ON DELETE CASCADE,
plan VARCHAR(20) DEFAULT 'free', created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS credentials (
user_id INT REFERENCES users(id) ON DELETE CASCADE, platform VARCHAR(50),
api_key TEXT, client_id TEXT, client_secret TEXT, other_data JSONB,
updated_at TIMESTAMP DEFAULT NOW(), PRIMARY KEY (user_id, platform)
);
CREATE TABLE IF NOT EXISTS cases (
id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE CASCADE,
platform VARCHAR(50) NOT NULL DEFAULT 'ozon', violator_url TEXT NOT NULL,
violator_article VARCHAR(50) NOT NULL, my_article VARCHAR(50), comment TEXT,
status VARCHAR(20) DEFAULT 'new',
created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS files (
id SERIAL PRIMARY KEY, case_id INT REFERENCES cases(id) ON DELETE CASCADE,
file_path TEXT NOT NULL, uploaded_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE CASCADE,
action VARCHAR(100) NOT NULL, details JSONB, created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS seo_keywords (
id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE CASCADE,
keyword VARCHAR(255) NOT NULL, article VARCHAR(100) NOT NULL,
platform VARCHAR(50) NOT NULL, created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS seo_history (
id SERIAL PRIMARY KEY, keyword_id INT REFERENCES seo_keywords(id) ON DELETE CASCADE,
position INT, checked_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS reference_images (
id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT NOT NULL, image_hash VARCHAR(64), article VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS repricer_rules (
id SERIAL PRIMARY KEY, user_id INT REFERENCES users(id) ON DELETE CASCADE,
product_name VARCHAR(255) NOT NULL, sku VARCHAR(100) NOT NULL,
min_price DECIMAL(10,2) NOT NULL, max_price DECIMAL(10,2) NOT NULL,
strategy VARCHAR(20) DEFAULT 'follow_competitor',
is_active BOOLEAN DEFAULT TRUE, current_price DECIMAL(10,2),
last_run TIMESTAMP, created_at TIMESTAMP DEFAULT NOW()
);
""")
conn.commit()
release_db_connection(conn)
print("✅ База данных инициализирована успешно")
except Exception as e:
print(f"⚠️ Ошибка инициализации БД: {e}")
init_db()
# Register blueprints
from .routes.cases import cases_blueprint
from .routes.credentials import credentials_blueprint
from .routes.auth import auth_blueprint
from .routes.dashboard import dashboard_blueprint
from .routes.reports import reports_blueprint
from .routes.ai import ai_blueprint
from .routes.seo import seo_blueprint
from .routes.content import content_blueprint
from .routes.finance import finance_blueprint
from .routes.team import team_blueprint
from .routes.repricer import repricer_blueprint
app.register_blueprint(cases_blueprint, url_prefix='/api')
app.register_blueprint(credentials_blueprint, url_prefix='/api')
app.register_blueprint(auth_blueprint, url_prefix='/api')
app.register_blueprint(dashboard_blueprint, url_prefix='/api')
app.register_blueprint(reports_blueprint, url_prefix='/api')
app.register_blueprint(ai_blueprint, url_prefix='/api')
app.register_blueprint(seo_blueprint, url_prefix='/api')
app.register_blueprint(content_blueprint, url_prefix='/api')
app.register_blueprint(finance_blueprint, url_prefix='/api')
app.register_blueprint(team_blueprint, url_prefix='/api')
app.register_blueprint(repricer_blueprint, url_prefix='/api')
# Health check endpoint - проверка состояния приложения и БД
from flask import jsonify
from .db import get_db_connection, release_db_connection
@app.route('/api/health')
def health_check():
db_status = "ok"
db_error = None
try:
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT 1")
release_db_connection(conn)
except Exception as e:
db_status = "error"
db_error = str(e)
status = "ok" if db_status == "ok" else "degraded"
return jsonify({
"status": status,
"backend": "ok",
"database": db_status,
"db_error": db_error
}), 200 if status == "ok" else 503
import threading
import time
def background_scanner():
"""Фоновая задача для имитации сканирования маркетплейсов."""
while True:
# Здесь в будущем будет вызов api.ozon_api и т.д.
print("--- [Scanner] Checking marketplaces for price dumping... ---")
# Эмитируем событие для примера (что сканер что-то нашел)
# socketio.emit('scanner_update', {'message': 'Сканер проверил 10 товаров...'}, namespace='/')
time.sleep(60)
# Start background thread
scanner_thread = threading.Thread(target=background_scanner, daemon=True)
scanner_thread.start()
if __name__ == '__main__':
# Use host 0.0.0.0 for Docker compatibility, port 5000
app.run(host='0.0.0.0', port=5000, debug=True)
# Use socketio.run instead of app.run
socketio.run(app, host='0.0.0.0', port=5000, debug=True, allow_unsafe_werkzeug=True)

View File

@@ -0,0 +1,15 @@
from .client import AvitoClient
from .services.items import ItemsService
from .services.messenger import MessengerService
class AvitoAPI:
"""
Main entry point for Avito SDK.
Instantiates all services with a shared client.
"""
def __init__(self, client_id: str = None, client_secret: str = None):
self.client = AvitoClient(client_id, client_secret)
# Initialize services
self.items = ItemsService(self.client)
self.messenger = MessengerService(self.client)

View File

@@ -0,0 +1,97 @@
import os
import requests
import time
from typing import Dict, Any, Optional
from .exceptions import AvitoAPIError, AvitoAuthError, AvitoRateLimitError, AvitoBadRequestError, AvitoNotFoundError
class AvitoClient:
"""Base client for interacting with Avito API."""
BASE_URL = "https://api.avito.ru"
def __init__(self, client_id: Optional[str] = None, client_secret: Optional[str] = None):
# Fetch from env if not provided
self.client_id = client_id or os.getenv('AVITO_CLIENT_ID')
self.client_secret = client_secret or os.getenv('AVITO_CLIENT_SECRET')
if not self.client_id or not self.client_secret:
raise ValueError("AVITO_CLIENT_ID and AVITO_CLIENT_SECRET must be provided")
self.session = requests.Session()
self.access_token = None
# Получаем токен сразу при инициализации
self._authenticate()
def _authenticate(self):
"""Exchange Client ID and Secret for a temporary OAuth access token."""
url = f"{self.BASE_URL}/token/"
data = {
"client_id": self.client_id,
"client_secret": self.client_secret,
"grant_type": "client_credentials"
}
response = requests.post(url, data=data)
if response.status_code != 200:
raise AvitoAuthError(f"Failed to get Avito token: {response.text}")
token_data = response.json()
self.access_token = token_data.get("access_token")
self.session.headers.update({
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json"
})
def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]:
"""Perform HTTP request to Avito API with automatic retries and token refresh."""
url = f"{self.BASE_URL}{path}"
for attempt in range(retries):
try:
response = self.session.request(method, url, json=data, params=params)
# Check for expired token (401) or Forbidden (403)
if response.status_code in (401, 403):
if attempt < retries - 1:
# Обновляем токен и пробуем снова
self._authenticate()
continue
raise AvitoAuthError(f"Authentication failed after retries: {response.text}")
# Check rate limiting (429)
if response.status_code == 429:
if attempt < retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
raise AvitoRateLimitError("Rate limit exceeded")
if response.status_code == 400:
raise AvitoBadRequestError(f"Bad Request: {response.text}")
if response.status_code == 404:
raise AvitoNotFoundError(f"Not Found: {response.text}")
response.raise_for_status()
if response.content:
return response.json()
return {}
except requests.exceptions.RequestException as e:
if attempt < retries - 1:
time.sleep(2 ** attempt)
continue
raise AvitoAPIError(f"Request failed: {str(e)}")
raise AvitoAPIError("Request failed after max retries")
def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("POST", path, data=data)
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("GET", path, params=params)
def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("PUT", path, data=data)

View File

@@ -0,0 +1,19 @@
class AvitoAPIError(Exception):
"""Base exception for all Avito API errors."""
pass
class AvitoAuthError(AvitoAPIError):
"""Raised when authentication fails (HTTP 401 or 403)."""
pass
class AvitoRateLimitError(AvitoAPIError):
"""Raised when API rate limits are exceeded (HTTP 429)."""
pass
class AvitoBadRequestError(AvitoAPIError):
"""Raised when API returns HTTP 400."""
pass
class AvitoNotFoundError(AvitoAPIError):
"""Raised when API returns HTTP 404."""
pass

View File

@@ -0,0 +1,15 @@
from typing import Dict, Any, Optional
from ..client import AvitoClient
class ItemsService:
"""Service to handle Avito Items (Listings)."""
def __init__(self, client: AvitoClient):
self.client = client
def get_info(self, item_id: str) -> Dict[str, Any]:
"""
Get info about a specific item.
Endpoint: /core/v1/items/{item_id}
"""
return self.client.get(f"/core/v1/items/{item_id}")

View File

@@ -0,0 +1,29 @@
from typing import Dict, Any, Optional
from ..client import AvitoClient
class MessengerService:
"""Service to handle Avito Messenger (Chats)."""
def __init__(self, client: AvitoClient):
self.client = client
def get_chats(self, user_id: str, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
"""
Get list of active chats for a user.
Endpoint: /messenger/v2/accounts/{user_id}/chats
"""
params = {"limit": limit, "offset": offset}
return self.client.get(f"/messenger/v2/accounts/{user_id}/chats", params=params)
def send_message(self, user_id: str, chat_id: str, text: str) -> Dict[str, Any]:
"""
Send a text message to a specific chat.
Endpoint: /messenger/v1/accounts/{user_id}/chats/{chat_id}/messages
"""
payload = {
"message": {
"text": text
},
"type": "text"
}
return self.client.post(f"/messenger/v1/accounts/{user_id}/chats/{chat_id}/messages", data=payload)

76
backend/bot.py Normal file
View File

@@ -0,0 +1,76 @@
import telebot
import os
import random
from dotenv import load_dotenv
from db import get_db_connection, release_db_connection
load_dotenv()
TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
if not TOKEN:
print("TELEGRAM_BOT_TOKEN not found. Bot will not start.")
exit()
bot = telebot.TeleBot(TOKEN)
@bot.message_handler(commands=['start', 'help'])
def send_welcome(message):
help_text = """
🚀 <b>AutoMarket: Директор магазина</b>
Я ваш мобильный помощник по управлению маркетплейсами.
<b>Команды:</b>
📊 /stats — Сводка продаж за сегодня
📉 /oos — Товары, которые скоро закончатся
🔔 /cases — Активные жалобы на нарушения
💬 /reviews — Последние отзывы
<i>Настройте TELEGRAM_CHAT_ID в .env, чтобы получать уведомления!</i>
"""
bot.reply_to(message, help_text, parse_mode='HTML')
@bot.message_handler(commands=['stats'])
def get_stats(message):
# В реальном приложении мы бы искали пользователя по chat_id
# Для демо берем агрегированные данные
total_sales = random.randint(150000, 450000)
orders = random.randint(20, 100)
report = f"""
📊 <b>Статистика за сегодня:</b>
━━━━━━━━━━━━━━━━━━
💰 Выручка: <b>{total_sales:,} ₽</b>
📦 Заказов: <b>{orders} шт.</b>
🚀 Рост к вчера: <b>+12%</b>
<i>Данные агрегированы по всем вашим магазинам.</i>
"""
bot.reply_to(message, report, parse_mode='HTML')
@bot.message_handler(commands=['oos'])
def get_oos(message):
items = [
{"name": "Беспроводная кофемолка", "left": 3},
{"name": "Набор ножей GT-5", "left": 5},
{"name": "Пылесос автомобильный", "left": 1}
]
text = "📉 <b>Критический запас (Out of Stock):</b>\n\n"
for item in items:
text += f"⚠️ {item['name']} — осталось <b>{item['left']} шт.</b>\n"
text += "\n<i>Рекомендуем сделать поставку в ближайшие 24 часа.</i>"
bot.reply_to(message, text, parse_mode='HTML')
@bot.message_handler(commands=['cases'])
def get_cases(message):
bot.reply_to(message, "🔔 <b>У вас 3 активных нарушения.</b>\nПо всем случаям ИИ уже подготовил претензии. Проверьте в веб-интерфейсе.")
@bot.message_handler(commands=['reviews'])
def get_reviews(message):
review = "⭐⭐⭐⭐\n'Товар хороший, но коробка помята...'\n\n💡 <i>ИИ советует: Извиниться за логистику и предложить скидку на след. покупку.</i>"
bot.reply_to(message, f"💬 <b>Новый отзыв Ozon:</b>\n\n{review}", parse_mode='HTML')
if __name__ == "__main__":
print("Bot is running...")
bot.infinity_polling()

View File

@@ -12,7 +12,13 @@ postgres_pool = pool.ThreadedConnectionPool(1, 10, dsn=DATABASE_URL)
def get_db_connection():
"""Return a connection from the pool. Caller must close it after use."""
return postgres_pool.getconn()
conn = postgres_pool.getconn()
# Устанавливаем схему automarket (таблицы созданы там, а не в public)
cur = conn.cursor()
cur.execute("SET search_path TO automarket, public")
cur.close()
conn.commit()
return conn
def release_db_connection(conn):
"""Return the connection to the pool."""

View File

@@ -0,0 +1,15 @@
from .client import FlowwowClient
from .services.products import ProductsService
from .services.orders import OrdersService
class FlowwowAPI:
"""
Main entry point for Flowwow SDK.
Instantiates all services with a shared client.
"""
def __init__(self, api_token: str = None):
self.client = FlowwowClient(api_token)
# Initialize services
self.products = ProductsService(self.client)
self.orders = OrdersService(self.client)

View File

@@ -0,0 +1,72 @@
import os
import requests
import time
from typing import Dict, Any, Optional
from .exceptions import FlowwowAPIError, FlowwowAuthError, FlowwowRateLimitError, FlowwowBadRequestError, FlowwowNotFoundError
class FlowwowClient:
"""Base client for interacting with Flowwow API."""
BASE_URL = "https://flowwow.com/api/v1"
def __init__(self, api_token: Optional[str] = None):
# Fetch from env if not provided
self.api_token = api_token or os.getenv('FLOWWOW_API_TOKEN')
if not self.api_token:
raise ValueError("FLOWWOW_API_TOKEN must be provided")
self.session = requests.Session()
# Flowwow usually uses X-Api-Key or Authorization Bearer.
# We will use Bearer as a generic default but it can be changed to X-Api-Key if their spec strictly requires it.
self.session.headers.update({
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json"
})
def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]:
"""Perform HTTP request to Flowwow API with automatic retries."""
url = f"{self.BASE_URL}{path}"
for attempt in range(retries):
try:
response = self.session.request(method, url, json=data, params=params)
# Check rate limiting
if response.status_code == 429:
if attempt < retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
raise FlowwowRateLimitError("Rate limit exceeded")
if response.status_code in (401, 403):
raise FlowwowAuthError(f"Authentication failed: {response.text}")
if response.status_code in (400, 422):
raise FlowwowBadRequestError(f"Bad Request: {response.text}")
if response.status_code == 404:
raise FlowwowNotFoundError(f"Not Found: {response.text}")
response.raise_for_status()
if response.content:
return response.json()
return {}
except requests.exceptions.RequestException as e:
if attempt < retries - 1:
time.sleep(2 ** attempt)
continue
raise FlowwowAPIError(f"Request failed: {str(e)}")
raise FlowwowAPIError("Request failed after max retries")
def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("POST", path, data=data)
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("GET", path, params=params)
def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("PUT", path, data=data)

View File

@@ -0,0 +1,19 @@
class FlowwowAPIError(Exception):
"""Base exception for all Flowwow API errors."""
pass
class FlowwowAuthError(FlowwowAPIError):
"""Raised when authentication fails (HTTP 401 or 403)."""
pass
class FlowwowRateLimitError(FlowwowAPIError):
"""Raised when API rate limits are exceeded (HTTP 429)."""
pass
class FlowwowBadRequestError(FlowwowAPIError):
"""Raised when API returns HTTP 400 or 422."""
pass
class FlowwowNotFoundError(FlowwowAPIError):
"""Raised when API returns HTTP 404."""
pass

View File

@@ -0,0 +1,29 @@
from typing import Dict, Any
from ..client import FlowwowClient
class OrdersService:
"""Service to handle Flowwow Orders."""
def __init__(self, client: FlowwowClient):
self.client = client
def get_new_orders(self, limit: int = 50) -> Dict[str, Any]:
"""
Get list of new, unprocessed orders.
Endpoint: /orders/new
"""
return self.client.get("/orders/new", params={"limit": limit})
def accept_order(self, order_id: str) -> Dict[str, Any]:
"""
Accept an order for processing.
Endpoint: /orders/{order_id}/accept
"""
return self.client.post(f"/orders/{order_id}/accept")
def upload_photo(self, order_id: str, image_url: str) -> Dict[str, Any]:
"""
Upload a photo of the completed order before shipping.
Endpoint: /orders/{order_id}/photo
"""
return self.client.post(f"/orders/{order_id}/photo", data={"image_url": image_url})

View File

@@ -0,0 +1,30 @@
from typing import Dict, Any, List
from ..client import FlowwowClient
class ProductsService:
"""Service to handle Flowwow Products."""
def __init__(self, client: FlowwowClient):
self.client = client
def get_list(self, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
"""
Get list of products.
Endpoint: /products
"""
params = {"limit": limit, "offset": offset}
return self.client.get("/products", params=params)
def update_price(self, product_id: str, price: float) -> Dict[str, Any]:
"""
Update product price.
Endpoint: /products/{product_id}/price
"""
return self.client.put(f"/products/{product_id}/price", data={"price": price})
def update_stock(self, product_id: str, quantity: int) -> Dict[str, Any]:
"""
Update product stock/availability.
Endpoint: /products/{product_id}/stock
"""
return self.client.put(f"/products/{product_id}/stock", data={"quantity": quantity})

View File

@@ -0,0 +1,15 @@
from .client import LamodaClient
from .services.products import ProductsService
from .services.orders import OrdersService
class LamodaAPI:
"""
Main entry point for Lamoda Seller Partner SDK.
Instantiates all services with a shared client.
"""
def __init__(self, api_key: str = None):
self.client = LamodaClient(api_key)
# Initialize services
self.products = ProductsService(self.client)
self.orders = OrdersService(self.client)

View File

@@ -0,0 +1,72 @@
import os
import requests
import time
from typing import Dict, Any, Optional
from .exceptions import LamodaAPIError, LamodaAuthError, LamodaRateLimitError, LamodaBadRequestError, LamodaNotFoundError
class LamodaClient:
"""Base client for interacting with Lamoda Seller Partner API."""
BASE_URL = "https://api.seller.lamoda.ru"
def __init__(self, api_key: Optional[str] = None):
# Fetch from env if not provided
self.api_key = api_key or os.getenv('LAMODA_API_KEY')
if not self.api_key:
raise ValueError("LAMODA_API_KEY must be provided")
self.session = requests.Session()
# Lamoda often uses X-API-Key or Authorization Bearer depending on the endpoint.
# We assume X-API-Key as the standard for B2B API integrations.
self.session.headers.update({
"X-API-Key": self.api_key,
"Content-Type": "application/json"
})
def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]:
"""Perform HTTP request to Lamoda API with automatic retries."""
url = f"{self.BASE_URL}{path}"
for attempt in range(retries):
try:
response = self.session.request(method, url, json=data, params=params)
# Check rate limiting
if response.status_code == 429:
if attempt < retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
raise LamodaRateLimitError("Rate limit exceeded")
if response.status_code in (401, 403):
raise LamodaAuthError(f"Authentication failed: {response.text}")
if response.status_code == 400:
raise LamodaBadRequestError(f"Bad Request: {response.text}")
if response.status_code == 404:
raise LamodaNotFoundError(f"Not Found: {response.text}")
response.raise_for_status()
if response.content:
return response.json()
return {}
except requests.exceptions.RequestException as e:
if attempt < retries - 1:
time.sleep(2 ** attempt)
continue
raise LamodaAPIError(f"Request failed: {str(e)}")
raise LamodaAPIError("Request failed after max retries")
def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("POST", path, data=data)
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("GET", path, params=params)
def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("PUT", path, data=data)

View File

@@ -0,0 +1,19 @@
class LamodaAPIError(Exception):
"""Base exception for all Lamoda API errors."""
pass
class LamodaAuthError(LamodaAPIError):
"""Raised when authentication fails (HTTP 401 or 403)."""
pass
class LamodaRateLimitError(LamodaAPIError):
"""Raised when API rate limits are exceeded (HTTP 429)."""
pass
class LamodaBadRequestError(LamodaAPIError):
"""Raised when API returns HTTP 400."""
pass
class LamodaNotFoundError(LamodaAPIError):
"""Raised when API returns HTTP 404."""
pass

View File

@@ -0,0 +1,22 @@
from typing import Dict, Any, List
from ..client import LamodaClient
class OrdersService:
"""Service to handle Lamoda Orders (FBS)."""
def __init__(self, client: LamodaClient):
self.client = client
def get_new_orders(self, limit: int = 100) -> Dict[str, Any]:
"""
Get list of new orders (assembly tasks).
Endpoint: /v1/orders/new
"""
return self.client.get("/v1/orders/new", params={"limit": limit})
def confirm_packaging(self, order_ids: List[str]) -> Dict[str, Any]:
"""
Confirm that orders are packaged and ready for dispatch.
Endpoint: /v1/orders/pack
"""
return self.client.post("/v1/orders/pack", data={"order_ids": order_ids})

View File

@@ -0,0 +1,31 @@
from typing import Dict, Any, List
from ..client import LamodaClient
class ProductsService:
"""Service to handle Lamoda Products/Assortment."""
def __init__(self, client: LamodaClient):
self.client = client
def get_list(self, limit: int = 100, offset: int = 0, **filters) -> Dict[str, Any]:
"""
Get list of products in the seller's catalog.
Endpoint: /v1/products
"""
params = {"limit": limit, "offset": offset}
params.update(filters)
return self.client.get("/v1/products", params=params)
def update_stocks(self, items: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Update stock levels (FBS).
Endpoint: /v1/stocks
"""
return self.client.post("/v1/stocks", data={"stocks": items})
def update_prices(self, items: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Update prices for products.
Endpoint: /v1/prices
"""
return self.client.post("/v1/prices", data={"prices": items})

View File

@@ -0,0 +1,15 @@
from .client import MegaClient
from .services.orders import OrdersService
from .services.assortment import AssortmentService
class MegaMarketAPI:
"""
Main entry point for MegaMarket SDK.
Instantiates all services with a shared client.
"""
def __init__(self, api_token: str = None):
self.client = MegaClient(api_token)
# Initialize services
self.orders = OrdersService(self.client)
self.assortment = AssortmentService(self.client)

View File

@@ -0,0 +1,79 @@
import os
import requests
import time
from typing import Dict, Any, Optional
from .exceptions import MegaAPIError, MegaAuthError, MegaRateLimitError, MegaBadRequestError, MegaNotFoundError
class MegaClient:
"""Base client for interacting with MegaMarket API."""
BASE_URL = "https://partner.goodsteam.tech/api/market/v1"
def __init__(self, api_token: Optional[str] = None):
# Fetch from env if not provided
self.api_token = api_token or os.getenv('MEGA_API_TOKEN')
if not self.api_token:
raise ValueError("MEGA_API_TOKEN must be provided")
self.session = requests.Session()
# Мегамаркет может требовать токен как Bearer, либо внутри тела JSON в "meta".
# Будем использовать Bearer, как наиболее распространенный стандарт для V1.
self.session.headers.update({
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json"
})
def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]:
"""Perform HTTP request to MegaMarket API with automatic retries."""
url = f"{self.BASE_URL}{path}"
for attempt in range(retries):
try:
response = self.session.request(method, url, json=data, params=params)
# Check rate limiting
if response.status_code == 429:
if attempt < retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
raise MegaRateLimitError("Rate limit exceeded")
if response.status_code in (401, 403):
raise MegaAuthError(f"Authentication failed: {response.text}")
if response.status_code == 400:
raise MegaBadRequestError(f"Bad Request: {response.text}")
if response.status_code == 404:
raise MegaNotFoundError(f"Not Found: {response.text}")
response.raise_for_status()
if response.content:
return response.json()
return {}
except requests.exceptions.RequestException as e:
if attempt < retries - 1:
time.sleep(2 ** attempt)
continue
raise MegaAPIError(f"Request failed: {str(e)}")
raise MegaAPIError("Request failed after max retries")
def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
# Мегамаркет во многих методах требует структуру {"meta": {}, "data": {}}
# Если data не обернута, обернем ее
if data and "data" not in data and "meta" not in data:
payload = {
"meta": {},
"data": data
}
else:
payload = data
return self.request("POST", path, data=payload)
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("GET", path, params=params)

View File

@@ -0,0 +1,19 @@
class MegaAPIError(Exception):
"""Base exception for all MegaMarket API errors."""
pass
class MegaAuthError(MegaAPIError):
"""Raised when authentication fails (HTTP 401 or 403)."""
pass
class MegaRateLimitError(MegaAPIError):
"""Raised when API rate limits are exceeded (HTTP 429)."""
pass
class MegaBadRequestError(MegaAPIError):
"""Raised when API returns HTTP 400 (Bad Request)."""
pass
class MegaNotFoundError(MegaAPIError):
"""Raised when API returns HTTP 404 (Not Found)."""
pass

View File

@@ -0,0 +1,32 @@
from typing import Dict, Any, List
from ..client import MegaClient
class AssortmentService:
"""Service to handle MegaMarket Assortment/Prices/Stocks."""
def __init__(self, client: MegaClient):
self.client = client
def update_stocks(self, stocks: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Обновить остатки.
Endpoint: /stockService/stock/update
"""
payload = {
"data": {
"stocks": stocks
}
}
return self.client.post("/stockService/stock/update", data=payload)
def update_prices(self, prices: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Обновить цены.
Endpoint: /priceService/price/update
"""
payload = {
"data": {
"prices": prices
}
}
return self.client.post("/priceService/price/update", data=payload)

View File

@@ -0,0 +1,55 @@
from typing import Dict, Any, List
from ..client import MegaClient
class OrdersService:
"""Service to handle MegaMarket Orders (FBS)."""
def __init__(self, client: MegaClient):
self.client = client
def get_new_orders(self, limit: int = 100) -> Dict[str, Any]:
"""
Мегамаркет обычно отправляет заказы webhook-ами, но также имеет методы пулинга или запроса списков.
Endpoint: /orderService/order/search (условно)
"""
payload = {
"data": {
"statuses": ["NEW"],
"limit": limit
}
}
return self.client.post("/orderService/order/search", data=payload)
def confirm_order(self, order_id: str, items: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Подтвердить заказ (отправить информацию о том, что он принят в работу).
Endpoint: /orderService/order/confirm
"""
payload = {
"data": {
"shipments": [
{
"shipmentId": order_id,
"items": items
}
]
}
}
return self.client.post("/orderService/order/confirm", data=payload)
def reject_order(self, order_id: str, reason: str) -> Dict[str, Any]:
"""
Отменить заказ.
Endpoint: /orderService/order/reject
"""
payload = {
"data": {
"shipments": [
{
"shipmentId": order_id,
"reason": {"type": reason}
}
]
}
}
return self.client.post("/orderService/order/reject", data=payload)

32
backend/middleware.py Normal file
View File

@@ -0,0 +1,32 @@
import os
import jwt
from functools import wraps
from flask import request, jsonify
# Should ideally be set in .env
SECRET_KEY = os.getenv('JWT_SECRET', 'super-secret-jwt-key-automarket')
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
token = None
auth_header = request.headers.get('Authorization')
if auth_header and auth_header.startswith('Bearer '):
token = auth_header.split(" ")[1]
# Fallback to query param for file downloads
if not token:
token = request.args.get('token')
if not token:
return jsonify({'message': 'Token is missing!'}), 401
try:
data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
user_info = data['user_id'] # передаём просто user_id (int)
except Exception as e:
return jsonify({'message': 'Token is invalid or expired!'}), 401
return f(user_info, *args, **kwargs)
return decorated

View File

@@ -0,0 +1,15 @@
from .client import MMClient
from .services.orders import OrdersService
from .services.shipments import ShipmentsService
class MagnitMarketAPI:
"""
Main entry point for Magnit Market SDK.
Instantiates all services with a shared client.
"""
def __init__(self, api_key: str = None):
self.client = MMClient(api_key)
# Initialize services
self.orders = OrdersService(self.client)
self.shipments = ShipmentsService(self.client)

67
backend/mm_api/client.py Normal file
View File

@@ -0,0 +1,67 @@
import os
import requests
import time
from typing import Dict, Any, Optional
from .exceptions import MMAPIError, MMAuthError, MMRateLimitError, MMBadRequestError, MMNotFoundError
class MMClient:
"""Base client for interacting with Magnit Market API."""
BASE_URL = "https://b2b-api.magnit.ru"
def __init__(self, api_key: Optional[str] = None):
# Fetch from env if not provided
self.api_key = api_key or os.getenv('MM_API_KEY')
if not self.api_key:
raise ValueError("MM_API_KEY must be provided")
self.session = requests.Session()
self.session.headers.update({
"X-Api-Key": self.api_key,
"Content-Type": "application/json"
})
def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]:
"""Perform HTTP request to MM API with automatic retries."""
url = f"{self.BASE_URL}{path}"
for attempt in range(retries):
try:
response = self.session.request(method, url, json=data, params=params)
# Check rate limiting
if response.status_code == 429:
if attempt < retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
raise MMRateLimitError("Rate limit exceeded")
if response.status_code in (401, 403):
raise MMAuthError(f"Authentication failed: {response.text}")
if response.status_code == 400:
raise MMBadRequestError(f"Bad Request: {response.text}")
if response.status_code == 404:
raise MMNotFoundError(f"Not Found: {response.text}")
response.raise_for_status()
if response.content:
return response.json()
return {}
except requests.exceptions.RequestException as e:
if attempt < retries - 1:
time.sleep(2 ** attempt)
continue
raise MMAPIError(f"Request failed: {str(e)}")
raise MMAPIError("Request failed after max retries")
def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("POST", path, data=data)
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("GET", path, params=params)

View File

@@ -0,0 +1,19 @@
class MMAPIError(Exception):
"""Base exception for all Magnit Market API errors."""
pass
class MMAuthError(MMAPIError):
"""Raised when authentication fails (HTTP 401 or 403)."""
pass
class MMRateLimitError(MMAPIError):
"""Raised when API rate limits are exceeded (HTTP 429)."""
pass
class MMBadRequestError(MMAPIError):
"""Raised when API returns HTTP 400 (Bad Request)."""
pass
class MMNotFoundError(MMAPIError):
"""Raised when API returns HTTP 404 (Not Found)."""
pass

View File

@@ -0,0 +1,52 @@
from typing import Dict, Any, List
from ..client import MMClient
class OrdersService:
"""Service to handle Magnit Market Orders (FBS)."""
def __init__(self, client: MMClient):
self.client = client
def get_unprocessed_list(self, dir: str = "ASC", page_size: int = 100, page_token: str = None, **filters) -> Dict[str, Any]:
"""
Get list of unprocessed assembly tasks.
Endpoint: /api/seller/v1/orders/list/unprocessed
"""
payload = {
"dir": dir,
"page_size": page_size
}
if page_token:
payload["page_token"] = page_token
# Add any extra filters like created_at, cutoff_time, etc.
payload.update(filters)
return self.client.post("/api/seller/v1/orders/list/unprocessed", data=payload)
def get_list(self, dir: str = "ASC", page_size: int = 100, page_token: str = None, **filters) -> Dict[str, Any]:
"""
Get list of orders by parameters.
Endpoint: /api/seller/v1/orders/list
"""
payload = {
"dir": dir,
"page_size": page_size
}
if page_token:
payload["page_token"] = page_token
payload.update(filters)
return self.client.post("/api/seller/v1/orders/list", data=payload)
def cancel_items(self, order_id: str, items: List[Dict[str, int]]) -> Dict[str, Any]:
"""
Cancel specific items in an order.
Endpoint: /api/seller/v1/orders/cancel-items
"""
payload = {
"order_id": order_id,
"items": items
}
return self.client.post("/api/seller/v1/orders/cancel-items", data=payload)

View File

@@ -0,0 +1,30 @@
from typing import Dict, Any
from ..client import MMClient
class ShipmentsService:
"""Service to handle Magnit Market Shipments."""
def __init__(self, client: MMClient):
self.client = client
def get_list(self, dir: str = "ASC", page_size: int = 100, page_token: str = None, **filters) -> Dict[str, Any]:
"""
Get list of shipments.
Endpoint: /api/seller/v1/shipments/list
"""
payload = {
"dir": dir,
"page_size": page_size
}
if page_token:
payload["page_token"] = page_token
payload.update(filters)
return self.client.post("/api/seller/v1/shipments/list", data=payload)
def confirm(self, shipment_id: str) -> Dict[str, Any]:
"""
Confirm a shipment.
Endpoint: /api/seller/v1/shipments/confirm
"""
return self.client.post("/api/seller/v1/shipments/confirm", data={"shipment_id": shipment_id})

View File

@@ -0,0 +1,15 @@
from .client import OzonClient
from .services.products import ProductsService
from .services.fbs import FBSService
class OzonAPI:
"""
Main entry point for Ozon SDK.
Instantiates all services with a shared client.
"""
def __init__(self, client_id: str = None, api_key: str = None):
self.client = OzonClient(client_id, api_key)
# Initialize services
self.products = ProductsService(self.client)
self.fbs = FBSService(self.client)

View File

@@ -0,0 +1,68 @@
import os
import requests
import time
from typing import Dict, Any, Optional
from .exceptions import OzonAPIError, OzonAuthError, OzonRateLimitError, OzonBadRequestError
class OzonClient:
"""Base client for interacting with Ozon API."""
BASE_URL = "https://api-seller.ozon.ru"
def __init__(self, client_id: Optional[str] = None, api_key: Optional[str] = None):
# Fetch from env if not provided
self.client_id = client_id or os.getenv('OZON_CLIENT_ID')
self.api_key = api_key or os.getenv('OZON_API_KEY')
if not self.client_id or not self.api_key:
raise ValueError("OZON_CLIENT_ID and OZON_API_KEY must be provided")
self.session = requests.Session()
self.session.headers.update({
"Client-Id": self.client_id,
"Api-Key": self.api_key,
"Content-Type": "application/json"
})
def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]:
"""Perform HTTP request to Ozon API with automatic retries."""
url = f"{self.BASE_URL}{path}"
for attempt in range(retries):
try:
response = self.session.request(method, url, json=data, params=params)
# Check rate limiting
if response.status_code == 429:
if attempt < retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
raise OzonRateLimitError("Rate limit exceeded")
if response.status_code in (401, 403):
raise OzonAuthError(f"Authentication failed: {response.text}")
if response.status_code == 400:
raise OzonBadRequestError(f"Bad Request: {response.text}")
response.raise_for_status()
# Ozon usually wraps responses in "result" key, but not always.
# We return the whole JSON parsed response.
if response.content:
return response.json()
return {}
except requests.exceptions.RequestException as e:
if attempt < retries - 1:
time.sleep(2 ** attempt)
continue
raise OzonAPIError(f"Request failed: {str(e)}")
raise OzonAPIError("Request failed after max retries")
def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("POST", path, data=data)
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("GET", path, params=params)

View File

@@ -0,0 +1,15 @@
class OzonAPIError(Exception):
"""Base exception for all Ozon API errors."""
pass
class OzonAuthError(OzonAPIError):
"""Raised when authentication fails (e.g. invalid Client-Id or Api-Key)."""
pass
class OzonRateLimitError(OzonAPIError):
"""Raised when API rate limits are exceeded (HTTP 429)."""
pass
class OzonBadRequestError(OzonAPIError):
"""Raised when API returns HTTP 400 (Bad Request)."""
pass

View File

@@ -0,0 +1,33 @@
from typing import Dict, Any, List
from ..client import OzonClient
class FBSService:
"""Service to handle FBS (Fulfillment by Seller) operations."""
def __init__(self, client: OzonClient):
self.client = client
def get_unfulfilled_list(self, dir: str = "ASC", filter_params: Dict[str, Any] = None, limit: int = 100, offset: int = 0) -> Dict[str, Any]:
"""
Get a list of new/unfulfilled FBS orders.
Endpoint: /v3/posting/fbs/unfulfilled/list
"""
payload = {
"dir": dir,
"filter": filter_params or {},
"limit": limit,
"offset": offset,
"with": {
"analytics_data": True,
"barcodes": True,
"financial_data": True
}
}
return self.client.post("/v3/posting/fbs/unfulfilled/list", data=payload)
def ship_packages(self, packages: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Ship unfulfilled orders.
Endpoint: /v3/posting/fbs/ship
"""
return self.client.post("/v3/posting/fbs/ship", data={"packages": packages})

View File

@@ -0,0 +1,42 @@
from typing import Dict, Any, List
from ..client import OzonClient
class ProductsService:
"""Service to handle product-related operations."""
def __init__(self, client: OzonClient):
self.client = client
def get_list(self, filter_params: Dict[str, Any] = None, last_id: str = "", limit: int = 100) -> Dict[str, Any]:
"""
Get a list of products.
Endpoint: /v2/product/list
"""
payload = {
"filter": filter_params or {},
"last_id": last_id,
"limit": limit
}
return self.client.post("/v2/product/list", data=payload)
def get_info(self, offer_id: str = None, product_id: int = None, sku: int = None) -> Dict[str, Any]:
"""
Get product info by offer_id, product_id or sku.
Endpoint: /v2/product/info
"""
payload = {}
if offer_id:
payload["offer_id"] = offer_id
if product_id:
payload["product_id"] = product_id
if sku:
payload["sku"] = sku
return self.client.post("/v2/product/info", data=payload)
def import_products(self, items: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Create or update products.
Endpoint: /v2/product/import
"""
return self.client.post("/v2/product/import", data={"items": items})

View File

@@ -3,3 +3,14 @@ flask-cors==4.0.1
python-dotenv==1.0.1
psycopg2-binary==2.9.9
Werkzeug==3.0.3
requests==2.31.0
PyJWT==2.8.0
bcrypt==4.1.3
reportlab==4.1.0
pandas==2.2.1
flasgger==0.9.7.1
flask-socketio==5.3.6
google-generativeai==0.5.2
ImageHash==4.3.1
Pillow==10.2.0
pyTelegramBotAPI==4.14.0

112
backend/routes/ai.py Normal file
View File

@@ -0,0 +1,112 @@
from flask import Blueprint, request, jsonify
from ..middleware import token_required
from ..utils.ai_utils import generate_complaint_text
ai_blueprint = Blueprint('ai', __name__)
@ai_blueprint.route('/ai/generate-complaint', methods=['POST'])
@token_required
def ai_generate_complaint(current_user_id):
"""
Генерация текста претензии с помощью ИИ
---
tags:
- AI
security:
- APIKeyHeader: []
parameters:
- name: body
in: body
required: true
schema:
type: object
properties:
platform:
type: string
violator_article:
type: string
my_article:
type: string
responses:
200:
description: Сгенерированный текст
"""
data = request.json
platform = data.get('platform', 'ozon')
v_art = data.get('violator_article', '')
my_art = data.get('my_article', '')
if not v_art:
return jsonify({"error": "Артикул нарушителя обязателен"}), 400
text = generate_complaint_text(platform, v_art, my_art)
return jsonify({"text": text})
@ai_blueprint.route('/ai/generate-reply', methods=['POST'])
@token_required
def ai_generate_reply(current_user_id):
"""
Генерация ответа на отзыв покупателя
---
tags:
- AI
security:
- APIKeyHeader: []
parameters:
- name: body
in: body
required: true
schema:
type: object
properties:
review_text:
type: string
rating:
type: integer
responses:
200:
description: Сгенерированный ответ
"""
data = request.json
review_text = data.get('review_text', '')
rating = data.get('rating', 5)
if not review_text:
return jsonify({"error": "Текст отзыва обязателен"}), 400
from ..utils.ai_utils import generate_review_reply
text = generate_review_reply(review_text, rating)
return jsonify({"text": text})
@ai_blueprint.route('/ai/generate-card', methods=['POST'])
@token_required
def ai_generate_card(current_user_id):
"""
Генерация полной карточки товара
"""
data = request.json
title = data.get('title')
if not title:
return jsonify({"error": "Название товара обязательно"}), 400
from ..utils.ai_utils import generate_product_card
card_data = generate_product_card(title)
return jsonify(card_data)
@ai_blueprint.route('/ai/analyze-competitor', methods=['POST'])
@token_required
def ai_analyze_competitor(current_user_id):
"""
Глубокий анализ конкурента
"""
data = request.json
my_desc = data.get('my_desc', '')
comp_desc = data.get('comp_desc', '')
if not comp_desc:
return jsonify({"error": "Описание конкурента обязательно"}), 400
from ..utils.ai_utils import analyze_competitor
result = analyze_competitor(my_desc, comp_desc)
return jsonify(result)

157
backend/routes/auth.py Normal file
View File

@@ -0,0 +1,157 @@
# backend/routes/auth.py
from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta
import bcrypt
import jwt
from ..db import get_db_connection, release_db_connection
from ..middleware import SECRET_KEY, token_required
auth_blueprint = Blueprint('auth', __name__)
@auth_blueprint.route('/auth/register', methods=['POST'])
def register():
"""
Регистрация нового пользователя
---
tags:
- Auth
parameters:
- name: body
in: body
required: true
schema:
type: object
properties:
username:
type: string
password:
type: string
responses:
201:
description: Пользователь успешно зарегистрирован
"""
try:
data = request.json
if not data:
return jsonify({"error": "Invalid JSON body"}), 400
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
conn = get_db_connection()
cur = conn.cursor()
# Check if exists
cur.execute("SELECT id FROM users WHERE username = %s", (username,))
if cur.fetchone():
release_db_connection(conn)
return jsonify({"error": "Username already exists"}), 400
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
cur.execute(
"INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id",
(username, hashed_password)
)
user_id = cur.fetchone()[0]
conn.commit()
release_db_connection(conn)
return jsonify({"message": "User registered successfully", "user_id": user_id}), 201
except Exception as e:
return jsonify({"error": f"Server error: {str(e)}"}), 500
@auth_blueprint.route('/auth/login', methods=['POST'])
def login():
"""
Вход в систему
---
tags:
- Auth
parameters:
- name: body
in: body
required: true
schema:
type: object
properties:
username:
type: string
password:
type: string
responses:
200:
description: Токен успешно сгенерирован
schema:
properties:
token:
type: string
"""
data = request.json
username = data.get('username')
password = data.get('password')
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, password_hash FROM users WHERE username = %s", (username,))
user = cur.fetchone()
release_db_connection(conn)
if not user or not bcrypt.checkpw(password.encode('utf-8'), user[1].encode('utf-8')):
return jsonify({"error": "Invalid credentials"}), 401
# Get more user info for team support
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, role, parent_id FROM users WHERE username = %s", (username,))
user_info = cur.fetchone()
release_db_connection(conn)
user_id = user_info[0]
role = user_info[1]
parent_id = user_info[2]
company_id = parent_id if parent_id else user_id
token = jwt.encode({
'user_id': user_id,
'company_id': company_id,
'role': role,
'exp': datetime.utcnow() + timedelta(days=7)
}, SECRET_KEY, algorithm="HS256")
# --- AUDIT LOG ---
from ..utils.audit_utils import log_action
log_action(user[0], 'LOGIN', {'ip': request.remote_addr})
# -----------------
return jsonify({"token": token})
@auth_blueprint.route('/auth/me', methods=['GET'])
@token_required
def get_me(current_user_id):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, username, plan FROM users WHERE id = %s", (current_user_id,))
user = cur.fetchone()
release_db_connection(conn)
if not user:
return jsonify({"error": "User not found"}), 404
return jsonify({"id": user[0], "username": user[1], "plan": user[2]})
@auth_blueprint.route('/auth/upgrade', methods=['POST'])
@token_required
def upgrade_plan(current_user_id):
"""Simulates payment success and upgrades to PRO."""
conn = get_db_connection()
cur = conn.cursor()
cur.execute("UPDATE users SET plan = 'pro' WHERE id = %s", (current_user_id,))
conn.commit()
release_db_connection(conn)
return jsonify({"message": "Successfully upgraded to PRO"}), 200

View File

@@ -5,6 +5,7 @@ from werkzeug.utils import secure_filename
from datetime import datetime
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
cases_blueprint = Blueprint('cases', __name__)
@@ -12,52 +13,130 @@ cases_blueprint = Blueprint('cases', __name__)
def serialize_case(row):
return {
"id": row[0],
"violator_url": row[1],
"violator_article": row[2],
"my_article": row[3],
"comment": row[4],
"status": row[5],
"created_at": row[6].isoformat() if row[6] else None,
"updated_at": row[7].isoformat() if row[7] else None,
"platform": row[1],
"violator_url": row[2],
"violator_article": row[3],
"my_article": row[4],
"comment": row[5],
"status": row[6],
"created_at": row[7].isoformat() if row[7] else None,
"updated_at": row[8].isoformat() if row[8] else None,
}
# ---------- CASE CRUD ----------
@cases_blueprint.route('/cases', methods=['GET'])
def list_cases():
# optional filters: ?article=...&status=...
@token_required
def list_cases(user_info):
company_id = user_info['company_id']
# ... rest of logic using company_id instead of current_user_id ...
"""
Получить список всех кейсов пользователя
---
tags:
- Cases
security:
- APIKeyHeader: []
parameters:
- name: article
in: query
type: string
description: Фильтр по артикулу
- name: status
in: query
type: string
description: Фильтр по статусу
- name: platform
in: query
type: string
description: Фильтр по площадке
responses:
200:
description: Список кейсов
"""
# optional filters: ?article=...&status=...&platform=...
article = request.args.get('article')
status = request.args.get('status')
platform = request.args.get('platform')
conn = get_db_connection()
cur = conn.cursor()
query = "SELECT * FROM cases"
params = []
conditions = []
query = "SELECT id, platform, violator_url, violator_article, my_article, comment, status, created_at, updated_at FROM cases"
params = [current_user_id]
conditions = ["user_id = %s"]
if article:
conditions.append("violator_article = %s")
params.append(article)
if status:
conditions.append("status = %s")
params.append(status)
if platform:
conditions.append("platform = %s")
params.append(platform)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY id DESC"
cur.execute(query, params)
rows = cur.fetchall()
release_db_connection(conn)
return jsonify([serialize_case(r) for r in rows])
@cases_blueprint.route('/cases', methods=['POST'])
def create_case():
@token_required
def create_case(current_user_id):
"""
Создать новый кейс
---
tags:
- Cases
security:
- APIKeyHeader: []
parameters:
- name: body
in: body
required: true
schema:
type: object
required: [platform, violator_url, violator_article]
properties:
platform:
type: string
violator_url:
type: string
violator_article:
type: string
my_article:
type: string
comment:
type: string
responses:
201:
description: Кейс создан
"""
data = request.json
required = ["violator_url", "violator_article"]
required = ["platform", "violator_url", "violator_article"]
for f in required:
if f not in data:
return jsonify({"error": f"Missing field {f}"}), 400
# --- PLAN LIMIT CHECK ---
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT plan FROM users WHERE id = %s", (current_user_id,))
user_plan = cur.fetchone()[0]
if user_plan == 'free':
cur.execute("SELECT COUNT(*) FROM cases WHERE user_id = %s", (current_user_id,))
count = cur.fetchone()[0]
if count >= 5:
release_db_connection(conn)
return jsonify({"error": "Лимит кейсов для Free тарифа (5 шт) исчерпан. Перейдите на PRO."}), 403
# ------------------------
cur.execute(
"""INSERT INTO cases (violator_url, violator_article, my_article, comment, status)
VALUES (%s, %s, %s, %s, %s) RETURNING id""",
"""INSERT INTO cases (user_id, platform, violator_url, violator_article, my_article, comment, status)
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id""",
(
current_user_id,
data.get('platform'),
data.get('violator_url'),
data.get('violator_article'),
data.get('my_article'),
@@ -68,13 +147,48 @@ def create_case():
case_id = cur.fetchone()[0]
conn.commit()
release_db_connection(conn)
# --- TELEGRAM NOTIFICATION ---
from ..utils.telegram_utils import send_telegram_notification
msg = (
f"🚀 <b>Новый кейс #{case_id}</b>\n"
f"Площадка: {data.get('platform').upper()}\n"
f"Артикул: {data.get('violator_article')}\n"
f"Статус: NEW"
)
send_telegram_notification(msg)
# -----------------------------
# --- AUDIT LOG ---
from ..utils.audit_utils import log_action
log_action(current_user_id, 'CREATE_CASE', {
'case_id': case_id,
'platform': data.get('platform'),
'article': data.get('violator_article')
})
# -----------------
# --- WEBSOCKET NOTIFICATION ---
try:
from ..app import socketio
socketio.emit('case_created', {
'id': case_id,
'platform': data.get('platform'),
'article': data.get('violator_article'),
'message': f"Новый кейс на {data.get('platform').upper()} создан!"
})
except Exception as e:
print(f"WS Emit Error: {e}")
# ------------------------------
return jsonify({"id": case_id}), 201
@cases_blueprint.route('/cases/<int:case_id>', methods=['GET'])
def get_case(case_id):
@token_required
def get_case(current_user_id, case_id):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT * FROM cases WHERE id = %s", (case_id,))
cur.execute("SELECT id, platform, violator_url, violator_article, my_article, comment, status, created_at, updated_at FROM cases WHERE id = %s AND user_id = %s", (case_id, current_user_id))
row = cur.fetchone()
release_db_connection(conn)
if not row:
@@ -82,38 +196,47 @@ def get_case(case_id):
return jsonify(serialize_case(row))
@cases_blueprint.route('/cases/<int:case_id>', methods=['PUT'])
def update_case(case_id):
@token_required
def update_case(current_user_id, case_id):
data = request.json
fields = []
params = []
allowed = ["violator_url", "violator_article", "my_article", "comment", "status"]
allowed = ["platform", "violator_url", "violator_article", "my_article", "comment", "status"]
for f in allowed:
if f in data:
fields.append(f"{f} = %s")
params.append(data[f])
if not fields:
return jsonify({"error": "No updatable fields provided"}), 400
params.append(case_id)
params.extend([case_id, current_user_id])
conn = get_db_connection()
cur = conn.cursor()
cur.execute(f"UPDATE cases SET {', '.join(fields)}, updated_at = %s WHERE id = %s",
(*params[:-1], datetime.utcnow(), case_id))
cur.execute(f"UPDATE cases SET {', '.join(fields)}, updated_at = %s WHERE id = %s AND user_id = %s",
(*params[:-2], datetime.utcnow(), case_id, current_user_id))
conn.commit()
release_db_connection(conn)
return jsonify({"message": "Case updated"})
@cases_blueprint.route('/cases/<int:case_id>', methods=['DELETE'])
def delete_case(case_id):
@token_required
def delete_case(current_user_id, case_id):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("DELETE FROM cases WHERE id = %s", (case_id,))
cur.execute("DELETE FROM cases WHERE id = %s AND user_id = %s", (case_id, current_user_id))
conn.commit()
release_db_connection(conn)
# --- AUDIT LOG ---
from ..utils.audit_utils import log_action
log_action(current_user_id, 'DELETE_CASE', {'case_id': case_id})
# -----------------
return jsonify({"message": "Case deleted"})
# ---------- FILE UPLOAD ----------
@cases_blueprint.route('/cases/<int:case_id>/files', methods=['POST'])
def upload_files(case_id):
@token_required
def upload_files(current_user_id, case_id):
if 'files' not in request.files:
return jsonify({"error": "No files part in request"}), 400
files = request.files.getlist('files')
@@ -121,6 +244,13 @@ def upload_files(case_id):
upload_folder = current_app.config['UPLOAD_FOLDER']
conn = get_db_connection()
cur = conn.cursor()
# Check ownership
cur.execute("SELECT id FROM cases WHERE id = %s AND user_id = %s", (case_id, current_user_id))
if not cur.fetchone():
release_db_connection(conn)
return jsonify({"error": "Case not found"}), 404
for f in files:
if f.filename == '':
continue
@@ -142,10 +272,11 @@ def upload_files(case_id):
# Get files for a case
@cases_blueprint.route('/cases/<int:case_id>/files', methods=['GET'])
def list_files(case_id):
@token_required
def list_files(current_user_id, case_id):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, file_path, uploaded_at FROM files WHERE case_id = %s", (case_id,))
cur.execute("SELECT f.id, f.file_path, f.uploaded_at FROM files f JOIN cases c ON f.case_id = c.id WHERE f.case_id = %s AND c.user_id = %s", (case_id, current_user_id))
rows = cur.fetchall()
release_db_connection(conn)
files = [{"id": r[0], "path": r[1], "uploaded_at": r[2].isoformat()} for r in rows]

76
backend/routes/content.py Normal file
View File

@@ -0,0 +1,76 @@
import os
from flask import Blueprint, request, jsonify, current_app
from werkzeug.utils import secure_filename
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
from ..utils.image_utils import calculate_image_hash
content_blueprint = Blueprint('content', __name__)
@content_blueprint.route('/content/reference', methods=['POST'])
@token_required
def upload_reference(current_user_id):
if 'file' not in request.files:
return jsonify({"error": "No file"}), 400
file = request.files['file']
article = request.form.get('article', 'unknown')
filename = secure_filename(file.filename)
save_path = os.path.join(current_app.config['UPLOAD_FOLDER'], f"ref_{current_user_id}_{filename}")
file.save(save_path)
# Calculate Hash
img_hash = calculate_image_hash(save_path)
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO reference_images (user_id, file_path, image_hash, article) VALUES (%s, %s, %s, %s) RETURNING id",
(current_user_id, save_path, img_hash, article)
)
ref_id = cur.fetchone()[0]
conn.commit()
release_db_connection(conn)
return jsonify({"id": ref_id, "hash": img_hash}), 201
@content_blueprint.route('/content/reference', methods=['GET'])
@token_required
def list_references(current_user_id):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, file_path, image_hash, article, created_at FROM reference_images WHERE user_id = %s", (current_user_id,))
rows = cur.fetchall()
release_db_connection(conn)
return jsonify([{
"id": r[0],
"filename": os.path.basename(r[1]),
"hash": r[2],
"article": r[3],
"date": r[4].isoformat()
} for r in rows])
@content_blueprint.route('/content/detect-clones/<int:ref_id>', methods=['GET'])
@token_required
def detect_clones(current_user_id, ref_id):
# В реальном приложении здесь запускается поиск по маркетплейсам
# Для демо имитируем нахождение нескольких нарушителей
clones = [
{
"platform": "ozon",
"url": "https://ozon.ru/product/123456",
"article": "OZ-9988",
"similarity": "98%",
"image_url": "https://ir.ozone.ru/s3/multimedia-1/654321.jpg"
},
{
"platform": "wildberries",
"url": "https://wildberries.ru/catalog/778899/detail.aspx",
"article": "WB-CLONE-1",
"similarity": "92%",
"image_url": "https://basket-01.wb.ru/vol77/part778/778899/images/big/1.jpg"
}
]
return jsonify(clones)

View File

@@ -0,0 +1,76 @@
# backend/routes/credentials.py
from flask import Blueprint, request, jsonify
from datetime import datetime
import json
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
credentials_blueprint = Blueprint('credentials', __name__)
@credentials_blueprint.route('/credentials', methods=['GET'])
@token_required
def list_credentials(current_user_id):
"""Return configured platforms (without exposing secrets)."""
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT platform FROM credentials WHERE user_id = %s", (current_user_id,))
rows = cur.fetchall()
release_db_connection(conn)
platforms = [r[0] for r in rows]
return jsonify({"configured_platforms": platforms})
@credentials_blueprint.route('/credentials/<string:platform>', methods=['POST'])
@token_required
def save_credentials(current_user_id, platform):
"""Save or update credentials for a specific platform."""
data = request.json
api_key = data.get('api_key')
client_id = data.get('client_id')
client_secret = data.get('client_secret')
other_data = data.get('other_data', {})
conn = get_db_connection()
cur = conn.cursor()
# --- PLAN LIMIT CHECK ---
cur.execute("SELECT plan FROM users WHERE id = %s", (current_user_id,))
user_plan = cur.fetchone()[0]
if user_plan == 'free':
# Count existing configured platforms for this user
cur.execute("SELECT COUNT(*) FROM credentials WHERE user_id = %s AND platform != %s", (current_user_id, platform))
count = cur.fetchone()[0]
if count >= 1:
release_db_connection(conn)
return jsonify({"error": "Лимит бесплатного тарифа исчерпан (максимум 1 интеграция). Перейдите на PRO."}), 403
# ------------------------
cur.execute(
"""INSERT INTO credentials (user_id, platform, api_key, client_id, client_secret, other_data, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (user_id, platform) DO UPDATE SET
api_key = EXCLUDED.api_key,
client_id = EXCLUDED.client_id,
client_secret = EXCLUDED.client_secret,
other_data = EXCLUDED.other_data,
updated_at = EXCLUDED.updated_at""",
(current_user_id, platform, api_key, client_id, client_secret, json.dumps(other_data), datetime.utcnow())
)
conn.commit()
release_db_connection(conn)
return jsonify({"message": f"Credentials for {platform} saved successfully."}), 200
@credentials_blueprint.route('/credentials/<string:platform>', methods=['DELETE'])
@token_required
def delete_credentials(current_user_id, platform):
"""Delete credentials for a specific platform."""
conn = get_db_connection()
cur = conn.cursor()
cur.execute("DELETE FROM credentials WHERE user_id = %s AND platform = %s", (current_user_id, platform))
conn.commit()
release_db_connection(conn)
return jsonify({"message": f"Credentials for {platform} deleted."}), 200

113
backend/routes/dashboard.py Normal file
View File

@@ -0,0 +1,113 @@
from flask import Blueprint, jsonify
from datetime import datetime, timedelta
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
dashboard_blueprint = Blueprint('dashboard', __name__)
@dashboard_blueprint.route('/dashboard/stats', methods=['GET'])
@token_required
def get_stats(user_info):
company_id = user_info['company_id']
conn = get_db_connection()
cur = conn.cursor()
# 1. Total cases
cur.execute("SELECT COUNT(*) FROM cases WHERE user_id = %s", (company_id,))
total_cases = cur.fetchone()[0]
# 2. Cases by status
cur.execute("SELECT status, COUNT(*) FROM cases WHERE user_id = %s GROUP BY status", (company_id,))
status_rows = cur.fetchall()
cases_by_status = {row[0]: row[1] for row in status_rows}
# 3. Total active integrations
cur.execute("SELECT COUNT(*) FROM credentials WHERE user_id = %s", (company_id,))
total_integrations = cur.fetchone()[0]
# 4. List of connected platforms
cur.execute("SELECT platform FROM credentials WHERE user_id = %s", (company_id,))
platforms = [row[0] for row in cur.fetchall()]
# 5. Recent activity (last 5 cases)
cur.execute("SELECT id, platform, violator_article, status, created_at FROM cases WHERE user_id = %s ORDER BY created_at DESC LIMIT 5", (company_id,))
recent_rows = cur.fetchall()
recent_activity = [
{
"id": r[0],
"platform": r[1],
"article": r[2],
"status": r[3],
"date": r[4].isoformat() if r[4] else None
} for r in recent_rows
]
# 6. Trend data (last 7 days)
trend = []
for i in range(6, -1, -1):
day = (datetime.utcnow() - timedelta(days=i)).date()
cur.execute("SELECT COUNT(*) FROM cases WHERE user_id = %s AND created_at::date = %s", (company_id, day))
count = cur.fetchone()[0]
trend.append({
"date": day.strftime('%d.%m'),
"count": count
})
# 5. Multi-account stores summary
cur.execute("SELECT id, platform, name FROM credentials WHERE user_id = %s", (company_id,))
stores_rows = cur.fetchall()
stores = []
total_sales = 0
for s in stores_rows:
import random
daily_sales = random.randint(5000, 50000)
total_sales += daily_sales
# Моделируем остатки (Inventory)
current_stock = random.randint(10, 500)
sales_velocity = random.randint(5, 40) # шт в день
days_left = current_stock // sales_velocity if sales_velocity > 0 else 999
stores.append({
"id": s[0],
"platform": s[1],
"name": s[2],
"status": "active",
"daily_sales": daily_sales,
"stock": current_stock,
"velocity": sales_velocity,
"days_left": days_left
})
release_db_connection(conn)
return jsonify({
"total_cases": total_cases,
"cases_by_status": cases_by_status,
"total_integrations": total_integrations,
"total_sales": total_sales,
"platforms": platforms,
"recent_activity": recent_activity,
"trend": trend,
"stores": stores
})
@dashboard_blueprint.route('/dashboard/logs', methods=['GET'])
@token_required
def get_logs(user_info):
company_id = user_info['company_id']
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT action, details, created_at FROM audit_log WHERE user_id = %s ORDER BY created_at DESC LIMIT 20", (company_id,))
rows = cur.fetchall()
release_db_connection(conn)
logs = [
{
"action": r[0],
"details": r[1],
"date": r[2].isoformat() if r[2] else None
} for r in rows
]
return jsonify(logs)

47
backend/routes/finance.py Normal file
View File

@@ -0,0 +1,47 @@
from flask import Blueprint, request, jsonify
from ..middleware import token_required
finance_blueprint = Blueprint('finance', __name__)
@finance_blueprint.route('/finance/calculate-pnl', methods=['POST'])
@token_required
def calculate_pnl(current_user_id):
"""
Расчет чистой прибыли и маржинальности.
"""
data = request.json
# Входные данные
sale_price = float(data.get('sale_price', 0)) # Цена продажи
purchase_price = float(data.get('purchase_price', 0)) # Себестоимость (закупка)
logistics = float(data.get('logistics', 0)) # Логистика
commission_pct = float(data.get('commission_pct', 0)) # Комиссия маркетплейса %
tax_pct = float(data.get('tax_pct', 6)) # Налог % (УСН)
adv_per_unit = float(data.get('adv_per_unit', 0)) # Реклама на 1 шт
other_costs = float(data.get('other_costs', 0)) # Прочие расходы (упаковка и т.д.)
# Расчеты
marketplace_commission = sale_price * (commission_pct / 100)
taxes = sale_price * (tax_pct / 100)
total_costs = (
purchase_price +
logistics +
marketplace_commission +
taxes +
adv_per_unit +
other_costs
)
net_profit = sale_price - total_costs
margin = (net_profit / sale_price * 100) if sale_price > 0 else 0
roi = (net_profit / purchase_price * 100) if purchase_price > 0 else 0
return jsonify({
"net_profit": round(net_profit, 2),
"margin": round(margin, 2),
"roi": round(roi, 2),
"total_costs": round(total_costs, 2),
"tax_amount": round(taxes, 2),
"commission_amount": round(marketplace_commission, 2)
})

175
backend/routes/reports.py Normal file
View File

@@ -0,0 +1,175 @@
import io
import os
import pandas as pd
from flask import Blueprint, send_file, jsonify, make_response
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
from reportlab.lib import colors
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
reports_blueprint = Blueprint('reports', __name__)
# Попытка зарегистрировать кириллический шрифт
FONT_PATH = os.path.join(os.path.dirname(__file__), '..', 'fonts', 'DejaVuSans.ttf')
FONT_NAME = 'DejaVuSans'
try:
if os.path.exists(FONT_PATH):
pdfmetrics.registerFont(TTFont(FONT_NAME, FONT_PATH))
else:
# Пытаемся найти системный шрифт на Windows или Linux
sys_fonts = [
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'C:\\Windows\\Fonts\\arial.ttf'
]
for f in sys_fonts:
if os.path.exists(f):
pdfmetrics.registerFont(TTFont(FONT_NAME, f))
break
except Exception as e:
print(f"Warning: Could not register Cyrillic font: {e}")
FONT_NAME = 'Helvetica' # Fallback
@reports_blueprint.route('/reports/cases/csv', methods=['GET'])
@token_required
def export_cases_csv(current_user_id):
"""
Экспорт всех кейсов в CSV
---
tags:
- Reports
security:
- APIKeyHeader: []
responses:
200:
description: CSV файл
"""
conn = get_db_connection()
query = """
SELECT id, platform, violator_url, violator_article, my_article, comment, status, created_at
FROM cases
WHERE user_id = %s
"""
df = pd.read_sql(query, conn, params=(current_user_id,))
release_db_connection(conn)
output = io.BytesIO()
df.to_csv(output, index=False, encoding='utf-8-sig')
output.seek(0)
return send_file(
output,
mimetype='text/csv',
as_attachment=True,
download_name=f'cases_export_{current_user_id}.csv'
)
@reports_blueprint.route('/reports/case/<int:case_id>/pdf', methods=['GET'])
@token_required
def export_case_pdf(current_user_id, case_id):
"""
Генерация PDF претензии для конкретного кейса
---
tags:
- Reports
security:
- APIKeyHeader: []
parameters:
- name: case_id
in: path
type: integer
required: true
responses:
200:
description: PDF файл претензии
"""
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
SELECT platform, violator_url, violator_article, my_article, comment, status, created_at
FROM cases
WHERE id = %s AND user_id = %s
""", (case_id, current_user_id))
row = cur.fetchone()
release_db_connection(conn)
if not row:
return jsonify({"error": "Case not found"}), 404
platform, v_url, v_art, my_art, comment, status, created_at = row
buffer = io.BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=letter)
styles = getSampleStyleSheet()
# Custom style for Russian text
ru_style = ParagraphStyle(
'Russian',
parent=styles['Normal'],
fontName=FONT_NAME,
fontSize=10,
leading=12
)
title_style = ParagraphStyle(
'RussianTitle',
parent=styles['Title'],
fontName=FONT_NAME,
fontSize=18,
spaceAfter=20
)
elements = []
# Header / Title
elements.append(Paragraph(f"Официальная претензия #{case_id}", title_style))
elements.append(Paragraph(f"Система мониторинга AutoMarket (OzonGuard)", ru_style))
elements.append(Spacer(1, 20))
# Info table
data = [
[Paragraph("Параметр", ru_style), Paragraph("Значение", ru_style)],
[Paragraph("Маркетплейс", ru_style), platform.upper()],
[Paragraph("Дата создания", ru_style), created_at.strftime('%Y-%m-%d %H:%M') if created_at else "-"],
[Paragraph("Артикул нарушителя", ru_style), v_art],
[Paragraph("Ссылка на товар", ru_style), Paragraph(v_url, ru_style)],
[Paragraph("Ваш артикул", ru_style), my_art or "-"],
[Paragraph("Текущий статус", ru_style), status.upper()]
]
t = Table(data, colWidths=[150, 350])
t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c3e50')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (-1, -1), FONT_NAME),
('FONTSIZE', (0, 0), (-1, -1), 10),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.whitesmoke),
('GRID', (0, 0), (-1, -1), 1, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))
elements.append(t)
elements.append(Spacer(1, 24))
# Description
elements.append(Paragraph("Описание нарушения:", ParagraphStyle('Sub', parent=ru_style, fontSize=12, spaceAfter=10)))
elements.append(Paragraph(comment or "Комментарий отсутствует", ru_style))
elements.append(Spacer(1, 40))
elements.append(Paragraph("Сгенерировано автоматически. Данный документ является официальным уведомлением о нарушении правил платформы.", ParagraphStyle('Footer', parent=ru_style, fontSize=8, textColor=colors.grey)))
doc.build(elements)
buffer.seek(0)
return send_file(
buffer,
mimetype='application/pdf',
as_attachment=True,
download_name=f'complaint_{case_id}.pdf'
)

View File

@@ -0,0 +1,81 @@
from flask import Blueprint, request, jsonify
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
import random
from datetime import datetime
repricer_blueprint = Blueprint('repricer', __name__)
@repricer_blueprint.route('/repricer/rules', methods=['GET'])
@token_required
def list_rules(user_info):
company_id = user_info['company_id']
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, product_name, sku, min_price, max_price, strategy, is_active, current_price, last_run FROM repricer_rules WHERE user_id = %s", (company_id,))
rows = cur.fetchall()
release_db_connection(conn)
return jsonify([{
"id": r[0],
"product_name": r[1],
"sku": r[2],
"min_price": float(r[3]),
"max_price": float(r[4]),
"strategy": r[5],
"is_active": r[6],
"current_price": float(r[7]) if r[7] else None,
"last_run": r[8].isoformat() if r[8] else None
} for r in rows])
@repricer_blueprint.route('/repricer/rules', methods=['POST'])
@token_required
def save_rule(user_info):
company_id = user_info['company_id']
data = request.json
conn = get_db_connection()
cur = conn.cursor()
if data.get('id'):
cur.execute(
"UPDATE repricer_rules SET product_name=%s, sku=%s, min_price=%s, max_price=%s, strategy=%s, is_active=%s WHERE id=%s AND user_id=%s",
(data['product_name'], data['sku'], data['min_price'], data['max_price'], data['strategy'], data['is_active'], data['id'], company_id)
)
else:
cur.execute(
"INSERT INTO repricer_rules (user_id, product_name, sku, min_price, max_price, strategy) VALUES (%s, %s, %s, %s, %s)",
(company_id, data['product_name'], data['sku'], data['min_price'], data['max_price'], data['strategy'])
)
conn.commit()
release_db_connection(conn)
return jsonify({"message": "Rule saved"})
@repricer_blueprint.route('/repricer/run', methods=['POST'])
@token_required
def run_repricer(user_info):
"""
Симуляция работы репрайзера: находит конкурентов и обновляет цены
"""
company_id = user_info['company_id']
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, min_price, max_price, current_price FROM repricer_rules WHERE user_id = %s AND is_active = TRUE", (company_id,))
rules = cur.fetchall()
updates = []
for r_id, min_p, max_p, curr_p in rules:
# Симулируем цену конкурента
comp_price = float(min_p) + random.uniform(-50, 150)
# Наша стратегия: на 1 рубль дешевле конкурента, но не ниже min_price
new_price = max(float(min_p), comp_price - 1)
new_price = min(new_price, float(max_p))
cur.execute("UPDATE repricer_rules SET current_price = %s, last_run = %s WHERE id = %s", (new_price, datetime.utcnow(), r_id))
updates.append({"id": r_id, "old": float(curr_p) if curr_p else 0, "new": round(new_price, 2), "competitor": round(comp_price, 2)})
conn.commit()
release_db_connection(conn)
return jsonify({"updates": updates})

78
backend/routes/seo.py Normal file
View File

@@ -0,0 +1,78 @@
from flask import Blueprint, request, jsonify
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
import random
from datetime import datetime, timedelta
seo_blueprint = Blueprint('seo', __name__)
@seo_blueprint.route('/seo/keywords', methods=['GET'])
@token_required
def list_keywords(current_user_id):
conn = get_db_connection()
cur = conn.cursor()
cur.execute("""
SELECT k.id, k.keyword, k.article, k.platform,
(SELECT position FROM seo_history h WHERE h.keyword_id = k.id ORDER BY checked_at DESC LIMIT 1) as last_pos
FROM seo_keywords k
WHERE k.user_id = %s
""", (current_user_id,))
rows = cur.fetchall()
release_db_connection(conn)
return jsonify([{
"id": r[0],
"keyword": r[1],
"article": r[2],
"platform": r[3],
"last_position": r[4]
} for r in rows])
@seo_blueprint.route('/seo/keywords', methods=['POST'])
@token_required
def add_keyword(current_user_id):
data = request.json
keyword = data.get('keyword')
article = data.get('article')
platform = data.get('platform', 'ozon')
if not keyword or not article:
return jsonify({"error": "Keyword and article are required"}), 400
conn = get_db_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO seo_keywords (user_id, keyword, article, platform) VALUES (%s, %s, %s, %s) RETURNING id",
(current_user_id, keyword, article, platform)
)
kid = cur.fetchone()[0]
# Generate some mock history immediately
for i in range(7):
date = datetime.now() - timedelta(days=i)
pos = random.randint(1, 150)
cur.execute("INSERT INTO seo_history (keyword_id, position, checked_at) VALUES (%s, %s, %s)", (kid, pos, date))
conn.commit()
release_db_connection(conn)
return jsonify({"id": kid}), 201
@seo_blueprint.route('/seo/history/<int:keyword_id>', methods=['GET'])
@token_required
def get_seo_history(current_user_id, keyword_id):
conn = get_db_connection()
cur = conn.cursor()
# Check ownership
cur.execute("SELECT id FROM seo_keywords WHERE id = %s AND user_id = %s", (keyword_id, current_user_id))
if not cur.fetchone():
release_db_connection(conn)
return jsonify({"error": "Not found"}), 404
cur.execute("SELECT position, checked_at FROM seo_history WHERE keyword_id = %s ORDER BY checked_at ASC", (keyword_id,))
rows = cur.fetchall()
release_db_connection(conn)
return jsonify([{
"position": r[0],
"date": r[1].strftime('%d.%m')
} for r in rows])

56
backend/routes/team.py Normal file
View File

@@ -0,0 +1,56 @@
from flask import Blueprint, request, jsonify
from ..db import get_db_connection, release_db_connection
from ..middleware import token_required
import bcrypt
team_blueprint = Blueprint('team', __name__)
@team_blueprint.route('/team/members', methods=['GET'])
@token_required
def list_team_members(user_info):
if user_info['role'] != 'admin':
return jsonify({"error": "Admin access required"}), 403
conn = get_db_connection()
cur = conn.cursor()
cur.execute("SELECT id, username, role, created_at FROM users WHERE parent_id = %s", (user_info['user_id'],))
rows = cur.fetchall()
release_db_connection(conn)
return jsonify([{
"id": r[0],
"username": r[1],
"role": r[2],
"created_at": r[3].isoformat()
} for r in rows])
@team_blueprint.route('/team/members', methods=['POST'])
@token_required
def add_team_member(user_info):
if user_info['role'] != 'admin':
return jsonify({"error": "Admin access required"}), 403
data = request.json
username = data.get('username')
password = data.get('password')
role = data.get('role', 'manager') # manager, viewer
if not username or not password:
return jsonify({"error": "Username and password required"}), 400
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
conn = get_db_connection()
cur = conn.cursor()
try:
cur.execute(
"INSERT INTO users (username, password_hash, role, parent_id) VALUES (%s, %s, %s, %s) RETURNING id",
(username, hashed_password, role, user_info['user_id'])
)
new_id = cur.fetchone()[0]
conn.commit()
return jsonify({"id": new_id, "message": "Team member added"}), 201
except Exception as e:
return jsonify({"error": "User already exists"}), 400
finally:
release_db_connection(conn)

53
backend/test_ae_api.py Normal file
View File

@@ -0,0 +1,53 @@
import os
import sys
from dotenv import load_dotenv
# Add parent dir to path so we can import backend packages
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.ae_api import AliExpressAPI
from backend.ae_api.exceptions import AEAPIError
def test_ae_api():
# Load environment variables
env_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(env_path)
# Check if key is loaded
api_token = os.getenv('AE_API_TOKEN')
user_id = os.getenv('AE_USER_ID')
if not api_token:
print("Error: AE_API_TOKEN must be in backend/.env")
return
print(f"Initializing AliExpressAPI...")
if not user_id:
print("Warning: AE_USER_ID not provided. Transactions API might fail.")
api = AliExpressAPI()
try:
# Test 1: Get Products Feeds (doesn't require user_id in URL)
print("\n--- Testing Products Feeds API ---")
feeds_response = api.products.get_products_feeds(limit=5)
print("Success! Response keys:", feeds_response.keys())
data = feeds_response.get("data", [])
print(f"Found {len(data)} feeds.")
if user_id:
# Test 2: Get Transactions
print("\n--- Testing Transactions API ---")
transactions_response = api.transactions.get_list(params={"limit": 5})
t_data = transactions_response.get("data", [])
print(f"Found {len(t_data)} transactions.")
except AEAPIError as e:
print(f"API Error: {e}")
except Exception as e:
print(f"Unexpected Error: {e}")
if __name__ == '__main__':
test_ae_api()

44
backend/test_avito_api.py Normal file
View File

@@ -0,0 +1,44 @@
import os
import sys
from dotenv import load_dotenv
# Add parent dir to path so we can import backend packages
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.avito_api import AvitoAPI
from backend.avito_api.exceptions import AvitoAPIError, AvitoAuthError
def test_avito_api():
# Load environment variables
env_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(env_path)
# Check if keys are loaded
client_id = os.getenv('AVITO_CLIENT_ID')
client_secret = os.getenv('AVITO_CLIENT_SECRET')
if not client_id or not client_secret:
print("Error: AVITO_CLIENT_ID and AVITO_CLIENT_SECRET must be in backend/.env")
return
print(f"Initializing AvitoAPI (Authenticating...)")
try:
api = AvitoAPI()
print("Success! Successfully authenticated and received access token.")
print(f"Token snippet: {api.client.access_token[:10]}...")
# For full testing we would need user_id (Avito user id)
# print("\n--- Testing Messenger API ---")
# chats_response = api.messenger.get_chats(user_id="12345", limit=5)
# print("Success! Response keys:", chats_response.keys())
except AvitoAuthError as e:
print(f"Authentication Failed: {e}")
except AvitoAPIError as e:
print(f"API Error: {e}")
except Exception as e:
print(f"Unexpected Error: {e}")
if __name__ == '__main__':
test_avito_api()

View File

@@ -0,0 +1,44 @@
import os
import sys
from dotenv import load_dotenv
# Add parent dir to path so we can import backend packages
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.flowwow_api import FlowwowAPI
from backend.flowwow_api.exceptions import FlowwowAPIError
def test_flowwow_api():
# Load environment variables
env_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(env_path)
# Check if key is loaded
api_token = os.getenv('FLOWWOW_API_TOKEN')
if not api_token:
print("Error: FLOWWOW_API_TOKEN must be in backend/.env")
return
print("Initializing FlowwowAPI...")
api = FlowwowAPI()
try:
# Test 1: Get Products
print("\n--- Testing Products API ---")
products_response = api.products.get_list(limit=5)
print("Success! Response keys:", products_response.keys())
items = products_response.get("items", [])
if not items:
items = products_response.get("data", [])
print(f"Found {len(items)} products in Flowwow catalog.")
except FlowwowAPIError as e:
print(f"API Error: {e}")
except Exception as e:
print(f"Unexpected Error: {e}")
if __name__ == '__main__':
test_flowwow_api()

View File

@@ -0,0 +1,45 @@
import os
import sys
from dotenv import load_dotenv
# Add parent dir to path so we can import backend packages
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.lamoda_api import LamodaAPI
from backend.lamoda_api.exceptions import LamodaAPIError
def test_lamoda_api():
# Load environment variables
env_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(env_path)
# Check if key is loaded
api_key = os.getenv('LAMODA_API_KEY')
if not api_key:
print("Error: LAMODA_API_KEY must be in backend/.env")
return
print("Initializing LamodaAPI...")
api = LamodaAPI()
try:
# Test 1: Get Products
print("\n--- Testing Products API ---")
products_response = api.products.get_list(limit=5)
print("Success! Response keys:", products_response.keys())
# Depending on Lamoda's response wrapper, it might be in 'data' or 'items'
items = products_response.get("data", [])
if not items:
items = products_response.get("items", [])
print(f"Found {len(items)} products in catalog.")
except LamodaAPIError as e:
print(f"API Error: {e}")
except Exception as e:
print(f"Unexpected Error: {e}")
if __name__ == '__main__':
test_lamoda_api()

44
backend/test_mega_api.py Normal file
View File

@@ -0,0 +1,44 @@
import os
import sys
from dotenv import load_dotenv
# Add parent dir to path so we can import backend packages
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.mega_api import MegaMarketAPI
from backend.mega_api.exceptions import MegaAPIError
def test_mega_api():
# Load environment variables
env_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(env_path)
# Check if key is loaded
api_token = os.getenv('MEGA_API_TOKEN')
if not api_token:
print("Error: MEGA_API_TOKEN must be in backend/.env")
return
print("Initializing MegaMarketAPI...")
api = MegaMarketAPI()
try:
# Test 1: Get Orders
print("\n--- Testing Orders API ---")
orders_response = api.orders.get_new_orders(limit=5)
print("Success! Response keys:", orders_response.keys())
# Usually wrapped in data
data = orders_response.get("data", {})
orders = data.get("orders", [])
print(f"Found {len(orders)} new orders.")
except MegaAPIError as e:
print(f"API Error: {e}")
except Exception as e:
print(f"Unexpected Error: {e}")
if __name__ == '__main__':
test_mega_api()

51
backend/test_mm_api.py Normal file
View File

@@ -0,0 +1,51 @@
import os
import sys
from dotenv import load_dotenv
# Add parent dir to path so we can import backend packages
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.mm_api import MagnitMarketAPI
from backend.mm_api.exceptions import MMAPIError
def test_mm_api():
# Load environment variables
env_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(env_path)
# Check if key is loaded
api_key = os.getenv('MM_API_KEY')
if not api_key:
print("Error: MM_API_KEY must be in backend/.env")
return
print("Initializing MagnitMarketAPI...")
api = MagnitMarketAPI()
try:
# Test 1: Get Unprocessed Orders
print("\n--- Testing Orders API (Unprocessed Orders) ---")
orders_response = api.orders.get_unprocessed_list(limit=5)
print("Success! Response keys:", orders_response.keys())
orders = orders_response.get("orders", [])
print(f"Found {len(orders)} unprocessed FBS orders.")
if orders:
order_id = orders[0].get("order_id")
print(f"First order - ID: {order_id}")
# Test 2: Get Shipments
print("\n--- Testing Shipments API ---")
shipments_response = api.shipments.get_list()
shipments = shipments_response.get("shipments", [])
print(f"Found {len(shipments)} shipments.")
except MMAPIError as e:
print(f"API Error: {e}")
except Exception as e:
print(f"Unexpected Error: {e}")
if __name__ == '__main__':
test_mm_api()

54
backend/test_ozon_api.py Normal file
View File

@@ -0,0 +1,54 @@
import os
import sys
from dotenv import load_dotenv
# Add parent dir to path so we can import backend packages
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.ozon_api import OzonAPI
from backend.ozon_api.exceptions import OzonAPIError
def test_ozon_api():
# Load environment variables
env_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(env_path)
# Check if keys are loaded
client_id = os.getenv('OZON_CLIENT_ID')
api_key = os.getenv('OZON_API_KEY')
if not client_id or not api_key:
print("Error: OZON_CLIENT_ID and OZON_API_KEY must be in backend/.env")
return
print(f"Initializing OzonAPI with Client-ID: {client_id}")
api = OzonAPI()
try:
# Test 1: Get products
print("\n--- Testing Products ---")
products_response = api.products.get_list(limit=5)
print("Success! Response keys:", products_response.keys())
# Ozon wraps responses in 'result'
result = products_response.get("result", {})
items = result.get("items", [])
print(f"Found {len(items)} products.")
if items:
product_id = items[0].get("product_id")
offer_id = items[0].get("offer_id")
print(f"First product - Product ID: {product_id}, Offer ID: {offer_id}")
# Test 2: Get specific product info
print("\n--- Testing Product Info ---")
info = api.products.get_info(product_id=product_id)
print("Product Info Name:", info.get("result", {}).get("name", "Unknown"))
except OzonAPIError as e:
print(f"API Error: {e}")
except Exception as e:
print(f"Unexpected Error: {e}")
if __name__ == '__main__':
test_ozon_api()

52
backend/test_wb_api.py Normal file
View File

@@ -0,0 +1,52 @@
import os
import sys
from dotenv import load_dotenv
# Add parent dir to path so we can import backend packages
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.wb_api import WildberriesAPI
from backend.wb_api.exceptions import WBAPIError
def test_wb_api():
# Load environment variables
env_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(env_path)
# Check if key is loaded
api_token = os.getenv('WB_API_TOKEN')
if not api_token:
print("Error: WB_API_TOKEN must be in backend/.env")
return
print("Initializing WildberriesAPI...")
api = WildberriesAPI()
try:
# Test 1: Get Content (Products)
print("\n--- Testing Content API (Products) ---")
cards_response = api.content.get_cards_list(limit=5)
print("Success! Response keys:", cards_response.keys())
cards = cards_response.get("cards", [])
print(f"Found {len(cards)} products.")
if cards:
nm_id = cards[0].get("nmID")
vendor_code = cards[0].get("vendorCode")
print(f"First product - nmID: {nm_id}, Vendor Code: {vendor_code}")
# Test 2: Get Marketplace (Orders)
print("\n--- Testing Marketplace API (New Orders) ---")
orders_response = api.marketplace.get_new_orders()
orders = orders_response.get("orders", [])
print(f"Found {len(orders)} new FBS orders.")
except WBAPIError as e:
print(f"API Error: {e}")
except Exception as e:
print(f"Unexpected Error: {e}")
if __name__ == '__main__':
test_wb_api()

59
backend/test_ym_api.py Normal file
View File

@@ -0,0 +1,59 @@
import os
import sys
from dotenv import load_dotenv
# Add parent dir to path so we can import backend packages
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from backend.ym_api import YandexMarketAPI
from backend.ym_api.exceptions import YMAPIError
def test_ym_api():
# Load environment variables
env_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(env_path)
# Check if key is loaded
api_token = os.getenv('YM_API_TOKEN')
campaign_id = os.getenv('YM_CAMPAIGN_ID')
business_id = os.getenv('YM_BUSINESS_ID')
if not api_token:
print("Error: YM_API_TOKEN must be in backend/.env")
return
print(f"Initializing YandexMarketAPI...")
if not campaign_id:
print("Warning: YM_CAMPAIGN_ID not provided. Orders API might fail.")
if not business_id:
print("Warning: YM_BUSINESS_ID not provided. Assortment API might fail.")
api = YandexMarketAPI()
try:
if business_id:
# Test 1: Get Assortment (Products)
print("\n--- Testing Assortment API (Business Offers) ---")
assortment_response = api.assortment.get_offer_mappings(limit=5)
print("Success! Response keys:", assortment_response.keys())
result = assortment_response.get("result", {})
offers = result.get("offerMappings", [])
print(f"Found {len(offers)} offers in business catalog.")
if campaign_id:
# Test 2: Get Orders
print("\n--- Testing Orders API (Campaign Orders) ---")
orders_response = api.orders.get_list(params={"limit": 5})
# Yandex returns lists under 'orders'
orders = orders_response.get("orders", [])
print(f"Found {len(orders)} orders for campaign.")
except YMAPIError as e:
print(f"API Error: {e}")
except Exception as e:
print(f"Unexpected Error: {e}")
if __name__ == '__main__':
test_ym_api()

132
backend/utils/ai_utils.py Normal file
View File

@@ -0,0 +1,132 @@
import os
import google.generativeai as genai
def generate_complaint_text(platform, violator_article, my_article=None):
"""Генерирует текст претензии с помощью Google Gemini."""
api_key = os.getenv('GEMINI_API_KEY')
if not api_key:
return "Ошибка: API ключ для Gemini не настроен в .env"
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-2.0-flash')
prompt = f"""
Ты - профессиональный юрист в сфере интеллектуальной собственности и e-commerce.
Напиши официальную и убедительную претензию для маркетплейса {platform}.
Суть нарушения: продавец с артикулом {violator_article} незаконно использует мой контент или нарушает правила ценообразования.
Мой оригинальный артикул для сравнения: {my_article if my_article else 'не указан'}.
Требования:
1. Текст должен быть на русском языке.
2. Стиль официально-деловой.
3. Упомяни о необходимости защиты прав правообладателя.
4. Текст должен быть лаконичным (до 500 знаков).
Ответ дай сразу в виде текста претензии, без лишних вступлений.
"""
try:
response = model.generate_content(prompt)
return response.text.strip()
except Exception as e:
return f"Ошибка при генерации текста ИИ: {str(e)}"
def generate_review_reply(review_text, rating):
"""Генерирует ответ на отзыв покупателя."""
api_key = os.getenv('GEMINI_API_KEY')
if not api_key:
return "Ошибка: API ключ не настроен."
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-2.0-flash')
prompt = f"""
Ты - менеджер по работе с клиентами в магазине на маркетплейсе.
Твоя задача: вежливо и профессионально ответить на отзыв покупателя.
Оценка покупателя: {rating} звезд(ы).
Текст отзыва: "{review_text}"
Правила:
1. Если оценка 4-5 звезд: поблагодари за покупку, вырази радость, предложи посмотреть другие товары нашего бренда.
2. Если оценка 1-3 звезды: извинись за неудобства, уточни, что мы работаем над качеством, и предложи обратиться в поддержку, если это уместно.
3. Стиль: дружелюбный, но деловой. Не используй слишком много восклицательных знаков.
4. Длина: 2-4 предложения.
5. Не используй шаблоны "Уважаемый покупатель", начни более естественно.
Ответ дай сразу в виде готового текста.
"""
try:
response = model.generate_content(prompt)
return response.text.strip()
except Exception as e:
return f"Ошибка ИИ: {str(e)}"
def generate_product_card(title):
"""Генерирует описание и характеристики товара по его названию."""
api_key = os.getenv('GEMINI_API_KEY')
if not api_key:
return {"error": "API ключ не настроен."}
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-2.0-flash')
prompt = f"""
Ты - эксперт по листингу на маркетплейсах Ozon и Wildberries.
Тебе дано название товара: "{title}"
Твоя задача - сгенерировать контент для карточки товара в формате JSON.
JSON должен содержать:
1. "description": Продающее описание (3-4 абзаца), выделяющее преимущества.
2. "features": Список из 5-8 ключевых характеристик (ключ: значение).
3. "seo_tags": Список из 10 ключевых слов для поиска.
4. "image_prompts": 3 подробных промпта для нейросети (на английском), чтобы сгенерировать идеальные фото этого товара (главное фото, в интерьере, детали).
Ответ дай ТОЛЬКО в виде чистого JSON, без лишних слов.
"""
try:
response = model.generate_content(prompt)
import json
# Очистка от markdown-обертки если есть
clean_json = response.text.replace('```json', '').replace('```', '').strip()
return json.loads(clean_json)
except Exception as e:
return {"error": f"Ошибка при генерации карточки: {str(e)}"}
def analyze_competitor(my_desc, comp_desc):
"""Сравнивает две карточки товара и выдает рекомендации."""
api_key = os.getenv('GEMINI_API_KEY')
if not api_key:
return {"error": "API ключ не настроен."}
genai.configure(api_key=api_key)
model = genai.GenerativeModel('gemini-2.0-flash')
prompt = f"""
Ты - старший аналитик по маркетплейсам. Твоя цель: найти способы "победить" карточку конкурента.
ОПИСАНИЕ МОЕГО ТОВАРА:
"{my_desc}"
ОПИСАНИЕ ТОВАРА КОНКУРЕНТА:
"{comp_desc}"
Твоя задача - провести анализ и выдать ответ в формате JSON:
1. "competitor_weakness": Список из 3-4 слабых мест конкурента (что он не упомянул, в чем он хуже).
2. "my_advantages": В чем мой товар объективно лучше или как это преподнести.
3. "strategy": Конкретные шаги, что изменить в моем описании или фото, чтобы забрать продажи у него.
4. "win_phrase": Один мощный рекламный слоган, который "бьет" в слабое место конкурента.
Ответ дай ТОЛЬКО в виде чистого JSON.
"""
try:
response = model.generate_content(prompt)
import json
clean_json = response.text.replace('```json', '').replace('```', '').strip()
return json.loads(clean_json)
except Exception as e:
return {"error": f"Ошибка анализа: {str(e)}"}

View File

@@ -0,0 +1,17 @@
import json
from ..db import get_db_connection, release_db_connection
def log_action(user_id, action, details=None):
"""Записывает действие пользователя в лог аудита."""
conn = get_db_connection()
try:
cur = conn.cursor()
cur.execute(
"INSERT INTO audit_log (user_id, action, details) VALUES (%s, %s, %s)",
(user_id, action, json.dumps(details) if details else None)
)
conn.commit()
except Exception as e:
print(f"Audit Log Error: {e}")
finally:
release_db_connection(conn)

View File

@@ -0,0 +1,20 @@
from PIL import Image
import imagehash
import os
def calculate_image_hash(image_path):
"""Вычисляет перцептивный хеш изображения."""
try:
img = Image.open(image_path)
hash_val = imagehash.phash(img)
return str(hash_val)
except Exception as e:
print(f"Hashing Error: {e}")
return None
def compare_hashes(hash1, hash2, threshold=5):
"""Сравнивает два хеша. Если разница меньше порога, изображения считаются похожими."""
h1 = imagehash.hex_to_hash(hash1)
h2 = imagehash.hex_to_hash(hash2)
diff = h1 - h2
return diff <= threshold

View File

@@ -0,0 +1,24 @@
import requests
import os
def send_telegram_notification(message):
token = os.getenv('TELEGRAM_BOT_TOKEN')
chat_id = os.getenv('TELEGRAM_CHAT_ID')
if not token or not chat_id:
print("Telegram notification skipped: Token or Chat ID not configured.")
return False
url = f"https://api.telegram.org/bot{token}/sendMessage"
payload = {
"chat_id": chat_id,
"text": message,
"parse_mode": "HTML"
}
try:
response = requests.post(url, json=payload, timeout=5)
return response.status_code == 200
except Exception as e:
print(f"Error sending Telegram message: {e}")
return False

View File

@@ -0,0 +1,15 @@
from .client import WBClient
from .services.content import ContentService
from .services.marketplace import MarketplaceService
class WildberriesAPI:
"""
Main entry point for Wildberries SDK.
Instantiates all services with a shared client.
"""
def __init__(self, api_token: str = None):
self.client = WBClient(api_token)
# Initialize services
self.content = ContentService(self.client)
self.marketplace = MarketplaceService(self.client)

67
backend/wb_api/client.py Normal file
View File

@@ -0,0 +1,67 @@
import os
import requests
import time
from typing import Dict, Any, Optional
from .exceptions import WBAPIError, WBAuthError, WBRateLimitError, WBBadRequestError
class WBClient:
"""Base client for interacting with Wildberries API."""
# WB domains
CONTENT_API = "https://content-api.wildberries.ru"
MARKETPLACE_API = "https://marketplace-api.wildberries.ru"
PRICES_API = "https://discounts-prices-api.wildberries.ru"
STATISTICS_API = "https://statistics-api.wildberries.ru"
def __init__(self, api_token: Optional[str] = None):
# Fetch from env if not provided
self.api_token = api_token or os.getenv('WB_API_TOKEN')
if not self.api_token:
raise ValueError("WB_API_TOKEN must be provided")
self.session = requests.Session()
self.session.headers.update({
"Authorization": self.api_token,
"Content-Type": "application/json"
})
def request(self, method: str, base_url: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]:
"""Perform HTTP request to WB API with automatic retries."""
url = f"{base_url}{path}"
for attempt in range(retries):
try:
response = self.session.request(method, url, json=data, params=params)
if response.status_code == 429:
if attempt < retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
raise WBRateLimitError("Rate limit exceeded")
if response.status_code in (401, 403):
raise WBAuthError(f"Authentication failed: {response.text}")
if response.status_code == 400:
raise WBBadRequestError(f"Bad Request: {response.text}")
response.raise_for_status()
if response.content:
return response.json()
return {}
except requests.exceptions.RequestException as e:
if attempt < retries - 1:
time.sleep(2 ** attempt)
continue
raise WBAPIError(f"Request failed: {str(e)}")
raise WBAPIError("Request failed after max retries")
def post(self, base_url: str, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("POST", base_url, path, data=data)
def get(self, base_url: str, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("GET", base_url, path, params=params)

View File

@@ -0,0 +1,15 @@
class WBAPIError(Exception):
"""Base exception for all Wildberries API errors."""
pass
class WBAuthError(WBAPIError):
"""Raised when authentication fails (HTTP 401)."""
pass
class WBRateLimitError(WBAPIError):
"""Raised when API rate limits are exceeded (HTTP 429)."""
pass
class WBBadRequestError(WBAPIError):
"""Raised when API returns HTTP 400 (Bad Request)."""
pass

View File

@@ -0,0 +1,39 @@
from typing import Dict, Any, List
from ..client import WBClient
class ContentService:
"""Service to handle WB product cards (Content API)."""
def __init__(self, client: WBClient):
self.client = client
self.base_url = WBClient.CONTENT_API
def get_cards_list(self, limit: int = 100, updated_at: str = "", last_id: str = "") -> Dict[str, Any]:
"""
Get list of nomenclatures (product cards).
Endpoint: /content/v2/get/cards/list
"""
payload = {
"settings": {
"cursor": {
"limit": limit
},
"filter": {
"withPhoto": -1
}
}
}
if updated_at:
payload["settings"]["cursor"]["updatedAt"] = updated_at
if last_id:
payload["settings"]["cursor"]["nmID"] = int(last_id)
return self.client.post(self.base_url, "/content/v2/get/cards/list", data=payload)
def create_cards(self, cards: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Create product cards.
Endpoint: /content/v2/cards/upload
"""
return self.client.post(self.base_url, "/content/v2/cards/upload", data=cards)

View File

@@ -0,0 +1,39 @@
from typing import Dict, Any, List
from ..client import WBClient
class MarketplaceService:
"""Service to handle WB FBS operations (Marketplace API)."""
def __init__(self, client: WBClient):
self.client = client
self.base_url = WBClient.MARKETPLACE_API
def get_new_orders(self) -> Dict[str, Any]:
"""
Get new orders (FBS).
Endpoint: /api/v3/orders/new
"""
return self.client.get(self.base_url, "/api/v3/orders/new")
def get_orders(self, limit: int = 1000, next: int = 0, date_from: int = 0, date_to: int = 0) -> Dict[str, Any]:
"""
Get list of orders by parameters.
Endpoint: /api/v3/orders
"""
params = {
"limit": limit,
"next": next
}
if date_from:
params["dateFrom"] = date_from
if date_to:
params["dateTo"] = date_to
return self.client.get(self.base_url, "/api/v3/orders", params=params)
def put_orders_status(self, orders: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Change status of orders (e.g. to assembly).
Endpoint: /api/v3/orders/status
"""
return self.client.post(self.base_url, "/api/v3/orders/status", data={"orders": orders})

View File

@@ -0,0 +1,15 @@
from .client import YMClient
from .services.orders import OrdersService
from .services.assortment import AssortmentService
class YandexMarketAPI:
"""
Main entry point for Yandex Market SDK.
Instantiates all services with a shared client.
"""
def __init__(self, api_token: str = None, campaign_id: str = None, business_id: str = None):
self.client = YMClient(api_token, campaign_id, business_id)
# Initialize services
self.orders = OrdersService(self.client)
self.assortment = AssortmentService(self.client)

75
backend/ym_api/client.py Normal file
View File

@@ -0,0 +1,75 @@
import os
import requests
import time
from typing import Dict, Any, Optional
from .exceptions import YMAPIError, YMAuthError, YMRateLimitError, YMBadRequestError, YMNotFoundError
class YMClient:
"""Base client for interacting with Yandex Market API."""
BASE_URL = "https://api.partner.market.yandex.ru/v2"
def __init__(self, api_token: Optional[str] = None, campaign_id: Optional[str] = None, business_id: Optional[str] = None):
# Fetch from env if not provided
self.api_token = api_token or os.getenv('YM_API_TOKEN')
self.campaign_id = campaign_id or os.getenv('YM_CAMPAIGN_ID')
self.business_id = business_id or os.getenv('YM_BUSINESS_ID')
if not self.api_token:
raise ValueError("YM_API_TOKEN must be provided")
self.session = requests.Session()
# Depending on app type, token might be Bearer or OAuth oauth_token=...
# We will use Bearer as it's the modern standard for YM API
self.session.headers.update({
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json"
})
def request(self, method: str, path: str, data: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, retries: int = 3) -> Dict[str, Any]:
"""Perform HTTP request to Yandex Market API with automatic retries."""
url = f"{self.BASE_URL}{path}"
for attempt in range(retries):
try:
response = self.session.request(method, url, json=data, params=params)
# Check rate limiting
if response.status_code == 429:
if attempt < retries - 1:
time.sleep(2 ** attempt) # Exponential backoff
continue
raise YMRateLimitError("Rate limit exceeded")
if response.status_code in (401, 403):
raise YMAuthError(f"Authentication failed: {response.text}")
if response.status_code == 400:
raise YMBadRequestError(f"Bad Request: {response.text}")
if response.status_code == 404:
raise YMNotFoundError(f"Not Found: {response.text}")
response.raise_for_status()
if response.content:
return response.json()
return {}
except requests.exceptions.RequestException as e:
if attempt < retries - 1:
time.sleep(2 ** attempt)
continue
raise YMAPIError(f"Request failed: {str(e)}")
raise YMAPIError("Request failed after max retries")
def post(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("POST", path, data=data)
def get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("GET", path, params=params)
def put(self, path: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return self.request("PUT", path, data=data)

View File

@@ -0,0 +1,19 @@
class YMAPIError(Exception):
"""Base exception for all Yandex Market API errors."""
pass
class YMAuthError(YMAPIError):
"""Raised when authentication fails (HTTP 401 or 403)."""
pass
class YMRateLimitError(YMAPIError):
"""Raised when API rate limits are exceeded (HTTP 429)."""
pass
class YMBadRequestError(YMAPIError):
"""Raised when API returns HTTP 400 (Bad Request)."""
pass
class YMNotFoundError(YMAPIError):
"""Raised when API returns HTTP 404 (Not Found)."""
pass

View File

@@ -0,0 +1,34 @@
from typing import Dict, Any, List, Optional
from ..client import YMClient
class AssortmentService:
"""Service to handle Yandex Market Assortment/Products."""
def __init__(self, client: YMClient):
self.client = client
def get_offer_mappings(self, business_id: Optional[str] = None, page_token: str = None, limit: int = 100) -> Dict[str, Any]:
"""
Get list of offers (products) in the business catalog.
Endpoint: /businesses/{businessId}/offer-mappings.json
"""
bid = business_id or self.client.business_id
if not bid:
raise ValueError("business_id must be provided either in the method call or client initialization.")
payload = {"limit": limit}
if page_token:
payload["page_token"] = page_token
return self.client.post(f"/businesses/{bid}/offer-mappings.json", data=payload)
def update_offer_mappings(self, offers: List[Dict[str, Any]], business_id: Optional[str] = None) -> Dict[str, Any]:
"""
Update offers (products) in the business catalog.
Endpoint: /businesses/{businessId}/offer-mappings/update.json
"""
bid = business_id or self.client.business_id
if not bid:
raise ValueError("business_id must be provided either in the method call or client initialization.")
return self.client.post(f"/businesses/{bid}/offer-mappings/update.json", data={"offerMappings": offers})

View File

@@ -0,0 +1,45 @@
from typing import Dict, Any, Optional
from ..client import YMClient
class OrdersService:
"""Service to handle Yandex Market Orders."""
def __init__(self, client: YMClient):
self.client = client
def get_list(self, campaign_id: Optional[str] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Get list of orders for a specific campaign.
Endpoint: /campaigns/{campaignId}/orders.json
"""
cid = campaign_id or self.client.campaign_id
if not cid:
raise ValueError("campaign_id must be provided either in the method call or client initialization.")
return self.client.get(f"/campaigns/{cid}/orders.json", params=params)
def get_order(self, order_id: str, campaign_id: Optional[str] = None) -> Dict[str, Any]:
"""
Get info about a specific order.
Endpoint: /campaigns/{campaignId}/orders/{orderId}.json
"""
cid = campaign_id or self.client.campaign_id
if not cid:
raise ValueError("campaign_id must be provided either in the method call or client initialization.")
return self.client.get(f"/campaigns/{cid}/orders/{order_id}.json")
def update_status(self, order_id: str, status: str, substatus: Optional[str] = None, campaign_id: Optional[str] = None) -> Dict[str, Any]:
"""
Update the status of an order.
Endpoint: /campaigns/{campaignId}/orders/{orderId}/status.json
"""
cid = campaign_id or self.client.campaign_id
if not cid:
raise ValueError("campaign_id must be provided either in the method call or client initialization.")
payload = {"order": {"status": status}}
if substatus:
payload["order"]["substatus"] = substatus
return self.client.put(f"/campaigns/{cid}/orders/{order_id}/status.json", data=payload)

View File

@@ -1,7 +1,45 @@
-- db/init.sql
-- Создаёт таблицы для хранения кейсов и файлов
-- Создаёт таблицы для хранения пользователей, ключей маркетплейсов, кейсов и файлов
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role VARCHAR(20) DEFAULT 'admin', -- admin, manager, viewer
parent_id INT REFERENCES users(id) ON DELETE CASCADE,
plan VARCHAR(20) DEFAULT 'free',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS repricer_rules (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
product_name VARCHAR(255) NOT NULL,
sku VARCHAR(100) NOT NULL,
min_price DECIMAL(10, 2) NOT NULL,
max_price DECIMAL(10, 2) NOT NULL,
strategy VARCHAR(20) DEFAULT 'follow_competitor', -- follow_competitor, match_lowest
is_active BOOLEAN DEFAULT TRUE,
current_price DECIMAL(10, 2),
last_run TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS credentials (
user_id INT REFERENCES users(id) ON DELETE CASCADE,
platform VARCHAR(50),
api_key TEXT,
client_id TEXT,
client_secret TEXT,
other_data JSONB,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, platform)
);
CREATE TABLE IF NOT EXISTS cases (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
platform VARCHAR(50) NOT NULL DEFAULT 'ozon',
violator_url TEXT NOT NULL,
violator_article VARCHAR(50) NOT NULL,
my_article VARCHAR(50),
@@ -17,3 +55,36 @@ CREATE TABLE IF NOT EXISTS files (
file_path TEXT NOT NULL,
uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_log (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
action VARCHAR(100) NOT NULL,
details JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS seo_keywords (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
keyword VARCHAR(255) NOT NULL,
article VARCHAR(100) NOT NULL,
platform VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS seo_history (
id SERIAL PRIMARY KEY,
keyword_id INT REFERENCES seo_keywords(id) ON DELETE CASCADE,
position INT,
checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS reference_images (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
image_hash VARCHAR(64),
article VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

29
docker-compose.yml Normal file
View File

@@ -0,0 +1,29 @@
# docker-compose.yml
version: '3.8'
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
ports:
- "5000:5000"
environment:
- DATABASE_URL=postgresql://postgres:a13k40yw@10.10.10.167:5432/automarket
- JWT_SECRET=9xdkVZRNkX2f2c6j8OsIN19M0KxdTeEQkDobfTfJbJr
- GEMINI_API_KEY=AIzaSyBW0EzWwf5wLS9Ad9SBZIMzseIuPLz_JSc
- OZON_CLIENT_ID=898289
- OZON_API_KEY=4467d825-d7aa-4804-abd0-9f9d2edef88e
volumes:
- ./backend/uploads:/app/backend/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
frontend:
build:
context: .
dockerfile: frontend/Dockerfile
ports:
- "80:80"
depends_on:
- backend

3
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY frontend/ /usr/share/nginx/html/

View File

@@ -1,22 +1,31 @@
/* frontend/css/styles.css */
/* Premium darkmode + glassmorphism design */
/* Ultimate Premium Dark Mode + Glassmorphism */
:root {
--font-family: 'Inter', sans-serif;
--color-bg: hsl(220, 10%, 10%);
--color-surface: hsla(220, 10%, 15%, 0.8);
--color-primary: hsl(210, 80%, 55%);
--color-primary-hover: hsl(210, 80%, 45%);
--color-success: hsl(140, 60%, 45%);
--color-error: hsl(0, 70%, 50%);
--color-text: hsl(0, 0%, 90%);
--radius: 0.75rem;
--shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
--font-family: 'Inter', system-ui, -apple-system, sans-serif;
/* Palette */
--color-bg: #0b0e14;
--color-surface: rgba(23, 28, 38, 0.7);
--color-surface-hover: rgba(35, 42, 56, 0.85);
--color-primary: #3b82f6; /* Modern Blue */
--color-primary-glow: rgba(59, 130, 246, 0.5);
--color-success: #10b981; /* Emerald */
--color-success-glow: rgba(16, 185, 129, 0.4);
--color-warning: #f59e0b; /* Amber */
--color-error: #ef4444; /* Rose */
--color-text: #f3f4f6;
--color-text-dim: #9ca3af;
/* Utils */
--radius-lg: 1.25rem;
--radius-md: 0.75rem;
--shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.1);
--glass-border: 1px solid rgba(255, 255, 255, 0.08);
}
*,
*::before,
*::after {
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
@@ -24,141 +33,307 @@
body {
font-family: var(--font-family);
background: var(--color-bg);
background-color: var(--color-bg);
background-image:
radial-gradient(circle at 10% 20%, rgba(59, 130, 246, 0.05) 0%, transparent 40%),
radial-gradient(circle at 90% 80%, rgba(16, 185, 129, 0.05) 0%, transparent 40%);
color: var(--color-text);
line-height: 1.6;
line-height: 1.5;
min-height: 100vh;
display: flex;
flex-direction: column;
-webkit-font-smoothing: antialiased;
}
/* Custom Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--color-bg); }
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.1);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
/* Header */
.header {
text-align: center;
padding: 2rem 1rem;
background: linear-gradient(135deg, hsla(210, 70%, 55%, 0.6), hsla(210, 70%, 35%, 0.6));
backdrop-filter: blur(12px);
box-shadow: var(--shadow);
padding: 2.5rem 1rem;
background: rgba(11, 14, 20, 0.8);
backdrop-filter: blur(20px);
border-bottom: var(--glass-border);
position: sticky;
top: 0;
z-index: 100;
}
.header h1 {
font-size: 2.2rem;
margin-bottom: 0.5rem;
color: var(--color-primary);
font-size: 2.5rem;
font-weight: 800;
background: linear-gradient(to right, #60a5fa, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.025em;
}
.header p {
color: var(--color-text-dim);
font-size: 0.95rem;
}
/* Auth Section */
.auth-box {
max-width: 420px;
margin: 6rem auto;
padding: 2.5rem;
background: var(--color-surface);
backdrop-filter: blur(16px);
border-radius: var(--radius-lg);
border: var(--glass-border);
box-shadow: var(--shadow-xl);
}
.auth-box h2 {
font-size: 1.75rem;
margin-bottom: 2rem;
text-align: center;
}
.auth-box input {
width: 100%;
padding: 0.875rem 1.25rem;
margin-top: 0.75rem;
background: rgba(0, 0, 0, 0.3);
border: var(--glass-border);
border-radius: var(--radius-md);
color: #fff;
transition: all 0.2s;
}
.auth-box input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-glow);
}
/* Tabs */
.tabs-container {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
background: rgba(255, 255, 255, 0.03);
margin-top: 1rem;
border-radius: var(--radius-md);
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.tab-btn {
background: transparent;
color: var(--color-text-dim);
border: none;
padding: 0.625rem 1.25rem;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.tab-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
.tab-btn.active {
background: var(--color-primary);
color: #fff;
box-shadow: 0 4px 12px var(--color-primary-glow);
}
/* Main Content */
.container {
flex: 1;
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
padding: 2.5rem 1.5rem;
}
.section {
margin-bottom: 2rem;
animation: fadeIn 0.4s ease-out;
}
h2 {
margin-bottom: 1rem;
font-weight: 600;
color: var(--color-primary);
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
/* Cards & Grid */
.platforms-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
.controls input,
.controls select {
padding: 0.5rem 0.75rem;
border: 1px solid hsla(0, 0%, 100%, 0.1);
border-radius: var(--radius);
.platform-card {
background: var(--color-surface);
color: var(--color-text);
padding: 1.75rem;
border-radius: var(--radius-lg);
border: var(--glass-border);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.platform-card:hover {
transform: translateY(-4px);
background: var(--color-surface-hover);
border-color: rgba(255,255,255,0.15);
box-shadow: var(--shadow-xl);
}
.platform-card h3 {
font-size: 1.25rem;
margin-bottom: 0.75rem;
}
/* Buttons */
.btn-primary {
background: var(--color-primary);
border: none;
color: #fff;
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-weight: 600;
padding: 0.75rem 1.5rem;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: background 0.2s;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-primary:hover {
background: var(--color-primary-hover);
filter: brightness(1.1);
transform: translateY(-1px);
box-shadow: 0 4px 15px var(--color-primary-glow);
}
.btn-secondary {
background: hsla(0, 0%, 100%, 0.1);
border: none;
background: rgba(255, 255, 255, 0.05);
color: var(--color-text);
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-weight: 500;
padding: 0.75rem 1.5rem;
border-radius: var(--radius-md);
border: var(--glass-border);
cursor: pointer;
transition: background 0.2s;
transition: all 0.2s;
}
.btn-secondary:hover {
background: hsla(0, 0%, 100%, 0.2);
background: rgba(255, 255, 255, 0.1);
}
/* Table */
.table {
width: 100%;
border-collapse: collapse;
backdrop-filter: blur(6px);
border-collapse: separate;
border-spacing: 0;
background: var(--color-surface);
border-radius: var(--radius);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow);
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid hsla(0, 0%, 100%, 0.05);
border: var(--glass-border);
}
.table th {
background: hsla(0, 0%, 100%, 0.05);
color: var(--color-primary);
background: rgba(255, 255, 255, 0.03);
padding: 1rem 1.5rem;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
color: var(--color-text-dim);
text-align: left;
}
.hidden { display: none; }
label {
display: block;
margin-bottom: 0.75rem;
color: var(--color-text);
.table td {
padding: 1.25rem 1.5rem;
border-top: var(--glass-border);
font-size: 0.95rem;
}
label input,
label textarea {
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
border: 1px solid hsla(0,0%,100%,0.1);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
.table tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
.preview {
/* Status Badges */
.status-pill {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status-new { background: rgba(59, 130, 246, 0.15); color: #60a5fa; }
.status-closed { background: rgba(16, 185, 129, 0.15); color: #34d399; }
/* Icons */
.icon-sm {
width: 18px;
height: 18px;
vertical-align: middle;
margin-right: 0.25rem;
stroke-width: 2.5px;
}
.icon-sm.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hidden { display: none !important; }
/* Form Controls */
input, select, textarea {
font-family: inherit;
transition: border-color 0.2s;
}
/* Chart Container */
canvas {
margin-top: 1rem;
filter: drop-shadow(0 0 10px rgba(59, 130, 246, 0.1));
}
/* Toast Notifications */
.toast-container {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 1000;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
flex-direction: column;
gap: 0.75rem;
}
.preview img {
max-width: 120px;
max-height: 120px;
object-fit: cover;
border-radius: var(--radius);
box-shadow: var(--shadow);
.toast {
background: var(--color-surface);
backdrop-filter: blur(20px);
border: var(--glass-border);
padding: 1rem 1.5rem;
border-radius: var(--radius-md);
box-shadow: var(--shadow-xl);
color: #fff;
min-width: 300px;
display: flex;
align-items: center;
gap: 0.75rem;
animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-left: 4px solid var(--color-primary);
}
@keyframes slideIn {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
.toast.success { border-left-color: var(--color-success); }
.toast.error { border-left-color: var(--color-error); }
.toast.info { border-left-color: var(--color-primary); }
.toast-message { font-size: 0.9rem; font-weight: 500; }

View File

@@ -3,22 +3,476 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OzonGuard подготовка жалоб</title>
<title>AutoMarket Универсальная панель управления</title>
<link rel="stylesheet" href="css/styles.css" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
</head>
<body>
<header class="header">
<h1>OzonGuard</h1>
<p>Быстро создавайте и отправляйте жалобы в поддержку Ozon</p>
<div style="display: flex; justify-content: space-between; align-items: center; max-width: 1200px; margin: 0 auto;">
<div>
<h1><i data-lucide="shield-check" style="vertical-align: middle; margin-right: 0.5rem;"></i>AutoMarket</h1>
<p>Управление жалобами и интеграциями для 10 маркетплейсов</p>
</div>
<div id="user-info" class="hidden">
<span id="username-display" style="margin-right: 1rem;"></span>
<button id="logout-btn" class="btn-secondary">Выйти</button>
</div>
</div>
</header>
<main class="container">
<!-- AUTH SECTION -->
<main id="auth-section" class="container">
<div class="auth-box">
<h2 id="auth-title">Вход в систему</h2>
<form id="auth-form">
<input type="text" id="auth-username" placeholder="Логин" required />
<input type="password" id="auth-password" placeholder="Пароль" required />
<button type="submit" id="auth-submit-btn" class="btn-primary" style="width: 100%; margin-top: 1rem;">Войти</button>
</form>
<p style="margin-top: 1rem; text-align: center;">
<a href="#" id="auth-toggle">Нет аккаунта? Зарегистрироваться</a>
</p>
</div>
</main>
<div id="main-app" class="hidden">
<div class="tabs-container">
<button class="tab-btn active" data-target="dashboard-section"><i data-lucide="layout-dashboard" class="icon-sm"></i> Дашборд</button>
<button class="tab-btn" data-target="cases-section"><i data-lucide="list" class="icon-sm"></i> Список жалоб</button>
<button class="tab-btn" data-target="integrations-section"><i data-lucide="settings" class="icon-sm"></i> Настройки</button>
<button class="tab-btn" data-target="billing-section"><i data-lucide="credit-card" class="icon-sm"></i> Тарифы</button>
<button class="tab-btn" data-target="logs-section"><i data-lucide="activity" class="icon-sm"></i> История</button>
<button class="tab-btn" data-target="reviews-section"><i data-lucide="message-square" class="icon-sm"></i> Отзывы</button>
<button class="tab-btn" data-target="seo-section"><i data-lucide="trending-up" class="icon-sm"></i> SEO</button>
<button class="tab-btn" data-target="content-section"><i data-lucide="image" class="icon-sm"></i> Контент</button>
<button class="tab-btn" data-target="finance-section"><i data-lucide="wallet" class="icon-sm"></i> Финансы</button>
<button class="tab-btn" data-target="generator-section"><i data-lucide="wand-2" class="icon-sm"></i> Генератор</button>
<button class="tab-btn" data-target="analytics-section"><i data-lucide="search-code" class="icon-sm"></i> Deep Insight</button>
<button class="tab-btn" data-target="repricer-section"><i data-lucide="tags" class="icon-sm"></i> Репрайзер</button>
</div>
<main class="container">
<!-- Dashboard Section -->
<section id="dashboard-section" class="section">
<h2>Сводная статистика</h2>
<div id="stats-grid" class="platforms-grid">
<div class="platform-card">
<p style="color: var(--color-text-dim); font-size: 0.9rem;">Всего кейсов</p>
<h3 id="stat-cases">0</h3>
</div>
<div class="platform-card">
<p style="color: var(--color-text-dim); font-size: 0.9rem;">Интеграций</p>
<h3 id="stat-integrations">0</h3>
</div>
<div class="platform-card">
<p style="color: var(--color-success); font-size: 0.9rem;">Выручка (всего сегодня)</p>
<h3 id="stat-sales">0 ₽</h3>
</div>
</div>
<div class="section" style="margin-top: 2rem; background: rgba(255,255,255,0.05); border-radius: 12px; padding: 1.5rem;">
<h3>Динамика кейсов</h3>
<canvas id="casesChart" style="max-height: 300px;"></canvas>
</div>
<div class="platforms-grid" style="margin-top: 1.5rem;">
<div class="platform-card" style="grid-column: 1 / -1;">
<h3>Состояние магазинов (Multi-account)</h3>
<table id="stores-table" class="table" style="margin-top: 1rem;">
<thead>
<tr>
<th>Магазин</th>
<th>Площадка</th>
<th>Статус API</th>
<th>Остатки (шт)</th>
<th>Дней до OOS</th>
<th>Продажи (сегодня)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="platform-card" style="grid-column: 1 / -1;">
<h3>Динамика нарушений</h3>
<canvas id="caseTrendChart" height="100"></canvas>
</div>
</div>
<div class="section" style="margin-top: 2rem;">
<h3>Последняя активность</h3>
<table id="recent-activity-table" class="table">
<thead>
<tr>
<th>ID</th>
<th>Площадка</th>
<th>Артикул</th>
<th>Статус</th>
<th>Дата</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Billing / Subscription Section -->
<section id="billing-section" class="section hidden">
<h2>Ваш тариф: <span id="current-plan-display">Free</span></h2>
<div class="platforms-grid" style="margin-top: 2rem;">
<div class="platform-card">
<h3>Тариф FREE</h3>
<p class="status">Активен по умолчанию</p>
<ul>
<li>Подключение: Максимум 1 маркетплейс</li>
<li>Базовая поддержка</li>
</ul>
<button class="btn-secondary" disabled style="margin-top:1rem; width:100%;">Уже активирован</button>
</div>
<div class="platform-card" style="border-color: var(--color-primary);">
<h3 style="color: var(--color-primary);">Тариф PRO</h3>
<p class="status" style="color: var(--color-primary);">Рекомендуемый</p>
<ul>
<li>Подключение: До 10 маркетплейсов</li>
<li>Безлимитное создание жалоб</li>
<li>Приоритетная поддержка</li>
</ul>
<button id="upgrade-btn" class="btn-primary" style="margin-top:1rem; width:100%;">Купить за 990₽</button>
</div>
</div>
</section>
<!-- Audit Logs List -->
<section id="logs-section" class="section hidden">
<h2>История действий</h2>
<div class="table-container">
<table id="logs-table" class="table">
<thead>
<tr>
<th>Действие</th>
<th>Детали</th>
<th>Дата</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</section>
<!-- Reviews Section -->
<section id="reviews-section" class="section hidden">
<h2>ИИ-Ответчик на отзывы</h2>
<div class="platforms-grid">
<div class="platform-card" style="grid-column: 1 / -1;">
<p style="margin-bottom: 1.5rem; color: var(--color-text-dim);">
Введите текст отзыва покупателя и выберите оценку, чтобы ИИ подготовил идеальный ответ.
</p>
<div style="display: flex; flex-direction: column; gap: 1rem; max-width: 600px;">
<label>
Текст отзыва
<textarea id="review-text-input" rows="4" placeholder="Например: Товар пришел быстро, качество отличное!"></textarea>
</label>
<label>
Оценка (звезды)
<select id="review-rating-input">
<option value="5">⭐⭐⭐⭐⭐ (5)</option>
<option value="4">⭐⭐⭐⭐ (4)</option>
<option value="3">⭐⭐⭐ (3)</option>
<option value="2">⭐⭐ (2)</option>
<option value="1">⭐ (1)</option>
</select>
</label>
<button class="btn-primary" id="ai-review-btn">
<i data-lucide="sparkles" class="icon-sm"></i> Сгенерировать ответ
</button>
<div id="ai-reply-container" class="hidden" style="margin-top: 1.5rem; animation: fadeIn 0.3s;">
<label>
Предложенный ответ ИИ
<textarea id="ai-reply-output" rows="5"></textarea>
</label>
<button class="btn-secondary" onclick="copyReply()">
<i data-lucide="copy" class="icon-sm"></i> Копировать в буфер
</button>
</div>
</div>
</div>
</div>
</section>
<!-- SEO Section -->
<section id="seo-section" class="section hidden">
<h2>SEO Мониторинг позиций</h2>
<div class="platforms-grid">
<div class="platform-card" style="grid-column: 1 / 1;">
<h3>Добавить ключевое слово</h3>
<form id="add-seo-form" class="platform-form" style="margin-top: 1rem;">
<input type="text" name="keyword" placeholder="Ключевое слово (например: кофемолка)" required>
<input type="text" name="article" placeholder="Твой артикул" required>
<select name="platform">
<option value="ozon">Ozon</option>
<option value="wildberries">Wildberries</option>
</select>
<button type="submit" class="btn-primary">Начать отслеживание</button>
</form>
</div>
<div class="platform-card" style="grid-column: 2 / -1;">
<h3>Отслеживаемые ключи</h3>
<table id="seo-table" class="table" style="margin-top: 1rem;">
<thead>
<tr>
<th>Ключ</th>
<th>Артикул</th>
<th>Позиция</th>
<th>Действие</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div id="seo-chart-container" class="platform-card hidden" style="margin-top: 1.5rem;">
<h3 id="seo-chart-title">История позиции</h3>
<canvas id="seoChart" height="150"></canvas>
</div>
</section>
<!-- Content Protection Section -->
<section id="content-section" class="section hidden">
<h2>Защита визуального контента</h2>
<div class="platforms-grid">
<div class="platform-card">
<h3>Загрузить эталон (оригинал)</h3>
<form id="add-reference-form" class="platform-form" style="margin-top: 1rem;">
<input type="text" name="article" placeholder="Артикул товара" required>
<input type="file" name="file" accept="image/*" required style="border: none; padding: 0.5rem 0;">
<button type="submit" class="btn-primary">Сохранить эталон</button>
</form>
</div>
<div class="platform-card" style="grid-column: 2 / -1;">
<h3>Твои оригиналы</h3>
<div id="reference-list" class="platforms-grid" style="grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); margin-top: 1rem;">
<!-- Reference images here -->
</div>
</div>
</div>
<div id="clones-detected-container" class="section hidden" style="margin-top: 2rem; border-top: var(--glass-border); padding-top: 2rem;">
<h3><i data-lucide="alert-triangle" class="icon-sm" style="color: var(--color-error);"></i> Обнаружены клоны</h3>
<div id="clones-list" class="platforms-grid" style="margin-top: 1rem;">
<!-- Detected clones here -->
</div>
</div>
</section>
<!-- Finance Section -->
<section id="finance-section" class="section hidden">
<h2>Калькулятор чистой прибыли (Unit Economics)</h2>
<div class="platforms-grid">
<div class="platform-card" style="grid-column: 1 / 1;">
<h3>Параметры товара</h3>
<form id="pnl-form" class="platform-form" style="margin-top: 1rem; display: flex; flex-direction: column; gap: 0.8rem;">
<label>Цена продажи (₽)<input type="number" name="sale_price" value="1000" step="0.1" required></label>
<label>Закупка (₽)<input type="number" name="purchase_price" value="400" step="0.1" required></label>
<label>Логистика (₽)<input type="number" name="logistics" value="100" step="0.1" required></label>
<label>Комиссия (%)<input type="number" name="commission_pct" value="15" step="0.1" required></label>
<label>Налог (УСН %)<input type="number" name="tax_pct" value="6" step="0.1" required></label>
<label>Реклама на 1 шт (₽)<input type="number" name="adv_per_unit" value="50" step="0.1" required></label>
<button type="submit" class="btn-primary">Рассчитать прибыль</button>
</form>
</div>
<div id="pnl-results" class="platform-card hidden" style="grid-column: 2 / -1; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center;">
<h3 style="margin-bottom: 2rem;">Результаты расчета</h3>
<div style="display: flex; gap: 2rem; flex-wrap: wrap; justify-content: center;">
<div>
<div style="color: var(--color-text-dim); font-size: 0.9rem;">Чистая прибыль</div>
<div id="pnl-net-profit" style="font-size: 2.5rem; font-weight: 800; color: var(--color-success);">0 ₽</div>
</div>
<div>
<div style="color: var(--color-text-dim); font-size: 0.9rem;">Маржа</div>
<div id="pnl-margin" style="font-size: 2.5rem; font-weight: 800; color: var(--color-primary);">0 %</div>
</div>
<div>
<div style="color: var(--color-text-dim); font-size: 0.9rem;">ROI</div>
<div id="pnl-roi" style="font-size: 2.5rem; font-weight: 800; color: var(--color-warning);">0 %</div>
</div>
</div>
<div style="margin-top: 2rem; width: 100%; max-width: 400px; text-align: left; padding: 1rem; background: rgba(0,0,0,0.2); border-radius: 12px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Комиссия МП:</span> <span id="pnl-comm-abs">0 ₽</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span>Налоги:</span> <span id="pnl-tax-abs">0 ₽</span>
</div>
<div style="display: flex; justify-content: space-between; font-weight: bold; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 0.5rem;">
<span>Себестоимость полная:</span> <span id="pnl-total-costs">0 ₽</span>
</div>
</div>
</div>
</div>
</section>
<!-- Product Generator Section -->
<section id="generator-section" class="section hidden">
<h2>Умный генератор карточек товара</h2>
<div class="platforms-grid">
<div class="platform-card" style="grid-column: 1 / -1;">
<p style="margin-bottom: 1.5rem; color: var(--color-text-dim);">
Введите название товара, и ИИ подготовит описание, характеристики и промпты для генерации изображений.
</p>
<div style="display: flex; gap: 1rem; max-width: 800px;">
<input type="text" id="gen-title-input" placeholder="Например: Беспроводная кофемолка с керамическими жерновами" style="flex: 1;">
<button class="btn-primary" id="ai-gen-card-btn">
<i data-lucide="sparkles" class="icon-sm"></i> Создать карточку
</button>
</div>
</div>
</div>
<div id="gen-result-container" class="hidden" style="margin-top: 2rem; animation: fadeIn 0.4s;">
<div class="platforms-grid">
<!-- Description & SEO -->
<div class="platform-card" style="grid-column: 1 / 2;">
<h3>Продающее описание</h3>
<div id="gen-description" style="margin-top: 1rem; line-height: 1.6; color: var(--color-text-dim);"></div>
<h3 style="margin-top: 2rem;">SEO Теги</h3>
<div id="gen-tags" style="display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem;"></div>
</div>
<!-- Features & Image Prompts -->
<div class="platform-card" style="grid-column: 2 / -1;">
<h3>Технические характеристики</h3>
<table id="gen-features-table" class="table" style="margin-top: 1rem;">
<tbody></tbody>
</table>
<h3 style="margin-top: 2rem;">Промпты для ИИ-фото (DALL-E/Midjourney)</h3>
<div id="gen-image-prompts" style="margin-top: 1rem;"></div>
</div>
</div>
</div>
</section>
<!-- Deep Insight Section -->
<section id="analytics-section" class="section hidden">
<h2>AI Анализ конкурентов (Deep Insight)</h2>
<div class="platforms-grid">
<div class="platform-card">
<h3>Сравнение товаров</h3>
<p style="color: var(--color-text-dim); font-size: 0.9rem; margin-bottom: 1rem;">
Вставьте описание вашего товара и описание конкурента, чтобы ИИ нашел способ его победить.
</p>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<label>Мое описание<textarea id="my-desc-input" rows="4" placeholder="Ваше описание..."></textarea></label>
<label>Описание конкурента<textarea id="comp-desc-input" rows="4" placeholder="Описание конкурента..."></textarea></label>
<button class="btn-primary" id="ai-analyze-comp-btn">
<i data-lucide="zap" class="icon-sm"></i> Запустить Deep Insight
</button>
</div>
</div>
<div id="insight-result" class="platform-card hidden" style="grid-column: 2 / -1;">
<div style="background: rgba(var(--color-primary-rgb), 0.1); border-left: 4px solid var(--color-primary); padding: 1rem; margin-bottom: 1.5rem;">
<h4 style="color: var(--color-primary);">Мощный слоган (Win Phrase):</h4>
<p id="insight-win-phrase" style="font-style: italic; font-size: 1.1rem; margin-top: 0.5rem;"></p>
</div>
<div class="platforms-grid" style="grid-template-columns: 1fr 1fr; gap: 1.5rem;">
<div>
<h4>Слабые места конкурента</h4>
<ul id="insight-weakness" style="margin-top: 0.5rem; color: var(--color-error); font-size: 0.9rem;"></ul>
</div>
<div>
<h4>Твои преимущества</h4>
<ul id="insight-advantages" style="margin-top: 0.5rem; color: var(--color-success); font-size: 0.9rem;"></ul>
</div>
</div>
<h4 style="margin-top: 1.5rem;">Стратегия захвата рынка</h4>
<p id="insight-strategy" style="margin-top: 0.5rem; line-height: 1.6; color: var(--color-text-dim);"></p>
</div>
</div>
</section>
<!-- Repricer Section -->
<section id="repricer-section" class="section hidden">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h2>Авто-репрайзер (Динамические цены)</h2>
<button class="btn-primary" id="run-repricer-btn">
<i data-lucide="refresh-cw" class="icon-sm"></i> Обновить цены сейчас
</button>
</div>
<div class="platforms-grid">
<div class="platform-card" style="grid-column: 1 / 1;">
<h3>Новое правило</h3>
<form id="repricer-form" class="platform-form" style="margin-top: 1rem; display: flex; flex-direction: column; gap: 0.8rem;">
<label>Товар<input type="text" name="product_name" placeholder="Название товара" required></label>
<label>SKU / Артикул<input type="text" name="sku" placeholder="Артикул" required></label>
<div style="display: flex; gap: 1rem;">
<label style="flex: 1;">Мин. цена (₽)<input type="number" name="min_price" required></label>
<label style="flex: 1;">Макс. цена (₽)<input type="number" name="max_price" required></label>
</div>
<label>Стратегия
<select name="strategy">
<option value="follow_competitor">На 1₽ дешевле конкурента</option>
<option value="match_lowest">Равняться на минимальную</option>
</select>
</label>
<button type="submit" class="btn-primary">Добавить правило</button>
</form>
</div>
<div class="platform-card" style="grid-column: 2 / -1;">
<h3>Активные правила</h3>
<table id="repricer-table" class="table" style="margin-top: 1rem;">
<thead>
<tr>
<th>Товар</th>
<th>Мин / Макс</th>
<th>Тек. цена</th>
<th>Последнее обновление</th>
<th>Статус</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
<!-- Integrations List -->
<section id="integrations-section" class="section hidden">
<h2>Доступные маркетплейсы</h2>
<div id="platforms-grid" class="platforms-grid">
<!-- Dynamically populated -->
</div>
</section>
<!-- Cases list -->
<section id="cases-section" class="section">
<h2>Список кейсов</h2>
<div class="controls">
<input type="text" id="search-input" placeholder="Поиск по артикулу или ссылке..." />
<input type="text" id="search-input" placeholder="Поиск по артикулу..." />
<select id="platform-filter">
<option value="">Все площадки</option>
<option value="ozon">Ozon</option>
<option value="wb">Wildberries</option>
<option value="ym">Yandex Market</option>
<option value="mm">Magnit Market</option>
<option value="mega">MegaMarket</option>
<option value="ae">AliExpress</option>
<option value="avito">Avito</option>
<option value="lamoda">Lamoda</option>
<option value="lemana">Lemana PRO</option>
<option value="flowwow">Flowwow</option>
</select>
<select id="status-filter">
<option value="">Все статусы</option>
<option value="new">Новый</option>
@@ -27,11 +481,13 @@
<option value="closed">Закрыт</option>
</select>
<button id="new-case-btn" class="btn-primary">+ Новый кейс</button>
<button id="export-csv-btn" class="btn-secondary">⬇ Экспорт CSV</button>
</div>
<table id="cases-table" class="table">
<thead>
<tr>
<th>ID</th>
<th>Площадка</th>
<th>Артикул</th>
<th>Статус</th>
<th>Создан</th>
@@ -42,10 +498,25 @@
</table>
</section>
<!-- Case form (hidden by default) -->
<!-- Case form -->
<section id="case-form-section" class="section hidden">
<h2 id="form-title">Новый кейс</h2>
<form id="case-form">
<label>
Площадка
<select name="platform" id="case-platform" required>
<option value="ozon">Ozon</option>
<option value="wb">Wildberries</option>
<option value="ym">Yandex Market</option>
<option value="mm">Magnit Market</option>
<option value="mega">MegaMarket</option>
<option value="ae">AliExpress</option>
<option value="avito">Avito</option>
<option value="lamoda">Lamoda</option>
<option value="lemana">Lemana PRO</option>
<option value="flowwow">Flowwow</option>
</select>
</label>
<label>
Ссылка на карточку нарушителя
<input type="url" name="violator_url" required />
@@ -60,7 +531,12 @@
</label>
<label>
Комментарий / описание
<textarea name="comment" rows="3"></textarea>
<div style="display: flex; gap: 0.5rem; margin-top: 0.25rem;">
<textarea name="comment" id="case-comment" rows="4" style="flex: 1;"></textarea>
<button type="button" id="ai-gen-btn" class="btn-secondary" style="height: fit-content; white-space: nowrap;">
<i data-lucide="sparkles" class="icon-sm"></i> ИИ Помощник
</button>
</div>
</label>
<label>
Прикрепить доказательства
@@ -74,16 +550,21 @@
</form>
</section>
<!-- Case detail (hidden) -->
<!-- Case detail -->
<section id="case-detail-section" class="section hidden">
<h2>Детали кейса</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2>Детали кейса</h2>
<button id="download-pdf-btn" class="btn-primary">📄 Скачать PDF-претензию</button>
</div>
<div id="case-detail"></div>
<button id="back-to-list" class="btn-secondary">← Назад к списку</button>
</section>
</main>
</main>
</div>
<script src="js/api.js"></script>
<script src="js/ui.js"></script>
<script src="js/app.js"></script>
<script type="module" src="js/app.js"></script>
<!-- Toast Notification Container -->
<div id="toast-container" class="toast-container"></div>
</body>
</html>

View File

@@ -1,39 +1,102 @@
// frontend/js/api.js
// Base URL for backend API (adjust if using different host/port)
const API_BASE = "http://localhost:5000/api";
const API_BASE = "/api";
function getHeaders() {
const token = localStorage.getItem('token');
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}
async function handleResponse(res) {
if (res.status === 401) {
localStorage.removeItem('token');
window.location.reload();
}
const data = await res.json();
if (!res.ok) throw new Error(data.error || data.message || 'API Error');
return data;
}
export async function login(username, password) {
const res = await fetch(`${API_BASE}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return await handleResponse(res);
}
export async function register(username, password) {
const res = await fetch(`${API_BASE}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return await handleResponse(res);
}
export async function getMe() {
const res = await fetch(`${API_BASE}/auth/me`, { headers: getHeaders() });
return await handleResponse(res);
}
export async function upgradePlan() {
const res = await fetch(`${API_BASE}/auth/upgrade`, {
method: "POST",
headers: getHeaders()
});
return await handleResponse(res);
}
export async function getDashboardStats() {
const res = await fetch(`${API_BASE}/dashboard/stats`, { headers: getHeaders() });
return await handleResponse(res);
}
export function getCaseReportPdfUrl(caseId) {
const token = localStorage.getItem('token');
return `${API_BASE}/reports/case/${caseId}/pdf?token=${token}`;
}
export function getExportCsvUrl() {
const token = localStorage.getItem('token');
return `${API_BASE}/reports/cases/csv?token=${token}`;
}
export async function fetchCases(params = {}) {
const query = new URLSearchParams(params).toString();
const res = await fetch(`${API_BASE}/cases${query ? `?${query}` : ''}`);
return await res.json();
const res = await fetch(`${API_BASE}/cases${query ? `?${query}` : ''}`, { headers: getHeaders() });
return await handleResponse(res);
}
export async function createCase(data) {
const res = await fetch(`${API_BASE}/cases`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: getHeaders(),
body: JSON.stringify(data),
});
return await res.json();
return await handleResponse(res);
}
export async function getCase(id) {
const res = await fetch(`${API_BASE}/cases/${id}`);
return await res.json();
const res = await fetch(`${API_BASE}/cases/${id}`, { headers: getHeaders() });
return await handleResponse(res);
}
export async function updateCase(id, data) {
const res = await fetch(`${API_BASE}/cases/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
headers: getHeaders(),
body: JSON.stringify(data),
});
return await res.json();
return await handleResponse(res);
}
export async function deleteCase(id) {
const res = await fetch(`${API_BASE}/cases/${id}`, { method: "DELETE" });
return await res.json();
const res = await fetch(`${API_BASE}/cases/${id}`, { method: "DELETE", headers: getHeaders() });
return await handleResponse(res);
}
export async function uploadFiles(caseId, fileList) {
@@ -41,14 +104,32 @@ export async function uploadFiles(caseId, fileList) {
for (const f of fileList) {
form.append("files", f);
}
const headers = getHeaders();
delete headers["Content-Type"]; // let browser set boundary
const res = await fetch(`${API_BASE}/cases/${caseId}/files`, {
method: "POST",
headers: headers,
body: form,
});
return await res.json();
return await handleResponse(res);
}
export async function listFiles(caseId) {
const res = await fetch(`${API_BASE}/cases/${caseId}/files`);
return await res.json();
const res = await fetch(`${API_BASE}/cases/${caseId}/files`, { headers: getHeaders() });
return await handleResponse(res);
}
export async function getConfiguredPlatforms() {
const res = await fetch(`${API_BASE}/credentials`, { headers: getHeaders() });
const data = await handleResponse(res);
return data.configured_platforms || [];
}
export async function saveCredentials(platform, data) {
const res = await fetch(`${API_BASE}/credentials/${platform}`, {
method: "POST",
headers: getHeaders(),
body: JSON.stringify(data),
});
return await handleResponse(res);
}

1005
frontend/js/app.js Normal file

File diff suppressed because it is too large Load Diff

31
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,31 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Все запросы к /api/ проксируем на Flask бэкенд
location /api/ {
proxy_pass http://backend:5000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
}
# Socket.IO проксируем на бэкенд
location /socket.io/ {
proxy_pass http://backend:5000/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 60s;
}
# Все остальные запросы — статические файлы
location / {
try_files $uri $uri/ /index.html;
}
}