This commit is contained in:
parent e0af34249d
commit 41ece8851b
41 changed files with 7451 additions and 0 deletions

722
app.py Normal file
View 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)

Binary file not shown.

BIN
data/Orders_import.xlsx Normal file

Binary file not shown.

BIN
data/Partners_import.xlsx Normal file

Binary file not shown.

Binary file not shown.

BIN
data/Products_import.xlsx Normal file

Binary file not shown.

221
database.py Normal file
View 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
View 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
View file

@ -0,0 +1,5 @@
from database import create_tables
if __name__ == "__main__":
create_tables()
print("✅ База данных инициализирована")

BIN
partners.db Normal file

Binary file not shown.

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
Flask==2.3.3
pandas==2.0.3
openpyxl==3.1.2

681
static/css/style.css Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

193
static/js/script.js Normal file
View 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
View 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
View 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
View 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>&copy; 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
View 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
View 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
View 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
View 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 %}

View 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
View 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
View 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
View 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
View 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()">&times;</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()">&times;</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 %}

View 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
View 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
View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

60
tests/test_calculation.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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** – фильтрация товаров по типу.