feat: initialize full-stack architecture with backend routes, database schema, and glassmorphic frontend UI
This commit is contained in:
28
backend/Dockerfile
Normal file
28
backend/Dockerfile
Normal 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", "backend/app.py"]
|
||||
@@ -2,30 +2,75 @@ 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)
|
||||
# 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
|
||||
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')
|
||||
|
||||
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)
|
||||
|
||||
@@ -8,3 +8,8 @@ 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
|
||||
|
||||
79
backend/routes/ai.py
Normal file
79
backend/routes/ai.py
Normal file
@@ -0,0 +1,79 @@
|
||||
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})
|
||||
@@ -11,6 +11,26 @@ 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: Пользователь успешно зарегистрирован
|
||||
"""
|
||||
data = request.json
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
@@ -41,6 +61,30 @@ def register():
|
||||
|
||||
@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')
|
||||
@@ -59,6 +103,11 @@ def login():
|
||||
'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'])
|
||||
|
||||
@@ -27,6 +27,30 @@ def serialize_case(row):
|
||||
@cases_blueprint.route('/cases', methods=['GET'])
|
||||
@token_required
|
||||
def list_cases(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')
|
||||
@@ -56,13 +80,55 @@ def list_cases(current_user_id):
|
||||
@cases_blueprint.route('/cases', methods=['POST'])
|
||||
@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 = ["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 (user_id, platform, violator_url, violator_article, my_article, comment, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id""",
|
||||
@@ -79,6 +145,40 @@ def create_case(current_user_id):
|
||||
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'])
|
||||
@@ -123,6 +223,12 @@ def delete_case(current_user_id, 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 ----------
|
||||
|
||||
76
backend/routes/content.py
Normal file
76
backend/routes/content.py
Normal 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)
|
||||
@@ -1,4 +1,5 @@
|
||||
from flask import Blueprint, jsonify
|
||||
from datetime import datetime, timedelta
|
||||
from ..db import get_db_connection, release_db_connection
|
||||
from ..middleware import token_required
|
||||
|
||||
@@ -7,6 +8,17 @@ dashboard_blueprint = Blueprint('dashboard', __name__)
|
||||
@dashboard_blueprint.route('/dashboard/stats', methods=['GET'])
|
||||
@token_required
|
||||
def get_stats(current_user_id):
|
||||
"""
|
||||
Получить общую статистику для дашборда
|
||||
---
|
||||
tags:
|
||||
- Dashboard
|
||||
security:
|
||||
- APIKeyHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Объект со статистикой и трендами
|
||||
"""
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -39,13 +51,76 @@ def get_stats(current_user_id):
|
||||
"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", (current_user_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", (current_user_id,))
|
||||
stores_rows = cur.fetchall()
|
||||
stores = []
|
||||
total_sales = 0
|
||||
|
||||
for s in stores_rows:
|
||||
# В реальной жизни здесь был бы вызов API Ozon/WB для получения продаж за сегодня
|
||||
# Для демонстрации генерируем случайные данные
|
||||
import random
|
||||
daily_sales = random.randint(5000, 50000)
|
||||
total_sales += daily_sales
|
||||
stores.append({
|
||||
"id": s[0],
|
||||
"platform": s[1],
|
||||
"name": s[2],
|
||||
"status": "active",
|
||||
"daily_sales": daily_sales
|
||||
})
|
||||
|
||||
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
|
||||
"recent_activity": recent_activity,
|
||||
"trend": trend,
|
||||
"stores": stores
|
||||
})
|
||||
|
||||
@dashboard_blueprint.route('/dashboard/logs', methods=['GET'])
|
||||
@token_required
|
||||
def get_logs(current_user_id):
|
||||
"""
|
||||
Получить лог аудита действий пользователя
|
||||
---
|
||||
tags:
|
||||
- Dashboard
|
||||
security:
|
||||
- APIKeyHeader: []
|
||||
responses:
|
||||
200:
|
||||
description: Список действий из лога
|
||||
"""
|
||||
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", (current_user_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
47
backend/routes/finance.py
Normal 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)
|
||||
})
|
||||
@@ -1,20 +1,55 @@
|
||||
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
|
||||
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
|
||||
@@ -38,6 +73,22 @@ def export_cases_csv(current_user_id):
|
||||
@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("""
|
||||
@@ -56,34 +107,62 @@ def export_case_pdf(current_user_id, case_id):
|
||||
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 = []
|
||||
|
||||
# Title
|
||||
elements.append(Paragraph(f"Официальная претензия #{case_id}", styles['Title']))
|
||||
elements.append(Spacer(1, 12))
|
||||
# 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 = [
|
||||
["Маркетплейс", platform.upper()],
|
||||
["Дата создания", created_at.strftime('%Y-%m-%d %H:%M') if created_at else "-"],
|
||||
["Артикул нарушителя", v_art],
|
||||
["Ссылка на товар", Paragraph(v_url, styles['Normal'])],
|
||||
["Ваш артикул", my_art or "-"],
|
||||
["Текущий статус", status.upper()]
|
||||
[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), (0, -1), colors.lightgrey),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black),
|
||||
('PADDING', (0, 0), (-1, -1), 6),
|
||||
('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("Описание нарушения:", styles['Heading2']))
|
||||
elements.append(Paragraph(comment or "Комментарий отсутствует", styles['Normal']))
|
||||
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)
|
||||
|
||||
78
backend/routes/seo.py
Normal file
78
backend/routes/seo.py
Normal 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])
|
||||
65
backend/utils/ai_utils.py
Normal file
65
backend/utils/ai_utils.py
Normal file
@@ -0,0 +1,65 @@
|
||||
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-1.5-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-1.5-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)}"
|
||||
17
backend/utils/audit_utils.py
Normal file
17
backend/utils/audit_utils.py
Normal 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)
|
||||
20
backend/utils/image_utils.py
Normal file
20
backend/utils/image_utils.py
Normal 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
|
||||
24
backend/utils/telegram_utils.py
Normal file
24
backend/utils/telegram_utils.py
Normal 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
|
||||
Reference in New Issue
Block a user