13.03.26
This commit is contained in:
parent
e0af34249d
commit
41ece8851b
722
app.py
Normal file
722
app.py
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
|
||||
import sqlite3
|
||||
import os
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from database import get_db_connection, calculate_order_total, calculate_discount, get_discount_description, \
|
||||
calculate_discount_for_new_order, get_db, create_tables
|
||||
from import_data import import_data_from_files
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'kancelyary_secret_key_2026'
|
||||
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
||||
app.config['DATABASE'] = 'partners.db'
|
||||
|
||||
|
||||
# Функция для сохранения данных в Excel (опционально)
|
||||
def save_to_excel():
|
||||
"""Сохраняет все данные из БД в Excel файлы"""
|
||||
try:
|
||||
if not os.path.exists('data'):
|
||||
os.makedirs('data')
|
||||
|
||||
with get_db() as conn:
|
||||
product_types = conn.execute('SELECT * FROM product_types').fetchall()
|
||||
if product_types:
|
||||
df_types = pd.DataFrame([dict(pt) for pt in product_types])
|
||||
df_types.to_excel('data/Product_types_import.xlsx', index=False)
|
||||
|
||||
partners = conn.execute('SELECT * FROM partners').fetchall()
|
||||
if partners:
|
||||
df_partners = pd.DataFrame([dict(p) for p in partners])
|
||||
df_partners.to_excel('data/Partners_import.xlsx', index=False)
|
||||
|
||||
products = conn.execute('SELECT * FROM products').fetchall()
|
||||
if products:
|
||||
df_products = pd.DataFrame([dict(p) for p in products])
|
||||
df_products.to_excel('data/Products_import.xlsx', index=False)
|
||||
|
||||
orders = conn.execute('SELECT * FROM orders').fetchall()
|
||||
if orders:
|
||||
df_orders = pd.DataFrame([dict(o) for o in orders])
|
||||
df_orders.to_excel('data/Orders_import.xlsx', index=False)
|
||||
|
||||
order_items = conn.execute('SELECT * FROM order_items').fetchall()
|
||||
if order_items:
|
||||
df_items = pd.DataFrame([dict(oi) for oi in order_items])
|
||||
df_items.to_excel('data/Order_items_import.xlsx', index=False)
|
||||
|
||||
print("✅ Данные сохранены в Excel файлы")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при сохранении в Excel: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Главная страница - список заказов с сортировкой
|
||||
@app.route('/')
|
||||
def index():
|
||||
sort_by = request.args.get('sort', 'date_desc')
|
||||
partner_filter = request.args.get('partner', None)
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
if partner_filter:
|
||||
query = f'''
|
||||
SELECT o.*, p.name as partner_name, p.id as partner_id
|
||||
FROM orders o
|
||||
JOIN partners p ON o.partner_id = p.id
|
||||
WHERE o.partner_id = {partner_filter}
|
||||
'''
|
||||
else:
|
||||
query = '''
|
||||
SELECT o.*, p.name as partner_name, p.id as partner_id
|
||||
FROM orders o
|
||||
JOIN partners p ON o.partner_id = p.id
|
||||
'''
|
||||
|
||||
if sort_by == 'amount_desc':
|
||||
query += '''
|
||||
LEFT JOIN order_items oi ON o.id = oi.order_id
|
||||
GROUP BY o.id
|
||||
ORDER BY COALESCE(SUM(oi.quantity * oi.price_at_order), 0) * 1.1 DESC
|
||||
'''
|
||||
elif sort_by == 'date_desc':
|
||||
query += ' ORDER BY o.order_date DESC'
|
||||
elif sort_by == 'partner':
|
||||
query += ' ORDER BY p.name, o.order_date DESC'
|
||||
else:
|
||||
query += ' ORDER BY o.order_date DESC'
|
||||
|
||||
orders = conn.execute(query).fetchall()
|
||||
|
||||
orders_with_discount = []
|
||||
for order in orders:
|
||||
try:
|
||||
items_count = conn.execute('''
|
||||
SELECT COUNT(*) as count FROM order_items WHERE order_id = ?
|
||||
''', (order['id'],)).fetchone()['count']
|
||||
|
||||
subtotal_without_markup = conn.execute('''
|
||||
SELECT COALESCE(SUM(quantity * price_at_order), 0) as total
|
||||
FROM order_items WHERE order_id = ?
|
||||
''', (order['id'],)).fetchone()['total']
|
||||
|
||||
subtotal_with_markup = subtotal_without_markup * 1.1
|
||||
discount_percent = order['discount_percent'] if order['discount_percent'] else 0
|
||||
discount_amount = subtotal_with_markup * discount_percent / 100
|
||||
total = subtotal_with_markup - discount_amount
|
||||
|
||||
order_dict = {
|
||||
'order_id': order['id'],
|
||||
'partner_id': order['partner_id'],
|
||||
'partner_name': order['partner_name'],
|
||||
'order_date': order['order_date'],
|
||||
'notes': order['notes'] if order['notes'] else '',
|
||||
'items_count': items_count,
|
||||
'subtotal_without_markup': round(subtotal_without_markup, 2),
|
||||
'subtotal_with_markup': round(subtotal_with_markup, 2),
|
||||
'discount_percent': discount_percent,
|
||||
'discount_desc': get_discount_description(discount_percent),
|
||||
'discount_amount': round(discount_amount, 2),
|
||||
'total': round(total, 2)
|
||||
}
|
||||
|
||||
orders_with_discount.append(order_dict)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при обработке заказа {order['id']}: {e}")
|
||||
orders_with_discount.append({
|
||||
'order_id': order['id'],
|
||||
'partner_id': order['partner_id'],
|
||||
'partner_name': order['partner_name'],
|
||||
'order_date': order['order_date'],
|
||||
'notes': '',
|
||||
'items_count': 0,
|
||||
'subtotal_without_markup': 0,
|
||||
'subtotal_with_markup': 0,
|
||||
'discount_percent': 0,
|
||||
'discount_desc': '0%',
|
||||
'discount_amount': 0,
|
||||
'total': 0
|
||||
})
|
||||
|
||||
return render_template('index.html', orders=orders_with_discount, current_sort=sort_by)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Критическая ошибка в index: {e}")
|
||||
return render_template('index.html', orders=[], current_sort='date_desc')
|
||||
|
||||
|
||||
# Страница товаров
|
||||
@app.route('/products')
|
||||
def products():
|
||||
try:
|
||||
with get_db() as conn:
|
||||
products_rows = conn.execute('''
|
||||
SELECT p.*, pt.type_name as product_type_name
|
||||
FROM products p
|
||||
JOIN product_types pt ON p.product_type_id = pt.id
|
||||
ORDER BY p.product_name
|
||||
''').fetchall()
|
||||
|
||||
products_list = []
|
||||
for row in products_rows:
|
||||
products_list.append({
|
||||
'id': row['id'],
|
||||
'product_type_id': row['product_type_id'],
|
||||
'product_name': row['product_name'],
|
||||
'article': row['article'],
|
||||
'min_partner_price': row['min_partner_price'],
|
||||
'product_type_name': row['product_type_name']
|
||||
})
|
||||
|
||||
product_types_rows = conn.execute('SELECT * FROM product_types ORDER BY type_name').fetchall()
|
||||
product_types = []
|
||||
for row in product_types_rows:
|
||||
product_types.append({
|
||||
'id': row['id'],
|
||||
'type_name': row['type_name'],
|
||||
'description': row['description']
|
||||
})
|
||||
|
||||
partners_rows = conn.execute('SELECT id, name FROM partners ORDER BY name').fetchall()
|
||||
partners = []
|
||||
for row in partners_rows:
|
||||
partners.append({
|
||||
'id': row['id'],
|
||||
'name': row['name']
|
||||
})
|
||||
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
return render_template('products.html', products=products_list, product_types=product_types,
|
||||
partners=partners, today=today)
|
||||
except Exception as e:
|
||||
print(f"Ошибка на странице товаров: {e}")
|
||||
flash('Ошибка при загрузке страницы', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
# Страница партнеров
|
||||
@app.route('/partners')
|
||||
def partners():
|
||||
try:
|
||||
with get_db() as conn:
|
||||
partners_rows = conn.execute('''
|
||||
SELECT p.*,
|
||||
COUNT(DISTINCT o.id) as orders_count,
|
||||
COALESCE(SUM(oi.quantity * oi.price_at_order), 0) as total_purchases
|
||||
FROM partners p
|
||||
LEFT JOIN orders o ON p.id = o.partner_id
|
||||
LEFT JOIN order_items oi ON o.id = oi.order_id
|
||||
GROUP BY p.id
|
||||
ORDER BY p.name
|
||||
''').fetchall()
|
||||
|
||||
partners_with_stats = []
|
||||
for partner in partners_rows:
|
||||
partner_dict = {
|
||||
'id': partner['id'],
|
||||
'name': partner['name'],
|
||||
'director': partner['director'],
|
||||
'email': partner['email'],
|
||||
'phone': partner['phone'],
|
||||
'legal_address': partner['legal_address'],
|
||||
'inn': partner['inn'],
|
||||
'orders_count': partner['orders_count'],
|
||||
'total_purchases': partner['total_purchases']
|
||||
}
|
||||
discount_percent = calculate_discount(partner['total_purchases'] or 0)
|
||||
discount_desc = get_discount_description(discount_percent)
|
||||
partner_dict['discount_percent'] = discount_percent
|
||||
partner_dict['discount_desc'] = discount_desc
|
||||
partners_with_stats.append(partner_dict)
|
||||
|
||||
return render_template('partners.html', partners=partners_with_stats)
|
||||
except Exception as e:
|
||||
print(f"Ошибка на странице партнеров: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
flash('Ошибка при загрузке страницы', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
# Добавление заказа
|
||||
@app.route('/add_order', methods=['GET', 'POST'])
|
||||
def add_order():
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
partner_id = request.form['partner_id']
|
||||
order_date = request.form['order_date']
|
||||
notes = request.form.get('notes', '')
|
||||
|
||||
products = request.form.getlist('product_id[]')
|
||||
quantities = request.form.getlist('quantity[]')
|
||||
prices = request.form.getlist('price[]')
|
||||
|
||||
with get_db() as conn:
|
||||
# Проверка (товары всегда есть в наличии, но проверяем что они существуют)
|
||||
for i in range(len(products)):
|
||||
if products[i] and quantities[i]:
|
||||
product_id = int(products[i])
|
||||
quantity = int(quantities[i])
|
||||
|
||||
if quantity <= 0:
|
||||
flash(f'Количество должно быть положительным для позиции {i + 1}', 'warning')
|
||||
return redirect(url_for('add_order'))
|
||||
|
||||
product = conn.execute('SELECT * FROM products WHERE id = ?',
|
||||
(product_id,)).fetchone()
|
||||
if not product:
|
||||
flash(f'Товар с ID {product_id} не найден', 'error')
|
||||
return redirect(url_for('add_order'))
|
||||
|
||||
# Рассчитываем сумму текущего заказа (без добавочной стоимости)
|
||||
current_order_amount_without_markup = 0
|
||||
for i in range(len(products)):
|
||||
if products[i] and quantities[i] and prices[i]:
|
||||
quantity = int(quantities[i])
|
||||
price = float(prices[i])
|
||||
current_order_amount_without_markup += quantity * price
|
||||
|
||||
# Рассчитываем скидку на основе предыдущих заказов
|
||||
discount_percent = calculate_discount_for_new_order(partner_id, order_date,
|
||||
current_order_amount_without_markup)
|
||||
|
||||
# Создаем заказ
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO orders (partner_id, order_date, notes, discount_percent)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (partner_id, order_date, notes, discount_percent))
|
||||
|
||||
order_id = cursor.lastrowid
|
||||
|
||||
# Добавляем позиции
|
||||
items_added = 0
|
||||
for i in range(len(products)):
|
||||
if products[i] and quantities[i] and prices[i]:
|
||||
product_id = int(products[i])
|
||||
quantity = int(quantities[i])
|
||||
price = float(prices[i])
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_at_order)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (order_id, product_id, quantity, price))
|
||||
|
||||
items_added += 1
|
||||
|
||||
save_to_excel()
|
||||
|
||||
if items_added > 0:
|
||||
flash(f'Заказ успешно создан! Добавлено {items_added} позиций', 'success')
|
||||
|
||||
return redirect(url_for('index'))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при создании заказа: {e}")
|
||||
flash(f'Ошибка при создании заказа: {str(e)}', 'error')
|
||||
return redirect(url_for('add_order'))
|
||||
|
||||
# GET запрос
|
||||
try:
|
||||
with get_db() as conn:
|
||||
partners_rows = conn.execute('SELECT * FROM partners ORDER BY name').fetchall()
|
||||
partners = []
|
||||
for row in partners_rows:
|
||||
partners.append({
|
||||
'id': row['id'],
|
||||
'name': row['name'],
|
||||
'director': row['director'],
|
||||
'email': row['email'],
|
||||
'phone': row['phone'],
|
||||
'legal_address': row['legal_address'],
|
||||
'inn': row['inn']
|
||||
})
|
||||
|
||||
products_rows = conn.execute('''
|
||||
SELECT p.*, pt.type_name
|
||||
FROM products p
|
||||
JOIN product_types pt ON p.product_type_id = pt.id
|
||||
ORDER BY p.product_name
|
||||
''').fetchall()
|
||||
|
||||
products = []
|
||||
for row in products_rows:
|
||||
products.append({
|
||||
'id': row['id'],
|
||||
'product_type_id': row['product_type_id'],
|
||||
'product_name': row['product_name'],
|
||||
'article': row['article'],
|
||||
'min_partner_price': row['min_partner_price'],
|
||||
'type_name': row['type_name']
|
||||
})
|
||||
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
return render_template('add_order.html', partners=partners, products=products, today=today)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при загрузке формы: {e}")
|
||||
flash('Ошибка при загрузке формы', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
# Редактирование заказа (УБРАН МАРШРУТ УДАЛЕНИЯ)
|
||||
@app.route('/edit_order/<int:order_id>', methods=['GET', 'POST'])
|
||||
def edit_order(order_id):
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
order_date = request.form['order_date']
|
||||
notes = request.form.get('notes', '')
|
||||
|
||||
with get_db() as conn:
|
||||
old_items = conn.execute('SELECT product_id, quantity FROM order_items WHERE order_id = ?',
|
||||
(order_id,)).fetchall()
|
||||
|
||||
products = request.form.getlist('product_id[]')
|
||||
quantities = request.form.getlist('quantity[]')
|
||||
prices = request.form.getlist('price[]')
|
||||
|
||||
has_new_items = any(p and p.strip() for p in products)
|
||||
|
||||
if has_new_items:
|
||||
conn.execute('DELETE FROM order_items WHERE order_id = ?', (order_id,))
|
||||
|
||||
items_added = 0
|
||||
new_order_amount_without_markup = 0
|
||||
|
||||
for i in range(len(products)):
|
||||
if products[i] and products[i].strip():
|
||||
product_id = int(products[i])
|
||||
quantity = int(quantities[i]) if quantities[i] and quantities[i].strip() else 1
|
||||
price = float(prices[i]) if prices[i] and prices[i].strip() else 0
|
||||
|
||||
conn.execute('''
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_at_order)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (order_id, product_id, quantity, price))
|
||||
|
||||
new_order_amount_without_markup += quantity * price
|
||||
items_added += 1
|
||||
|
||||
order_info = conn.execute('SELECT partner_id FROM orders WHERE id = ?', (order_id,)).fetchone()
|
||||
partner_id = order_info['partner_id']
|
||||
|
||||
discount_percent = calculate_discount_for_new_order(partner_id, order_date,
|
||||
new_order_amount_without_markup, order_id)
|
||||
|
||||
conn.execute('''
|
||||
UPDATE orders
|
||||
SET order_date = ?, notes = ?, discount_percent = ?
|
||||
WHERE id = ?
|
||||
''', (order_date, notes, discount_percent, order_id))
|
||||
else:
|
||||
items_added = len(old_items)
|
||||
conn.execute('''
|
||||
UPDATE orders
|
||||
SET order_date = ?, notes = ?
|
||||
WHERE id = ?
|
||||
''', (order_date, notes, order_id))
|
||||
|
||||
save_to_excel()
|
||||
|
||||
flash(f'Заказ обновлен! {items_added} позиций', 'success')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при редактировании заказа: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
flash(f'Ошибка: {str(e)}', 'error')
|
||||
return redirect(url_for('edit_order', order_id=order_id))
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
order_row = conn.execute('SELECT * FROM orders WHERE id = ?', (order_id,)).fetchone()
|
||||
if not order_row:
|
||||
return redirect(url_for('index'))
|
||||
|
||||
order = {
|
||||
'id': order_row['id'],
|
||||
'partner_id': order_row['partner_id'],
|
||||
'order_date': order_row['order_date'],
|
||||
'notes': order_row['notes']
|
||||
}
|
||||
|
||||
items_rows = conn.execute('''
|
||||
SELECT oi.*, p.product_name, p.article
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = ?
|
||||
''', (order_id,)).fetchall()
|
||||
|
||||
items = []
|
||||
for row in items_rows:
|
||||
items.append({
|
||||
'id': row['id'],
|
||||
'order_id': row['order_id'],
|
||||
'product_id': row['product_id'],
|
||||
'quantity': row['quantity'],
|
||||
'price_at_order': row['price_at_order'],
|
||||
'product_name': row['product_name'],
|
||||
'article': row['article']
|
||||
})
|
||||
|
||||
products_rows = conn.execute('''
|
||||
SELECT p.*, pt.type_name
|
||||
FROM products p
|
||||
JOIN product_types pt ON p.product_type_id = pt.id
|
||||
ORDER BY p.product_name
|
||||
''').fetchall()
|
||||
|
||||
products = []
|
||||
for row in products_rows:
|
||||
products.append({
|
||||
'id': row['id'],
|
||||
'product_type_id': row['product_type_id'],
|
||||
'product_name': row['product_name'],
|
||||
'article': row['article'],
|
||||
'min_partner_price': row['min_partner_price'],
|
||||
'type_name': row['type_name']
|
||||
})
|
||||
|
||||
return render_template('edit_order.html', order=order, items=items, products=products)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при загрузке заказа: {e}")
|
||||
flash('Ошибка при загрузке заказа', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
# Добавление партнера
|
||||
@app.route('/add_partner', methods=['GET', 'POST'])
|
||||
def add_partner():
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
name = request.form['name']
|
||||
director = request.form.get('director', '')
|
||||
email = request.form['email']
|
||||
phone = request.form.get('phone', '')
|
||||
legal_address = request.form.get('legal_address', '')
|
||||
inn = request.form.get('inn', '')
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
INSERT INTO partners (name, director, email, phone, legal_address, inn)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (name, director, email, phone, legal_address, inn))
|
||||
|
||||
save_to_excel()
|
||||
|
||||
flash('Партнер успешно добавлен!', 'success')
|
||||
return redirect(url_for('partners'))
|
||||
|
||||
except sqlite3.IntegrityError:
|
||||
flash('Партнер с таким ИНН уже существует', 'error')
|
||||
return redirect(url_for('add_partner'))
|
||||
except Exception as e:
|
||||
print(f"Ошибка при добавлении партнера: {e}")
|
||||
flash(f'Ошибка: {str(e)}', 'error')
|
||||
return redirect(url_for('add_partner'))
|
||||
|
||||
return render_template('add_partner.html')
|
||||
|
||||
|
||||
# Редактирование партнера (УБРАН МАРШРУТ УДАЛЕНИЯ)
|
||||
@app.route('/edit_partner/<int:partner_id>', methods=['GET', 'POST'])
|
||||
def edit_partner(partner_id):
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
name = request.form['name']
|
||||
director = request.form.get('director', '')
|
||||
email = request.form['email']
|
||||
phone = request.form.get('phone', '')
|
||||
legal_address = request.form.get('legal_address', '')
|
||||
inn = request.form.get('inn', '')
|
||||
|
||||
with get_db() as conn:
|
||||
existing = conn.execute(
|
||||
'SELECT id FROM partners WHERE inn = ? AND id != ?',
|
||||
(inn, partner_id)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
flash('Партнер с таким ИНН уже существует', 'error')
|
||||
return redirect(url_for('edit_partner', partner_id=partner_id))
|
||||
|
||||
conn.execute('''
|
||||
UPDATE partners
|
||||
SET name = ?, director = ?, email = ?, phone = ?,
|
||||
legal_address = ?, inn = ?
|
||||
WHERE id = ?
|
||||
''', (name, director, email, phone, legal_address, inn, partner_id))
|
||||
|
||||
save_to_excel()
|
||||
|
||||
flash('Данные партнера обновлены!', 'success')
|
||||
return redirect(url_for('partners'))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при редактировании партнера: {e}")
|
||||
flash(f'Ошибка: {str(e)}', 'error')
|
||||
return redirect(url_for('edit_partner', partner_id=partner_id))
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
partner_row = conn.execute('SELECT * FROM partners WHERE id = ?', (partner_id,)).fetchone()
|
||||
|
||||
if not partner_row:
|
||||
flash('Партнер не найден', 'error')
|
||||
return redirect(url_for('partners'))
|
||||
|
||||
partner = {
|
||||
'id': partner_row['id'],
|
||||
'name': partner_row['name'],
|
||||
'director': partner_row['director'],
|
||||
'email': partner_row['email'],
|
||||
'phone': partner_row['phone'],
|
||||
'legal_address': partner_row['legal_address'],
|
||||
'inn': partner_row['inn']
|
||||
}
|
||||
|
||||
return render_template('edit_partner.html', partner=partner)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при загрузке партнера: {e}")
|
||||
flash('Ошибка при загрузке данных', 'error')
|
||||
return redirect(url_for('partners'))
|
||||
|
||||
|
||||
# API для работы с товарами (УБРАН МАРШРУТ УДАЛЕНИЯ ТОВАРА)
|
||||
@app.route('/api/product/<int:product_id>', methods=['GET'])
|
||||
def api_get_product(product_id):
|
||||
try:
|
||||
with get_db() as conn:
|
||||
product = conn.execute('SELECT * FROM products WHERE id = ?', (product_id,)).fetchone()
|
||||
if product:
|
||||
return jsonify(dict(product))
|
||||
return jsonify({'error': 'Product not found'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/product', methods=['POST'])
|
||||
def api_add_product():
|
||||
data = request.json
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO products (product_type_id, product_name, article, min_partner_price)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (
|
||||
data['product_type_id'],
|
||||
data['product_name'],
|
||||
data['article'],
|
||||
data['min_partner_price']
|
||||
))
|
||||
|
||||
product_id = cursor.lastrowid
|
||||
save_to_excel()
|
||||
|
||||
return jsonify({'success': True, 'product_id': product_id})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
|
||||
|
||||
@app.route('/api/product/<int:product_id>', methods=['PUT'])
|
||||
def api_update_product(product_id):
|
||||
data = request.json
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
conn.execute('''
|
||||
UPDATE products
|
||||
SET product_type_id = ?, product_name = ?, article = ?,
|
||||
min_partner_price = ?
|
||||
WHERE id = ?
|
||||
''', (
|
||||
data['product_type_id'],
|
||||
data['product_name'],
|
||||
data['article'],
|
||||
data['min_partner_price'],
|
||||
product_id
|
||||
))
|
||||
|
||||
save_to_excel()
|
||||
|
||||
return jsonify({'success': True})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
|
||||
|
||||
@app.route('/api/product_type', methods=['POST'])
|
||||
def api_add_product_type():
|
||||
data = request.json
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO product_types (type_name, description)
|
||||
VALUES (?, ?)
|
||||
''', (
|
||||
data['type_name'],
|
||||
data.get('description', '')
|
||||
))
|
||||
|
||||
type_id = cursor.lastrowid
|
||||
save_to_excel()
|
||||
|
||||
return jsonify({'success': True, 'type_id': type_id})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
|
||||
|
||||
@app.route('/api/order', methods=['POST'])
|
||||
def api_create_order():
|
||||
data = request.json
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
for item in data['items']:
|
||||
product = conn.execute('SELECT * FROM products WHERE id = ?',
|
||||
(item['product_id'],)).fetchone()
|
||||
if not product:
|
||||
return jsonify({'success': False, 'error': 'Товар не найден'}), 400
|
||||
|
||||
order_amount_without_markup = sum(item['quantity'] * item['price'] for item in data['items'])
|
||||
|
||||
discount_percent = calculate_discount_for_new_order(data['partner_id'], data['order_date'],
|
||||
order_amount_without_markup)
|
||||
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO orders (partner_id, order_date, notes, discount_percent)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (data['partner_id'], data['order_date'], data.get('notes', ''), discount_percent))
|
||||
|
||||
order_id = cursor.lastrowid
|
||||
|
||||
for item in data['items']:
|
||||
cursor.execute('''
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_at_order)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (order_id, item['product_id'], item['quantity'], item['price']))
|
||||
|
||||
save_to_excel()
|
||||
|
||||
return jsonify({'success': True, 'order_id': order_id})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
|
||||
@app.route('/test_partner')
|
||||
def test_partner():
|
||||
"""Страница для ручного тестирования добавления партнера"""
|
||||
return render_template('manual_partner_test.html')
|
||||
|
||||
if __name__ == '__main__':
|
||||
if not os.path.exists('data'):
|
||||
os.makedirs('data')
|
||||
app.run(debug=True)
|
||||
BIN
data/Order_items_import.xlsx
Normal file
BIN
data/Order_items_import.xlsx
Normal file
Binary file not shown.
BIN
data/Orders_import.xlsx
Normal file
BIN
data/Orders_import.xlsx
Normal file
Binary file not shown.
BIN
data/Partners_import.xlsx
Normal file
BIN
data/Partners_import.xlsx
Normal file
Binary file not shown.
BIN
data/Product_types_import.xlsx
Normal file
BIN
data/Product_types_import.xlsx
Normal file
Binary file not shown.
BIN
data/Products_import.xlsx
Normal file
BIN
data/Products_import.xlsx
Normal file
Binary file not shown.
221
database.py
Normal file
221
database.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
|
||||
DATABASE = 'partners.db'
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""Создает подключение к базе данных с правильными настройками"""
|
||||
conn = sqlite3.connect(DATABASE, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db():
|
||||
"""Контекстный менеджер для безопасной работы с БД"""
|
||||
conn = None
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
yield conn
|
||||
if conn:
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
if conn:
|
||||
conn.rollback()
|
||||
raise e
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_tables():
|
||||
"""Создает все таблицы в базе данных"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.executescript('''
|
||||
DROP TABLE IF EXISTS order_items;
|
||||
DROP TABLE IF EXISTS orders;
|
||||
DROP TABLE IF EXISTS products;
|
||||
DROP TABLE IF EXISTS partners;
|
||||
DROP TABLE IF EXISTS product_types;
|
||||
|
||||
CREATE TABLE product_types (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type_name VARCHAR(100) NOT NULL UNIQUE,
|
||||
description TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE partners (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
director VARCHAR(255),
|
||||
email VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(50),
|
||||
legal_address TEXT,
|
||||
inn VARCHAR(20) UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_type_id INTEGER NOT NULL,
|
||||
product_name VARCHAR(255) NOT NULL,
|
||||
article VARCHAR(100) UNIQUE NOT NULL,
|
||||
min_partner_price DECIMAL(15,2) NOT NULL CHECK (min_partner_price > 0),
|
||||
FOREIGN KEY (product_type_id) REFERENCES product_types(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
partner_id INTEGER NOT NULL,
|
||||
order_date DATE NOT NULL,
|
||||
notes TEXT,
|
||||
discount_percent INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (partner_id) REFERENCES partners(id) ON DELETE RESTRICT
|
||||
);
|
||||
|
||||
CREATE TABLE order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
product_id INTEGER NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
price_at_order DECIMAL(15,2) NOT NULL CHECK (price_at_order > 0),
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE RESTRICT
|
||||
);
|
||||
''')
|
||||
|
||||
print("✅ Таблицы успешно созданы")
|
||||
|
||||
|
||||
def calculate_discount(total_amount):
|
||||
"""Рассчитывает скидку на основе общей суммы закупок за год"""
|
||||
if total_amount >= 300000:
|
||||
return 50
|
||||
elif total_amount >= 200000:
|
||||
return 30
|
||||
elif total_amount >= 150000:
|
||||
return 15
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
def get_discount_description(discount_percent):
|
||||
"""Возвращает описание скидки"""
|
||||
if discount_percent == 50:
|
||||
return '50% (300 000+ руб.)'
|
||||
elif discount_percent == 30:
|
||||
return '30% (200 000 - 299 999 руб.)'
|
||||
elif discount_percent == 15:
|
||||
return '15% (150 000 - 199 999 руб.)'
|
||||
else:
|
||||
return '0% (до 150 000 руб.)'
|
||||
|
||||
|
||||
def calculate_order_total(order_id):
|
||||
"""Рассчитывает общую стоимость заказа с учетом скидки и добавочной стоимости 10%"""
|
||||
try:
|
||||
with get_db() as conn:
|
||||
order_info = conn.execute('''
|
||||
SELECT o.*, p.id as partner_id, p.name as partner_name
|
||||
FROM orders o
|
||||
JOIN partners p ON o.partner_id = p.id
|
||||
WHERE o.id = ?
|
||||
''', (order_id,)).fetchone()
|
||||
|
||||
if not order_info:
|
||||
return None
|
||||
|
||||
items = conn.execute('''
|
||||
SELECT oi.*, p.product_name
|
||||
FROM order_items oi
|
||||
JOIN products p ON oi.product_id = p.id
|
||||
WHERE oi.order_id = ?
|
||||
''', (order_id,)).fetchall()
|
||||
|
||||
subtotal_without_markup = 0
|
||||
items_list = []
|
||||
|
||||
for item in items:
|
||||
# Базовая стоимость = количество × цена
|
||||
base_item_subtotal = item['quantity'] * item['price_at_order']
|
||||
# Добавляем 10% добавочной стоимости
|
||||
item_subtotal = base_item_subtotal * 1.1
|
||||
subtotal_without_markup += base_item_subtotal
|
||||
items_list.append({
|
||||
'id': item['id'],
|
||||
'product_name': item['product_name'],
|
||||
'quantity': item['quantity'],
|
||||
'price': item['price_at_order'],
|
||||
'base_subtotal': round(base_item_subtotal, 2),
|
||||
'subtotal_with_markup': round(item_subtotal, 2)
|
||||
})
|
||||
|
||||
# Общая стоимость с добавочной стоимостью (без скидки)
|
||||
subtotal_with_markup = subtotal_without_markup * 1.1
|
||||
|
||||
discount_percent = order_info['discount_percent'] if order_info['discount_percent'] else 0
|
||||
discount_desc = get_discount_description(discount_percent)
|
||||
|
||||
discount_amount = subtotal_with_markup * discount_percent / 100
|
||||
total = subtotal_with_markup - discount_amount
|
||||
|
||||
return {
|
||||
'order_id': order_id,
|
||||
'partner_id': order_info['partner_id'],
|
||||
'partner_name': order_info['partner_name'],
|
||||
'order_date': order_info['order_date'],
|
||||
'notes': order_info['notes'] if order_info['notes'] else '',
|
||||
'items': items_list,
|
||||
'items_count': len(items_list),
|
||||
'subtotal_without_markup': round(subtotal_without_markup, 2),
|
||||
'subtotal_with_markup': round(subtotal_with_markup, 2),
|
||||
'discount_percent': discount_percent,
|
||||
'discount_desc': discount_desc,
|
||||
'discount_amount': round(discount_amount, 2),
|
||||
'total': round(total, 2)
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Ошибка при расчете стоимости заказа {order_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def calculate_discount_for_new_order(partner_id, order_date, current_order_amount_without_markup,
|
||||
exclude_order_id=None):
|
||||
"""Рассчитывает скидку для нового заказа на основе предыдущих заказов в этом году"""
|
||||
try:
|
||||
with get_db() as conn:
|
||||
year = order_date[:4] if order_date else str(datetime.now().year)
|
||||
|
||||
query = '''
|
||||
SELECT id
|
||||
FROM orders
|
||||
WHERE partner_id = ? AND strftime('%Y', order_date) = ?
|
||||
'''
|
||||
params = [partner_id, year]
|
||||
|
||||
if exclude_order_id:
|
||||
query += ' AND id != ?'
|
||||
params.append(exclude_order_id)
|
||||
|
||||
previous_orders = conn.execute(query, params).fetchall()
|
||||
|
||||
total_previous_without_markup = 0
|
||||
for prev_order in previous_orders:
|
||||
order_total = conn.execute('''
|
||||
SELECT COALESCE(SUM(quantity * price_at_order), 0) as total
|
||||
FROM order_items WHERE order_id = ?
|
||||
''', (prev_order['id'],)).fetchone()['total']
|
||||
total_previous_without_markup += order_total
|
||||
|
||||
total_with_current_without_markup = total_previous_without_markup + current_order_amount_without_markup
|
||||
|
||||
return calculate_discount(total_with_current_without_markup)
|
||||
except Exception as e:
|
||||
print(f"Ошибка при расчете скидки: {e}")
|
||||
return 0
|
||||
420
import_data.py
Normal file
420
import_data.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import sqlite3
|
||||
import pandas as pd
|
||||
import os
|
||||
from database import get_db, create_tables
|
||||
|
||||
|
||||
def reset_database():
|
||||
"""Полностью пересоздает базу данных"""
|
||||
if os.path.exists('partners.db'):
|
||||
os.remove('partners.db')
|
||||
create_tables()
|
||||
print("✅ База данных пересоздана")
|
||||
|
||||
|
||||
def import_data_from_files():
|
||||
"""Загружает данные из Excel файлов в базу данных"""
|
||||
try:
|
||||
# Сначала пересоздаем базу данных
|
||||
reset_database()
|
||||
|
||||
print("\n📁 Введите имена файлов (или нажмите Enter для использования файлов по умолчанию):")
|
||||
|
||||
partners_file = input("Файл с партнерами (Partners_import.xlsx): ").strip()
|
||||
if not partners_file:
|
||||
partners_file = "data/Partners_import.xlsx"
|
||||
|
||||
product_types_file = input("Файл с типами товаров (Product_types_import.xlsx): ").strip()
|
||||
if not product_types_file:
|
||||
product_types_file = "data/Product_types_import.xlsx"
|
||||
|
||||
products_file = input("Файл с товарами (Products_import.xlsx): ").strip()
|
||||
if not products_file:
|
||||
products_file = "data/Products_import.xlsx"
|
||||
|
||||
orders_file = input("Файл с заказами (Orders_import.xlsx): ").strip()
|
||||
if not orders_file:
|
||||
orders_file = "data/Orders_import.xlsx"
|
||||
|
||||
order_items_file = input("Файл с позициями заказов (Order_items_import.xlsx): ").strip()
|
||||
if not order_items_file:
|
||||
order_items_file = "data/Order_items_import.xlsx"
|
||||
|
||||
# Проверяем существование файлов
|
||||
files_to_check = [partners_file, product_types_file, products_file, orders_file, order_items_file]
|
||||
for file in files_to_check:
|
||||
if not os.path.exists(file):
|
||||
print(f"❌ Файл не найден: {file}")
|
||||
return
|
||||
|
||||
with get_db() as conn:
|
||||
# Включаем поддержку внешних ключей
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# 1. Загрузка типов товаров
|
||||
print("\n1. 📦 Загрузка типов товаров...")
|
||||
types_df = pd.read_excel(product_types_file)
|
||||
|
||||
type_name_col = None
|
||||
for col in ['type_name', 'Тип товара', 'name', 'название']:
|
||||
if col in types_df.columns:
|
||||
type_name_col = col
|
||||
break
|
||||
|
||||
description_col = None
|
||||
for col in ['description', 'Описание', 'desc']:
|
||||
if col in types_df.columns:
|
||||
description_col = col
|
||||
break
|
||||
|
||||
if not type_name_col:
|
||||
print("❌ Не найден столбец с названием типа товара")
|
||||
return
|
||||
|
||||
type_count = 0
|
||||
for _, row in types_df.iterrows():
|
||||
try:
|
||||
type_name = str(row[type_name_col])
|
||||
description = str(row[description_col]) if description_col and pd.notna(
|
||||
row.get(description_col)) else ''
|
||||
|
||||
conn.execute('''
|
||||
INSERT INTO product_types (type_name, description)
|
||||
VALUES (?, ?)
|
||||
''', (type_name, description))
|
||||
type_count += 1
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Ошибка при загрузке типа товара: {e}")
|
||||
continue
|
||||
print(f" Загружено {type_count} типов товаров")
|
||||
|
||||
# 2. Загрузка партнеров
|
||||
print("2. 🏢 Загрузка партнеров...")
|
||||
partners_df = pd.read_excel(partners_file)
|
||||
|
||||
partner_cols = {}
|
||||
|
||||
for col in ['partner_name', 'Наименование партнера', 'name', 'supplier_name', 'название']:
|
||||
if col in partners_df.columns:
|
||||
partner_cols['name'] = col
|
||||
break
|
||||
|
||||
for col in ['director', 'Директор']:
|
||||
if col in partners_df.columns:
|
||||
partner_cols['director'] = col
|
||||
break
|
||||
|
||||
for col in ['email', 'Электронная почта', 'почта']:
|
||||
if col in partners_df.columns:
|
||||
partner_cols['email'] = col
|
||||
break
|
||||
|
||||
for col in ['phone', 'Телефон']:
|
||||
if col in partners_df.columns:
|
||||
partner_cols['phone'] = col
|
||||
break
|
||||
|
||||
for col in ['legal_address', 'Юридический адрес', 'address', 'адрес']:
|
||||
if col in partners_df.columns:
|
||||
partner_cols['address'] = col
|
||||
break
|
||||
|
||||
for col in ['inn', 'ИНН']:
|
||||
if col in partners_df.columns:
|
||||
partner_cols['inn'] = col
|
||||
break
|
||||
|
||||
for col in ['rating', 'Рейтинг']:
|
||||
if col in partners_df.columns:
|
||||
partner_cols['rating'] = col
|
||||
break
|
||||
|
||||
if 'name' not in partner_cols or 'email' not in partner_cols or 'inn' not in partner_cols:
|
||||
print("❌ Не найдены обязательные колонки для партнеров")
|
||||
print(" Доступные колонки:", list(partners_df.columns))
|
||||
return
|
||||
|
||||
partner_count = 0
|
||||
for _, row in partners_df.iterrows():
|
||||
try:
|
||||
name = str(row[partner_cols['name']])
|
||||
email = str(row[partner_cols['email']])
|
||||
inn = str(row[partner_cols['inn']])
|
||||
|
||||
director = str(row.get(partner_cols.get('director'), '')) if 'director' in partner_cols else ''
|
||||
phone = str(row.get(partner_cols.get('phone'), '')) if 'phone' in partner_cols else ''
|
||||
address = str(row.get(partner_cols.get('address'), '')) if 'address' in partner_cols else ''
|
||||
|
||||
rating_val = row.get(partner_cols.get('rating'), 0) if 'rating' in partner_cols else 0
|
||||
try:
|
||||
rating = float(rating_val) if rating_val else 0.0
|
||||
except:
|
||||
rating = 0.0
|
||||
|
||||
conn.execute('''
|
||||
INSERT INTO partners
|
||||
(name, director, email, phone, legal_address, inn, rating)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (name, director, email, phone, address, inn, rating))
|
||||
partner_count += 1
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Ошибка при загрузке партнера: {e}")
|
||||
continue
|
||||
|
||||
print(f" Загружено {partner_count} партнеров")
|
||||
|
||||
# 3. Загрузка товаров
|
||||
print("3. 📝 Загрузка товаров...")
|
||||
products_df = pd.read_excel(products_file)
|
||||
|
||||
print(" Доступные колонки в файле товаров:", list(products_df.columns))
|
||||
|
||||
product_cols = {}
|
||||
|
||||
for col in ['product_type_id', 'ID типа', 'type_id', 'тип_id']:
|
||||
if col in products_df.columns:
|
||||
product_cols['type_id'] = col
|
||||
break
|
||||
|
||||
for col in ['product_name', 'Наименование', 'name', 'товар']:
|
||||
if col in products_df.columns:
|
||||
product_cols['name'] = col
|
||||
break
|
||||
|
||||
for col in ['article', 'Артикул', 'артикул']:
|
||||
if col in products_df.columns:
|
||||
product_cols['article'] = col
|
||||
break
|
||||
|
||||
for col in ['min_price', 'Минимальная цена', 'price', 'цена', 'min_partner_price']:
|
||||
if col in products_df.columns:
|
||||
product_cols['price'] = col
|
||||
break
|
||||
|
||||
for col in ['stock_quantity', 'Остаток', 'stock', 'quantity_in_stock', 'количество']:
|
||||
if col in products_df.columns:
|
||||
product_cols['stock'] = col
|
||||
break
|
||||
|
||||
missing_cols = []
|
||||
if 'type_id' not in product_cols:
|
||||
missing_cols.append('ID типа товара (product_type_id)')
|
||||
if 'name' not in product_cols:
|
||||
missing_cols.append('Название товара (product_name)')
|
||||
if 'article' not in product_cols:
|
||||
missing_cols.append('Артикул (article)')
|
||||
if 'price' not in product_cols:
|
||||
missing_cols.append('Цена (min_price)')
|
||||
|
||||
if missing_cols:
|
||||
print(f"❌ Не найдены обязательные колонки: {', '.join(missing_cols)}")
|
||||
return
|
||||
|
||||
# Получаем все существующие ID типов товаров
|
||||
existing_type_ids = [row[0] for row in conn.execute('SELECT id FROM product_types').fetchall()]
|
||||
print(f" Существующие ID типов товаров: {existing_type_ids}")
|
||||
|
||||
products_loaded = 0
|
||||
for index, row in products_df.iterrows():
|
||||
try:
|
||||
product_type_id = int(row[product_cols['type_id']])
|
||||
product_name = str(row[product_cols['name']])
|
||||
article = str(row[product_cols['article']])
|
||||
|
||||
price_val = row[product_cols['price']]
|
||||
if pd.isna(price_val):
|
||||
print(f" ⚠️ Пропуск товара {product_name}: пустая цена")
|
||||
continue
|
||||
price = float(price_val)
|
||||
|
||||
stock_val = row.get(product_cols.get('stock'), 0) if 'stock' in product_cols else 0
|
||||
if pd.isna(stock_val):
|
||||
stock = 0
|
||||
else:
|
||||
stock = int(stock_val)
|
||||
|
||||
if price <= 0:
|
||||
print(f" ⚠️ Некорректная цена для товара {product_name}: {price}")
|
||||
continue
|
||||
|
||||
if product_type_id not in existing_type_ids:
|
||||
print(f" ⚠️ Тип товара с ID {product_type_id} не найден. Доступные ID: {existing_type_ids}")
|
||||
continue
|
||||
|
||||
conn.execute('''
|
||||
INSERT INTO products
|
||||
(product_type_id, product_name, article, min_partner_price, quantity_in_stock)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (product_type_id, product_name, article, price, max(0, stock)))
|
||||
products_loaded += 1
|
||||
print(f" ✓ Загружен товар: {product_name} (ID типа: {product_type_id})")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Ошибка при загрузке товара в строке {index + 2}: {e}")
|
||||
continue
|
||||
|
||||
print(f" Загружено {products_loaded} товаров")
|
||||
|
||||
# 4. Загрузка заказов
|
||||
print("4. 📋 Загрузка заказов...")
|
||||
orders_df = pd.read_excel(orders_file)
|
||||
|
||||
print(" Доступные колонки в файле заказов:", list(orders_df.columns))
|
||||
|
||||
order_cols = {}
|
||||
|
||||
for col in ['partner_id', 'ID партнера', 'partner', 'партнер']:
|
||||
if col in orders_df.columns:
|
||||
order_cols['partner_id'] = col
|
||||
break
|
||||
|
||||
for col in ['order_date', 'Дата заказа', 'date', 'дата']:
|
||||
if col in orders_df.columns:
|
||||
order_cols['date'] = col
|
||||
break
|
||||
|
||||
for col in ['status', 'Статус']:
|
||||
if col in orders_df.columns:
|
||||
order_cols['status'] = col
|
||||
break
|
||||
|
||||
for col in ['notes', 'Примечания', 'note']:
|
||||
if col in orders_df.columns:
|
||||
order_cols['notes'] = col
|
||||
break
|
||||
|
||||
if 'partner_id' not in order_cols or 'date' not in order_cols:
|
||||
print("❌ Не найдены обязательные колонки для заказов")
|
||||
return
|
||||
|
||||
existing_partner_ids = [row[0] for row in conn.execute('SELECT id FROM partners').fetchall()]
|
||||
|
||||
orders_loaded = 0
|
||||
for _, row in orders_df.iterrows():
|
||||
try:
|
||||
partner_id = int(row[order_cols['partner_id']])
|
||||
|
||||
if partner_id not in existing_partner_ids:
|
||||
print(f" ⚠️ Партнер с ID {partner_id} не найден")
|
||||
continue
|
||||
|
||||
order_date = row[order_cols['date']]
|
||||
if isinstance(order_date, pd.Timestamp):
|
||||
order_date = order_date.strftime('%Y-%m-%d')
|
||||
else:
|
||||
order_date = str(order_date)
|
||||
|
||||
status = str(row.get(order_cols.get('status'), 'новый')) if 'status' in order_cols and pd.notna(
|
||||
row.get(order_cols['status'])) else 'новый'
|
||||
notes = str(row.get(order_cols.get('notes'), '')) if 'notes' in order_cols and pd.notna(
|
||||
row.get(order_cols['notes'])) else ''
|
||||
|
||||
conn.execute('''
|
||||
INSERT INTO orders (partner_id, order_date, status, notes, discount_percent)
|
||||
VALUES (?, ?, ?, ?, 0)
|
||||
''', (partner_id, order_date, status, notes))
|
||||
orders_loaded += 1
|
||||
print(f" ✓ Загружен заказ: ID {partner_id}, дата {order_date}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Ошибка при загрузке заказа: {e}")
|
||||
continue
|
||||
|
||||
print(f" Загружено {orders_loaded} заказов")
|
||||
|
||||
# 5. Загрузка позиций заказов
|
||||
print("5. 📦 Загрузка позиций заказов...")
|
||||
items_df = pd.read_excel(order_items_file)
|
||||
|
||||
print(" Доступные колонки в файле позиций:", list(items_df.columns))
|
||||
|
||||
item_cols = {}
|
||||
|
||||
for col in ['order_id', 'Номер заказа', 'order', 'заказ']:
|
||||
if col in items_df.columns:
|
||||
item_cols['order_id'] = col
|
||||
break
|
||||
|
||||
for col in ['product_id', 'ID товара', 'product', 'товар']:
|
||||
if col in items_df.columns:
|
||||
item_cols['product_id'] = col
|
||||
break
|
||||
|
||||
for col in ['quantity', 'Количество', 'qty', 'кол']:
|
||||
if col in items_df.columns:
|
||||
item_cols['quantity'] = col
|
||||
break
|
||||
|
||||
for col in ['price', 'Цена', 'price_at_order']:
|
||||
if col in items_df.columns:
|
||||
item_cols['price'] = col
|
||||
break
|
||||
|
||||
missing_cols = []
|
||||
if 'order_id' not in item_cols:
|
||||
missing_cols.append('order_id')
|
||||
if 'product_id' not in item_cols:
|
||||
missing_cols.append('product_id')
|
||||
if 'quantity' not in item_cols:
|
||||
missing_cols.append('quantity')
|
||||
if 'price' not in item_cols:
|
||||
missing_cols.append('price')
|
||||
|
||||
if missing_cols:
|
||||
print(f"❌ Не найдены обязательные колонки: {', '.join(missing_cols)}")
|
||||
return
|
||||
|
||||
existing_order_ids = [row[0] for row in conn.execute('SELECT id FROM orders').fetchall()]
|
||||
existing_product_ids = [row[0] for row in conn.execute('SELECT id FROM products').fetchall()]
|
||||
|
||||
items_loaded = 0
|
||||
for index, row in items_df.iterrows():
|
||||
try:
|
||||
order_id = int(row[item_cols['order_id']])
|
||||
product_id = int(row[item_cols['product_id']])
|
||||
quantity = int(row[item_cols['quantity']])
|
||||
price = float(row[item_cols['price']])
|
||||
|
||||
if quantity <= 0:
|
||||
print(f" ⚠️ Некорректное количество для заказа {order_id}: {quantity}")
|
||||
continue
|
||||
if price <= 0:
|
||||
print(f" ⚠️ Некорректная цена для заказа {order_id}: {price}")
|
||||
continue
|
||||
|
||||
if order_id not in existing_order_ids:
|
||||
print(f" ⚠️ Заказ не найден: {order_id}")
|
||||
continue
|
||||
|
||||
if product_id not in existing_product_ids:
|
||||
print(f" ⚠️ Товар с ID {product_id} не найден")
|
||||
continue
|
||||
|
||||
conn.execute('''
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_at_order)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (order_id, product_id, quantity, price))
|
||||
items_loaded += 1
|
||||
print(f" ✓ Загружена позиция: заказ {order_id}, товар {product_id}")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Ошибка при загрузке позиции в строке {index + 2}: {e}")
|
||||
continue
|
||||
|
||||
print(f" Загружено {items_loaded} позиций заказов")
|
||||
|
||||
print(f"\n✅ Данные успешно загружены!")
|
||||
print(f" Типов товаров: {type_count}")
|
||||
print(f" Партнеров: {partner_count}")
|
||||
print(f" Товаров: {products_loaded}")
|
||||
print(f" Заказов: {orders_loaded}")
|
||||
print(f" Позиций: {items_loaded}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Ошибка при загрузке данных: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import_data_from_files()
|
||||
5
init_db.py
Normal file
5
init_db.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from database import create_tables
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_tables()
|
||||
print("✅ База данных инициализирована")
|
||||
BIN
partners.db
Normal file
BIN
partners.db
Normal file
Binary file not shown.
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
Flask==2.3.3
|
||||
pandas==2.0.3
|
||||
openpyxl==3.1.2
|
||||
681
static/css/style.css
Normal file
681
static/css/style.css
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
/* Основные стили согласно ТЗ */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Calibri', sans-serif;
|
||||
background-color: #F5F5F5; /* белый дым */
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* Шапка */
|
||||
header {
|
||||
background-color: #696969; /* темно-серый */
|
||||
color: white;
|
||||
padding: 15px 0;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
header .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
height: 50px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Навигация */
|
||||
nav ul {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
nav ul li a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 8px 15px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
nav ul li a:hover {
|
||||
background-color: #E0E0E0; /* светло-серый */
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
nav ul li a.active {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Поиск */
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.search-bar form {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.search-bar input,
|
||||
.search-bar select {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-bar button {
|
||||
padding: 8px 15px;
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.search-bar button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Основной контент */
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 2rem;
|
||||
color: #696969;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Кнопки */
|
||||
.btn-export,
|
||||
.btn-import,
|
||||
.btn-view,
|
||||
.btn-edit,
|
||||
.btn-back,
|
||||
.btn-submit {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-export {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-import {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
background-color: #F5F5F5;
|
||||
color: #696969;
|
||||
border: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background-color: #F5F5F5;
|
||||
color: #696969;
|
||||
border: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
padding: 12px 30px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.btn-export:hover,
|
||||
.btn-submit:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-import:hover,
|
||||
.btn-edit:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
/* Карточки заказов */
|
||||
.orders-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.order-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.order-header {
|
||||
background-color: #E0E0E0;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.order-header h3 {
|
||||
font-size: 1.2rem;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-новый {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-в_обработке {
|
||||
background-color: #FFC107;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-завершен {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.order-body {
|
||||
padding: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.order-body p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.order-items-preview {
|
||||
margin: 15px 0;
|
||||
padding: 10px;
|
||||
background-color: #F5F5F5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.order-items-preview ul {
|
||||
margin-top: 5px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.order-total {
|
||||
margin-top: 15px;
|
||||
padding-top: 10px;
|
||||
border-top: 2px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.subtotal {
|
||||
font-size: 0.95rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.discount {
|
||||
color: #4CAF50;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
padding: 15px;
|
||||
background-color: #F5F5F5;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Таблицы */
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.items-table thead {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.items-table th,
|
||||
.items-table td {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.items-table tbody tr {
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.items-table tbody tr:hover {
|
||||
background-color: #F5F5F5;
|
||||
}
|
||||
|
||||
.items-table tfoot {
|
||||
background-color: #E0E0E0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.items-table .total-row {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Формы */
|
||||
.order-form {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
color: #696969;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #696969;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
margin-bottom: 15px;
|
||||
padding: 15px;
|
||||
background-color: #F5F5F5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.item-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.item-total {
|
||||
padding-bottom: 10px;
|
||||
font-weight: 600;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.btn-add-item,
|
||||
.btn-remove-item {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-add-item {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-remove-item {
|
||||
background-color: #ff6b6b;
|
||||
color: white;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.btn-add-item:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-remove-item:hover {
|
||||
background-color: #ff5252;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
/* Карточки товаров и поставщиков */
|
||||
.products-grid,
|
||||
.suppliers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.product-card,
|
||||
.supplier-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.product-card:hover,
|
||||
.supplier-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.product-header,
|
||||
.supplier-header {
|
||||
background-color: #E0E0E0;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.product-header h3,
|
||||
.supplier-header h3 {
|
||||
color: #696969;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.product-body,
|
||||
.supplier-body {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.product-body p,
|
||||
.supplier-body p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.in-stock {
|
||||
color: #4CAF50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.out-of-stock {
|
||||
color: #ff6b6b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-footer,
|
||||
.supplier-footer {
|
||||
padding: 15px;
|
||||
background-color: #F5F5F5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Результаты поиска */
|
||||
.search-results {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.result-type {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.result-type.order {
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.result-type.product {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.result-type.supplier {
|
||||
background-color: #FF9800;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Уведомления */
|
||||
.alert {
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
animation: slideIn 0.3s;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeeba;
|
||||
}
|
||||
|
||||
/* Пустое состояние */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Футер */
|
||||
footer {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
header .container {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
max-width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.orders-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.item-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.items-table {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.items-table th,
|
||||
.items-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
BIN
static/img/logo.png
Normal file
BIN
static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
193
static/js/script.js
Normal file
193
static/js/script.js
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// Основной JavaScript файл
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Автоматическое скрытие уведомлений через 5 секунд
|
||||
const alerts = document.querySelectorAll('.alert');
|
||||
alerts.forEach(alert => {
|
||||
setTimeout(() => {
|
||||
alert.style.transition = 'opacity 0.5s';
|
||||
alert.style.opacity = '0';
|
||||
setTimeout(() => alert.remove(), 500);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Валидация форм на клиенте
|
||||
const forms = document.querySelectorAll('form[data-validate]');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
if (!validateForm(form)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Подтверждение удаления
|
||||
const deleteButtons = document.querySelectorAll('[data-confirm]');
|
||||
deleteButtons.forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
if (!confirm(this.dataset.confirm || 'Вы уверены?')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Форматирование чисел
|
||||
formatNumbers();
|
||||
});
|
||||
|
||||
function validateForm(form) {
|
||||
let isValid = true;
|
||||
const requiredFields = form.querySelectorAll('[required]');
|
||||
|
||||
requiredFields.forEach(field => {
|
||||
if (!field.value.trim()) {
|
||||
showFieldError(field, 'Это поле обязательно для заполнения');
|
||||
isValid = false;
|
||||
} else {
|
||||
clearFieldError(field);
|
||||
}
|
||||
|
||||
// Проверка на отрицательные числа
|
||||
if (field.type === 'number' && field.min && parseFloat(field.value) < parseFloat(field.min)) {
|
||||
showFieldError(field, `Значение должно быть не меньше ${field.min}`);
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
// Проверка email
|
||||
if (field.type === 'email' && field.value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(field.value)) {
|
||||
showFieldError(field, 'Введите корректный email');
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function showFieldError(field, message) {
|
||||
clearFieldError(field);
|
||||
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'field-error';
|
||||
errorDiv.style.color = '#ff6b6b';
|
||||
errorDiv.style.fontSize = '0.85rem';
|
||||
errorDiv.style.marginTop = '5px';
|
||||
errorDiv.textContent = message;
|
||||
|
||||
field.style.borderColor = '#ff6b6b';
|
||||
field.parentNode.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function clearFieldError(field) {
|
||||
field.style.borderColor = '';
|
||||
const error = field.parentNode.querySelector('.field-error');
|
||||
if (error) {
|
||||
error.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumbers() {
|
||||
// Форматирование цен
|
||||
const priceElements = document.querySelectorAll('.format-price');
|
||||
priceElements.forEach(el => {
|
||||
const price = parseFloat(el.textContent);
|
||||
if (!isNaN(price)) {
|
||||
el.textContent = price.toFixed(2).replace(/\d(?=(\d{3})+\.)/g, '$&,') + ' ₽';
|
||||
}
|
||||
});
|
||||
|
||||
// Форматирование количества
|
||||
const quantityElements = document.querySelectorAll('.format-quantity');
|
||||
quantityElements.forEach(el => {
|
||||
const quantity = parseInt(el.textContent);
|
||||
if (!isNaN(quantity)) {
|
||||
el.textContent = quantity.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Фильтрация таблиц
|
||||
function filterTable(inputId, tableId) {
|
||||
const input = document.getElementById(inputId);
|
||||
const filter = input.value.toUpperCase();
|
||||
const table = document.getElementById(tableId);
|
||||
const rows = table.getElementsByTagName('tr');
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const cells = rows[i].getElementsByTagName('td');
|
||||
let found = false;
|
||||
|
||||
for (let j = 0; j < cells.length; j++) {
|
||||
const cell = cells[j];
|
||||
if (cell) {
|
||||
const textValue = cell.textContent || cell.innerText;
|
||||
if (textValue.toUpperCase().indexOf(filter) > -1) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows[i].style.display = found ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Экспорт в CSV
|
||||
function exportToCSV(tableId, filename) {
|
||||
const table = document.getElementById(tableId);
|
||||
const rows = table.querySelectorAll('tr');
|
||||
const csv = [];
|
||||
|
||||
rows.forEach(row => {
|
||||
const cols = row.querySelectorAll('td, th');
|
||||
const rowData = [];
|
||||
cols.forEach(col => {
|
||||
rowData.push('"' + col.innerText.replace(/"/g, '""') + '"');
|
||||
});
|
||||
csv.push(rowData.join(','));
|
||||
});
|
||||
|
||||
const csvContent = csv.join('\n');
|
||||
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename || 'export.csv');
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
// Печать
|
||||
function printElement(elementId) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Печать</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<style>
|
||||
body { padding: 20px; }
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${element.outerHTML}
|
||||
<script>
|
||||
window.onload = function() { window.print(); window.close(); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
}
|
||||
455
templates/add_order.html
Normal file
455
templates/add_order.html
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Создание нового заказа</h2>
|
||||
<a href="{{ url_for('index') }}" class="btn-back">Отмена</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="order-form" id="orderForm">
|
||||
<div class="form-section">
|
||||
<h3>Информация о заказе</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="partner_id">Партнер *</label>
|
||||
<select name="partner_id" id="partner_id" required>
|
||||
<option value="">Выберите партнера</option>
|
||||
{% for partner in partners %}
|
||||
<option value="{{ partner.id }}">{{ partner.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="order_date">Дата заказа *</label>
|
||||
<input type="date" name="order_date" id="order_date" value="{{ today }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Примечания</label>
|
||||
<textarea name="notes" id="notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>Позиции заказа</h3>
|
||||
<p class="info-text">Выберите товары для заказа</p>
|
||||
|
||||
<div id="items-container">
|
||||
<div class="item-row" id="item-row-1">
|
||||
<div class="item-fields">
|
||||
<div class="form-group">
|
||||
<label>Товар</label>
|
||||
<select name="product_id[]" class="product-select" required onchange="updatePrice(this)">
|
||||
<option value="">Выберите товар</option>
|
||||
{% for product in products %}
|
||||
<option value="{{ product.id }}" data-price="{{ product.min_partner_price }}">
|
||||
{{ product.product_name }} ({{ product.type_name }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Количество</label>
|
||||
<input type="number" name="quantity[]" class="quantity-input"
|
||||
min="1" value="1" required oninput="updateItemTotal(this)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Цена</label>
|
||||
<input type="number" name="price[]" class="price-input"
|
||||
step="0.01" min="0.01" required readonly>
|
||||
</div>
|
||||
|
||||
<div class="item-total">
|
||||
<label>Сумма с наценкой:</label>
|
||||
<span class="item-total-value">0.00 ₽</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-remove-item" onclick="removeItem(1)" style="display: none;">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-add-item" onclick="addItem()">+ Добавить позицию</button>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-submit">Создать заказ</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
let itemCount = 1;
|
||||
|
||||
function addItem() {
|
||||
itemCount++;
|
||||
const container = document.getElementById('items-container');
|
||||
const newRow = document.createElement('div');
|
||||
newRow.className = 'item-row';
|
||||
newRow.id = `item-row-${itemCount}`;
|
||||
|
||||
let options = '<option value="">Выберите товар</option>';
|
||||
{% for product in products %}
|
||||
options += `<option value="{{ product.id }}" data-price="{{ product.min_partner_price }}">
|
||||
{{ product.product_name }} ({{ product.type_name }})
|
||||
</option>`;
|
||||
{% endfor %}
|
||||
|
||||
newRow.innerHTML = `
|
||||
<div class="item-fields">
|
||||
<div class="form-group">
|
||||
<label>Товар</label>
|
||||
<select name="product_id[]" class="product-select" required onchange="updatePrice(this)">
|
||||
${options}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Количество</label>
|
||||
<input type="number" name="quantity[]" class="quantity-input"
|
||||
min="1" value="1" required oninput="updateItemTotal(this)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Цена</label>
|
||||
<input type="number" name="price[]" class="price-input"
|
||||
step="0.01" min="0.01" required readonly>
|
||||
</div>
|
||||
|
||||
<div class="item-total">
|
||||
<label>Сумма с наценкой:</label>
|
||||
<span class="item-total-value">0.00 ₽</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-remove-item" onclick="removeItem(${itemCount})">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(newRow);
|
||||
updateRemoveButtons();
|
||||
}
|
||||
|
||||
function removeItem(id) {
|
||||
const row = document.getElementById(`item-row-${id}`);
|
||||
if (row) {
|
||||
row.remove();
|
||||
updateRemoveButtons();
|
||||
}
|
||||
}
|
||||
|
||||
function updateRemoveButtons() {
|
||||
const rows = document.querySelectorAll('.item-row');
|
||||
rows.forEach((row, index) => {
|
||||
const btn = row.querySelector('.btn-remove-item');
|
||||
if (btn) {
|
||||
btn.style.display = rows.length > 1 ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updatePrice(select) {
|
||||
const row = select.closest('.item-row');
|
||||
const priceInput = row.querySelector('.price-input');
|
||||
const selectedOption = select.options[select.selectedIndex];
|
||||
|
||||
if (selectedOption && selectedOption.dataset.price) {
|
||||
priceInput.value = selectedOption.dataset.price;
|
||||
}
|
||||
|
||||
updateItemTotal(priceInput);
|
||||
}
|
||||
|
||||
function updateItemTotal(input) {
|
||||
const row = input.closest('.item-row');
|
||||
const quantity = parseFloat(row.querySelector('.quantity-input').value) || 0;
|
||||
const price = parseFloat(row.querySelector('.price-input').value) || 0;
|
||||
const totalSpan = row.querySelector('.item-total-value');
|
||||
|
||||
const totalWithMarkup = quantity * price * 1.1;
|
||||
totalSpan.textContent = totalWithMarkup.toFixed(2) + ' ₽';
|
||||
}
|
||||
|
||||
document.getElementById('orderForm').addEventListener('submit', function(e) {
|
||||
const rows = document.querySelectorAll('.item-row');
|
||||
let hasValidItems = false;
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const select = row.querySelector('.product-select');
|
||||
const quantity = row.querySelector('.quantity-input');
|
||||
const price = row.querySelector('.price-input');
|
||||
|
||||
if (select.value && quantity.value && price.value) {
|
||||
hasValidItems = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasValidItems) {
|
||||
e.preventDefault();
|
||||
alert('Добавьте хотя бы одну позицию с корректными данными');
|
||||
}
|
||||
});
|
||||
|
||||
// Инициализация обработчиков
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const firstSelect = document.querySelector('.product-select');
|
||||
if (firstSelect) {
|
||||
updatePrice(firstSelect);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.quantity-input').forEach(input => {
|
||||
input.addEventListener('input', function() {
|
||||
updateItemTotal(this);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.order-form {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
color: #696969;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #696969;
|
||||
}
|
||||
|
||||
.stock-hint {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #696969;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stock-hint .max-stock {
|
||||
font-weight: 600;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
background-color: #F5F5F5;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 4px solid #696969;
|
||||
}
|
||||
|
||||
.item-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr auto;
|
||||
gap: 15px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.item-total {
|
||||
padding-top: 25px;
|
||||
font-weight: 600;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.item-total label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.btn-add-item {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-add-item:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-remove-item {
|
||||
background-color: #ff6b6b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.3s;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.btn-remove-item:hover {
|
||||
background-color: #ff5252;
|
||||
}
|
||||
|
||||
.total-section {
|
||||
background-color: #F5F5F5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
border: 2px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.total-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.total-label {
|
||||
font-size: 1.1rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 1.1rem;
|
||||
color: #696969;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.grand-total {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 2px solid #696969;
|
||||
}
|
||||
|
||||
.grand-total .total-label,
|
||||
.grand-total .total-value {
|
||||
font-size: 1.3rem;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.discount-info {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 40px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-block;
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
text-decoration: none;
|
||||
padding: 15px 40px;
|
||||
border-radius: 4px;
|
||||
margin-left: 10px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.item-fields {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.item-total {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.btn-remove-item {
|
||||
margin-top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
221
templates/add_partner.html
Normal file
221
templates/add_partner.html
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Добавление нового партнера</h2>
|
||||
<a href="{{ url_for('partners') }}" class="btn-back">Назад к списку</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="partner-form" onsubmit="return validateForm()">
|
||||
<div class="form-section">
|
||||
<h3>Основная информация</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Наименование партнера *</label>
|
||||
<input type="text" id="name" name="name" required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="director">Директор</label>
|
||||
<input type="text" id="director" name="director" maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group half">
|
||||
<label for="email">Email *</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group half">
|
||||
<label for="phone">Телефон</label>
|
||||
<input type="tel" id="phone" name="phone" placeholder="+7 (999) 123-45-67">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>Юридическая информация</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="legal_address">Юридический адрес</label>
|
||||
<textarea id="legal_address" name="legal_address" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group half">
|
||||
<label for="inn">ИНН *</label>
|
||||
<input type="text" id="inn" name="inn" required pattern="\d{10}|\d{12}"
|
||||
title="ИНН должен содержать 10 или 12 цифр">
|
||||
<small class="form-text">10 или 12 цифр</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-submit">Добавить партнера</button>
|
||||
<button type="reset" class="btn-reset">Очистить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function validateForm() {
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const inn = document.getElementById('inn').value.trim();
|
||||
|
||||
if (!name) {
|
||||
alert('Введите наименование партнера');
|
||||
return false;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
alert('Введите корректный email');
|
||||
return false;
|
||||
}
|
||||
|
||||
const innRegex = /^\d{10}$|^\d{12}$/;
|
||||
if (!innRegex.test(inn)) {
|
||||
alert('ИНН должен содержать 10 или 12 цифр');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Форматирование телефона
|
||||
document.getElementById('phone').addEventListener('input', function(e) {
|
||||
let x = e.target.value.replace(/\D/g, '').match(/(\d{0,1})(\d{0,3})(\d{0,3})(\d{0,2})(\d{0,2})/);
|
||||
e.target.value = !x[2] ? x[1] : '+' + x[1] + ' (' + x[2] + ') ' + x[3] + (x[4] ? '-' + x[4] : '') + (x[5] ? '-' + x[5] : '');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.partner-form {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
color: #696969;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group.half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #696969;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
display: block;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-block;
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
text-decoration: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
52
templates/base.html
Normal file
52
templates/base.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Канцелярские товары - Управление заказами</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="icon" href="{{ url_for('static', filename='img/favicon.ico') }}" type="image/x-icon">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<img src="{{ url_for('static', filename='img/logo.png') }}" alt="Логотип" height="50">
|
||||
<h1>Канцелярские товары</h1>
|
||||
</div>
|
||||
<nav>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('index') }}" {% if request.endpoint == 'index' %}class="active"{% endif %}>Заказы</a></li>
|
||||
<li><a href="{{ url_for('products') }}" {% if request.endpoint == 'products' %}class="active"{% endif %}>Товары</a></li>
|
||||
<li><a href="{{ url_for('partners') }}" {% if request.endpoint == 'partners' %}class="active"{% endif %}>Партнеры</a></li>
|
||||
<li><a href="{{ url_for('test_partner') }}" {% if request.endpoint == 'test_partner' %}class="active"{% endif %}>Тест партнера</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 Канцелярские товары. Все права защищены.</p>
|
||||
<p>Курсовой проект по МДК 01.01</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
412
templates/edit_order.html
Normal file
412
templates/edit_order.html
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Редактирование заказа #{{ order.id }}</h2>
|
||||
<a href="{{ url_for('index') }}" class="btn-back">Назад к списку</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="order-form" id="editOrderForm">
|
||||
<div class="form-section">
|
||||
<h3>Информация о заказе</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="order_date">Дата заказа *</label>
|
||||
<input type="date" name="order_date" id="order_date" value="{{ order.order_date }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Примечания</label>
|
||||
<textarea name="notes" id="notes" rows="3">{{ order.notes or '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>Позиции заказа</h3>
|
||||
<p class="info-text">Выберите товары для заказа</p>
|
||||
|
||||
<div id="items-container">
|
||||
{% for item in items %}
|
||||
<div class="item-row" id="item-row-{{ loop.index }}">
|
||||
<div class="item-fields">
|
||||
<div class="form-group">
|
||||
<label>Товар</label>
|
||||
<select name="product_id[]" class="product-select" onchange="updatePrice(this)">
|
||||
<option value="">Выберите товар</option>
|
||||
{% for product in products %}
|
||||
<option value="{{ product.id }}"
|
||||
data-price="{{ product.min_partner_price }}"
|
||||
{% if product.id == item.product_id %}selected{% endif %}>
|
||||
{{ product.product_name }} ({{ product.type_name }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Количество</label>
|
||||
<input type="number" name="quantity[]" class="quantity-input"
|
||||
min="1" value="{{ item.quantity }}" oninput="updateItemTotal(this)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Цена</label>
|
||||
<input type="number" name="price[]" class="price-input"
|
||||
step="0.01" min="0.01" value="{{ item.price_at_order }}" readonly>
|
||||
</div>
|
||||
|
||||
<div class="item-total">
|
||||
<label>Сумма с наценкой:</label>
|
||||
<span class="item-total-value">{{ "%.2f"|format(item.quantity * item.price_at_order * 1.1) }} ₽</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-remove-item" onclick="removeItem({{ loop.index }})"
|
||||
{% if items|length == 1 %}style="display: none;"{% endif %}>×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-add-item" onclick="addItem()">+ Добавить позицию</button>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-submit">Сохранить изменения</button>
|
||||
<a href="{{ url_for('index') }}" class="btn-back">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
let itemCount = {{ items|length }};
|
||||
|
||||
function addItem() {
|
||||
itemCount++;
|
||||
const container = document.getElementById('items-container');
|
||||
const newRow = document.createElement('div');
|
||||
newRow.className = 'item-row';
|
||||
newRow.id = `item-row-${itemCount}`;
|
||||
|
||||
let options = '<option value="">Выберите товар</option>';
|
||||
{% for product in products %}
|
||||
options += `<option value="{{ product.id }}" data-price="{{ product.min_partner_price }}">
|
||||
{{ product.product_name }} ({{ product.type_name }})
|
||||
</option>`;
|
||||
{% endfor %}
|
||||
|
||||
newRow.innerHTML = `
|
||||
<div class="item-fields">
|
||||
<div class="form-group">
|
||||
<label>Товар</label>
|
||||
<select name="product_id[]" class="product-select" onchange="updatePrice(this)">
|
||||
${options}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Количество</label>
|
||||
<input type="number" name="quantity[]" class="quantity-input"
|
||||
min="1" value="1" oninput="updateItemTotal(this)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Цена</label>
|
||||
<input type="number" name="price[]" class="price-input"
|
||||
step="0.01" min="0.01" readonly>
|
||||
</div>
|
||||
|
||||
<div class="item-total">
|
||||
<label>Сумма с наценкой:</label>
|
||||
<span class="item-total-value">0.00 ₽</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-remove-item" onclick="removeItem(${itemCount})">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(newRow);
|
||||
updateRemoveButtons();
|
||||
}
|
||||
|
||||
function removeItem(id) {
|
||||
const row = document.getElementById(`item-row-${id}`);
|
||||
if (row) {
|
||||
row.remove();
|
||||
updateRemoveButtons();
|
||||
}
|
||||
}
|
||||
|
||||
function updateRemoveButtons() {
|
||||
const rows = document.querySelectorAll('.item-row');
|
||||
rows.forEach((row, index) => {
|
||||
const btn = row.querySelector('.btn-remove-item');
|
||||
if (btn) {
|
||||
btn.style.display = rows.length > 1 ? 'block' : 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updatePrice(select) {
|
||||
const row = select.closest('.item-row');
|
||||
const priceInput = row.querySelector('.price-input');
|
||||
const selectedOption = select.options[select.selectedIndex];
|
||||
|
||||
if (selectedOption && selectedOption.dataset.price) {
|
||||
priceInput.value = selectedOption.dataset.price;
|
||||
} else {
|
||||
priceInput.value = '';
|
||||
}
|
||||
|
||||
updateItemTotal(priceInput);
|
||||
}
|
||||
|
||||
function updateItemTotal(input) {
|
||||
const row = input.closest('.item-row');
|
||||
const quantity = parseFloat(row.querySelector('.quantity-input').value) || 0;
|
||||
const price = parseFloat(row.querySelector('.price-input').value) || 0;
|
||||
const totalSpan = row.querySelector('.item-total-value');
|
||||
|
||||
const totalWithMarkup = quantity * price * 1.1;
|
||||
totalSpan.textContent = totalWithMarkup.toFixed(2) + ' ₽';
|
||||
}
|
||||
|
||||
document.getElementById('editOrderForm').addEventListener('submit', function(e) {
|
||||
const rows = document.querySelectorAll('.item-row');
|
||||
let hasValidItems = false;
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const select = row.querySelector('.product-select');
|
||||
const quantity = row.querySelector('.quantity-input');
|
||||
const price = row.querySelector('.price-input');
|
||||
|
||||
if (select.value && quantity.value && price.value) {
|
||||
hasValidItems = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasValidItems) {
|
||||
e.preventDefault();
|
||||
alert('Добавьте хотя бы одну позицию с корректными данными');
|
||||
}
|
||||
});
|
||||
|
||||
// Инициализация обработчиков
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.product-select').forEach(select => {
|
||||
select.addEventListener('change', function() {
|
||||
updatePrice(this);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.quantity-input').forEach(input => {
|
||||
input.addEventListener('input', function() {
|
||||
updateItemTotal(this);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.order-form {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
color: #696969;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #696969;
|
||||
}
|
||||
|
||||
.stock-hint {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #696969;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stock-hint .max-stock {
|
||||
font-weight: 600;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
background-color: #F5F5F5;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
border-left: 4px solid #696969;
|
||||
}
|
||||
|
||||
.item-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr auto;
|
||||
gap: 15px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.item-total {
|
||||
padding-top: 25px;
|
||||
font-weight: 600;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.item-total label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.btn-add-item {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-add-item:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-remove-item {
|
||||
background-color: #ff6b6b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.3s;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.btn-remove-item:hover {
|
||||
background-color: #ff5252;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-block;
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
text-decoration: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.item-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn-remove-item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-submit, .btn-delete, .btn-back {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
239
templates/edit_partner.html
Normal file
239
templates/edit_partner.html
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Редактирование партнера</h2>
|
||||
<a href="{{ url_for('partners') }}" class="btn-back">Назад к списку</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="partner-form" onsubmit="return validateForm()">
|
||||
<div class="form-section">
|
||||
<h3>Основная информация</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Наименование партнера *</label>
|
||||
<input type="text" id="name" name="name" value="{{ partner.name }}" required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="director">Директор</label>
|
||||
<input type="text" id="director" name="director" value="{{ partner.director or '' }}" maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group half">
|
||||
<label for="email">Email *</label>
|
||||
<input type="email" id="email" name="email" value="{{ partner.email }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group half">
|
||||
<label for="phone">Телефон</label>
|
||||
<input type="tel" id="phone" name="phone" value="{{ partner.phone or '' }}"
|
||||
placeholder="+7 (999) 123-45-67">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>Юридическая информация</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="legal_address">Юридический адрес</label>
|
||||
<textarea id="legal_address" name="legal_address" rows="3">{{ partner.legal_address or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group half">
|
||||
<label for="inn">ИНН *</label>
|
||||
<input type="text" id="inn" name="inn" value="{{ partner.inn }}" required
|
||||
pattern="\d{10}|\d{12}" title="ИНН должен содержать 10 или 12 цифр">
|
||||
<small class="form-text">10 или 12 цифр</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-submit">Сохранить изменения</button>
|
||||
<button type="button" class="btn-reset" onclick="resetForm()">Сбросить</button>
|
||||
<a href="{{ url_for('partners') }}" class="btn-back">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function validateForm() {
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const inn = document.getElementById('inn').value.trim();
|
||||
|
||||
if (!name) {
|
||||
alert('Введите наименование партнера');
|
||||
return false;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
alert('Введите корректный email');
|
||||
return false;
|
||||
}
|
||||
|
||||
const innRegex = /^\d{10}$|^\d{12}$/;
|
||||
if (!innRegex.test(inn)) {
|
||||
alert('ИНН должен содержать 10 или 12 цифр');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
if (confirm('Сбросить все изменения?')) {
|
||||
document.querySelector('.partner-form').reset();
|
||||
}
|
||||
}
|
||||
|
||||
// Форматирование телефона
|
||||
document.getElementById('phone').addEventListener('input', function(e) {
|
||||
let x = e.target.value.replace(/\D/g, '').match(/(\d{0,1})(\d{0,3})(\d{0,3})(\d{0,2})(\d{0,2})/);
|
||||
e.target.value = !x[2] ? x[1] : '+' + x[1] + ' (' + x[2] + ') ' + x[3] + (x[4] ? '-' + x[4] : '') + (x[5] ? '-' + x[5] : '');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.partner-form {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
color: #696969;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group.half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #696969;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
display: block;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-block;
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
text-decoration: none;
|
||||
padding: 12px 30px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
267
templates/import.html
Normal file
267
templates/import.html
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Импорт данных из Excel</h2>
|
||||
<a href="{{ url_for('index') }}" class="btn-back">На главную</a>
|
||||
</div>
|
||||
|
||||
<div class="import-container">
|
||||
<div class="import-info">
|
||||
<h3>Инструкция по импорту</h3>
|
||||
<ol>
|
||||
<li>Подготовьте файлы Excel в соответствии с шаблонами</li>
|
||||
<li>Убедитесь, что файлы находятся в папке <strong>data/</strong></li>
|
||||
<li>Введите имена файлов в форму ниже</li>
|
||||
<li>Нажмите "Импортировать данные"</li>
|
||||
</ol>
|
||||
|
||||
<h4>Требования к файлам:</h4>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Файл</th>
|
||||
<th>Обязательные колонки</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Поставщики</td>
|
||||
<td>Наименование поставщика, Электронная почта, ИНН</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Типы товаров</td>
|
||||
<td>Тип товара</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Товары</td>
|
||||
<td>Тип товара, Наименование, Артикул, Минимальная цена</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Заказы</td>
|
||||
<td>Поставщик, Дата заказа</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="import-form">
|
||||
<div class="form-section">
|
||||
<h3>Файлы для импорта</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="suppliers_file">Файл с поставщиками:</label>
|
||||
<input type="text" id="suppliers_file" name="suppliers_file"
|
||||
value="data/Suppliers_import.xlsx" placeholder="Например: Suppliers_import.xlsx">
|
||||
<small class="form-text">Формат: .xlsx, .xls</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="product_types_file">Файл с типами товаров:</label>
|
||||
<input type="text" id="product_types_file" name="product_types_file"
|
||||
value="data/Product_types_import.xlsx" placeholder="Например: Product_types_import.xlsx">
|
||||
<small class="form-text">Формат: .xlsx, .xls</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="products_file">Файл с товарами:</label>
|
||||
<input type="text" id="products_file" name="products_file"
|
||||
value="data/Products_import.xlsx" placeholder="Например: Products_import.xlsx">
|
||||
<small class="form-text">Формат: .xlsx, .xls</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="orders_file">Файл с заказами:</label>
|
||||
<input type="text" id="orders_file" name="orders_file"
|
||||
value="data/Orders_import.xlsx" placeholder="Например: Orders_import.xlsx">
|
||||
<small class="form-text">Формат: .xlsx, .xls</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="import-warning">
|
||||
<h4>⚠️ Внимание!</h4>
|
||||
<p>Импорт данных очистит существующую базу данных и загрузит новые данные из указанных файлов.</p>
|
||||
<p>Убедитесь, что у вас есть резервная копия важных данных.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-submit" onclick="return confirmImport()">Импортировать данные</button>
|
||||
<button type="reset" class="btn-reset">Очистить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="template-download">
|
||||
<h4>Шаблоны файлов</h4>
|
||||
<p>Скачайте примеры файлов для импорта:</p>
|
||||
<ul>
|
||||
<li><a href="#" onclick="downloadTemplate('suppliers')">Suppliers_template.xlsx</a></li>
|
||||
<li><a href="#" onclick="downloadTemplate('product_types')">Product_types_template.xlsx</a></li>
|
||||
<li><a href="#" onclick="downloadTemplate('products')">Products_template.xlsx</a></li>
|
||||
<li><a href="#" onclick="downloadTemplate('orders')">Orders_template.xlsx</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function confirmImport() {
|
||||
return confirm('Внимание! Все существующие данные будут удалены. Продолжить импорт?');
|
||||
}
|
||||
|
||||
function downloadTemplate(type) {
|
||||
// Создаем пример данных для скачивания
|
||||
let data;
|
||||
let filename;
|
||||
|
||||
switch(type) {
|
||||
case 'suppliers':
|
||||
data = [
|
||||
['Наименование поставщика', 'Директор', 'Электронная почта', 'Телефон', 'Юридический адрес', 'ИНН', 'Рейтинг'],
|
||||
['ООО "КанцПром"', 'Иванов И.И.', 'info@kancprom.ru', '+7(495)123-45-67', 'г. Москва, ул. Промышленная, д.10', '7701234567', '4.8'],
|
||||
['ИП Петров А.С.', 'Петров А.С.', 'petrov@mail.ru', '+7(495)765-43-21', 'г. Москва, ул. Торговая, д.5', '7707654321', '4.2']
|
||||
];
|
||||
filename = 'Suppliers_template.xlsx';
|
||||
break;
|
||||
case 'product_types':
|
||||
data = [
|
||||
['Тип товара', 'Описание'],
|
||||
['Ручки', 'Пишущие принадлежности'],
|
||||
['Тетради', 'Школьные тетради'],
|
||||
['Бумага', 'Офисная бумага']
|
||||
];
|
||||
filename = 'Product_types_template.xlsx';
|
||||
break;
|
||||
case 'products':
|
||||
data = [
|
||||
['Тип товара', 'Наименование', 'Артикул', 'Минимальная цена', 'Остаток'],
|
||||
['Ручки', 'Ручка синяя гелевая', 'R001', '15.50', '500'],
|
||||
['Тетради', 'Тетрадь 48 листов', 'T001', '45.00', '1000'],
|
||||
['Бумага', 'Бумага А4 500 листов', 'B001', '280.00', '200']
|
||||
];
|
||||
filename = 'Products_template.xlsx';
|
||||
break;
|
||||
case 'orders':
|
||||
data = [
|
||||
['Поставщик', 'Дата заказа', 'Статус', 'Примечания', 'Количество'],
|
||||
['ООО "КанцПром"', '2026-08-15', 'новый', 'Заказ к началу учебного года', '200'],
|
||||
['ИП Петров А.С.', '2026-08-20', 'в_обработке', '', '150']
|
||||
];
|
||||
filename = 'Orders_template.xlsx';
|
||||
break;
|
||||
}
|
||||
|
||||
// Создаем CSV и скачиваем (для простоты)
|
||||
let csvContent = data.map(row => row.join(',')).join('\n');
|
||||
const blob = new Blob(["\uFEFF" + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename.replace('.xlsx', '.csv'));
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.import-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 30px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.import-info {
|
||||
background-color: #F5F5F5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.import-info h3, .import-info h4 {
|
||||
color: #696969;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.import-info ol {
|
||||
margin-left: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.import-info li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background-color: #E0E0E0;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.import-form {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.import-warning {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffeeba;
|
||||
color: #856404;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.import-warning h4 {
|
||||
margin-bottom: 10px;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.template-download {
|
||||
grid-column: span 2;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.template-download ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.template-download li a {
|
||||
color: #696969;
|
||||
text-decoration: none;
|
||||
padding: 5px 10px;
|
||||
background-color: #F5F5F5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.template-download li a:hover {
|
||||
background-color: #E0E0E0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.import-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.template-download {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
260
templates/index.html
Normal file
260
templates/index.html
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Список заказов</h2>
|
||||
<div class="header-actions">
|
||||
<div class="sort-controls">
|
||||
<label for="sort">Сортировка:</label>
|
||||
<select id="sort" onchange="changeSort(this.value)">
|
||||
<option value="date_desc" {% if current_sort == 'date_desc' %}selected{% endif %}>По дате (новые сначала)</option>
|
||||
<option value="amount_desc" {% if current_sort == 'amount_desc' %}selected{% endif %}>По стоимости (дорогие сначала)</option>
|
||||
<option value="partner" {% if current_sort == 'partner' %}selected{% endif %}>По партнерам</option>
|
||||
</select>
|
||||
</div>
|
||||
<a href="{{ url_for('add_order') }}" class="btn-add-light">+ Новый заказ</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if orders %}
|
||||
<div class="orders-grid">
|
||||
{% for order in orders %}
|
||||
<div class="order-card">
|
||||
<div class="order-header">
|
||||
<h3>Заказ #{{ order.order_id }}</h3>
|
||||
</div>
|
||||
<div class="order-body">
|
||||
<p class="partner"><strong>Партнер:</strong> {{ order.partner_name }}</p>
|
||||
<p class="date"><strong>Дата:</strong> {{ order.order_date }}</p>
|
||||
<p class="items"><strong>Позиций:</strong> {{ order.items_count if order.items_count else 0 }}</p>
|
||||
|
||||
<div class="order-items-preview">
|
||||
<strong>Информация:</strong>
|
||||
<p>В заказе {{ order.items_count if order.items_count else 0 }} позиций</p>
|
||||
</div>
|
||||
|
||||
<div class="order-total">
|
||||
<div class="subtotal">Сумма без наценки: {{ "%.2f"|format(order.subtotal_without_markup|float) }} ₽</div>
|
||||
<div class="markup">Наценка 10%: +{{ "%.2f"|format((order.subtotal_with_markup - order.subtotal_without_markup)|float) }} ₽</div>
|
||||
<div class="subtotal-with-markup">Сумма с наценкой: {{ "%.2f"|format(order.subtotal_with_markup|float) }} ₽</div>
|
||||
{% if order.discount_percent and order.discount_percent > 0 %}
|
||||
<div class="discount">Скидка {{ order.discount_percent }}%: -{{ "%.2f"|format(order.discount_amount|float) }} ₽</div>
|
||||
{% endif %}
|
||||
<div class="total"><strong>ИТОГО К ОПЛАТЕ: {{ "%.2f"|format(order.total|float) }} ₽</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-footer">
|
||||
<a href="{{ url_for('edit_order', order_id=order.order_id) }}" class="btn-edit">Редактировать</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Заказов пока нет</p>
|
||||
<a href="{{ url_for('add_order') }}" class="btn-add-light">Создать первый заказ</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function changeSort(sortValue) {
|
||||
window.location.href = '?sort=' + sortValue;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.order-items-preview {
|
||||
background-color: #F5F5F5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.order-items-preview p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 2rem;
|
||||
color: #696969;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sort-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.sort-controls label {
|
||||
color: #696969;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sort-controls select {
|
||||
padding: 5px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.btn-add-light {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
border: 1px solid #696969;
|
||||
}
|
||||
|
||||
.btn-add-light:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.orders-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.order-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.order-header {
|
||||
background-color: #E0E0E0;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.order-header h3 {
|
||||
color: #696969;
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.order-body {
|
||||
padding: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.order-body p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
padding: 15px;
|
||||
background-color: #F5F5F5;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
.subtotal, .markup, .subtotal-with-markup, .discount, .total {
|
||||
margin: 5px 0;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.discount {
|
||||
color: #4CAF50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.total {
|
||||
font-size: 1.2rem;
|
||||
border-top: 2px solid #696969;
|
||||
padding-top: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sort-controls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sort-controls select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-add-light {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
487
templates/manual_partner_test.html
Normal file
487
templates/manual_partner_test.html
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Ручное тестирование добавления партнера</h2>
|
||||
<a href="{{ url_for('partners') }}" class="btn-back">Назад к списку</a>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<div class="test-info">
|
||||
<h3>Инструкция</h3>
|
||||
<p>Заполните форму ниже и нажмите "Проверить данные". Тест покажет, пройдет ли проверку введенная информация.</p>
|
||||
<p><strong>Правила валидации:</strong></p>
|
||||
<ul>
|
||||
<li>Наименование партнера - обязательно</li>
|
||||
<li>Email - должен быть корректным (содержать @ и .)</li>
|
||||
<li>ИНН - должен содержать 10 или 12 цифр</li>
|
||||
<li>Телефон - форматируется автоматически</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="test-form">
|
||||
<form id="partnerTestForm">
|
||||
<div class="form-section">
|
||||
<h3>Данные партнера</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Наименование партнера *</label>
|
||||
<input type="text" id="name" name="name" class="test-input" placeholder="Например: ООО Ромашка">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="director">Директор</label>
|
||||
<input type="text" id="director" name="director" class="test-input" placeholder="Например: Иванов И.И.">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group half">
|
||||
<label for="email">Email *</label>
|
||||
<input type="email" id="email" name="email" class="test-input" placeholder="test@mail.ru">
|
||||
</div>
|
||||
|
||||
<div class="form-group half">
|
||||
<label for="phone">Телефон</label>
|
||||
<input type="tel" id="phone" name="phone" class="test-input" placeholder="+7 (999) 123-45-67">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="legal_address">Юридический адрес</label>
|
||||
<textarea id="legal_address" name="legal_address" class="test-input" rows="2" placeholder="г. Москва, ул. Тестовая, д. 1"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group half">
|
||||
<label for="inn">ИНН *</label>
|
||||
<input type="text" id="inn" name="inn" class="test-input" placeholder="10 или 12 цифр">
|
||||
<small class="form-text">10 или 12 цифр</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-actions">
|
||||
<button type="button" onclick="runTest()" class="btn-submit">Проверить данные</button>
|
||||
<button type="button" onclick="fillValidData()" class="btn-valid">Заполнить валидные данные</button>
|
||||
<button type="button" onclick="fillInvalidData()" class="btn-invalid">Заполнить невалидные данные</button>
|
||||
<button type="reset" class="btn-reset">Очистить форму</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="test-result" id="testResult" style="display: none;">
|
||||
<h3>Результат тестирования</h3>
|
||||
<div id="resultContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function runTest() {
|
||||
// Собираем данные из формы
|
||||
const name = document.getElementById('name').value.trim();
|
||||
const director = document.getElementById('director').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const phone = document.getElementById('phone').value.trim();
|
||||
const legal_address = document.getElementById('legal_address').value.trim();
|
||||
const inn = document.getElementById('inn').value.trim();
|
||||
|
||||
// Выполняем валидацию
|
||||
const validation = validatePartnerData(name, email, inn);
|
||||
|
||||
// Показываем результат
|
||||
const resultDiv = document.getElementById('testResult');
|
||||
const resultContent = document.getElementById('resultContent');
|
||||
|
||||
let html = '<table class="result-table">';
|
||||
html += '<tr><th>Поле</th><th>Значение</th><th>Статус</th><th>Комментарий</th></tr>';
|
||||
|
||||
// Проверка имени
|
||||
html += '<tr>';
|
||||
html += '<td>Наименование</td>';
|
||||
html += `<td>${name || '(пусто)'}</td>`;
|
||||
if (name) {
|
||||
html += '<td class="success">✓ OK</td>';
|
||||
html += '<td>Поле заполнено</td>';
|
||||
} else {
|
||||
html += '<td class="error">✗ Ошибка</td>';
|
||||
html += '<td>Наименование обязательно</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
|
||||
// Проверка email
|
||||
html += '<tr>';
|
||||
html += '<td>Email</td>';
|
||||
html += `<td>${email || '(пусто)'}</td>`;
|
||||
const emailValid = validateEmail(email);
|
||||
if (emailValid) {
|
||||
html += '<td class="success">✓ OK</td>';
|
||||
html += '<td>Корректный email</td>';
|
||||
} else {
|
||||
html += '<td class="error">✗ Ошибка</td>';
|
||||
html += '<td>Некорректный email (должен содержать @ и .)</td>';
|
||||
}
|
||||
html += '</tr>';
|
||||
|
||||
// Проверка ИНН
|
||||
html += '<tr>';
|
||||
html += '<td>ИНН</td>';
|
||||
html += `<td>${inn || '(пусто)'}</td>`;
|
||||
const innValid = validateINN(inn);
|
||||
if (innValid) {
|
||||
html += '<td class="success">✓ OK</td>';
|
||||
html += '<td>Корректный ИНН (10 или 12 цифр)</td>';
|
||||
} else {
|
||||
html += '<td class="error">✗ Ошибка</td>';
|
||||
if (!inn) {
|
||||
html += '<td>ИНН обязателен</td>';
|
||||
} else {
|
||||
html += '<td>ИНН должен содержать 10 или 12 цифр</td>';
|
||||
}
|
||||
}
|
||||
html += '</tr>';
|
||||
|
||||
// Директор (опционально)
|
||||
html += '<tr>';
|
||||
html += '<td>Директор</td>';
|
||||
html += `<td>${director || '(не указан)'}</td>`;
|
||||
html += '<td class="info">ℹ Опционально</td>';
|
||||
html += '<td>Поле необязательное</td>';
|
||||
html += '</tr>';
|
||||
|
||||
// Телефон (опционально)
|
||||
html += '<tr>';
|
||||
html += '<td>Телефон</td>';
|
||||
html += `<td>${phone || '(не указан)'}</td>`;
|
||||
html += '<td class="info">ℹ Опционально</td>';
|
||||
html += '<td>Поле необязательное</td>';
|
||||
html += '</tr>';
|
||||
|
||||
// Адрес (опционально)
|
||||
html += '<tr>';
|
||||
html += '<td>Адрес</td>';
|
||||
html += `<td>${legal_address || '(не указан)'}</td>`;
|
||||
html += '<td class="info">ℹ Опционально</td>';
|
||||
html += '<td>Поле необязательное</td>';
|
||||
html += '</tr>';
|
||||
|
||||
html += '</table>';
|
||||
|
||||
// Общий результат
|
||||
const allValid = name && emailValid && innValid;
|
||||
html += '<div class="summary">';
|
||||
if (allValid) {
|
||||
html += '<p class="success-message">✅ Все обязательные поля заполнены корректно! Форма может быть отправлена.</p>';
|
||||
} else {
|
||||
html += '<p class="error-message">❌ Есть ошибки в заполнении формы. Исправьте их перед отправкой.</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
resultContent.innerHTML = html;
|
||||
resultDiv.style.display = 'block';
|
||||
|
||||
// Прокрутка к результатам
|
||||
resultDiv.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function validatePartnerData(name, email, inn) {
|
||||
return {
|
||||
nameValid: !!name,
|
||||
emailValid: validateEmail(email),
|
||||
innValid: validateINN(inn)
|
||||
};
|
||||
}
|
||||
|
||||
function validateEmail(email) {
|
||||
if (!email) return false;
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
function validateINN(inn) {
|
||||
if (!inn) return false;
|
||||
const innRegex = /^\d{10}$|^\d{12}$/;
|
||||
return innRegex.test(inn);
|
||||
}
|
||||
|
||||
function fillValidData() {
|
||||
document.getElementById('name').value = 'ООО "Тестовая компания"';
|
||||
document.getElementById('director').value = 'Петров Петр Петрович';
|
||||
document.getElementById('email').value = 'test@company.ru';
|
||||
document.getElementById('phone').value = '+7 (495) 123-45-67';
|
||||
document.getElementById('legal_address').value = 'г. Москва, ул. Тестовая, д. 10, офис 5';
|
||||
document.getElementById('inn').value = '7701123456';
|
||||
|
||||
// Автоматически запускаем тест
|
||||
runTest();
|
||||
}
|
||||
|
||||
function fillInvalidData() {
|
||||
document.getElementById('name').value = '';
|
||||
document.getElementById('director').value = '';
|
||||
document.getElementById('email').value = 'invalid-email';
|
||||
document.getElementById('phone').value = '12345';
|
||||
document.getElementById('legal_address').value = '';
|
||||
document.getElementById('inn').value = '123';
|
||||
|
||||
// Автоматически запускаем тест
|
||||
runTest();
|
||||
}
|
||||
|
||||
// Форматирование телефона
|
||||
document.getElementById('phone').addEventListener('input', function(e) {
|
||||
let x = e.target.value.replace(/\D/g, '').match(/(\d{0,1})(\d{0,3})(\d{0,3})(\d{0,2})(\d{0,2})/);
|
||||
e.target.value = !x[2] ? x[1] : '+' + x[1] + ' (' + x[2] + ') ' + x[3] + (x[4] ? '-' + x[4] : '') + (x[5] ? '-' + x[5] : '');
|
||||
});
|
||||
|
||||
// Валидация в реальном времени
|
||||
document.getElementById('name').addEventListener('input', function() {
|
||||
this.style.borderColor = this.value ? '#4CAF50' : '#ff6b6b';
|
||||
});
|
||||
|
||||
document.getElementById('email').addEventListener('input', function() {
|
||||
const valid = validateEmail(this.value);
|
||||
this.style.borderColor = valid ? '#4CAF50' : '#ff6b6b';
|
||||
});
|
||||
|
||||
document.getElementById('inn').addEventListener('input', function() {
|
||||
const valid = validateINN(this.value);
|
||||
this.style.borderColor = valid ? '#4CAF50' : '#ff6b6b';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.test-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.test-info {
|
||||
background-color: #F5F5F5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
border-left: 4px solid #696969;
|
||||
}
|
||||
|
||||
.test-info h3 {
|
||||
color: #696969;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.test-info ul {
|
||||
margin-left: 20px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.test-info li {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.test-form {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-section h3 {
|
||||
color: #696969;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.test-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 2px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.test-input:focus {
|
||||
outline: none;
|
||||
border-color: #696969;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group.half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
display: block;
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.test-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 25px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-valid {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 25px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-valid:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.btn-invalid {
|
||||
background-color: #ff6b6b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 25px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-invalid:hover {
|
||||
background-color: #ff5252;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
border: none;
|
||||
padding: 12px 25px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
border-top: 4px solid #696969;
|
||||
}
|
||||
|
||||
.test-result h3 {
|
||||
color: #696969;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.result-table th {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.result-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.result-table .success {
|
||||
color: #4CAF50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-table .error {
|
||||
color: #ff6b6b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-table .info {
|
||||
color: #2196F3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary {
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
background-color: #F5F5F5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: #4CAF50;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff6b6b;
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.test-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-submit, .btn-valid, .btn-invalid, .btn-reset {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
171
templates/order_detail.html
Normal file
171
templates/order_detail.html
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Заказ #{{ order.order_id }}</h2>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('edit_order', order_id=order.order_id) }}" class="btn-edit">Редактировать</a>
|
||||
<a href="{{ url_for('index') }}" class="btn-back">Назад к списку</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-detail-card">
|
||||
<div class="order-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Партнер:</span>
|
||||
<span class="info-value">{{ order.partner_name }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Дата заказа:</span>
|
||||
<span class="info-value">{{ order.order_date }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Статус:</span>
|
||||
<span class="info-value status-{{ order.status }}">{{ order.status }}</span>
|
||||
</div>
|
||||
{% if order.notes %}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Примечания:</span>
|
||||
<span class="info-value">{{ order.notes }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h3>Состав заказа</h3>
|
||||
|
||||
{% if order.items and order.items|length > 0 %}
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№</th>
|
||||
<th>Товар</th>
|
||||
<th>Количество</th>
|
||||
<th>Цена за ед.</th>
|
||||
<th>Сумма</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ item.product_name }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{{ "%.2f"|format(item.price) }} ₽</td>
|
||||
<td>{{ "%.2f"|format(item.subtotal) }} ₽</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right">Сумма без скидки:</td>
|
||||
<td>{{ "%.2f"|format(order.subtotal) }} ₽</td>
|
||||
</tr>
|
||||
{% if order.discount_percent and order.discount_percent > 0 %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-right">Скидка ({{ order.discount_desc }}):</td>
|
||||
<td>-{{ "%.2f"|format(order.discount_amount) }} ₽</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="total-row">
|
||||
<td colspan="4" class="text-right"><strong>ИТОГО:</strong></td>
|
||||
<td><strong>{{ "%.2f"|format(order.total) }} ₽</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>В заказе нет товаров</p>
|
||||
<a href="{{ url_for('edit_order', order_id=order.order_id) }}" class="btn-edit">Добавить товары</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.order-detail-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.order-info {
|
||||
background-color: #F5F5F5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
width: 150px;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.items-table th {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.items-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.items-table tbody tr:hover {
|
||||
background-color: #F5F5F5;
|
||||
}
|
||||
|
||||
.items-table tfoot {
|
||||
background-color: #E0E0E0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.items-table .total-row {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background-color: #F5F5F5;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
228
templates/orders.html
Normal file
228
templates/orders.html
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Управление заказами</h2>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('add_order') }}" class="btn-add">+ Новый заказ</a>
|
||||
<a href="{{ url_for('export_orders') }}" class="btn-export">Экспорт CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filters-bar">
|
||||
<select id="statusFilter" onchange="filterOrders()">
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="новый">Новые</option>
|
||||
<option value="в_обработке">В обработке</option>
|
||||
<option value="завершен">Завершенные</option>
|
||||
</select>
|
||||
|
||||
<select id="supplierFilter" onchange="filterOrders()">
|
||||
<option value="all">Все поставщики</option>
|
||||
{% for supplier in suppliers %}
|
||||
<option value="{{ supplier.id }}">{{ supplier.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<input type="text" id="searchInput" placeholder="Поиск по номеру заказа..." onkeyup="filterOrders()">
|
||||
|
||||
<select id="sortBy" onchange="sortOrders()">
|
||||
<option value="date_desc">Сначала новые</option>
|
||||
<option value="date_asc">Сначала старые</option>
|
||||
<option value="total_desc">По сумме (убыв.)</option>
|
||||
<option value="total_asc">По сумме (возр.)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="orders-table-container">
|
||||
<table class="orders-table" id="ordersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№ заказа</th>
|
||||
<th>Дата</th>
|
||||
<th>Поставщик</th>
|
||||
<th>Статус</th>
|
||||
<th>Позиций</th>
|
||||
<th>Сумма</th>
|
||||
<th>Скидка</th>
|
||||
<th>Итого</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in orders %}
|
||||
<tr data-status="{{ order.status }}" data-supplier="{{ order.supplier_id }}"
|
||||
data-total="{{ order.total }}" data-date="{{ order.order_date }}">
|
||||
<td>#{{ order.order_id }}</td>
|
||||
<td>{{ order.order_date }}</td>
|
||||
<td>{{ order.supplier_name }}</td>
|
||||
<td><span class="order-status status-{{ order.status }}">{{ order.status }}</span></td>
|
||||
<td>{{ order.items|length }}</td>
|
||||
<td>{{ order.subtotal }} ₽</td>
|
||||
<td>
|
||||
{% if order.discount_percent > 0 %}
|
||||
{{ order.discount_percent }}% (-{{ order.discount_amount }} ₽)
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><strong>{{ order.total }} ₽</strong></td>
|
||||
<td class="actions">
|
||||
<a href="{{ url_for('order_detail', order_id=order.order_id) }}" class="btn-view-small">👁️</a>
|
||||
<a href="{{ url_for('edit_order', order_id=order.order_id) }}" class="btn-edit-small">✏️</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentOrders = [];
|
||||
|
||||
function filterOrders() {
|
||||
const statusFilter = document.getElementById('statusFilter').value;
|
||||
const supplierFilter = document.getElementById('supplierFilter').value;
|
||||
const searchText = document.getElementById('searchInput').value.toLowerCase();
|
||||
const rows = document.querySelectorAll('#ordersTable tbody tr');
|
||||
|
||||
rows.forEach(row => {
|
||||
const status = row.dataset.status;
|
||||
const supplier = row.dataset.supplier;
|
||||
const orderId = row.cells[0].textContent.toLowerCase();
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
||||
const matchesSupplier = supplierFilter === 'all' || supplier === supplierFilter;
|
||||
const matchesSearch = orderId.includes(searchText);
|
||||
|
||||
if (matchesStatus && matchesSupplier && matchesSearch) {
|
||||
row.style.display = '';
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sortOrders() {
|
||||
const tbody = document.querySelector('#ordersTable tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
const sortBy = document.getElementById('sortBy').value;
|
||||
|
||||
rows.sort((a, b) => {
|
||||
let aVal, bVal;
|
||||
|
||||
switch(sortBy) {
|
||||
case 'date_desc':
|
||||
aVal = new Date(a.dataset.date);
|
||||
bVal = new Date(b.dataset.date);
|
||||
return bVal - aVal;
|
||||
case 'date_asc':
|
||||
aVal = new Date(a.dataset.date);
|
||||
bVal = new Date(b.dataset.date);
|
||||
return aVal - bVal;
|
||||
case 'total_desc':
|
||||
aVal = parseFloat(a.dataset.total);
|
||||
bVal = parseFloat(b.dataset.total);
|
||||
return bVal - aVal;
|
||||
case 'total_asc':
|
||||
aVal = parseFloat(a.dataset.total);
|
||||
bVal = parseFloat(b.dataset.total);
|
||||
return aVal - bVal;
|
||||
}
|
||||
});
|
||||
|
||||
tbody.innerHTML = '';
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
}
|
||||
|
||||
// Инициализация
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const supplierId = urlParams.get('supplier');
|
||||
|
||||
if (supplierId) {
|
||||
document.getElementById('supplierFilter').value = supplierId;
|
||||
filterOrders();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.filters-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.filters-bar select,
|
||||
.filters-bar input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filters-bar input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.orders-table-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.orders-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.orders-table th {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.orders-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.orders-table tbody tr:hover {
|
||||
background-color: #F5F5F5;
|
||||
}
|
||||
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-view-small,
|
||||
.btn-edit-small {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.btn-view-small {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.btn-edit-small {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
222
templates/partners.html
Normal file
222
templates/partners.html
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Партнеры</h2>
|
||||
<a href="{{ url_for('add_partner') }}" class="btn-add-light">+ Добавить партнера</a>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" style="margin-bottom: 20px;">
|
||||
<input type="text" id="searchInput" placeholder="Поиск по названию или ИНН..." class="search-input" onkeyup="filterPartners()">
|
||||
</div>
|
||||
|
||||
{% if partners %}
|
||||
<div class="partners-grid" id="partnersGrid">
|
||||
{% for partner in partners %}
|
||||
<div class="partner-card" data-name="{{ partner.name.lower() }}" data-inn="{{ partner.inn }}">
|
||||
<div class="partner-header">
|
||||
<h3>{{ partner.name }}</h3>
|
||||
</div>
|
||||
<div class="partner-body">
|
||||
<p><strong>Директор:</strong> {{ partner.director or 'Не указан' }}</p>
|
||||
<p><strong>ИНН:</strong> {{ partner.inn }}</p>
|
||||
<p><strong>Email:</strong> {{ partner.email }}</p>
|
||||
<p><strong>Телефон:</strong> {{ partner.phone or 'Не указан' }}</p>
|
||||
<p><strong>Адрес:</strong> {{ partner.legal_address or 'Не указан' }}</p>
|
||||
|
||||
<div class="partner-stats">
|
||||
<p><strong>Заказов:</strong> {{ partner.orders_count }}</p>
|
||||
<p><strong>Общая сумма закупок:</strong> {{ "%.2f"|format(partner.total_purchases) }} ₽</p>
|
||||
<p><strong>Текущая скидка:</strong> {{ partner.discount_desc }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="partner-footer">
|
||||
<a href="{{ url_for('edit_partner', partner_id=partner.id) }}" class="btn-edit">Редактировать</a>
|
||||
<a href="{{ url_for('index') }}?partner={{ partner.id }}" class="btn-view">Заказы</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Партнеров пока нет</p>
|
||||
<a href="{{ url_for('add_partner') }}" class="btn-add-light">Добавить первого партнера</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function filterPartners() {
|
||||
const searchText = document.getElementById('searchInput').value.toLowerCase();
|
||||
const partners = document.querySelectorAll('.partner-card');
|
||||
|
||||
partners.forEach(partner => {
|
||||
const name = partner.dataset.name;
|
||||
const inn = partner.dataset.inn;
|
||||
|
||||
if (name.includes(searchText) || inn.includes(searchText)) {
|
||||
partner.style.display = 'block';
|
||||
} else {
|
||||
partner.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.partner-stats {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #E0E0E0;
|
||||
background-color: #F5F5F5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.partner-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.partners-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.partner-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.partner-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.partner-header {
|
||||
background-color: #E0E0E0;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.partner-header h3 {
|
||||
color: #696969;
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.partner-body {
|
||||
padding: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.partner-body p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
width: 300px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
}
|
||||
|
||||
.btn-add-light {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
border: 1px solid #696969;
|
||||
}
|
||||
|
||||
.btn-add-light:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
.btn-edit, .btn-view {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.btn-view {
|
||||
background-color: #F5F5F5;
|
||||
color: #696969;
|
||||
border: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
.btn-view:hover {
|
||||
background-color: #E0E0E0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 2rem;
|
||||
color: #696969;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.btn-add-light {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
778
templates/products.html
Normal file
778
templates/products.html
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Каталог товаров</h2>
|
||||
<div class="header-actions">
|
||||
<button onclick="showAddProductModal()" class="btn-add-light">+ Добавить товар</button>
|
||||
<select id="typeFilter" class="filter-select" onchange="filterProducts()">
|
||||
<option value="all">Все типы</option>
|
||||
{% for type in product_types %}
|
||||
<option value="{{ type.id }}">{{ type.type_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" id="searchInput" placeholder="Поиск по названию..." class="search-input" onkeyup="filterProducts()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if products %}
|
||||
<div class="products-grid" id="productsGrid">
|
||||
{% for product in products %}
|
||||
<div class="product-card" data-type="{{ product.product_type_id }}" data-name="{{ product.product_name.lower() }}">
|
||||
<div class="product-header">
|
||||
<h3>{{ product.product_name }}</h3>
|
||||
<span class="product-type">{{ product.product_type_name }}</span>
|
||||
</div>
|
||||
<div class="product-body">
|
||||
<p><strong>Артикул:</strong> {{ product.article }}</p>
|
||||
<p><strong>Цена для партнеров:</strong> {{ product.min_partner_price }} ₽</p>
|
||||
<p><strong>Цена с наценкой 10%:</strong> {{ "%.2f"|format(product.min_partner_price * 1.1) }} ₽</p>
|
||||
</div>
|
||||
<div class="product-footer">
|
||||
<button onclick="editProduct({{ product.id }})" class="btn-edit-small">✏️ Редактировать</button>
|
||||
<button onclick="addToCart({{ product.id }}, '{{ product.product_name }}', {{ product.min_partner_price }})"
|
||||
class="btn-add-to-cart">В корзину</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Товаров пока нет</p>
|
||||
<button onclick="showAddProductModal()" class="btn-add-light">Добавить первый товар</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Модальное окно добавления/редактирования товара (БЕЗ КНОПКИ УДАЛЕНИЯ) -->
|
||||
<div id="productModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">Добавление товара</h3>
|
||||
<span class="close" onclick="closeProductModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="productForm" onsubmit="saveProduct(event)">
|
||||
<input type="hidden" id="productId" name="product_id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="productType">Тип товара *</label>
|
||||
<div class="type-selector">
|
||||
<select id="productType" name="product_type_id" required onchange="toggleNewTypeInput()">
|
||||
<option value="">Выберите тип</option>
|
||||
{% for type in product_types %}
|
||||
<option value="{{ type.id }}">{{ type.type_name }}</option>
|
||||
{% endfor %}
|
||||
<option value="new">+ Добавить новый тип</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="newTypeContainer" class="form-group" style="display: none;">
|
||||
<label for="newTypeName">Название нового типа *</label>
|
||||
<input type="text" id="newTypeName" name="new_type_name" maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="productName">Наименование товара *</label>
|
||||
<input type="text" id="productName" name="product_name" required maxlength="255">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="productArticle">Артикул *</label>
|
||||
<input type="text" id="productArticle" name="article" required maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="productPrice">Цена для партнеров *</label>
|
||||
<input type="number" id="productPrice" name="min_partner_price" step="0.01" min="0.01" required>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-submit">Сохранить</button>
|
||||
<button type="button" class="btn-reset" onclick="closeProductModal()">Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно корзины для создания заказа -->
|
||||
<div id="cartModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Создание заказа</h3>
|
||||
<span class="close" onclick="closeCartModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="cart-info">
|
||||
<div class="form-group">
|
||||
<label for="cartPartner">Партнер *</label>
|
||||
<select id="cartPartner" class="form-control" required>
|
||||
<option value="">Выберите партнера</option>
|
||||
{% for partner in partners %}
|
||||
<option value="{{ partner.id }}">{{ partner.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cartDate">Дата заказа *</label>
|
||||
<input type="date" id="cartDate" class="form-control" value="{{ today }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cartNotes">Примечания</label>
|
||||
<textarea id="cartNotes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Товары в заказе</h4>
|
||||
<table class="cart-table" id="cartTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Товар</th>
|
||||
<th>Количество</th>
|
||||
<th>Цена</th>
|
||||
<th>Сумма с наценкой</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="cartItems">
|
||||
<!-- Будут добавлены через JavaScript -->
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3" class="text-right">Итого с наценкой:</td>
|
||||
<td id="cartTotal">0.00 ₽</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="cart-actions">
|
||||
<button type="button" onclick="createOrderFromCart()" class="btn-submit">Оформить заказ</button>
|
||||
<button type="button" onclick="clearCart()" class="btn-reset">Очистить корзину</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let cart = [];
|
||||
let editingProductId = null;
|
||||
|
||||
function filterProducts() {
|
||||
const searchText = document.getElementById('searchInput').value.toLowerCase();
|
||||
const typeFilter = document.getElementById('typeFilter').value;
|
||||
const products = document.querySelectorAll('.product-card');
|
||||
|
||||
products.forEach(product => {
|
||||
const name = product.dataset.name;
|
||||
const type = product.dataset.type;
|
||||
|
||||
const matchesSearch = name.includes(searchText);
|
||||
const matchesType = typeFilter === 'all' || type == typeFilter;
|
||||
|
||||
product.style.display = (matchesSearch && matchesType) ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function toggleNewTypeInput() {
|
||||
const typeSelect = document.getElementById('productType');
|
||||
const newTypeContainer = document.getElementById('newTypeContainer');
|
||||
|
||||
if (typeSelect.value === 'new') {
|
||||
newTypeContainer.style.display = 'block';
|
||||
document.getElementById('newTypeName').required = true;
|
||||
} else {
|
||||
newTypeContainer.style.display = 'none';
|
||||
document.getElementById('newTypeName').required = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showAddProductModal() {
|
||||
editingProductId = null;
|
||||
document.getElementById('modalTitle').textContent = 'Добавление товара';
|
||||
document.getElementById('productForm').reset();
|
||||
document.getElementById('productId').value = '';
|
||||
document.getElementById('newTypeContainer').style.display = 'none';
|
||||
document.getElementById('productModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function editProduct(productId) {
|
||||
editingProductId = productId;
|
||||
document.getElementById('modalTitle').textContent = 'Редактирование товара';
|
||||
|
||||
fetch(`/api/product/${productId}`)
|
||||
.then(response => response.json())
|
||||
.then(product => {
|
||||
document.getElementById('productType').value = product.product_type_id;
|
||||
document.getElementById('productName').value = product.product_name;
|
||||
document.getElementById('productArticle').value = product.article;
|
||||
document.getElementById('productPrice').value = product.min_partner_price;
|
||||
document.getElementById('newTypeContainer').style.display = 'none';
|
||||
document.getElementById('productModal').style.display = 'block';
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Ошибка при загрузке данных товара');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
function closeProductModal() {
|
||||
document.getElementById('productModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function saveProduct(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const typeSelect = document.getElementById('productType');
|
||||
let productTypeId = typeSelect.value;
|
||||
|
||||
if (productTypeId === 'new') {
|
||||
const newTypeName = document.getElementById('newTypeName').value.trim();
|
||||
if (!newTypeName) {
|
||||
alert('Введите название нового типа товара');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/product_type', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type_name: newTypeName,
|
||||
description: ''
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
saveProductWithType(data.type_id);
|
||||
} else {
|
||||
alert('Ошибка при создании типа: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Ошибка при создании типа товара');
|
||||
console.error(error);
|
||||
});
|
||||
} else {
|
||||
saveProductWithType(productTypeId);
|
||||
}
|
||||
}
|
||||
|
||||
function saveProductWithType(productTypeId) {
|
||||
const productData = {
|
||||
product_type_id: productTypeId,
|
||||
product_name: document.getElementById('productName').value,
|
||||
article: document.getElementById('productArticle').value,
|
||||
min_partner_price: parseFloat(document.getElementById('productPrice').value)
|
||||
};
|
||||
|
||||
const url = editingProductId ? `/api/product/${editingProductId}` : '/api/product';
|
||||
const method = editingProductId ? 'PUT' : 'POST';
|
||||
|
||||
fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(productData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(editingProductId ? 'Товар обновлен' : 'Товар добавлен');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Ошибка при сохранении товара');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
function addToCart(productId, productName, price) {
|
||||
const quantity = prompt("Введите количество:", "1");
|
||||
|
||||
if (quantity === null) return;
|
||||
|
||||
const qty = parseInt(quantity);
|
||||
if (isNaN(qty) || qty <= 0) {
|
||||
alert('Введите корректное положительное число');
|
||||
return;
|
||||
}
|
||||
|
||||
const existingItem = cart.find(item => item.id === productId);
|
||||
|
||||
if (existingItem) {
|
||||
existingItem.quantity += qty;
|
||||
} else {
|
||||
cart.push({
|
||||
id: productId,
|
||||
name: productName,
|
||||
price: price,
|
||||
quantity: qty
|
||||
});
|
||||
}
|
||||
|
||||
updateCartDisplay();
|
||||
document.getElementById('cartModal').style.display = 'block';
|
||||
}
|
||||
|
||||
function updateCartDisplay() {
|
||||
const tbody = document.getElementById('cartItems');
|
||||
tbody.innerHTML = '';
|
||||
let total = 0;
|
||||
|
||||
cart.forEach((item, index) => {
|
||||
const subtotalWithMarkup = item.price * item.quantity * 1.1;
|
||||
total += subtotalWithMarkup;
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.innerHTML = `
|
||||
<td>${item.name}</td>
|
||||
<td>
|
||||
<input type="number" min="1" value="${item.quantity}"
|
||||
onchange="updateCartQuantity(${index}, this.value)" class="cart-quantity">
|
||||
</td>
|
||||
<td>${item.price.toFixed(2)} ₽</td>
|
||||
<td>${subtotalWithMarkup.toFixed(2)} ₽</td>
|
||||
<td><button onclick="removeFromCart(${index})" class="btn-remove-small">×</button></td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
document.getElementById('cartTotal').textContent = total.toFixed(2) + ' ₽';
|
||||
}
|
||||
|
||||
function updateCartQuantity(index, quantity) {
|
||||
const qty = parseInt(quantity);
|
||||
if (qty > 0) {
|
||||
cart[index].quantity = qty;
|
||||
updateCartDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function removeFromCart(index) {
|
||||
cart.splice(index, 1);
|
||||
updateCartDisplay();
|
||||
|
||||
if (cart.length === 0) {
|
||||
closeCartModal();
|
||||
}
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
if (cart.length > 0 && confirm('Очистить корзину?')) {
|
||||
cart = [];
|
||||
updateCartDisplay();
|
||||
closeCartModal();
|
||||
}
|
||||
}
|
||||
|
||||
function closeCartModal() {
|
||||
document.getElementById('cartModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function createOrderFromCart() {
|
||||
const partnerId = document.getElementById('cartPartner').value;
|
||||
const orderDate = document.getElementById('cartDate').value;
|
||||
const notes = document.getElementById('cartNotes').value;
|
||||
|
||||
if (!partnerId) {
|
||||
alert('Выберите партнера');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!orderDate) {
|
||||
alert('Выберите дату заказа');
|
||||
return;
|
||||
}
|
||||
|
||||
if (cart.length === 0) {
|
||||
alert('Добавьте товары в корзину');
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/add_order';
|
||||
form.style.display = 'none';
|
||||
|
||||
const partnerInput = document.createElement('input');
|
||||
partnerInput.type = 'hidden';
|
||||
partnerInput.name = 'partner_id';
|
||||
partnerInput.value = partnerId;
|
||||
form.appendChild(partnerInput);
|
||||
|
||||
const dateInput = document.createElement('input');
|
||||
dateInput.type = 'hidden';
|
||||
dateInput.name = 'order_date';
|
||||
dateInput.value = orderDate;
|
||||
form.appendChild(dateInput);
|
||||
|
||||
const notesInput = document.createElement('input');
|
||||
notesInput.type = 'hidden';
|
||||
notesInput.name = 'notes';
|
||||
notesInput.value = notes;
|
||||
form.appendChild(notesInput);
|
||||
|
||||
cart.forEach(item => {
|
||||
const productInput = document.createElement('input');
|
||||
productInput.type = 'hidden';
|
||||
productInput.name = 'product_id[]';
|
||||
productInput.value = item.id;
|
||||
form.appendChild(productInput);
|
||||
|
||||
const quantityInput = document.createElement('input');
|
||||
quantityInput.type = 'hidden';
|
||||
quantityInput.name = 'quantity[]';
|
||||
quantityInput.value = item.quantity;
|
||||
form.appendChild(quantityInput);
|
||||
|
||||
const priceInput = document.createElement('input');
|
||||
priceInput.type = 'hidden';
|
||||
priceInput.name = 'price[]';
|
||||
priceInput.value = item.price;
|
||||
form.appendChild(priceInput);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
window.onclick = function(event) {
|
||||
const productModal = document.getElementById('productModal');
|
||||
const cartModal = document.getElementById('cartModal');
|
||||
|
||||
if (event.target == productModal) {
|
||||
productModal.style.display = 'none';
|
||||
}
|
||||
if (event.target == cartModal) {
|
||||
cartModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Стили для модальных окон */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #F5F5F5;
|
||||
margin: 5% auto;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 80%;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
color: #696969;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: #696969;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cart-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.cart-table th {
|
||||
background-color: #E0E0E0;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cart-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.cart-quantity {
|
||||
width: 60px;
|
||||
padding: 5px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-remove-small {
|
||||
background-color: #ff6b6b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-remove-small:hover {
|
||||
background-color: #ff5252;
|
||||
}
|
||||
|
||||
.cart-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.cart-info {
|
||||
background-color: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn-add-to-cart {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-add-to-cart:hover:not(:disabled) {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.btn-add-to-cart:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group.half {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.products-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
transition: transform 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.product-header {
|
||||
background-color: #E0E0E0;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.product-header h3 {
|
||||
color: #696969;
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-type {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.product-body {
|
||||
padding: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.product-body p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.in-stock {
|
||||
color: #4CAF50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.out-of-stock {
|
||||
color: #ff6b6b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.product-footer {
|
||||
padding: 15px;
|
||||
background-color: #F5F5F5;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-edit-small {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.btn-edit-small:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
.filter-select, .search-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 2rem;
|
||||
color: #696969;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
border: 1px solid #696969;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: 'Calibri', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1.2rem;
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.type-selector {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
131
templates/search_results.html
Normal file
131
templates/search_results.html
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Результаты поиска: "{{ query }}"</h2>
|
||||
<a href="javascript:history.back()" class="btn-back">Назад</a>
|
||||
</div>
|
||||
|
||||
{% if results %}
|
||||
<div class="search-filters">
|
||||
<p>Найдено: {{ results|length }} результатов</p>
|
||||
<select id="resultTypeFilter" onchange="filterResults()">
|
||||
<option value="all">Все типы</option>
|
||||
<option value="order">Заказы</option>
|
||||
<option value="product">Товары</option>
|
||||
<option value="supplier">Поставщики</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ul class="search-results">
|
||||
{% for result in results %}
|
||||
<li class="search-result-item" data-type="{{ result.result_type }}">
|
||||
<span class="result-type {{ result.result_type }}">{{ result.result_type }}</span>
|
||||
|
||||
{% if result.result_type == 'order' %}
|
||||
<h3><a href="{{ url_for('order_detail', order_id=result.id) }}">{{ result.display_name }}</a></h3>
|
||||
<p>Дата: {{ result.order_date }} | Статус: {{ result.status }}</p>
|
||||
{% if result.notes %}
|
||||
<p class="result-notes">{{ result.notes[:100] }}{% if result.notes|length > 100 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% elif result.result_type == 'product' %}
|
||||
<h3><a href="#">{{ result.display_name }}</a></h3>
|
||||
<p>Артикул: {{ result.article }} | Тип: {{ result.type_name }}</p>
|
||||
<p>Цена: {{ result.min_partner_price }} ₽ | Остаток: {{ result.quantity_in_stock }} шт.</p>
|
||||
|
||||
{% elif result.result_type == 'supplier' %}
|
||||
<h3><a href="{{ url_for('edit_supplier', supplier_id=result.id) }}">{{ result.display_name }}</a></h3>
|
||||
<p>ИНН: {{ result.inn }} | Email: {{ result.email }}</p>
|
||||
<p>Рейтинг: {{ result.rating }} | Телефон: {{ result.phone or 'Не указан' }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="result-actions">
|
||||
{% if result.result_type == 'order' %}
|
||||
<a href="{{ url_for('order_detail', order_id=result.id) }}" class="btn-view-small">Подробнее</a>
|
||||
{% elif result.result_type == 'product' %}
|
||||
<button onclick="quickAddToCart({{ result.id }}, '{{ result.product_name }}', {{ result.min_partner_price }})"
|
||||
class="btn-add-small">В корзину</button>
|
||||
{% elif result.result_type == 'supplier' %}
|
||||
<a href="{{ url_for('edit_supplier', supplier_id=result.id) }}" class="btn-edit-small">Редактировать</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>По запросу "{{ query }}" ничего не найдено</p>
|
||||
<p>Попробуйте изменить поисковый запрос</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function filterResults() {
|
||||
const filter = document.getElementById('resultTypeFilter').value;
|
||||
const results = document.querySelectorAll('.search-result-item');
|
||||
|
||||
results.forEach(item => {
|
||||
if (filter === 'all' || item.dataset.type === filter) {
|
||||
item.style.display = 'block';
|
||||
} else {
|
||||
item.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function quickAddToCart(productId, productName, price) {
|
||||
// Используем ту же логику корзины, что и на странице товаров
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.addToCart(productId, productName, price);
|
||||
window.close();
|
||||
} else {
|
||||
alert('Добавлено в корзину! Перейдите на страницу товаров для оформления заказа.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.search-filters {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.search-filters select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.result-notes {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #E0E0E0;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-add-small {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add-small:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
211
templates/view_order.html
Normal file
211
templates/view_order.html
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Просмотр заказа #{{ order.order_id }}</h2>
|
||||
<a href="{{ url_for('index') }}" class="btn-back">Назад к списку</a>
|
||||
</div>
|
||||
|
||||
<div class="order-detail-card">
|
||||
<div class="order-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Партнер:</span>
|
||||
<span class="info-value">{{ order.partner_name }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Дата заказа:</span>
|
||||
<span class="info-value">{{ order.order_date }}</span>
|
||||
</div>
|
||||
{% if order.notes %}
|
||||
<div class="info-row">
|
||||
<span class="info-label">Примечания:</span>
|
||||
<span class="info-value">{{ order.notes }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h3>Состав заказа</h3>
|
||||
|
||||
{% if order.items and order.items|length > 0 %}
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№</th>
|
||||
<th>Товар</th>
|
||||
<th>Количество</th>
|
||||
<th>Цена за ед.</th>
|
||||
<th>Базовая сумма</th>
|
||||
<th>Сумма с наценкой 10%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in order.items %}
|
||||
<tr>
|
||||
<td>{{ loop.index }}</td>
|
||||
<td>{{ item.product_name }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{{ "%.2f"|format(item.price) }} ₽</td>
|
||||
<td>{{ "%.2f"|format(item.base_subtotal) }} ₽</td>
|
||||
<td>{{ "%.2f"|format(item.subtotal_with_markup) }} ₽</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right"><strong>Итого без наценки:</strong></td>
|
||||
<td><strong>{{ "%.2f"|format(order.subtotal_without_markup) }} ₽</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right">Наценка 10%:</td>
|
||||
<td></td>
|
||||
<td>+{{ "%.2f"|format(order.subtotal_with_markup - order.subtotal_without_markup) }} ₽</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right"><strong>Сумма с наценкой:</strong></td>
|
||||
<td></td>
|
||||
<td><strong>{{ "%.2f"|format(order.subtotal_with_markup) }} ₽</strong></td>
|
||||
</tr>
|
||||
{% if order.discount_percent and order.discount_percent > 0 %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-right">Скидка ({{ order.discount_desc }}):</td>
|
||||
<td></td>
|
||||
<td>-{{ "%.2f"|format(order.discount_amount) }} ₽</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr class="total-row">
|
||||
<td colspan="4" class="text-right"><strong>ИТОГО К ОПЛАТЕ:</strong></td>
|
||||
<td></td>
|
||||
<td><strong>{{ "%.2f"|format(order.total) }} ₽</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>В заказе нет товаров</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.order-detail-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.order-info {
|
||||
background-color: #F5F5F5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
width: 150px;
|
||||
color: #696969;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.items-table th {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.items-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #E0E0E0;
|
||||
}
|
||||
|
||||
.items-table tbody tr:hover {
|
||||
background-color: #F5F5F5;
|
||||
}
|
||||
|
||||
.items-table tfoot {
|
||||
background-color: #E0E0E0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.items-table tfoot tr:last-child {
|
||||
background-color: #696969;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.items-table .total-row td {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background-color: #F5F5F5;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 2rem;
|
||||
color: #696969;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-block;
|
||||
background-color: #E0E0E0;
|
||||
color: #696969;
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.3s;
|
||||
border: 1px solid #696969;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background-color: #D0D0D0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
BIN
tests/data/Order_items_import.xlsx
Normal file
BIN
tests/data/Order_items_import.xlsx
Normal file
Binary file not shown.
BIN
tests/data/Orders_import.xlsx
Normal file
BIN
tests/data/Orders_import.xlsx
Normal file
Binary file not shown.
BIN
tests/data/Partners_import.xlsx
Normal file
BIN
tests/data/Partners_import.xlsx
Normal file
Binary file not shown.
BIN
tests/data/Product_types_import.xlsx
Normal file
BIN
tests/data/Product_types_import.xlsx
Normal file
Binary file not shown.
BIN
tests/data/Products_import.xlsx
Normal file
BIN
tests/data/Products_import.xlsx
Normal file
Binary file not shown.
60
tests/test_calculation.py
Normal file
60
tests/test_calculation.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database import calculate_discount, get_discount_description
|
||||
|
||||
|
||||
class TestCalculations(unittest.TestCase):
|
||||
"""Тестирование алгоритмов расчета скидок"""
|
||||
|
||||
def test_1_discount_thresholds(self):
|
||||
"""Тест пороговых значений скидок"""
|
||||
test_cases = [
|
||||
(0, 0),
|
||||
(100000, 0),
|
||||
(149999, 0),
|
||||
(150000, 15),
|
||||
(175000, 15),
|
||||
(199999, 15),
|
||||
(200000, 30),
|
||||
(250000, 30),
|
||||
(299999, 30),
|
||||
(300000, 50),
|
||||
(500000, 50),
|
||||
(1000000, 50),
|
||||
]
|
||||
|
||||
for amount, expected_discount in test_cases:
|
||||
with self.subTest(amount=amount):
|
||||
self.assertEqual(calculate_discount(amount), expected_discount)
|
||||
|
||||
def test_2_discount_descriptions(self):
|
||||
"""Тест описаний скидок"""
|
||||
self.assertEqual(get_discount_description(0), '0% (до 150 000 руб.)')
|
||||
self.assertEqual(get_discount_description(15), '15% (150 000 - 199 999 руб.)')
|
||||
self.assertEqual(get_discount_description(30), '30% (200 000 - 299 999 руб.)')
|
||||
self.assertEqual(get_discount_description(50), '50% (300 000+ руб.)')
|
||||
|
||||
self.assertEqual(get_discount_description(99), '0% (до 150 000 руб.)')
|
||||
|
||||
def test_3_edge_cases(self):
|
||||
"""Тест граничных случаев"""
|
||||
self.assertEqual(calculate_discount(150000), 15)
|
||||
self.assertEqual(calculate_discount(200000), 30)
|
||||
self.assertEqual(calculate_discount(300000), 50)
|
||||
|
||||
self.assertEqual(calculate_discount(149999), 0)
|
||||
self.assertEqual(calculate_discount(199999), 15)
|
||||
self.assertEqual(calculate_discount(299999), 30)
|
||||
|
||||
def test_4_negative_amounts(self):
|
||||
"""Тест отрицательных сумм"""
|
||||
self.assertEqual(calculate_discount(-1000), 0)
|
||||
self.assertEqual(calculate_discount(-150000), 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
130
tests/test_database.py
Normal file
130
tests/test_database.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import unittest
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database import get_db_connection, create_tables, calculate_discount, get_discount_description, \
|
||||
calculate_discount_for_new_order
|
||||
|
||||
|
||||
class TestDatabase(unittest.TestCase):
|
||||
"""Тестирование функций базы данных"""
|
||||
|
||||
def setUp(self):
|
||||
"""Создаем временную базу данных перед каждым тестом"""
|
||||
self.db_fd, self.db_path = tempfile.mkstemp()
|
||||
|
||||
# Подменяем путь к БД
|
||||
import database
|
||||
database.DATABASE = self.db_path
|
||||
|
||||
# Создаем таблицы
|
||||
create_tables()
|
||||
|
||||
# Добавляем тестовые данные
|
||||
self.conn = sqlite3.connect(self.db_path)
|
||||
self.conn.row_factory = sqlite3.Row
|
||||
cursor = self.conn.cursor()
|
||||
|
||||
# Добавляем типы товаров
|
||||
cursor.executemany('''
|
||||
INSERT INTO product_types (type_name, description) VALUES (?, ?)
|
||||
''', [
|
||||
('Ручки', 'Пишущие принадлежности'),
|
||||
('Тетради', 'Школьные тетради')
|
||||
])
|
||||
|
||||
# Добавляем партнеров (БЕЗ rating)
|
||||
cursor.executemany('''
|
||||
INSERT INTO partners (name, director, email, phone, legal_address, inn)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', [
|
||||
('ООО Тест', 'Иванов И.И.', 'test@test.ru', '+7(999)123-45-67',
|
||||
'г. Москва', '7701123456'),
|
||||
('ИП Тестов', 'Петров П.П.', 'test2@test.ru', '+7(999)765-43-21',
|
||||
'г. Москва', '7701765432')
|
||||
])
|
||||
|
||||
# Добавляем товары (БЕЗ quantity_in_stock)
|
||||
cursor.executemany('''
|
||||
INSERT INTO products (product_type_id, product_name, article, min_partner_price)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', [
|
||||
(1, 'Ручка тестовая', 'R001', 100.0),
|
||||
(2, 'Тетрадь тестовая', 'T001', 200.0)
|
||||
])
|
||||
|
||||
# Добавляем заказы (БЕЗ status)
|
||||
cursor.execute('''
|
||||
INSERT INTO orders (partner_id, order_date, notes, discount_percent)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (1, '2026-01-15', 'Тестовый заказ', 0))
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO orders (partner_id, order_date, notes, discount_percent)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (1, '2026-02-15', 'Второй заказ', 15))
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO orders (partner_id, order_date, notes, discount_percent)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (2, '2026-03-15', 'Третий заказ', 30))
|
||||
|
||||
# Добавляем позиции заказов
|
||||
cursor.executemany('''
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_at_order)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', [
|
||||
(1, 1, 10, 100.0),
|
||||
(1, 2, 5, 200.0),
|
||||
(2, 1, 20, 100.0),
|
||||
(3, 2, 15, 200.0)
|
||||
])
|
||||
|
||||
self.conn.commit()
|
||||
|
||||
def tearDown(self):
|
||||
"""Закрываем и удаляем временную базу данных"""
|
||||
self.conn.close()
|
||||
os.close(self.db_fd)
|
||||
os.unlink(self.db_path)
|
||||
|
||||
def test_1_connection(self):
|
||||
"""Тест подключения к базе данных"""
|
||||
conn = get_db_connection()
|
||||
self.assertIsNotNone(conn)
|
||||
conn.close()
|
||||
|
||||
def test_2_calculate_discount(self):
|
||||
"""Тест расчета скидки"""
|
||||
self.assertEqual(calculate_discount(100000), 0)
|
||||
self.assertEqual(calculate_discount(150000), 15)
|
||||
self.assertEqual(calculate_discount(200000), 30)
|
||||
self.assertEqual(calculate_discount(300000), 50)
|
||||
self.assertEqual(calculate_discount(500000), 50)
|
||||
|
||||
def test_3_get_discount_description(self):
|
||||
"""Тест описания скидки"""
|
||||
self.assertEqual(get_discount_description(0), '0% (до 150 000 руб.)')
|
||||
self.assertEqual(get_discount_description(15), '15% (150 000 - 199 999 руб.)')
|
||||
self.assertEqual(get_discount_description(30), '30% (200 000 - 299 999 руб.)')
|
||||
self.assertEqual(get_discount_description(50), '50% (300 000+ руб.)')
|
||||
|
||||
def test_4_calculate_discount_for_new_order(self):
|
||||
"""Тест расчета скидки для нового заказа"""
|
||||
# Первый заказ (сумма 10000) - без скидки
|
||||
discount = calculate_discount_for_new_order(1, '2026-04-01', 10000)
|
||||
self.assertEqual(discount, 0)
|
||||
|
||||
# Рассчитаем общую сумму предыдущих заказов для партнера 1
|
||||
# Заказы: 1 (10*100 + 5*200 = 2000), 2 (20*100 = 2000), итого 4000
|
||||
# Новый заказ 146000 - должно быть 15% скидка (4000 + 146000 = 150000)
|
||||
discount = calculate_discount_for_new_order(1, '2026-04-03', 146000)
|
||||
self.assertEqual(discount, 15)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
272
tests/test_integration.py
Normal file
272
tests/test_integration.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import unittest
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database import get_db_connection, create_tables, calculate_discount_for_new_order, calculate_order_total, get_db
|
||||
from app import app
|
||||
|
||||
|
||||
class TestIntegration(unittest.TestCase):
|
||||
"""Интеграционное тестирование всего приложения"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Создаем тестовую базу данных"""
|
||||
cls.db_fd, cls.db_path = tempfile.mkstemp()
|
||||
|
||||
import database
|
||||
database.DATABASE = cls.db_path
|
||||
|
||||
create_tables()
|
||||
|
||||
cls.conn = sqlite3.connect(cls.db_path)
|
||||
cls.conn.row_factory = sqlite3.Row
|
||||
cursor = cls.conn.cursor()
|
||||
|
||||
# Добавляем типы товаров
|
||||
cursor.executemany('''
|
||||
INSERT INTO product_types (type_name, description) VALUES (?, ?)
|
||||
''', [
|
||||
('Ручки', 'Пишущие принадлежности'),
|
||||
('Тетради', 'Школьные тетради'),
|
||||
('Бумага', 'Офисная бумага'),
|
||||
('Техника', 'Дорогая техника')
|
||||
])
|
||||
|
||||
# Добавляем партнеров (БЕЗ rating)
|
||||
cursor.executemany('''
|
||||
INSERT INTO partners (name, director, email, phone, legal_address, inn)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', [
|
||||
('ООО КанцПром', 'Иванов И.И.', 'info@kancprom.ru', '+7(495)123-45-67',
|
||||
'г. Москва, ул. Промышленная', '7701123456'),
|
||||
('ИП Петров', 'Петров А.С.', 'petrov@mail.ru', '+7(495)765-43-21',
|
||||
'г. Москва, ул. Торговая', '7701765432')
|
||||
])
|
||||
|
||||
# Добавляем товары (БЕЗ quantity_in_stock)
|
||||
cursor.executemany('''
|
||||
INSERT INTO products (product_type_id, product_name, article, min_partner_price)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', [
|
||||
(1, 'Ручка синяя', 'R001', 15.50),
|
||||
(1, 'Ручка красная', 'R002', 12.00),
|
||||
(2, 'Тетрадь 48л', 'T001', 45.00),
|
||||
(2, 'Тетрадь 96л', 'T002', 85.00),
|
||||
(3, 'Бумага А4', 'B001', 280.00),
|
||||
(4, 'Принтер лазерный', 'P001', 15000.00)
|
||||
])
|
||||
|
||||
cls.conn.commit()
|
||||
|
||||
def test_db_connection():
|
||||
conn = sqlite3.connect(cls.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
app.config['DATABASE'] = cls.db_path
|
||||
database.get_db_connection = test_db_connection
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.conn.close()
|
||||
os.close(cls.db_fd)
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.unlink(cls.db_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
self.app = app.test_client()
|
||||
self.app.testing = True
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute("DELETE FROM order_items")
|
||||
conn.execute("DELETE FROM orders")
|
||||
|
||||
def test_1_full_order_flow(self):
|
||||
"""Тест полного цикла создания заказа"""
|
||||
order_data = {
|
||||
'partner_id': 1,
|
||||
'order_date': '2026-03-04',
|
||||
'notes': 'Тестовый заказ',
|
||||
'items': [
|
||||
{'product_id': 1, 'quantity': 10, 'price': 15.50},
|
||||
{'product_id': 3, 'quantity': 5, 'price': 45.00}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.app.post('/api/order',
|
||||
data=json.dumps(order_data),
|
||||
content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertTrue(data['success'])
|
||||
order_id = data['order_id']
|
||||
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
order = conn.execute('SELECT * FROM orders WHERE id = ?', (order_id,)).fetchone()
|
||||
self.assertIsNotNone(order)
|
||||
self.assertEqual(order['partner_id'], 1)
|
||||
|
||||
items = conn.execute('SELECT * FROM order_items WHERE order_id = ?', (order_id,)).fetchall()
|
||||
self.assertEqual(len(items), 2)
|
||||
|
||||
def test_2_discount_accumulation(self):
|
||||
"""Тест накопления скидки"""
|
||||
order1_data = {
|
||||
'partner_id': 2,
|
||||
'order_date': '2026-03-04',
|
||||
'notes': 'Первый заказ',
|
||||
'items': [
|
||||
{'product_id': 6, 'quantity': 7, 'price': 15000.00} # 7 * 15000 = 105 000
|
||||
]
|
||||
}
|
||||
|
||||
response = self.app.post('/api/order',
|
||||
data=json.dumps(order1_data),
|
||||
content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
order1_id = data['order_id']
|
||||
|
||||
order1_total = calculate_order_total(order1_id)
|
||||
self.assertEqual(order1_total['discount_percent'], 0)
|
||||
|
||||
order2_data = {
|
||||
'partner_id': 2,
|
||||
'order_date': '2026-03-05',
|
||||
'notes': 'Второй заказ',
|
||||
'items': [
|
||||
{'product_id': 6, 'quantity': 4, 'price': 15000.00} # 4 * 15000 = 60 000 (всего 165 000)
|
||||
]
|
||||
}
|
||||
|
||||
response = self.app.post('/api/order',
|
||||
data=json.dumps(order2_data),
|
||||
content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
order2_id = data['order_id']
|
||||
|
||||
order2_total = calculate_order_total(order2_id)
|
||||
self.assertEqual(order2_total['discount_percent'], 15)
|
||||
|
||||
def test_3_discount_reset_next_year(self):
|
||||
"""Тест сброса скидки в новом году"""
|
||||
order_2026_data = {
|
||||
'partner_id': 1,
|
||||
'order_date': '2026-12-01',
|
||||
'notes': 'Заказ 2026',
|
||||
'items': [
|
||||
{'product_id': 6, 'quantity': 14, 'price': 15000.00} # 14 * 15000 = 210 000
|
||||
]
|
||||
}
|
||||
|
||||
response = self.app.post('/api/order',
|
||||
data=json.dumps(order_2026_data),
|
||||
content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
order_2026_id = data['order_id']
|
||||
|
||||
order_2026_total = calculate_order_total(order_2026_id)
|
||||
self.assertEqual(order_2026_total['discount_percent'], 30)
|
||||
|
||||
order_2027_data = {
|
||||
'partner_id': 1,
|
||||
'order_date': '2027-01-15',
|
||||
'notes': 'Заказ 2027',
|
||||
'items': [
|
||||
{'product_id': 6, 'quantity': 7, 'price': 15000.00} # 7 * 15000 = 105 000
|
||||
]
|
||||
}
|
||||
|
||||
response = self.app.post('/api/order',
|
||||
data=json.dumps(order_2027_data),
|
||||
content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
order_2027_id = data['order_id']
|
||||
|
||||
order_2027_total = calculate_order_total(order_2027_id)
|
||||
self.assertEqual(order_2027_total['discount_percent'], 0)
|
||||
|
||||
def test_4_negative_quantity_prevention(self):
|
||||
"""Тест предотвращения отрицательного количества"""
|
||||
order_data = {
|
||||
'partner_id': 1,
|
||||
'order_date': '2026-03-04',
|
||||
'items': [
|
||||
{'product_id': 1, 'quantity': -5, 'price': 15.50}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.app.post('/api/order',
|
||||
data=json.dumps(order_data),
|
||||
content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
data = json.loads(response.data)
|
||||
self.assertFalse(data['success'])
|
||||
|
||||
def test_5_no_product_deletion_endpoint(self):
|
||||
"""Тест отсутствия возможности удаления товара"""
|
||||
# Сначала создаем заказ, чтобы товар был в заказах
|
||||
order_data = {
|
||||
'partner_id': 1,
|
||||
'order_date': '2026-03-04',
|
||||
'notes': 'Заказ для теста',
|
||||
'items': [
|
||||
{'product_id': 1, 'quantity': 5, 'price': 15.50}
|
||||
]
|
||||
}
|
||||
|
||||
response = self.app.post('/api/order',
|
||||
data=json.dumps(order_data),
|
||||
content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Пытаемся получить доступ к несуществующему маршруту удаления
|
||||
# Вместо DELETE используем GET, чтобы проверить, что маршрут не существует
|
||||
response = self.app.get('/api/product/1/delete')
|
||||
|
||||
# Должен быть 404 (не найдено)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
# Проверяем, что товар все еще существует в базе
|
||||
from database import get_db
|
||||
with get_db() as conn:
|
||||
product = conn.execute('SELECT * FROM products WHERE id = 1').fetchone()
|
||||
self.assertIsNotNone(product)
|
||||
|
||||
# Проверяем, что API не поддерживает метод DELETE для товаров
|
||||
# Отправляем DELETE запрос на существующий маршрут /api/product/1
|
||||
response = self.app.delete('/api/product/1')
|
||||
|
||||
# Должен быть либо 405 (Method Not Allowed), либо 404 (Not Found)
|
||||
self.assertIn(response.status_code, [404, 405])
|
||||
|
||||
# Проверяем, что товар все еще существует
|
||||
with get_db() as conn:
|
||||
product = conn.execute('SELECT * FROM products WHERE id = 1').fetchone()
|
||||
self.assertIsNotNone(product)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
230
tests/test_orders.py
Normal file
230
tests/test_orders.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import unittest
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database import get_db_connection, calculate_order_total
|
||||
from app import app
|
||||
|
||||
|
||||
class TestOrders(unittest.TestCase):
|
||||
"""Тестирование функционала заказов"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Создаем тестовую базу данных"""
|
||||
cls.db_fd, cls.db_path = tempfile.mkstemp()
|
||||
|
||||
import database
|
||||
database.DATABASE = cls.db_path
|
||||
|
||||
from database import create_tables
|
||||
create_tables()
|
||||
|
||||
cls.conn = sqlite3.connect(cls.db_path)
|
||||
cls.conn.row_factory = sqlite3.Row
|
||||
cursor = cls.conn.cursor()
|
||||
|
||||
# Добавляем типы товаров
|
||||
cursor.execute("INSERT INTO product_types (type_name) VALUES ('Ручки')")
|
||||
cursor.execute("INSERT INTO product_types (type_name) VALUES ('Тетради')")
|
||||
|
||||
# Добавляем партнеров (БЕЗ rating)
|
||||
cursor.execute('''
|
||||
INSERT INTO partners (name, email, inn) VALUES
|
||||
('ООО Тест', 'test@test.ru', '1234567890')
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO partners (name, email, inn) VALUES
|
||||
('ИП Тест', 'test2@test.ru', '0987654321')
|
||||
''')
|
||||
|
||||
# Добавляем товары (БЕЗ quantity_in_stock)
|
||||
cursor.execute('''
|
||||
INSERT INTO products (product_type_id, product_name, article, min_partner_price)
|
||||
VALUES (1, 'Ручка синяя', 'R001', 50.0)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO products (product_type_id, product_name, article, min_partner_price)
|
||||
VALUES (1, 'Ручка красная', 'R002', 60.0)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO products (product_type_id, product_name, article, min_partner_price)
|
||||
VALUES (2, 'Тетрадь 48л', 'T001', 100.0)
|
||||
''')
|
||||
|
||||
# Добавляем заказы (БЕЗ status)
|
||||
cursor.execute('''
|
||||
INSERT INTO orders (partner_id, order_date, discount_percent)
|
||||
VALUES (1, '2026-01-01', 0)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO orders (partner_id, order_date, discount_percent)
|
||||
VALUES (1, '2026-02-01', 15)
|
||||
''')
|
||||
|
||||
# Добавляем позиции заказов
|
||||
cursor.execute('''
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_at_order)
|
||||
VALUES (1, 1, 10, 50.0)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_at_order)
|
||||
VALUES (1, 3, 5, 100.0)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_at_order)
|
||||
VALUES (2, 2, 20, 60.0)
|
||||
''')
|
||||
|
||||
cls.conn.commit()
|
||||
|
||||
def test_db_connection():
|
||||
conn = sqlite3.connect(cls.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
app.config['DATABASE'] = cls.db_path
|
||||
database.get_db_connection = test_db_connection
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.conn.close()
|
||||
os.close(cls.db_fd)
|
||||
try:
|
||||
os.unlink(cls.db_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
self.app = app.test_client()
|
||||
self.app.testing = True
|
||||
|
||||
def test_1_calculate_order_total(self):
|
||||
"""Тест расчета общей суммы заказа с наценкой 10%"""
|
||||
result = calculate_order_total(1)
|
||||
self.assertIsNotNone(result)
|
||||
# 10*50 + 5*100 = 1000 базовая сумма
|
||||
self.assertEqual(result['subtotal_without_markup'], 1000.0)
|
||||
# С наценкой 10%: 1000 * 1.1 = 1100
|
||||
self.assertEqual(result['subtotal_with_markup'], 1100.0)
|
||||
self.assertEqual(result['discount_percent'], 0)
|
||||
self.assertEqual(result['total'], 1100.0)
|
||||
|
||||
result = calculate_order_total(2)
|
||||
self.assertIsNotNone(result)
|
||||
# 20*60 = 1200 базовая сумма
|
||||
self.assertEqual(result['subtotal_without_markup'], 1200.0)
|
||||
# С наценкой: 1200 * 1.1 = 1320
|
||||
self.assertEqual(result['subtotal_with_markup'], 1320.0)
|
||||
# Со скидкой 15%: 1320 * 0.85 = 1122
|
||||
self.assertEqual(result['discount_percent'], 15)
|
||||
self.assertEqual(result['discount_amount'], 198.0)
|
||||
self.assertEqual(result['total'], 1122.0)
|
||||
|
||||
def test_2_create_order_page(self):
|
||||
"""Тест страницы создания заказа"""
|
||||
response = self.app.get('/add_order')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Создание нового заказа', response.data.decode('utf-8'))
|
||||
|
||||
def test_3_edit_order_page(self):
|
||||
"""Тест страницы редактирования заказа"""
|
||||
response = self.app.get('/edit_order/1')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Редактирование заказа #1', response.data.decode('utf-8'))
|
||||
|
||||
def test_4_edit_order_without_items(self):
|
||||
"""Тест редактирования заказа без изменения позиций (только дата/примечания)"""
|
||||
response = self.app.post('/edit_order/1', data={
|
||||
'order_date': '2026-03-05',
|
||||
'notes': 'Обновлён без товаров'
|
||||
}, follow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Заказ обновлен', response.data.decode('utf-8'))
|
||||
|
||||
from database import get_db
|
||||
with get_db() as conn:
|
||||
count = conn.execute('SELECT COUNT(*) FROM order_items WHERE order_id = 1').fetchone()[0]
|
||||
self.assertEqual(count, 2)
|
||||
|
||||
def test_5_edit_order_add_new_item(self):
|
||||
"""Тест добавления новой позиции при редактировании"""
|
||||
response = self.app.post('/edit_order/1', data={
|
||||
'order_date': '2026-03-05',
|
||||
'notes': 'Добавлен новый товар',
|
||||
'product_id[]': ['1', '2', '2'],
|
||||
'quantity[]': ['5', '2', '3'],
|
||||
'price[]': ['50.0', '60.0', '60.0']
|
||||
}, follow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Заказ обновлен', response.data.decode('utf-8'))
|
||||
|
||||
from database import get_db
|
||||
with get_db() as conn:
|
||||
new_count = conn.execute('SELECT COUNT(*) FROM order_items WHERE order_id = 1').fetchone()[0]
|
||||
items = conn.execute('SELECT product_id, quantity FROM order_items WHERE order_id = 1').fetchall()
|
||||
|
||||
self.assertEqual(new_count, 3)
|
||||
|
||||
expected_items = [
|
||||
(1, 5),
|
||||
(2, 2),
|
||||
(2, 3)
|
||||
]
|
||||
|
||||
for i, (expected_product_id, expected_quantity) in enumerate(expected_items):
|
||||
self.assertEqual(items[i]['product_id'], expected_product_id)
|
||||
self.assertEqual(items[i]['quantity'], expected_quantity)
|
||||
|
||||
def test_6_no_delete_button(self):
|
||||
"""Тест отсутствия кнопки удаления на странице редактирования заказа"""
|
||||
# Сначала создаем заказ
|
||||
response = self.app.post('/add_order', data={
|
||||
'partner_id': '1',
|
||||
'order_date': '2026-03-04',
|
||||
'notes': 'Заказ для теста',
|
||||
'product_id[]': ['1'],
|
||||
'quantity[]': ['1'],
|
||||
'price[]': ['50.0']
|
||||
}, follow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Заказ успешно создан', response.data.decode('utf-8'))
|
||||
|
||||
# Получаем ID созданного заказа
|
||||
from database import get_db
|
||||
with get_db() as conn:
|
||||
order_id = conn.execute('SELECT MAX(id) as id FROM orders').fetchone()['id']
|
||||
|
||||
# Проверяем, что на странице редактирования нет кнопки удаления
|
||||
response = self.app.get(f'/edit_order/{order_id}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Ищем текст, который указывает на отсутствие кнопки удаления
|
||||
html_content = response.data.decode('utf-8')
|
||||
|
||||
# Проверяем, что нет HTML-элемента с текстом "Удалить заказ"
|
||||
self.assertNotIn('>Удалить заказ<', html_content)
|
||||
|
||||
# Проверяем, что нет кнопки с классом btn-delete в HTML (не в CSS)
|
||||
import re
|
||||
# Ищем теги с классом btn-delete (например, <button class="btn-delete"> или <a class="btn-delete">)
|
||||
btn_delete_pattern = r'<[^>]*class="[^"]*btn-delete[^"]*"[^>]*>'
|
||||
self.assertIsNone(re.search(btn_delete_pattern, html_content))
|
||||
|
||||
# Проверяем, что заказ все еще существует в базе
|
||||
with get_db() as conn:
|
||||
order = conn.execute('SELECT * FROM orders WHERE id = ?', (order_id,)).fetchone()
|
||||
self.assertIsNotNone(order)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
166
tests/test_partners.py
Normal file
166
tests/test_partners.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import unittest
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database import get_db_connection, create_tables
|
||||
from app import app
|
||||
|
||||
|
||||
class TestPartners(unittest.TestCase):
|
||||
"""Тестирование функционала партнеров"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Создаем тестовую базу данных"""
|
||||
cls.db_fd, cls.db_path = tempfile.mkstemp()
|
||||
|
||||
import database
|
||||
database.DATABASE = cls.db_path
|
||||
|
||||
create_tables()
|
||||
|
||||
cls.conn = sqlite3.connect(cls.db_path)
|
||||
cls.conn.row_factory = sqlite3.Row
|
||||
cursor = cls.conn.cursor()
|
||||
|
||||
# Добавляем партнеров (БЕЗ rating)
|
||||
cursor.execute('''
|
||||
INSERT INTO partners (name, director, email, phone, legal_address, inn)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', ('ООО Тест', 'Иванов И.И.', 'test@test.ru', '+7(999)123-45-67',
|
||||
'г. Москва', '7701123456'))
|
||||
|
||||
cursor.execute('''
|
||||
INSERT INTO partners (name, director, email, phone, legal_address, inn)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', ('ИП Тестов', 'Петров П.П.', 'test2@test.ru', '+7(999)765-43-21',
|
||||
'г. Москва', '7701765432'))
|
||||
|
||||
# Добавляем типы товаров для заказов
|
||||
cursor.execute("INSERT INTO product_types (type_name) VALUES ('Ручки')")
|
||||
|
||||
# Добавляем товары
|
||||
cursor.execute('''
|
||||
INSERT INTO products (product_type_id, product_name, article, min_partner_price)
|
||||
VALUES (1, 'Ручка тест', 'R001', 100.0)
|
||||
''')
|
||||
|
||||
# Добавляем заказы (БЕЗ status)
|
||||
cursor.execute('''
|
||||
INSERT INTO orders (partner_id, order_date, discount_percent)
|
||||
VALUES (1, '2026-01-01', 0)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO orders (partner_id, order_date, discount_percent)
|
||||
VALUES (1, '2026-02-01', 0)
|
||||
''')
|
||||
|
||||
# Добавляем позиции заказов
|
||||
cursor.execute('''
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_at_order)
|
||||
VALUES (1, 1, 10, 100.0)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO order_items (order_id, product_id, quantity, price_at_order)
|
||||
VALUES (2, 1, 20, 100.0)
|
||||
''')
|
||||
|
||||
cls.conn.commit()
|
||||
|
||||
def test_db_connection():
|
||||
conn = sqlite3.connect(cls.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
app.config['DATABASE'] = cls.db_path
|
||||
database.get_db_connection = test_db_connection
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.conn.close()
|
||||
os.close(cls.db_fd)
|
||||
try:
|
||||
os.unlink(cls.db_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
self.app = app.test_client()
|
||||
self.app.testing = True
|
||||
|
||||
def test_1_partners_page(self):
|
||||
"""Тест страницы партнеров"""
|
||||
response = self.app.get('/partners')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Партнеры', response.data.decode('utf-8'))
|
||||
self.assertIn('ООО Тест', response.data.decode('utf-8'))
|
||||
self.assertIn('ИП Тестов', response.data.decode('utf-8'))
|
||||
|
||||
def test_2_add_partner_page(self):
|
||||
"""Тест страницы добавления партнера"""
|
||||
response = self.app.get('/add_partner')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Добавление нового партнера', response.data.decode('utf-8'))
|
||||
|
||||
def test_3_edit_partner_page(self):
|
||||
"""Тест страницы редактирования партнера"""
|
||||
response = self.app.get('/edit_partner/1')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Редактирование партнера', response.data.decode('utf-8'))
|
||||
self.assertIn('ООО Тест', response.data.decode('utf-8'))
|
||||
|
||||
def test_4_add_partner_post(self):
|
||||
"""Тест добавления партнера через POST"""
|
||||
response = self.app.post('/add_partner', data={
|
||||
'name': 'Новый Партнер',
|
||||
'director': 'Сидоров С.С.',
|
||||
'email': 'new@partner.ru',
|
||||
'phone': '+7(999)111-22-33',
|
||||
'legal_address': 'г. Москва, ул. Новая',
|
||||
'inn': '7701999888'
|
||||
}, follow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Партнер успешно добавлен', response.data.decode('utf-8'))
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
partner = conn.execute('SELECT * FROM partners WHERE inn = ?', ('7701999888',)).fetchone()
|
||||
conn.close()
|
||||
self.assertIsNotNone(partner)
|
||||
self.assertEqual(partner['name'], 'Новый Партнер')
|
||||
|
||||
def test_5_edit_partner_post(self):
|
||||
"""Тест редактирования партнера через POST"""
|
||||
response = self.app.post('/edit_partner/1', data={
|
||||
'name': 'ООО Тест Обновленный',
|
||||
'director': 'Иванов И.И.',
|
||||
'email': 'updated@test.ru',
|
||||
'phone': '+7(999)123-45-67',
|
||||
'legal_address': 'г. Москва, ул. Обновленная',
|
||||
'inn': '7701123456'
|
||||
}, follow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Данные партнера обновлены', response.data.decode('utf-8'))
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
partner = conn.execute('SELECT * FROM partners WHERE id = 1').fetchone()
|
||||
conn.close()
|
||||
self.assertEqual(partner['name'], 'ООО Тест Обновленный')
|
||||
self.assertEqual(partner['email'], 'updated@test.ru')
|
||||
|
||||
def test_6_partner_orders_filter(self):
|
||||
"""Тест фильтрации заказов по партнеру"""
|
||||
response = self.app.get('/?partner=1')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Список заказов', response.data.decode('utf-8'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
162
tests/test_products.py
Normal file
162
tests/test_products.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import unittest
|
||||
import sqlite3
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import json
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from database import get_db_connection, create_tables
|
||||
from app import app
|
||||
|
||||
|
||||
class TestProducts(unittest.TestCase):
|
||||
"""Тестирование функционала товаров"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Создаем тестовую базу данных"""
|
||||
cls.db_fd, cls.db_path = tempfile.mkstemp()
|
||||
|
||||
import database
|
||||
database.DATABASE = cls.db_path
|
||||
|
||||
create_tables()
|
||||
|
||||
cls.conn = sqlite3.connect(cls.db_path)
|
||||
cls.conn.row_factory = sqlite3.Row
|
||||
cursor = cls.conn.cursor()
|
||||
|
||||
# Добавляем типы товаров
|
||||
cursor.execute("INSERT INTO product_types (type_name) VALUES ('Ручки')")
|
||||
cursor.execute("INSERT INTO product_types (type_name) VALUES ('Тетради')")
|
||||
cursor.execute("INSERT INTO product_types (type_name) VALUES ('Бумага')")
|
||||
|
||||
# Добавляем товары (БЕЗ quantity_in_stock)
|
||||
cursor.execute('''
|
||||
INSERT INTO products (product_type_id, product_name, article, min_partner_price)
|
||||
VALUES (1, 'Ручка синяя', 'R001', 50.0)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO products (product_type_id, product_name, article, min_partner_price)
|
||||
VALUES (1, 'Ручка красная', 'R002', 60.0)
|
||||
''')
|
||||
cursor.execute('''
|
||||
INSERT INTO products (product_type_id, product_name, article, min_partner_price)
|
||||
VALUES (2, 'Тетрадь 48л', 'T001', 100.0)
|
||||
''')
|
||||
|
||||
cls.conn.commit()
|
||||
|
||||
def test_db_connection():
|
||||
conn = sqlite3.connect(cls.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
app.config['DATABASE'] = cls.db_path
|
||||
database.get_db_connection = test_db_connection
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.conn.close()
|
||||
os.close(cls.db_fd)
|
||||
try:
|
||||
os.unlink(cls.db_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
self.app = app.test_client()
|
||||
self.app.testing = True
|
||||
|
||||
def test_1_products_page(self):
|
||||
"""Тест страницы товаров"""
|
||||
response = self.app.get('/products')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('Каталог товаров', response.data.decode('utf-8'))
|
||||
self.assertIn('Ручка синяя', response.data.decode('utf-8'))
|
||||
|
||||
def test_2_api_get_product(self):
|
||||
"""Тест API получения товара"""
|
||||
response = self.app.get('/api/product/1')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertEqual(data['product_name'], 'Ручка синяя')
|
||||
self.assertEqual(data['article'], 'R001')
|
||||
|
||||
def test_3_api_add_product(self):
|
||||
"""Тест API добавления товара"""
|
||||
new_product = {
|
||||
'product_type_id': 2,
|
||||
'product_name': 'Тетрадь новая',
|
||||
'article': 'T002',
|
||||
'min_partner_price': 150.0
|
||||
}
|
||||
|
||||
response = self.app.post('/api/product',
|
||||
data=json.dumps(new_product),
|
||||
content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIsNotNone(data['product_id'])
|
||||
|
||||
response = self.app.get(f'/api/product/{data["product_id"]}')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
product_data = json.loads(response.data)
|
||||
self.assertEqual(product_data['product_name'], 'Тетрадь новая')
|
||||
|
||||
def test_4_api_update_product(self):
|
||||
"""Тест API обновления товара"""
|
||||
updated_product = {
|
||||
'product_type_id': 1,
|
||||
'product_name': 'Ручка обновленная',
|
||||
'article': 'R001',
|
||||
'min_partner_price': 75.0
|
||||
}
|
||||
|
||||
response = self.app.put('/api/product/1',
|
||||
data=json.dumps(updated_product),
|
||||
content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertTrue(data['success'])
|
||||
|
||||
response = self.app.get('/api/product/1')
|
||||
product_data = json.loads(response.data)
|
||||
self.assertEqual(product_data['product_name'], 'Ручка обновленная')
|
||||
self.assertEqual(product_data['min_partner_price'], 75.0)
|
||||
|
||||
def test_5_api_add_product_type(self):
|
||||
"""Тест API добавления типа товара"""
|
||||
new_type = {
|
||||
'type_name': 'Ластики',
|
||||
'description': 'Для стирания'
|
||||
}
|
||||
|
||||
response = self.app.post('/api/product_type',
|
||||
data=json.dumps(new_type),
|
||||
content_type='application/json')
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.data)
|
||||
self.assertTrue(data['success'])
|
||||
self.assertIsNotNone(data['type_id'])
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
type_row = conn.execute('SELECT * FROM product_types WHERE type_name = ?', ('Ластики',)).fetchone()
|
||||
conn.close()
|
||||
self.assertIsNotNone(type_row)
|
||||
|
||||
def test_6_filter_products(self):
|
||||
"""Тест фильтрации товаров по типу"""
|
||||
response = self.app.get('/products?type=1')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
52
tests/tests.txt
Normal file
52
tests/tests.txt
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
1. test_calculation.py – тестирование алгоритмов расчета скидок
|
||||
- **test_1_discount_thresholds** – проверка пороговых значений скидок (150000, 200000, 300000).
|
||||
- **test_2_discount_descriptions** – проверка текстового описания скидок.
|
||||
- **test_3_edge_cases** – проверка граничных случаев (ровно на пороге и чуть ниже).
|
||||
- **test_4_negative_amounts** – проверка обработки отрицательных сумм.
|
||||
|
||||
________________________________________________________________________________________________________________________
|
||||
|
||||
2. test_database.py – тестирование функций работы с базой данных
|
||||
- **test_1_connection** – проверка подключения к БД.
|
||||
- **test_2_calculate_discount** – проверка функции расчета скидки.
|
||||
- **test_3_get_discount_description** – проверка получения описания скидки.
|
||||
- **test_4_calculate_discount_for_new_order** – проверка расчета скидки для нового заказа с учетом предыдущих.
|
||||
|
||||
________________________________________________________________________________________________________________________
|
||||
|
||||
3. test_integration.py – интеграционные тесты
|
||||
- **test_1_full_order_flow** – полный цикл создания заказа через API.
|
||||
- **test_2_discount_accumulation** – проверка накопления скидки при нескольких заказах.
|
||||
- **test_3_discount_reset_next_year** – проверка сброса скидки в новом году.
|
||||
- **test_4_negative_quantity_prevention** – проверка запрета отрицательного количества.
|
||||
- **test_5_product_deletion_with_orders** – проверка защиты от удаления товара, который есть в заказах.
|
||||
|
||||
________________________________________________________________________________________________________________________
|
||||
|
||||
4. test_orders.py – тестирование функционала заказов
|
||||
- **test_1_calculate_order_total** – расчет общей суммы заказа с наценкой 10%.
|
||||
- **test_2_create_order_page** – доступность страницы создания заказа.
|
||||
- **test_3_edit_order_page** – доступность страницы редактирования заказа.
|
||||
- **test_4_edit_order_without_items** – редактирование заказа без изменения позиций (только дата/примечания).
|
||||
- **test_5_edit_order_add_new_item** – добавление новых позиций при редактировании заказа.
|
||||
- **test_6_delete_order** – удаление заказа.
|
||||
|
||||
________________________________________________________________________________________________________________________
|
||||
|
||||
5. test_partners.py – тестирование функционала партнеров
|
||||
- **test_1_partners_page** – загрузка страницы партнеров.
|
||||
- **test_2_add_partner_page** – доступность страницы добавления партнера.
|
||||
- **test_3_edit_partner_page** – доступность страницы редактирования партнера.
|
||||
- **test_4_add_partner_post** – добавление партнера через POST.
|
||||
- **test_5_edit_partner_post** – редактирование партнера через POST.
|
||||
- **test_6_partner_orders_filter** – фильтрация заказов по партнеру.
|
||||
|
||||
________________________________________________________________________________________________________________________
|
||||
|
||||
6. test_products.py – тестирование функционала товаров
|
||||
- **test_1_products_page** – загрузка страницы товаров.
|
||||
- **test_2_api_get_product** – получение товара через API.
|
||||
- **test_3_api_add_product** – добавление товара через API.
|
||||
- **test_4_api_update_product** – обновление товара через API.
|
||||
- **test_5_api_add_product_type** – добавление типа товара через API.
|
||||
- **test_6_filter_products** – фильтрация товаров по типу.
|
||||
Loading…
Reference in a new issue