feat: initialize full-stack architecture with backend routes, database schema, and glassmorphic frontend UI

This commit is contained in:
2026-05-13 00:12:19 +03:00
parent bd73f18d5c
commit 0732acbda5
21 changed files with 1937 additions and 307 deletions

79
backend/routes/ai.py Normal file
View 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})

View File

@@ -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'])

View File

@@ -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
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

@@ -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
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)
})

View File

@@ -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
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])