demo
This commit is contained in:
parent
7b075038c9
commit
71defa5161
794
app.py
Normal file
794
app.py
Normal file
|
|
@ -0,0 +1,794 @@
|
||||||
|
from flask import Flask, render_template, request, redirect, url_for, flash
|
||||||
|
import sqlite3
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
DATABASE_PATH = BASE_DIR / "jewelry.db"
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["SECRET_KEY"] = "jewelry-secret"
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DATABASE_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
if DATABASE_PATH.exists():
|
||||||
|
return
|
||||||
|
conn = get_db()
|
||||||
|
|
||||||
|
# Таблица металлов
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS metal_types (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
purity INTEGER NOT NULL,
|
||||||
|
price_per_gram REAL NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Таблица товаров
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
metal_type_id INTEGER NOT NULL,
|
||||||
|
sku TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
weight_grams REAL NOT NULL,
|
||||||
|
complexity_factor REAL NOT NULL,
|
||||||
|
calculated_price REAL NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Таблица партнеров
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS partners (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
company_name TEXT NOT NULL,
|
||||||
|
director_name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
phone TEXT NOT NULL,
|
||||||
|
legal_address TEXT NOT NULL,
|
||||||
|
inn TEXT NOT NULL UNIQUE,
|
||||||
|
total_sales REAL NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Таблица заявок
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
partner_id INTEGER NOT NULL,
|
||||||
|
order_number TEXT NOT NULL UNIQUE,
|
||||||
|
order_date TEXT NOT NULL,
|
||||||
|
discount_percent REAL NOT NULL,
|
||||||
|
subtotal_amount REAL NOT NULL,
|
||||||
|
total_amount REAL NOT NULL,
|
||||||
|
notes TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Таблица позиций заявок
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS order_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
order_id INTEGER NOT NULL,
|
||||||
|
product_id INTEGER NOT NULL,
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
price_at_moment REAL NOT NULL,
|
||||||
|
line_total REAL NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Заполняем демо-данными
|
||||||
|
conn.execute("INSERT OR IGNORE INTO metal_types (name, purity, price_per_gram) VALUES ('Золото', 585, 3500)")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO metal_types (name, purity, price_per_gram) VALUES ('Золото', 750, 4500)")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO metal_types (name, purity, price_per_gram) VALUES ('Серебро', 925, 85)")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO metal_types (name, purity, price_per_gram) VALUES ('Платина', 950, 2800)")
|
||||||
|
|
||||||
|
conn.execute("INSERT OR IGNORE INTO products (metal_type_id, sku, name, weight_grams, complexity_factor, calculated_price, description) VALUES (1, 'RNG-001', 'Кольцо Классическое', 3.5, 1.2, 8600.70, 'Классическое обручальное кольцо')")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO products (metal_type_id, sku, name, weight_grams, complexity_factor, calculated_price, description) VALUES (1, 'EAR-001', 'Серьги Капля', 2.8, 1.5, 8600.70, 'Элегантные серьги-капли')")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO products (metal_type_id, sku, name, weight_grams, complexity_factor, calculated_price, description) VALUES (2, 'CHN-001', 'Цепь Плетение', 12.0, 1.0, 40500.00, 'Массивная золотая цепь')")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO products (metal_type_id, sku, name, weight_grams, complexity_factor, calculated_price, description) VALUES (3, 'BRC-001', 'Браслет Нежный', 6.5, 1.3, 663.91, 'Серебряный браслет с фианитами')")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO products (metal_type_id, sku, name, weight_grams, complexity_factor, calculated_price, description) VALUES (3, 'EAR-002', 'Серьги Цветок', 3.0, 1.2, 283.05, 'Серебряные серьги-цветы')")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO products (metal_type_id, sku, name, weight_grams, complexity_factor, calculated_price, description) VALUES (4, 'RNG-002', 'Кольцо Престиж', 5.5, 2.0, 29260.00, 'Платиновое кольцо с бриллиантом')")
|
||||||
|
|
||||||
|
conn.execute("INSERT OR IGNORE INTO partners (company_name, director_name, email, phone, legal_address, inn, total_sales) VALUES ('Ювелирный Дом Блеск', 'Иван Петров', 'blisk@example.com', '+7 495 123-45-67', 'Москва, ул. Цветная, д.1', '770123456789', 158200)")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO partners (company_name, director_name, email, phone, legal_address, inn, total_sales) VALUES ('ИП Сидорова А.А.', 'Анна Сидорова', 'sidorova@example.com', '+7 812 765-43-21', 'СПб, Невский пр., д.10', '781234567890', 0)")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO partners (company_name, director_name, email, phone, legal_address, inn, total_sales) VALUES ('Магазин Золотой Лотос', 'Сергей Иванов', 'lotus@example.com', '+7 383 555-11-22', 'Новосибирск, ул. Ленина, д.5', '540123456789', 0)")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO partners (company_name, director_name, email, phone, legal_address, inn, total_sales) VALUES ('Ювелирная Лавка', 'Михаил Козлов', 'lavka@example.com', '+7 495 888-99-00', 'Москва, ул. Тверская, д.10', '500123456789', 650000)")
|
||||||
|
|
||||||
|
conn.execute("INSERT OR IGNORE INTO orders (partner_id, order_number, order_date, discount_percent, subtotal_amount, total_amount, notes) VALUES (1, 'ORD-001', '2026-03-20', 5, 24653.40, 23420.73, 'Срочная поставка')")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO orders (partner_id, order_number, order_date, discount_percent, subtotal_amount, total_amount, notes) VALUES (2, 'ORD-002', '2026-03-15', 0, 40500.00, 40500.00, '')")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO orders (partner_id, order_number, order_date, discount_percent, subtotal_amount, total_amount, notes) VALUES (4, 'ORD-003', '2026-03-10', 5, 8600.70, 8170.67, '')")
|
||||||
|
|
||||||
|
conn.execute("INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, price_at_moment, line_total) VALUES (1, 1, 2, 8600.70, 17201.40)")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, price_at_moment, line_total) VALUES (1, 2, 1, 8600.70, 8600.70)")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, price_at_moment, line_total) VALUES (2, 3, 1, 40500.00, 40500.00)")
|
||||||
|
conn.execute("INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, price_at_moment, line_total) VALUES (3, 5, 2, 283.05, 566.10)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("База данных создана с демо-данными!")
|
||||||
|
|
||||||
|
# ==================== ФУНКЦИИ ВАЛИДАЦИИ ====================
|
||||||
|
|
||||||
|
def validate_inn(inn):
|
||||||
|
"""Проверка ИНН (10 или 12 цифр)"""
|
||||||
|
if not inn or not inn.strip():
|
||||||
|
return False, "ИНН не может быть пустым"
|
||||||
|
inn = inn.strip()
|
||||||
|
if not inn.isdigit():
|
||||||
|
return False, "ИНН должен содержать только цифры"
|
||||||
|
if len(inn) not in [10, 12]:
|
||||||
|
return False, "ИНН должен содержать 10 или 12 цифр"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def validate_phone(phone):
|
||||||
|
"""Проверка телефона (должен содержать минимум 10 цифр)"""
|
||||||
|
if not phone or not phone.strip():
|
||||||
|
return False, "Телефон не может быть пустым"
|
||||||
|
|
||||||
|
digits = re.sub(r'\D', '', phone)
|
||||||
|
|
||||||
|
if len(digits) < 10:
|
||||||
|
return False, f"Слишком мало цифр в номере телефона (найдено {len(digits)}, требуется минимум 10)"
|
||||||
|
if len(digits) > 15:
|
||||||
|
return False, f"Слишком много цифр в номере телефона (найдено {len(digits)}, максимум 15)"
|
||||||
|
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def validate_email(email):
|
||||||
|
"""Проверка email"""
|
||||||
|
if not email or not email.strip():
|
||||||
|
return False, "Email не может быть пустым"
|
||||||
|
email = email.strip()
|
||||||
|
if '@' not in email or '.' not in email:
|
||||||
|
return False, "Введите корректный email (пример: name@domain.ru)"
|
||||||
|
if email.count('@') != 1:
|
||||||
|
return False, "Email должен содержать один символ @"
|
||||||
|
local, domain = email.split('@')
|
||||||
|
if not local or not domain:
|
||||||
|
return False, "Email должен содержать имя и домен"
|
||||||
|
if '.' not in domain:
|
||||||
|
return False, "Домен должен содержать точку"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def validate_address(address):
|
||||||
|
"""Проверка юридического адреса"""
|
||||||
|
if not address or not address.strip():
|
||||||
|
return False, "Юридический адрес не может быть пустым"
|
||||||
|
if len(address.strip()) < 5:
|
||||||
|
return False, "Адрес слишком короткий (минимум 5 символов)"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def validate_company_name(name):
|
||||||
|
"""Проверка названия компании"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
return False, "Название компании не может быть пустым"
|
||||||
|
if len(name.strip()) < 2:
|
||||||
|
return False, "Название компании слишком короткое (минимум 2 символа)"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def validate_director_name(name):
|
||||||
|
"""Проверка ФИО директора"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
return False, "ФИО директора не может быть пустым"
|
||||||
|
if len(name.strip()) < 5:
|
||||||
|
return False, "ФИО директора слишком короткое (минимум 5 символов)"
|
||||||
|
if ' ' not in name.strip():
|
||||||
|
return False, "Введите полное ФИО (фамилию и имя)"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def validate_sku(sku):
|
||||||
|
"""Проверка артикула товара"""
|
||||||
|
if not sku or not sku.strip():
|
||||||
|
return False, "Артикул не может быть пустым"
|
||||||
|
sku = sku.strip()
|
||||||
|
if len(sku) < 3:
|
||||||
|
return False, "Артикул слишком короткий (минимум 3 символа)"
|
||||||
|
if len(sku) > 50:
|
||||||
|
return False, "Артикул слишком длинный (максимум 50 символов)"
|
||||||
|
if not re.match(r'^[A-Za-z0-9\-_]+$', sku):
|
||||||
|
return False, "Артикул может содержать только буквы, цифры, дефис и подчеркивание"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def validate_product_name(name):
|
||||||
|
"""Проверка названия товара"""
|
||||||
|
if not name or not name.strip():
|
||||||
|
return False, "Название товара не может быть пустым"
|
||||||
|
if len(name.strip()) < 2:
|
||||||
|
return False, "Название товара слишком короткое (минимум 2 символа)"
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
def validate_weight(weight):
|
||||||
|
"""Проверка веса товара"""
|
||||||
|
try:
|
||||||
|
w = float(weight)
|
||||||
|
if w <= 0:
|
||||||
|
return False, "Вес должен быть больше 0"
|
||||||
|
if w > 10000:
|
||||||
|
return False, "Вес слишком большой (максимум 10000 г)"
|
||||||
|
return True, ""
|
||||||
|
except ValueError:
|
||||||
|
return False, "Вес должен быть числом"
|
||||||
|
|
||||||
|
def validate_complexity(complexity):
|
||||||
|
"""Проверка коэффициента сложности"""
|
||||||
|
try:
|
||||||
|
c = float(complexity)
|
||||||
|
if c < 1.0:
|
||||||
|
return False, "Коэффициент сложности должен быть не меньше 1.0"
|
||||||
|
if c > 5.0:
|
||||||
|
return False, "Коэффициент сложности слишком большой (максимум 5.0)"
|
||||||
|
return True, ""
|
||||||
|
except ValueError:
|
||||||
|
return False, "Коэффициент сложности должен быть числом"
|
||||||
|
|
||||||
|
def calculate_product_price(metal_price, weight, purity, complexity):
|
||||||
|
return round(metal_price * weight * (purity / 1000) * complexity, 2)
|
||||||
|
|
||||||
|
def calculate_partner_discount(order_subtotal):
|
||||||
|
"""
|
||||||
|
Расчет скидки на основе суммы текущей заявки
|
||||||
|
- до 500 000 ₽ → 0%
|
||||||
|
- от 500 000 до 1 000 000 ₽ → 5%
|
||||||
|
- от 1 000 000 до 2 000 000 ₽ → 10%
|
||||||
|
- свыше 2 000 000 ₽ → 15%
|
||||||
|
"""
|
||||||
|
if order_subtotal >= 2000000:
|
||||||
|
return 15.0
|
||||||
|
if order_subtotal >= 1000000:
|
||||||
|
return 10.0
|
||||||
|
if order_subtotal >= 500000:
|
||||||
|
return 5.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def get_discount_description(discount):
|
||||||
|
"""Возвращает описание диапазона скидки"""
|
||||||
|
if discount == 15:
|
||||||
|
return "свыше 2 000 000 ₽"
|
||||||
|
if discount == 10:
|
||||||
|
return "от 1 000 000 до 2 000 000 ₽"
|
||||||
|
if discount == 5:
|
||||||
|
return "от 500 000 до 1 000 000 ₽"
|
||||||
|
return "до 500 000 ₽"
|
||||||
|
|
||||||
|
# ==================== ОСНОВНЫЕ МАРШРУТЫ ====================
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
conn = get_db()
|
||||||
|
|
||||||
|
orders = conn.execute("""
|
||||||
|
SELECT o.*, p.company_name as partner_name, p.total_sales as partner_total_sales
|
||||||
|
FROM orders o
|
||||||
|
JOIN partners p ON p.id = o.partner_id
|
||||||
|
ORDER BY o.order_date DESC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
partners = conn.execute("SELECT id, company_name FROM partners ORDER BY company_name").fetchall()
|
||||||
|
|
||||||
|
partner_filter = request.args.get('partner_id', '')
|
||||||
|
|
||||||
|
if partner_filter and partner_filter.isdigit():
|
||||||
|
filtered_orders = [o for o in orders if o['partner_id'] == int(partner_filter)]
|
||||||
|
else:
|
||||||
|
filtered_orders = orders
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return render_template("index.html", orders=filtered_orders, partners=partners, selected_partner=partner_filter)
|
||||||
|
|
||||||
|
# ==================== УПРАВЛЕНИЕ ТОВАРАМИ ====================
|
||||||
|
|
||||||
|
@app.route("/products")
|
||||||
|
def products():
|
||||||
|
conn = get_db()
|
||||||
|
products = conn.execute("""
|
||||||
|
SELECT p.*, mt.name as metal_name, mt.purity, mt.price_per_gram
|
||||||
|
FROM products p
|
||||||
|
JOIN metal_types mt ON mt.id = p.metal_type_id
|
||||||
|
ORDER BY p.name
|
||||||
|
""").fetchall()
|
||||||
|
metal_types = conn.execute("SELECT * FROM metal_types").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return render_template("products.html", products=products, metal_types=metal_types)
|
||||||
|
|
||||||
|
@app.route("/products/add", methods=["GET", "POST"])
|
||||||
|
def product_add():
|
||||||
|
conn = get_db()
|
||||||
|
metal_types = conn.execute("SELECT * FROM metal_types").fetchall()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
metal_type_id = request.form["metal_type_id"]
|
||||||
|
sku = request.form["sku"].strip() if request.form["sku"] else ""
|
||||||
|
name = request.form["name"].strip() if request.form["name"] else ""
|
||||||
|
weight = request.form["weight_grams"]
|
||||||
|
complexity = request.form["complexity_factor"]
|
||||||
|
description = request.form.get("description", "").strip()
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
sku_valid, sku_error = validate_sku(sku)
|
||||||
|
if not sku_valid:
|
||||||
|
errors.append(sku_error)
|
||||||
|
|
||||||
|
name_valid, name_error = validate_product_name(name)
|
||||||
|
if not name_valid:
|
||||||
|
errors.append(name_error)
|
||||||
|
|
||||||
|
weight_valid, weight_error = validate_weight(weight)
|
||||||
|
if not weight_valid:
|
||||||
|
errors.append(weight_error)
|
||||||
|
|
||||||
|
complexity_valid, complexity_error = validate_complexity(complexity)
|
||||||
|
if not complexity_valid:
|
||||||
|
errors.append(complexity_error)
|
||||||
|
|
||||||
|
if not metal_type_id:
|
||||||
|
errors.append("Выберите тип металла")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
flash(error, "error")
|
||||||
|
conn.close()
|
||||||
|
return render_template("product_form.html", product=None, metal_types=metal_types)
|
||||||
|
|
||||||
|
metal = conn.execute("SELECT price_per_gram, purity FROM metal_types WHERE id = ?", (int(metal_type_id),)).fetchone()
|
||||||
|
price = calculate_product_price(metal["price_per_gram"], float(weight), metal["purity"], float(complexity))
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO products (metal_type_id, sku, name, weight_grams, complexity_factor, calculated_price, description)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (int(metal_type_id), sku, name, float(weight), float(complexity), price, description))
|
||||||
|
conn.commit()
|
||||||
|
flash("Товар успешно добавлен!", "success")
|
||||||
|
return redirect(url_for("products"))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
flash("Ошибка: артикул уже существует!", "error")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Ошибка: {e}", "error")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return render_template("product_form.html", product=None, metal_types=metal_types)
|
||||||
|
|
||||||
|
@app.route("/products/<int:product_id>/edit", methods=["GET", "POST"])
|
||||||
|
def product_edit(product_id):
|
||||||
|
conn = get_db()
|
||||||
|
product = conn.execute("SELECT * FROM products WHERE id = ?", (product_id,)).fetchone()
|
||||||
|
metal_types = conn.execute("SELECT * FROM metal_types").fetchall()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
metal_type_id = request.form["metal_type_id"]
|
||||||
|
sku = request.form["sku"].strip() if request.form["sku"] else ""
|
||||||
|
name = request.form["name"].strip() if request.form["name"] else ""
|
||||||
|
weight = request.form["weight_grams"]
|
||||||
|
complexity = request.form["complexity_factor"]
|
||||||
|
description = request.form.get("description", "").strip()
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
sku_valid, sku_error = validate_sku(sku)
|
||||||
|
if not sku_valid:
|
||||||
|
errors.append(sku_error)
|
||||||
|
|
||||||
|
name_valid, name_error = validate_product_name(name)
|
||||||
|
if not name_valid:
|
||||||
|
errors.append(name_error)
|
||||||
|
|
||||||
|
weight_valid, weight_error = validate_weight(weight)
|
||||||
|
if not weight_valid:
|
||||||
|
errors.append(weight_error)
|
||||||
|
|
||||||
|
complexity_valid, complexity_error = validate_complexity(complexity)
|
||||||
|
if not complexity_valid:
|
||||||
|
errors.append(complexity_error)
|
||||||
|
|
||||||
|
if not metal_type_id:
|
||||||
|
errors.append("Выберите тип металла")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
flash(error, "error")
|
||||||
|
conn.close()
|
||||||
|
return render_template("product_form.html", product=product, metal_types=metal_types)
|
||||||
|
|
||||||
|
metal = conn.execute("SELECT price_per_gram, purity FROM metal_types WHERE id = ?", (int(metal_type_id),)).fetchone()
|
||||||
|
price = calculate_product_price(metal["price_per_gram"], float(weight), metal["purity"], float(complexity))
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE products SET metal_type_id=?, sku=?, name=?, weight_grams=?,
|
||||||
|
complexity_factor=?, calculated_price=?, description=? WHERE id=?
|
||||||
|
""", (int(metal_type_id), sku, name, float(weight), float(complexity), price, description, product_id))
|
||||||
|
conn.commit()
|
||||||
|
flash("Товар успешно обновлен!", "success")
|
||||||
|
return redirect(url_for("products"))
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Ошибка: {e}", "error")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return render_template("product_form.html", product=product, metal_types=metal_types)
|
||||||
|
|
||||||
|
@app.route("/products/<int:product_id>/delete", methods=["POST"])
|
||||||
|
def product_delete(product_id):
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
conn.execute("DELETE FROM products WHERE id = ?", (product_id,))
|
||||||
|
conn.commit()
|
||||||
|
flash("Товар удален!", "success")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
flash("Нельзя удалить товар, который есть в заявках!", "error")
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for("products"))
|
||||||
|
|
||||||
|
# ==================== УПРАВЛЕНИЕ ПАРТНЕРАМИ ====================
|
||||||
|
|
||||||
|
@app.route("/partners")
|
||||||
|
def partners():
|
||||||
|
conn = get_db()
|
||||||
|
partners = conn.execute("SELECT * FROM partners ORDER BY company_name").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return render_template("partners.html", partners=partners)
|
||||||
|
|
||||||
|
@app.route("/partners/add", methods=["GET", "POST"])
|
||||||
|
def partner_add():
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
company_name = request.form["company_name"].strip() if request.form["company_name"] else ""
|
||||||
|
director_name = request.form["director_name"].strip() if request.form["director_name"] else ""
|
||||||
|
email = request.form["email"].strip() if request.form["email"] else ""
|
||||||
|
phone = request.form["phone"].strip() if request.form["phone"] else ""
|
||||||
|
legal_address = request.form["legal_address"].strip() if request.form["legal_address"] else ""
|
||||||
|
inn = request.form["inn"].strip() if request.form["inn"] else ""
|
||||||
|
total_sales = float(request.form.get("total_sales", 0))
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
company_valid, company_error = validate_company_name(company_name)
|
||||||
|
if not company_valid:
|
||||||
|
errors.append(company_error)
|
||||||
|
|
||||||
|
director_valid, director_error = validate_director_name(director_name)
|
||||||
|
if not director_valid:
|
||||||
|
errors.append(director_error)
|
||||||
|
|
||||||
|
email_valid, email_error = validate_email(email)
|
||||||
|
if not email_valid:
|
||||||
|
errors.append(email_error)
|
||||||
|
|
||||||
|
phone_valid, phone_error = validate_phone(phone)
|
||||||
|
if not phone_valid:
|
||||||
|
errors.append(phone_error)
|
||||||
|
|
||||||
|
address_valid, address_error = validate_address(legal_address)
|
||||||
|
if not address_valid:
|
||||||
|
errors.append(address_error)
|
||||||
|
|
||||||
|
inn_valid, inn_error = validate_inn(inn)
|
||||||
|
if not inn_valid:
|
||||||
|
errors.append(inn_error)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
flash(error, "error")
|
||||||
|
return render_template("partner_form.html", partner=None)
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO partners (company_name, director_name, email, phone, legal_address, inn, total_sales)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (company_name, director_name, email, phone, legal_address, inn, total_sales))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
flash("Партнер успешно добавлен!", "success")
|
||||||
|
return redirect(url_for("partners"))
|
||||||
|
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
flash("Ошибка: партнер с таким ИНН уже существует!", "error")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Ошибка: {e}", "error")
|
||||||
|
|
||||||
|
return render_template("partner_form.html", partner=None)
|
||||||
|
|
||||||
|
@app.route("/partners/<int:partner_id>/edit", methods=["GET", "POST"])
|
||||||
|
def partner_edit(partner_id):
|
||||||
|
conn = get_db()
|
||||||
|
partner = conn.execute("SELECT * FROM partners WHERE id = ?", (partner_id,)).fetchone()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
company_name = request.form["company_name"].strip() if request.form["company_name"] else ""
|
||||||
|
director_name = request.form["director_name"].strip() if request.form["director_name"] else ""
|
||||||
|
email = request.form["email"].strip() if request.form["email"] else ""
|
||||||
|
phone = request.form["phone"].strip() if request.form["phone"] else ""
|
||||||
|
legal_address = request.form["legal_address"].strip() if request.form["legal_address"] else ""
|
||||||
|
inn = request.form["inn"].strip() if request.form["inn"] else ""
|
||||||
|
total_sales = float(request.form.get("total_sales", 0))
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
company_valid, company_error = validate_company_name(company_name)
|
||||||
|
if not company_valid:
|
||||||
|
errors.append(company_error)
|
||||||
|
|
||||||
|
director_valid, director_error = validate_director_name(director_name)
|
||||||
|
if not director_valid:
|
||||||
|
errors.append(director_error)
|
||||||
|
|
||||||
|
email_valid, email_error = validate_email(email)
|
||||||
|
if not email_valid:
|
||||||
|
errors.append(email_error)
|
||||||
|
|
||||||
|
phone_valid, phone_error = validate_phone(phone)
|
||||||
|
if not phone_valid:
|
||||||
|
errors.append(phone_error)
|
||||||
|
|
||||||
|
address_valid, address_error = validate_address(legal_address)
|
||||||
|
if not address_valid:
|
||||||
|
errors.append(address_error)
|
||||||
|
|
||||||
|
inn_valid, inn_error = validate_inn(inn)
|
||||||
|
if not inn_valid:
|
||||||
|
errors.append(inn_error)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
flash(error, "error")
|
||||||
|
return render_template("partner_form.html", partner=partner)
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE partners SET company_name=?, director_name=?, email=?, phone=?,
|
||||||
|
legal_address=?, inn=?, total_sales=? WHERE id=?
|
||||||
|
""", (company_name, director_name, email, phone, legal_address, inn, total_sales, partner_id))
|
||||||
|
conn.commit()
|
||||||
|
flash("Партнер успешно обновлен!", "success")
|
||||||
|
return redirect(url_for("partners"))
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Ошибка: {e}", "error")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return render_template("partner_form.html", partner=partner)
|
||||||
|
|
||||||
|
@app.route("/partners/<int:partner_id>/delete", methods=["POST"])
|
||||||
|
def partner_delete(partner_id):
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
conn.execute("DELETE FROM partners WHERE id = ?", (partner_id,))
|
||||||
|
conn.commit()
|
||||||
|
flash("Партнер удален!", "success")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
flash("Нельзя удалить партнера с существующими заявками!", "error")
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for("partners"))
|
||||||
|
|
||||||
|
# ==================== УПРАВЛЕНИЕ ЗАЯВКАМИ (С НОВОЙ СКИДКОЙ) ====================
|
||||||
|
|
||||||
|
@app.route("/orders/add", methods=["GET", "POST"])
|
||||||
|
def order_add():
|
||||||
|
conn = get_db()
|
||||||
|
partners = conn.execute("SELECT * FROM partners ORDER BY company_name").fetchall()
|
||||||
|
products = conn.execute("SELECT * FROM products ORDER BY name").fetchall()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
partner_id = int(request.form["partner_id"])
|
||||||
|
order_number = request.form["order_number"].strip()
|
||||||
|
notes = request.form.get("notes", "")
|
||||||
|
|
||||||
|
if not order_number:
|
||||||
|
flash("Номер заявки не может быть пустым!", "error")
|
||||||
|
return render_template("order_form.html", order=None, partners=partners, products=products)
|
||||||
|
|
||||||
|
product_ids = request.form.getlist("product_id[]")
|
||||||
|
quantities = request.form.getlist("quantity[]")
|
||||||
|
|
||||||
|
subtotal = 0.0
|
||||||
|
items = []
|
||||||
|
has_items = False
|
||||||
|
|
||||||
|
for i in range(len(product_ids)):
|
||||||
|
if product_ids[i] and quantities[i] and float(quantities[i]) > 0:
|
||||||
|
has_items = True
|
||||||
|
prod_id = int(product_ids[i])
|
||||||
|
qty = float(quantities[i])
|
||||||
|
prod = conn.execute("SELECT calculated_price FROM products WHERE id = ?", (prod_id,)).fetchone()
|
||||||
|
price = prod["calculated_price"]
|
||||||
|
line_total = qty * price
|
||||||
|
subtotal += line_total
|
||||||
|
items.append((prod_id, qty, price, line_total))
|
||||||
|
|
||||||
|
if not has_items:
|
||||||
|
flash("Добавьте хотя бы один товар в заявку!", "error")
|
||||||
|
return render_template("order_form.html", order=None, partners=partners, products=products)
|
||||||
|
|
||||||
|
# РАСЧЕТ СКИДКИ НА ОСНОВЕ СУММЫ ЗАЯВКИ
|
||||||
|
discount = calculate_partner_discount(subtotal)
|
||||||
|
total_amount = round(subtotal * (1 - discount / 100), 2)
|
||||||
|
|
||||||
|
cursor = conn.execute("""
|
||||||
|
INSERT INTO orders (partner_id, order_number, order_date, discount_percent, subtotal_amount, total_amount, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (partner_id, order_number, datetime.now().strftime("%Y-%m-%d"), discount, round(subtotal, 2), total_amount, notes))
|
||||||
|
order_id = cursor.lastrowid
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO order_items (order_id, product_id, quantity, price_at_moment, line_total)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (order_id, item[0], item[1], item[2], item[3]))
|
||||||
|
|
||||||
|
# Обновляем общую сумму продаж партнера (накопительная)
|
||||||
|
conn.execute("UPDATE partners SET total_sales = total_sales + ? WHERE id = ?", (subtotal, partner_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
discount_range = get_discount_description(discount)
|
||||||
|
flash(f"Заявка успешно создана! Скидка: {discount}% ({discount_range})", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
flash("Ошибка: номер заявки уже существует!", "error")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Ошибка: {e}", "error")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return render_template("order_form.html", order=None, partners=partners, products=products)
|
||||||
|
|
||||||
|
@app.route("/orders/<int:order_id>/edit", methods=["GET", "POST"])
|
||||||
|
def order_edit(order_id):
|
||||||
|
conn = get_db()
|
||||||
|
order = conn.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone()
|
||||||
|
order_items = conn.execute("SELECT * FROM order_items WHERE order_id = ?", (order_id,)).fetchall()
|
||||||
|
partners = conn.execute("SELECT * FROM partners ORDER BY company_name").fetchall()
|
||||||
|
products = conn.execute("SELECT * FROM products ORDER BY name").fetchall()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
partner_id = int(request.form["partner_id"])
|
||||||
|
order_number = request.form["order_number"].strip()
|
||||||
|
notes = request.form.get("notes", "")
|
||||||
|
|
||||||
|
product_ids = request.form.getlist("product_id[]")
|
||||||
|
quantities = request.form.getlist("quantity[]")
|
||||||
|
|
||||||
|
subtotal = 0.0
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for i in range(len(product_ids)):
|
||||||
|
if product_ids[i] and quantities[i] and float(quantities[i]) > 0:
|
||||||
|
prod_id = int(product_ids[i])
|
||||||
|
qty = float(quantities[i])
|
||||||
|
prod = conn.execute("SELECT calculated_price FROM products WHERE id = ?", (prod_id,)).fetchone()
|
||||||
|
price = prod["calculated_price"]
|
||||||
|
line_total = qty * price
|
||||||
|
subtotal += line_total
|
||||||
|
items.append((prod_id, qty, price, line_total))
|
||||||
|
|
||||||
|
# РАСЧЕТ СКИДКИ НА ОСНОВЕ СУММЫ ЗАЯВКИ
|
||||||
|
discount = calculate_partner_discount(subtotal)
|
||||||
|
total_amount = round(subtotal * (1 - discount / 100), 2)
|
||||||
|
|
||||||
|
# Возвращаем старую сумму продаж партнеру
|
||||||
|
old_subtotal = order["subtotal_amount"]
|
||||||
|
conn.execute("UPDATE partners SET total_sales = total_sales - ? WHERE id = ?", (old_subtotal, order["partner_id"]))
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE orders SET partner_id=?, order_number=?, discount_percent=?,
|
||||||
|
subtotal_amount=?, total_amount=?, notes=? WHERE id=?
|
||||||
|
""", (partner_id, order_number, discount, round(subtotal, 2), total_amount, notes, order_id))
|
||||||
|
|
||||||
|
conn.execute("DELETE FROM order_items WHERE order_id = ?", (order_id,))
|
||||||
|
for item in items:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO order_items (order_id, product_id, quantity, price_at_moment, line_total)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (order_id, item[0], item[1], item[2], item[3]))
|
||||||
|
|
||||||
|
conn.execute("UPDATE partners SET total_sales = total_sales + ? WHERE id = ?", (subtotal, partner_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
discount_range = get_discount_description(discount)
|
||||||
|
flash(f"Заявка успешно обновлена! Скидка: {discount}% ({discount_range})", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Ошибка: {e}", "error")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return render_template("order_form.html", order=order, order_items=order_items, partners=partners, products=products)
|
||||||
|
|
||||||
|
@app.route("/orders/<int:order_id>/delete", methods=["POST"])
|
||||||
|
def order_delete(order_id):
|
||||||
|
conn = get_db()
|
||||||
|
order = conn.execute("SELECT partner_id, subtotal_amount FROM orders WHERE id = ?", (order_id,)).fetchone()
|
||||||
|
if order:
|
||||||
|
conn.execute("UPDATE partners SET total_sales = total_sales - ? WHERE id = ?", (order["subtotal_amount"], order["partner_id"]))
|
||||||
|
conn.execute("DELETE FROM orders WHERE id = ?", (order_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
flash("Заявка удалена!", "success")
|
||||||
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
# ==================== УПРАВЛЕНИЕ МЕТАЛЛАМИ ====================
|
||||||
|
|
||||||
|
@app.route("/metal_types")
|
||||||
|
def metal_types():
|
||||||
|
conn = get_db()
|
||||||
|
metals = conn.execute("SELECT * FROM metal_types").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return render_template("metal_types.html", metals=metals)
|
||||||
|
|
||||||
|
@app.route("/metal_types/add", methods=["GET", "POST"])
|
||||||
|
def metal_type_add():
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
name = request.form["name"].strip()
|
||||||
|
purity = int(request.form["purity"])
|
||||||
|
price = float(request.form["price_per_gram"])
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
flash("Название металла не может быть пустым!", "error")
|
||||||
|
return render_template("metal_form.html", metal=None)
|
||||||
|
|
||||||
|
if purity <= 0 or purity > 1000:
|
||||||
|
flash("Проба должна быть от 1 до 1000!", "error")
|
||||||
|
return render_template("metal_form.html", metal=None)
|
||||||
|
|
||||||
|
if price <= 0:
|
||||||
|
flash("Цена должна быть больше 0!", "error")
|
||||||
|
return render_template("metal_form.html", metal=None)
|
||||||
|
|
||||||
|
conn.execute("INSERT INTO metal_types (name, purity, price_per_gram) VALUES (?, ?, ?)",
|
||||||
|
(name, purity, price))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
flash("Тип металла добавлен!", "success")
|
||||||
|
return redirect(url_for("metal_types"))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
flash("Ошибка: такой металл уже существует!", "error")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Ошибка: {e}", "error")
|
||||||
|
|
||||||
|
return render_template("metal_form.html", metal=None)
|
||||||
|
|
||||||
|
@app.route("/metal_types/<int:metal_id>/edit", methods=["GET", "POST"])
|
||||||
|
def metal_type_edit(metal_id):
|
||||||
|
conn = get_db()
|
||||||
|
metal = conn.execute("SELECT * FROM metal_types WHERE id = ?", (metal_id,)).fetchone()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
name = request.form["name"].strip()
|
||||||
|
purity = int(request.form["purity"])
|
||||||
|
price = float(request.form["price_per_gram"])
|
||||||
|
|
||||||
|
conn.execute("UPDATE metal_types SET name=?, purity=?, price_per_gram=? WHERE id=?",
|
||||||
|
(name, purity, price, metal_id))
|
||||||
|
conn.commit()
|
||||||
|
flash("Тип металла обновлен!", "success")
|
||||||
|
return redirect(url_for("metal_types"))
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Ошибка: {e}", "error")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return render_template("metal_form.html", metal=metal)
|
||||||
|
|
||||||
|
@app.route("/metal_types/<int:metal_id>/delete", methods=["POST"])
|
||||||
|
def metal_type_delete(metal_id):
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
conn.execute("DELETE FROM metal_types WHERE id = ?", (metal_id,))
|
||||||
|
conn.commit()
|
||||||
|
flash("Тип металла удален!", "success")
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
flash("Нельзя удалить металл, который используется в товарах!", "error")
|
||||||
|
conn.close()
|
||||||
|
return redirect(url_for("metal_types"))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
app.run(debug=True, port=5072)
|
||||||
BIN
data_exports/metal_types_export.xlsx
Normal file
BIN
data_exports/metal_types_export.xlsx
Normal file
Binary file not shown.
BIN
data_exports/order_items_export.xlsx
Normal file
BIN
data_exports/order_items_export.xlsx
Normal file
Binary file not shown.
BIN
data_exports/order_statuses_export.xlsx
Normal file
BIN
data_exports/order_statuses_export.xlsx
Normal file
Binary file not shown.
BIN
data_exports/orders_export..xlsx
Normal file
BIN
data_exports/orders_export..xlsx
Normal file
Binary file not shown.
BIN
data_exports/partners_export.xlsx
Normal file
BIN
data_exports/partners_export.xlsx
Normal file
Binary file not shown.
BIN
data_exports/products_export.xlsx
Normal file
BIN
data_exports/products_export.xlsx
Normal file
Binary file not shown.
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Flask
|
||||||
55
schema.sql
Normal file
55
schema.sql
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
-- 1. Типы металлов
|
||||||
|
CREATE TABLE metal_types (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
purity INTEGER NOT NULL,
|
||||||
|
price_per_gram REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 2. Продукция
|
||||||
|
CREATE TABLE products (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
metal_type_id INTEGER NOT NULL REFERENCES metal_types(id),
|
||||||
|
sku TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
weight_grams REAL NOT NULL,
|
||||||
|
complexity_factor REAL NOT NULL DEFAULT 1.0,
|
||||||
|
calculated_price REAL NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. Партнеры
|
||||||
|
CREATE TABLE partners (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
company_name TEXT NOT NULL,
|
||||||
|
director_name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
phone TEXT NOT NULL,
|
||||||
|
legal_address TEXT NOT NULL,
|
||||||
|
inn TEXT NOT NULL UNIQUE,
|
||||||
|
total_sales REAL NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 4. Заявки
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
partner_id INTEGER NOT NULL REFERENCES partners(id),
|
||||||
|
order_number TEXT NOT NULL UNIQUE,
|
||||||
|
order_date TEXT NOT NULL,
|
||||||
|
discount_percent REAL NOT NULL,
|
||||||
|
total_amount REAL NOT NULL,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 5. Позиции заявки
|
||||||
|
CREATE TABLE order_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
|
||||||
|
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
price_at_moment REAL NOT NULL,
|
||||||
|
line_total REAL NOT NULL
|
||||||
|
);
|
||||||
111
seed_data.py
Normal file
111
seed_data.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
def seed_demo_data(conn):
|
||||||
|
cursor = conn.cursor()
|
||||||
|
print("Наполнение базы данных демо-данными...")
|
||||||
|
|
||||||
|
# 1. Типы металлов
|
||||||
|
metals = [
|
||||||
|
("Золото", 585, 3500.0),
|
||||||
|
("Золото", 750, 4500.0),
|
||||||
|
("Серебро", 925, 85.0),
|
||||||
|
("Платина", 950, 2800.0),
|
||||||
|
]
|
||||||
|
cursor.executemany("INSERT INTO metal_types (name, purity, price_per_gram) VALUES (?, ?, ?)", metals)
|
||||||
|
|
||||||
|
cursor.execute("SELECT id, price_per_gram, purity FROM metal_types")
|
||||||
|
metal_data = cursor.fetchall()
|
||||||
|
gold_585_id = metal_data[0][0]
|
||||||
|
gold_750_id = metal_data[1][0]
|
||||||
|
silver_id = metal_data[2][0]
|
||||||
|
platinum_id = metal_data[3][0]
|
||||||
|
|
||||||
|
# 2. Продукция
|
||||||
|
products = [
|
||||||
|
(gold_585_id, "RNG-001", "Кольцо 'Классическое'", 3.5, 1.2, ""),
|
||||||
|
(gold_585_id, "RNG-002", "Кольцо 'Виктория'", 4.2, 1.5, "С бриллиантами"),
|
||||||
|
(gold_585_id, "EAR-001", "Серьги 'Капля'", 2.8, 1.5, ""),
|
||||||
|
(gold_750_id, "CHN-001", "Цепь 'Плетение'", 12.0, 1.0, "Длина 50 см"),
|
||||||
|
(gold_750_id, "PNT-001", "Подвеска 'Сердце'", 3.2, 1.3, ""),
|
||||||
|
(silver_id, "BRC-001", "Браслет 'Нежный'", 6.5, 1.3, ""),
|
||||||
|
(silver_id, "EAR-002", "Серьги 'Цветок'", 3.0, 1.2, ""),
|
||||||
|
(platinum_id, "RNG-003", "Кольцо 'Престиж'", 5.5, 2.0, "Эксклюзив"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for p in products:
|
||||||
|
metal_row = next(m for m in metal_data if m[0] == p[0])
|
||||||
|
price = (metal_row[1] * p[3]) * (metal_row[2] / 1000) * p[4]
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO products (metal_type_id, sku, name, weight_grams, complexity_factor, calculated_price, description)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (p[0], p[1], p[2], p[3], p[4], round(price, 2), p[5]))
|
||||||
|
|
||||||
|
# 3. Партнеры
|
||||||
|
partners = [
|
||||||
|
("Ювелирный Дом 'Блеск'", "Иван Петров", "blisk@example.com", "+7 (495) 123-45-67", "Москва, ул. Цветная, д.1", "770123456789", 0),
|
||||||
|
("ИП Сидорова А.А.", "Анна Сидорова", "sidorova@example.com", "+7 (812) 765-43-21", "СПб, Невский пр., д.10", "781234567890", 0),
|
||||||
|
("Магазин 'Золотой Лотос'", "Сергей Иванов", "lotus@example.com", "+7 (383) 555-11-22", "Новосибирск, ул. Ленина, д.5", "540123456789", 0),
|
||||||
|
("Торговый Дом 'Алмаз'", "Елена Соколова", "almaz@example.com", "+7 (343) 222-33-44", "Екатеринбург, пр. Ленина, д.15", "660123456789", 0),
|
||||||
|
]
|
||||||
|
cursor.executemany("""
|
||||||
|
INSERT INTO partners (company_name, director_name, email, phone, legal_address, inn, total_sales)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", partners)
|
||||||
|
|
||||||
|
# 4. Получаем ID для создания заявок
|
||||||
|
cursor.execute("SELECT id, company_name FROM partners")
|
||||||
|
partners_list = cursor.fetchall()
|
||||||
|
partner1_id = partners_list[0][0]
|
||||||
|
partner2_id = partners_list[1][0]
|
||||||
|
partner3_id = partners_list[2][0]
|
||||||
|
|
||||||
|
cursor.execute("SELECT id, calculated_price, name FROM products")
|
||||||
|
products_list = cursor.fetchall()
|
||||||
|
|
||||||
|
# 5. Создаем заявки
|
||||||
|
orders_data = [
|
||||||
|
(partner1_id, "ORD-001", datetime.now().strftime("%Y-%m-%d"), 0, 0, "Срочная поставка"),
|
||||||
|
(partner2_id, "ORD-002", (datetime.now() - timedelta(days=5)).strftime("%Y-%m-%d"), 0, 0, ""),
|
||||||
|
(partner3_id, "ORD-003", (datetime.now() - timedelta(days=15)).strftime("%Y-%m-%d"), 0, 0, "Пробная партия"),
|
||||||
|
]
|
||||||
|
|
||||||
|
order_ids = []
|
||||||
|
for o in orders_data:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO orders (partner_id, order_number, order_date, discount_percent, total_amount, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""", o)
|
||||||
|
order_ids.append(cursor.lastrowid)
|
||||||
|
|
||||||
|
# 6. Позиции для заявок
|
||||||
|
items_data = [
|
||||||
|
# Заявка 1
|
||||||
|
[(order_ids[0], products_list[0][0], 2, products_list[0][1], 2*products_list[0][1]),
|
||||||
|
(order_ids[0], products_list[1][0], 1, products_list[1][1], 1*products_list[1][1])],
|
||||||
|
# Заявка 2
|
||||||
|
[(order_ids[1], products_list[2][0], 1, products_list[2][1], 1*products_list[2][1]),
|
||||||
|
(order_ids[1], products_list[3][0], 3, products_list[3][1], 3*products_list[3][1])],
|
||||||
|
# Заявка 3
|
||||||
|
[(order_ids[2], products_list[4][0], 2, products_list[4][1], 2*products_list[4][1]),
|
||||||
|
(order_ids[2], products_list[5][0], 1, products_list[5][1], 1*products_list[5][1])],
|
||||||
|
]
|
||||||
|
|
||||||
|
for items in items_data:
|
||||||
|
for item in items:
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO order_items (order_id, product_id, quantity, price_at_moment, line_total)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", item)
|
||||||
|
|
||||||
|
# 7. Обновляем суммы заявок и total_sales партнеров
|
||||||
|
for i, order_id in enumerate(order_ids):
|
||||||
|
cursor.execute("SELECT SUM(line_total) FROM order_items WHERE order_id = ?", (order_id,))
|
||||||
|
total = cursor.fetchone()[0] or 0
|
||||||
|
cursor.execute("UPDATE orders SET total_amount = ? WHERE id = ?", (total, order_id))
|
||||||
|
|
||||||
|
partner_id = orders_data[i][0]
|
||||||
|
cursor.execute("UPDATE partners SET total_sales = total_sales + ? WHERE id = ?", (total, partner_id))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Демо-данные успешно загружены.")
|
||||||
143
static/app.js
Normal file
143
static/app.js
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// Подтверждение удаления
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-confirm]");
|
||||||
|
if (!button) return;
|
||||||
|
if (!window.confirm(button.getAttribute("data-confirm"))) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Автоматический расчет стоимости товара при изменении полей
|
||||||
|
function calculateProductPrice() {
|
||||||
|
const metalSelect = document.getElementById('metal_type_id');
|
||||||
|
const weightInput = document.getElementById('weight_grams');
|
||||||
|
const complexityInput = document.getElementById('complexity_factor');
|
||||||
|
const priceDisplay = document.getElementById('calculated_price_preview');
|
||||||
|
|
||||||
|
if (metalSelect && weightInput && complexityInput && priceDisplay) {
|
||||||
|
const selectedOption = metalSelect.options[metalSelect.selectedIndex];
|
||||||
|
const pricePerGram = parseFloat(selectedOption?.dataset?.pricePerGram || 0);
|
||||||
|
const purity = parseFloat(selectedOption?.dataset?.purity || 0);
|
||||||
|
const weight = parseFloat(weightInput.value) || 0;
|
||||||
|
const complexity = parseFloat(complexityInput.value) || 1;
|
||||||
|
|
||||||
|
if (pricePerGram > 0 && purity > 0 && weight > 0) {
|
||||||
|
const price = pricePerGram * weight * (purity / 1000) * complexity;
|
||||||
|
priceDisplay.innerText = price.toFixed(2) + ' ₽';
|
||||||
|
} else {
|
||||||
|
priceDisplay.innerText = '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расчет скидки партнера в форме заявки
|
||||||
|
function calculatePartnerDiscount(totalSales) {
|
||||||
|
if (totalSales > 2000000) return 15;
|
||||||
|
if (totalSales > 1000000) return 10;
|
||||||
|
if (totalSales > 500000) return 5;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление предварительного расчета в форме заявки
|
||||||
|
function updateOrderPreview() {
|
||||||
|
const partnerSelect = document.getElementById('partner_id');
|
||||||
|
const itemsContainer = document.getElementById('items_container');
|
||||||
|
|
||||||
|
if (!partnerSelect || !itemsContainer) return;
|
||||||
|
|
||||||
|
let subtotal = 0;
|
||||||
|
const rows = itemsContainer.querySelectorAll('.item-row');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const productSelect = row.querySelector('select[name="product_id[]"]');
|
||||||
|
const quantityInput = row.querySelector('input[name="quantity[]"]');
|
||||||
|
|
||||||
|
if (productSelect && productSelect.value && quantityInput && quantityInput.value) {
|
||||||
|
const price = parseFloat(productSelect.options[productSelect.selectedIndex]?.dataset?.price || 0);
|
||||||
|
const quantity = parseFloat(quantityInput.value) || 0;
|
||||||
|
subtotal += price * quantity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let discount = 0;
|
||||||
|
if (partnerSelect.value) {
|
||||||
|
const totalSales = parseFloat(partnerSelect.options[partnerSelect.selectedIndex]?.dataset?.sales || 0);
|
||||||
|
discount = calculatePartnerDiscount(totalSales);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = subtotal * (1 - discount / 100);
|
||||||
|
|
||||||
|
const subtotalSpan = document.getElementById('preview_subtotal');
|
||||||
|
const discountSpan = document.getElementById('preview_discount');
|
||||||
|
const totalSpan = document.getElementById('preview_total');
|
||||||
|
|
||||||
|
if (subtotalSpan) subtotalSpan.innerText = subtotal.toFixed(2);
|
||||||
|
if (discountSpan) discountSpan.innerText = discount;
|
||||||
|
if (totalSpan) totalSpan.innerText = total.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавление строки товара в заявку
|
||||||
|
function addOrderItemRow() {
|
||||||
|
const container = document.getElementById('items_container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const template = document.getElementById('item_row_template');
|
||||||
|
if (!template) return;
|
||||||
|
|
||||||
|
const newRow = template.content.cloneNode(true);
|
||||||
|
container.appendChild(newRow);
|
||||||
|
|
||||||
|
attachItemEvents(container.lastElementChild);
|
||||||
|
updateOrderPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление строки товара
|
||||||
|
function removeOrderItem(button) {
|
||||||
|
const container = document.getElementById('items_container');
|
||||||
|
const row = button.closest('.item-row');
|
||||||
|
if (container.children.length > 1) {
|
||||||
|
row.remove();
|
||||||
|
updateOrderPreview();
|
||||||
|
} else {
|
||||||
|
alert('Должен быть хотя бы один товар в заявке');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Привязка событий к строке товара
|
||||||
|
function attachItemEvents(row) {
|
||||||
|
const select = row.querySelector('select[name="product_id[]"]');
|
||||||
|
const quantity = row.querySelector('input[name="quantity[]"]');
|
||||||
|
const removeBtn = row.querySelector('.remove-item');
|
||||||
|
|
||||||
|
if (select) select.addEventListener('change', updateOrderPreview);
|
||||||
|
if (quantity) quantity.addEventListener('input', updateOrderPreview);
|
||||||
|
if (removeBtn) {
|
||||||
|
removeBtn.addEventListener('click', () => removeOrderItem(removeBtn));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация всех динамических элементов
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Для формы товара
|
||||||
|
const metalSelect = document.getElementById('metal_type_id');
|
||||||
|
const weightInput = document.getElementById('weight_grams');
|
||||||
|
const complexityInput = document.getElementById('complexity_factor');
|
||||||
|
|
||||||
|
if (metalSelect) metalSelect.addEventListener('change', calculateProductPrice);
|
||||||
|
if (weightInput) weightInput.addEventListener('input', calculateProductPrice);
|
||||||
|
if (complexityInput) complexityInput.addEventListener('input', calculateProductPrice);
|
||||||
|
|
||||||
|
calculateProductPrice();
|
||||||
|
|
||||||
|
// Для формы заявки
|
||||||
|
const partnerSelect = document.getElementById('partner_id');
|
||||||
|
if (partnerSelect) partnerSelect.addEventListener('change', updateOrderPreview);
|
||||||
|
|
||||||
|
const addItemBtn = document.getElementById('add_item_btn');
|
||||||
|
if (addItemBtn) addItemBtn.addEventListener('click', addOrderItemRow);
|
||||||
|
|
||||||
|
const existingRows = document.querySelectorAll('#items_container .item-row');
|
||||||
|
existingRows.forEach(row => attachItemEvents(row));
|
||||||
|
|
||||||
|
updateOrderPreview();
|
||||||
|
});
|
||||||
5
static/favicon.svg
Normal file
5
static/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<circle cx="16" cy="16" r="14" fill="#D2691E"/>
|
||||||
|
<circle cx="16" cy="16" r="8" fill="#FFFAF0"/>
|
||||||
|
<path d="M16 8 L16 24 M8 16 L24 16" stroke="#D2691E" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 241 B |
6
static/logo.svg
Normal file
6
static/logo.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<circle cx="50" cy="50" r="45" fill="#D2691E" stroke="#F5DEB3" stroke-width="2"/>
|
||||||
|
<circle cx="50" cy="50" r="25" fill="#FFFAF0"/>
|
||||||
|
<path d="M50 25 L50 75 M25 50 L75 50" stroke="#D2691E" stroke-width="3"/>
|
||||||
|
<circle cx="50" cy="50" r="8" fill="#D2691E"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
71
static/style.css
Normal file
71
static/style.css
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/* Добавляем стили для предпросмотра и анимаций */
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
background: #f9f5e8;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview h4 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row {
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row:hover {
|
||||||
|
background: rgba(245, 222, 179, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-item {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для пустых состояний */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 16px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для таблиц на мобильных */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.table-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
192
templates/base.html
Normal file
192
templates/base.html
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Ювелирный Склад</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: Constantia, Georgia, serif;
|
||||||
|
background: #FFFAF0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
nav {
|
||||||
|
background: #F5DEB3;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 2px solid #D2691E;
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
color: #D2691E;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
nav a:hover {
|
||||||
|
background: rgba(210, 105, 30, 0.2);
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #D2691E;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #F5DEB3;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #F5DEB3;
|
||||||
|
color: #D2691E;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
tr:hover {
|
||||||
|
background: #FFF8F0;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
padding: 12px 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.alert-success {
|
||||||
|
background: #e8f5e9;
|
||||||
|
border-left-color: #4caf50;
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
.alert-error {
|
||||||
|
background: #ffebee;
|
||||||
|
border-left-color: #f44336;
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stat-card {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 150px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-card h3 {
|
||||||
|
color: #D2691E;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.stat-card .number {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #8B4513;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #D2691E;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #b85c1a;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #F5DEB3;
|
||||||
|
color: #D2691E;
|
||||||
|
}
|
||||||
|
.btn-small {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.form-group input, .form-group select, .form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #F5DEB3;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.form-card {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.item-row select {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
.item-row input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container { padding: 1rem; }
|
||||||
|
nav { justify-content: center; }
|
||||||
|
table { font-size: 0.9rem; }
|
||||||
|
th, td { padding: 8px; }
|
||||||
|
.item-row { flex-direction: column; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="/">🏠 Заявки</a>
|
||||||
|
<a href="/products">💎 Товары</a>
|
||||||
|
<a href="/partners">🤝 Партнеры</a>
|
||||||
|
<a href="/metal_types">🔩 Металлы</a>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, msg in messages %}
|
||||||
|
<div class="alert alert-{{ category if category else 'info' }}">{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
88
templates/index.html
Normal file
88
templates/index.html
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>📋 Заявки партнеров</h1>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<a href="/orders/add" class="btn btn-primary">➕ Новая заявка</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters" style="background: #F5DEB3; padding: 15px; border-radius: 8px; margin-bottom: 20px;">
|
||||||
|
<form method="get" style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
|
||||||
|
<label style="font-weight: bold;">Фильтр по партнеру:</label>
|
||||||
|
<select name="partner_id" onchange="this.form.submit()" style="padding: 8px; border-radius: 5px; border: 1px solid #D2691E;">
|
||||||
|
<option value="">Все партнеры</option>
|
||||||
|
{% for p in partners %}
|
||||||
|
<option value="{{ p.id }}" {% if selected_partner == p.id|string %}selected{% endif %}>{{ p.company_name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if selected_partner %}
|
||||||
|
<a href="/" class="btn btn-secondary btn-small">Сбросить фильтр</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Всего заявок</h3>
|
||||||
|
<div class="number">{{ orders|length }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Общая сумма (без скидки)</h3>
|
||||||
|
<div class="number">
|
||||||
|
{% set total = namespace(value=0) %}
|
||||||
|
{% for order in orders %}
|
||||||
|
{% set total.value = total.value + order.subtotal_amount %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ "%.2f"|format(total.value) }} ₽
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Общая сумма (со скидкой)</h3>
|
||||||
|
<div class="number">
|
||||||
|
{% set total_disc = namespace(value=0) %}
|
||||||
|
{% for order in orders %}
|
||||||
|
{% set total_disc.value = total_disc.value + order.total_amount %}
|
||||||
|
{% endfor %}
|
||||||
|
{{ "%.2f"|format(total_disc.value) }} ₽
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Номер</th>
|
||||||
|
<th>Партнер</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Сумма (без скидки)</th>
|
||||||
|
<th>Скидка</th>
|
||||||
|
<th>Сумма (со скидкой)</th>
|
||||||
|
<th>Примечание</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for order in orders %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ order.order_number }}</strong></td>
|
||||||
|
<td>{{ order.partner_name }}</td>
|
||||||
|
<td>{{ order.order_date }}</td>
|
||||||
|
<td>{{ "%.2f"|format(order.subtotal_amount) }} ₽</td>
|
||||||
|
<td><span style="background: #D2691E; color: white; padding: 3px 8px; border-radius: 15px;">{{ order.discount_percent }}%</span></td>
|
||||||
|
<td><strong style="color: #D2691E;">{{ "%.2f"|format(order.total_amount) }} ₽</strong></td>
|
||||||
|
<td>{{ order.notes or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/orders/{{ order.id }}/edit" class="btn btn-secondary btn-small">✏️</a>
|
||||||
|
<form method="post" action="/orders/{{ order.id }}/delete" style="display: inline;" onsubmit="return confirm('Удалить заявку?')">
|
||||||
|
<button type="submit" class="btn btn-primary btn-small">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" style="text-align: center;">Нет заявок. Создайте первую!</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
27
templates/metal_form.html
Normal file
27
templates/metal_form.html
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if metal %}✏️ Редактирование металла{% else %}➕ Добавление металла{% endif %}</h1>
|
||||||
|
|
||||||
|
<form method="post" class="form-card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название металла *</label>
|
||||||
|
<input type="text" name="name" value="{{ metal.name if metal else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Проба *</label>
|
||||||
|
<input type="number" name="purity" value="{{ metal.purity if metal else '' }}" required>
|
||||||
|
<small>Например: 585, 750, 925, 950</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Цена за грамм (₽) *</label>
|
||||||
|
<input type="number" step="0.01" name="price_per_gram" value="{{ metal.price_per_gram if metal else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Сохранить</button>
|
||||||
|
<a href="/metal_types" class="btn btn-secondary">❌ Отмена</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
45
templates/metal_types.html
Normal file
45
templates/metal_types.html
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>🔩 Типы металлов</h1>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<a href="/metal_types/add" class="btn btn-primary">➕ Добавить металл</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Виды металлов</h3>
|
||||||
|
<div class="number">{{ metals|length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Металл</th>
|
||||||
|
<th>Проба</th>
|
||||||
|
<th>Цена за грамм</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for metal in metals %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ metal.name }}</strong></td>
|
||||||
|
<td>{{ metal.purity }}</td>
|
||||||
|
<td>{{ "%.2f"|format(metal.price_per_gram) }} ₽</td>
|
||||||
|
<td>
|
||||||
|
<a href="/metal_types/{{ metal.id }}/edit" class="btn btn-secondary btn-small">✏️</a>
|
||||||
|
<form method="post" action="/metal_types/{{ metal.id }}/delete" style="display: inline;" onsubmit="return confirm('Удалить тип металла?')">
|
||||||
|
<button type="submit" class="btn btn-primary btn-small">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="text-align: center;">Нет данных</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
216
templates/order_form.html
Normal file
216
templates/order_form.html
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if order %}✏️ Редактирование заявки{% else %}➕ Новая заявка{% endif %}</h1>
|
||||||
|
|
||||||
|
<form method="post" id="orderForm">
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Номер заявки *</label>
|
||||||
|
<input type="text" name="order_number" value="{{ order.order_number if order else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Партнер *</label>
|
||||||
|
<select name="partner_id" id="partner_id" required>
|
||||||
|
<option value="">Выберите партнера</option>
|
||||||
|
{% for p in partners %}
|
||||||
|
<option value="{{ p.id }}" data-sales="{{ p.total_sales }}" data-name="{{ p.company_name }}" {% if order and order.partner_id == p.id %}selected{% endif %}>
|
||||||
|
{{ p.company_name }} (продажи: {{ "%.2f"|format(p.total_sales) }} ₽)
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Примечания</label>
|
||||||
|
<textarea name="notes" rows="2">{{ order.notes if order else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin: 20px 0;">
|
||||||
|
|
||||||
|
<h3>📦 Товары в заявке</h3>
|
||||||
|
<div id="items_container">
|
||||||
|
{% if order_items %}
|
||||||
|
{% for item in order_items %}
|
||||||
|
<div class="item-row">
|
||||||
|
<select name="product_id[]" required>
|
||||||
|
<option value="">Выберите товар</option>
|
||||||
|
{% for prod in products %}
|
||||||
|
<option value="{{ prod.id }}" data-price="{{ prod.calculated_price }}" {% if item.product_id == prod.id %}selected{% endif %}>
|
||||||
|
{{ prod.name }} - {{ "%.2f"|format(prod.calculated_price) }} ₽
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="number" name="quantity[]" placeholder="Количество" step="0.01" value="{{ item.quantity }}" required>
|
||||||
|
<button type="button" class="btn btn-primary btn-small remove-item">×</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="item-row">
|
||||||
|
<select name="product_id[]" required>
|
||||||
|
<option value="">Выберите товар</option>
|
||||||
|
{% for prod in products %}
|
||||||
|
<option value="{{ prod.id }}" data-price="{{ prod.calculated_price }}">{{ prod.name }} - {{ "%.2f"|format(prod.calculated_price) }} ₽</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="number" name="quantity[]" placeholder="Количество" step="0.01" required>
|
||||||
|
<button type="button" class="btn btn-primary btn-small remove-item">×</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="add_item_btn" class="btn btn-secondary" style="margin: 10px 0;">+ Добавить товар</button>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px; padding: 15px; background: #FFFAF0; border-radius: 8px; border: 1px solid #F5DEB3;">
|
||||||
|
<h3>💰 Расчет стоимости</h3>
|
||||||
|
|
||||||
|
<div style="background: #F5DEB3; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
|
||||||
|
<small style="font-weight: bold;">📊 Шкала скидок от суммы заявки:</small><br>
|
||||||
|
<small>• до 500 000 ₽ → 0%</small><br>
|
||||||
|
<small>• от 500 000 до 1 000 000 ₽ → 5%</small><br>
|
||||||
|
<small>• от 1 000 000 до 2 000 000 ₽ → 10%</small><br>
|
||||||
|
<small>• свыше 2 000 000 ₽ → 15%</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||||||
|
<span>Сумма заявки:</span>
|
||||||
|
<strong id="preview_subtotal">0.00</strong> ₽
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px;">
|
||||||
|
<span>Скидка:</span>
|
||||||
|
<strong id="preview_discount">0</strong>%
|
||||||
|
<span id="discount_range" style="font-size: 0.8rem; color: #666;"></span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 10px; padding-top: 10px; border-top: 1px dashed #F5DEB3;">
|
||||||
|
<span style="font-size: 1.2rem;">Сумма к оплате:</span>
|
||||||
|
<strong style="font-size: 1.4rem; color: #D2691E;" id="preview_total">0.00</strong> ₽
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-top: 10px; font-size: 0.9rem; color: #666;">
|
||||||
|
<span>Вы экономите:</span>
|
||||||
|
<span id="preview_savings">0.00</span> ₽
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; margin-top: 20px;">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Сохранить заявку</button>
|
||||||
|
<a href="/" class="btn btn-secondary">❌ Отмена</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function calculateDiscount(subtotal) {
|
||||||
|
if (subtotal >= 2000000) return 15;
|
||||||
|
if (subtotal >= 1000000) return 10;
|
||||||
|
if (subtotal >= 500000) return 5;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiscountRange(discount) {
|
||||||
|
if (discount === 15) return '(свыше 2 000 000 ₽)';
|
||||||
|
if (discount === 10) return '(от 1 000 000 до 2 000 000 ₽)';
|
||||||
|
if (discount === 5) return '(от 500 000 до 1 000 000 ₽)';
|
||||||
|
return '(до 500 000 ₽)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
let subtotal = 0;
|
||||||
|
const rows = document.querySelectorAll('.item-row');
|
||||||
|
rows.forEach(row => {
|
||||||
|
const select = row.querySelector('select[name="product_id[]"]');
|
||||||
|
const qty = row.querySelector('input[name="quantity[]"]');
|
||||||
|
if (select && select.value && qty && qty.value) {
|
||||||
|
const price = parseFloat(select.options[select.selectedIndex].dataset.price);
|
||||||
|
const quantity = parseFloat(qty.value);
|
||||||
|
if (!isNaN(price) && !isNaN(quantity) && quantity > 0) {
|
||||||
|
subtotal += price * quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const discount = calculateDiscount(subtotal);
|
||||||
|
const total = subtotal * (1 - discount / 100);
|
||||||
|
const savings = subtotal - total;
|
||||||
|
const discountRange = getDiscountRange(discount);
|
||||||
|
|
||||||
|
document.getElementById('preview_subtotal').innerText = subtotal.toFixed(2);
|
||||||
|
document.getElementById('preview_discount').innerText = discount;
|
||||||
|
document.getElementById('discount_range').innerText = discountRange;
|
||||||
|
document.getElementById('preview_total').innerText = total.toFixed(2);
|
||||||
|
document.getElementById('preview_savings').innerText = savings.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('add_item_btn').addEventListener('click', function() {
|
||||||
|
const container = document.getElementById('items_container');
|
||||||
|
const newRow = document.createElement('div');
|
||||||
|
newRow.className = 'item-row';
|
||||||
|
newRow.style.display = 'flex';
|
||||||
|
newRow.style.gap = '10px';
|
||||||
|
newRow.style.marginBottom = '10px';
|
||||||
|
newRow.innerHTML = `
|
||||||
|
<select name="product_id[]" required style="flex: 2; padding: 8px; border: 1px solid #F5DEB3; border-radius: 5px;">
|
||||||
|
<option value="">Выберите товар</option>
|
||||||
|
{% for prod in products %}
|
||||||
|
<option value="{{ prod.id }}" data-price="{{ prod.calculated_price }}">{{ prod.name }} - {{ "%.2f"|format(prod.calculated_price) }} ₽</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<input type="number" name="quantity[]" placeholder="Количество" step="0.01" required style="flex: 1; padding: 8px; border: 1px solid #F5DEB3; border-radius: 5px;">
|
||||||
|
<button type="button" class="btn btn-primary btn-small remove-item">×</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(newRow);
|
||||||
|
attachEvents(newRow);
|
||||||
|
updatePreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
function attachEvents(row) {
|
||||||
|
const select = row.querySelector('select');
|
||||||
|
const qty = row.querySelector('input');
|
||||||
|
const removeBtn = row.querySelector('.remove-item');
|
||||||
|
if (select) select.addEventListener('change', updatePreview);
|
||||||
|
if (qty) qty.addEventListener('input', updatePreview);
|
||||||
|
if (removeBtn) {
|
||||||
|
removeBtn.addEventListener('click', function() {
|
||||||
|
if (document.querySelectorAll('.item-row').length > 1) {
|
||||||
|
row.remove();
|
||||||
|
updatePreview();
|
||||||
|
} else {
|
||||||
|
alert('Должен быть хотя бы один товар');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.item-row').forEach(row => attachEvents(row));
|
||||||
|
updatePreview();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.item-row select {
|
||||||
|
flex: 2;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #F5DEB3;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.item-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #F5DEB3;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.item-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.item-row select, .item-row input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
EOF
|
||||||
86
templates/partner_form.html
Normal file
86
templates/partner_form.html
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if partner %}✏️ Редактирование партнера{% else %}➕ Добавление партнера{% endif %}</h1>
|
||||||
|
|
||||||
|
<form method="post" class="form-card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название компании *</label>
|
||||||
|
<input type="text" name="company_name" value="{{ partner.company_name if partner else '' }}" required>
|
||||||
|
<small>Минимум 2 символа</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>ФИО директора *</label>
|
||||||
|
<input type="text" name="director_name" value="{{ partner.director_name if partner else '' }}" required>
|
||||||
|
<small>Полное имя (фамилия и имя, минимум 5 символов)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email *</label>
|
||||||
|
<input type="email" name="email" value="{{ partner.email if partner else '' }}" required>
|
||||||
|
<small>example@domain.ru</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Телефон *</label>
|
||||||
|
<input type="text" name="phone" id="phone" value="{{ partner.phone if partner else '' }}" required>
|
||||||
|
<small>Формат: +7 (999) 123-45-67 или 89123456789 (минимум 10 цифр)</small>
|
||||||
|
<div id="phone-hint" style="font-size: 12px; margin-top: 5px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Юридический адрес *</label>
|
||||||
|
<input type="text" name="legal_address" value="{{ partner.legal_address if partner else '' }}" required>
|
||||||
|
<small>Полный адрес, минимум 5 символов</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>ИНН *</label>
|
||||||
|
<input type="text" name="inn" value="{{ partner.inn if partner else '' }}" required>
|
||||||
|
<small>10 или 12 цифр, только цифры</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if partner %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Накопленный объем продаж (₽)</label>
|
||||||
|
<input type="number" step="0.01" name="total_sales" value="{{ partner.total_sales }}">
|
||||||
|
<small>Изменение этой суммы повлияет на скидку партнера</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Сохранить</button>
|
||||||
|
<a href="/partners" class="btn btn-secondary">❌ Отмена</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function countPhoneDigits() {
|
||||||
|
const phoneInput = document.getElementById('phone');
|
||||||
|
const hintDiv = document.getElementById('phone-hint');
|
||||||
|
if (phoneInput && hintDiv) {
|
||||||
|
const digits = phoneInput.value.replace(/\\D/g, '');
|
||||||
|
const digitCount = digits.length;
|
||||||
|
if (digitCount > 0 && digitCount < 10) {
|
||||||
|
hintDiv.innerHTML = '⚠️ Найдено ' + digitCount + ' цифр. Требуется минимум 10 цифр.';
|
||||||
|
hintDiv.style.color = '#f44336';
|
||||||
|
} else if (digitCount >= 10 && digitCount <= 15) {
|
||||||
|
hintDiv.innerHTML = '✓ ' + digitCount + ' цифр. OK';
|
||||||
|
hintDiv.style.color = '#4caf50';
|
||||||
|
} else if (digitCount > 15) {
|
||||||
|
hintDiv.innerHTML = '⚠️ Слишком много цифр (' + digitCount + '). Максимум 15.';
|
||||||
|
hintDiv.style.color = '#f44336';
|
||||||
|
} else {
|
||||||
|
hintDiv.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneInput = document.getElementById('phone');
|
||||||
|
if (phoneInput) {
|
||||||
|
phoneInput.addEventListener('input', countPhoneDigits);
|
||||||
|
countPhoneDigits();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
EOF
|
||||||
60
templates/partners.html
Normal file
60
templates/partners.html
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>🤝 Партнеры</h1>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<a href="/partners/add" class="btn btn-primary">➕ Добавить партнера</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Всего партнеров</h3>
|
||||||
|
<div class="number">{{ partners|length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Компания</th>
|
||||||
|
<th>Директор</th>
|
||||||
|
<th>Телефон</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Адрес</th>
|
||||||
|
<th>ИНН</th>
|
||||||
|
<th>Продажи</th>
|
||||||
|
<th>Скидка</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for partner in partners %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ partner.company_name }}</strong></td>
|
||||||
|
<td>{{ partner.director_name }}</td>
|
||||||
|
<td>{{ partner.phone }}</td>
|
||||||
|
<td>{{ partner.email }}</td>
|
||||||
|
<td>{{ partner.legal_address }}</td>
|
||||||
|
<td>{{ partner.inn }}</td>
|
||||||
|
<td>{{ "%.2f"|format(partner.total_sales) }} ₽</td>
|
||||||
|
<td>
|
||||||
|
{% if partner.total_sales > 2000000 %}15%
|
||||||
|
{% elif partner.total_sales > 1000000 %}10%
|
||||||
|
{% elif partner.total_sales > 500000 %}5%
|
||||||
|
{% else %}0%{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/partners/{{ partner.id }}/edit" class="btn btn-secondary btn-small">✏️</a>
|
||||||
|
<form method="post" action="/partners/{{ partner.id }}/delete" style="display: inline;" onsubmit="return confirm('Удалить партнера? Все его заявки также будут удалены.')">
|
||||||
|
<button type="submit" class="btn btn-primary btn-small">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" style="text-align: center;">Нет партнеров</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
48
templates/product_form.html
Normal file
48
templates/product_form.html
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>{% if product %}✏️ Редактирование товара{% else %}➕ Добавление товара{% endif %}</h1>
|
||||||
|
|
||||||
|
<form method="post" class="form-card">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Тип металла *</label>
|
||||||
|
<select name="metal_type_id" required>
|
||||||
|
{% for mt in metal_types %}
|
||||||
|
<option value="{{ mt.id }}" {% if product and product.metal_type_id == mt.id %}selected{% endif %}>
|
||||||
|
{{ mt.name }} ({{ mt.purity }} проба) - {{ "%.2f"|format(mt.price_per_gram) }} ₽/г
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Артикул *</label>
|
||||||
|
<input type="text" name="sku" value="{{ product.sku if product else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название *</label>
|
||||||
|
<input type="text" name="name" value="{{ product.name if product else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Вес (граммы) *</label>
|
||||||
|
<input type="number" step="0.01" name="weight_grams" value="{{ product.weight_grams if product else '' }}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Коэффициент сложности *</label>
|
||||||
|
<input type="number" step="0.1" name="complexity_factor" value="{{ product.complexity_factor if product else '1.0' }}" required>
|
||||||
|
<small>1.0 - простые, 1.5 - сложные, 2.0 - эксклюзивные</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Описание</label>
|
||||||
|
<textarea name="description" rows="3">{{ product.description if product else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Сохранить</button>
|
||||||
|
<a href="/products" class="btn btn-secondary">❌ Отмена</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
53
templates/products.html
Normal file
53
templates/products.html
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>💎 Ювелирные изделия</h1>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<a href="/products/add" class="btn btn-primary">➕ Добавить товар</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Всего товаров</h3>
|
||||||
|
<div class="number">{{ products|length }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Артикул</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Металл</th>
|
||||||
|
<th>Вес (г)</th>
|
||||||
|
<th>Коэф.</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>Описание</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for product in products %}
|
||||||
|
<tr>
|
||||||
|
<td><strong>{{ product.sku }}</strong></td>
|
||||||
|
<td>{{ product.name }}</td>
|
||||||
|
<td>{{ product.metal_name }} ({{ product.purity }})</td>
|
||||||
|
<td>{{ product.weight_grams }}</td>
|
||||||
|
<td>{{ product.complexity_factor }}</td>
|
||||||
|
<td><strong>{{ "%.2f"|format(product.calculated_price) }} ₽</strong></td>
|
||||||
|
<td>{{ product.description or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/products/{{ product.id }}/edit" class="btn btn-secondary btn-small">✏️</a>
|
||||||
|
<form method="post" action="/products/{{ product.id }}/delete" style="display: inline;" onsubmit="return confirm('Удалить товар?')">
|
||||||
|
<button type="submit" class="btn btn-primary btn-small">🗑️</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" style="text-align: center;">Нет товаров</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
63
tests/test_app.py
Normal file
63
tests/test_app.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_DIR = Path(__file__).resolve().parent
|
||||||
|
sys.path.insert(0, str(PROJECT_DIR))
|
||||||
|
|
||||||
|
import app
|
||||||
|
|
||||||
|
|
||||||
|
class JewelryInventoryTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
app.init_db(force_reset=True)
|
||||||
|
cls.client = app.app.test_client()
|
||||||
|
|
||||||
|
def test_index_route(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"\xd0\x97\xd0\xb0\xd1\x8f\xd0\xb2\xd0\xba\xd0\xb8", response.data)
|
||||||
|
|
||||||
|
def test_products_route(self):
|
||||||
|
response = self.client.get("/products")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"\xd0\xae\xd0\xb2\xd0\xb5\xd0\xbb\xd0\xb8\xd1\x80\xd0\xbd\xd1\x8b\xd0\xb5", response.data)
|
||||||
|
|
||||||
|
def test_partners_route(self):
|
||||||
|
response = self.client.get("/partners")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_metal_types_route(self):
|
||||||
|
response = self.client.get("/metal_types")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_product_price_calculation(self):
|
||||||
|
price = app.calculate_product_price(3500, 3.5, 585, 1.2)
|
||||||
|
self.assertAlmostEqual(price, 8600.70, places=2)
|
||||||
|
|
||||||
|
def test_partner_discount_calculation(self):
|
||||||
|
self.assertEqual(app.calculate_partner_discount(400000), 0)
|
||||||
|
self.assertEqual(app.calculate_partner_discount(600000), 5)
|
||||||
|
self.assertEqual(app.calculate_partner_discount(1200000), 10)
|
||||||
|
self.assertEqual(app.calculate_partner_discount(2500000), 15)
|
||||||
|
|
||||||
|
def test_product_create(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/products/add",
|
||||||
|
data={
|
||||||
|
"metal_type_id": "1",
|
||||||
|
"sku": "TEST-001",
|
||||||
|
"name": "Тестовое кольцо",
|
||||||
|
"weight_grams": "4.0",
|
||||||
|
"complexity_factor": "1.3",
|
||||||
|
"description": "Тестовое изделие",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"\xd0\xa2\xd0\xbe\xd0\xb2\xd0\xb0\xd1\x80 \xd1\x81\xd0\xbe\xd0\xb7\xd0\xb4\xd0\xb0\xd0\xbd", response.data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
139
xlsx_utils.py
Normal file
139
xlsx_utils.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.sax.saxutils import escape
|
||||||
|
from zipfile import ZIP_DEFLATED, ZipFile
|
||||||
|
|
||||||
|
MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
|
||||||
|
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||||
|
PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
|
||||||
|
CONTENT_TYPES_NS = "http://schemas.openxmlformats.org/package/2006/content-types"
|
||||||
|
DOC_PROPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
|
||||||
|
CORE_PROPS_NS = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
||||||
|
DC_NS = "http://purl.org/dc/elements/1.1/"
|
||||||
|
DCTERMS_NS = "http://purl.org/dc/terms/"
|
||||||
|
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
|
||||||
|
|
||||||
|
def date_to_excel_serial(value: date | datetime | str) -> int:
|
||||||
|
if isinstance(value, str):
|
||||||
|
parsed = datetime.strptime(value, "%Y-%m-%d").date()
|
||||||
|
elif isinstance(value, datetime):
|
||||||
|
parsed = value.date()
|
||||||
|
else:
|
||||||
|
parsed = value
|
||||||
|
return (parsed - date(1899, 12, 30)).days
|
||||||
|
|
||||||
|
|
||||||
|
def _col_name(index: int) -> str:
|
||||||
|
letters = ""
|
||||||
|
while index:
|
||||||
|
index, remainder = divmod(index - 1, 26)
|
||||||
|
letters = chr(65 + remainder) + letters
|
||||||
|
return letters
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_xml(value: object, row_index: int, col_index: int) -> str:
|
||||||
|
ref = f"{_col_name(col_index)}{row_index}"
|
||||||
|
if value is None or value == "":
|
||||||
|
return f'<c r="{ref}" t="inlineStr"><is><t></t></is></c>'
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return f'<c r="{ref}" t="b"><v>{1 if value else 0}</v></c>'
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return f'<c r="{ref}"><v>{value}</v></c>'
|
||||||
|
if isinstance(value, (date, datetime)):
|
||||||
|
return f'<c r="{ref}" s="1"><v>{date_to_excel_serial(value)}</v></c>'
|
||||||
|
return f'<c r="{ref}" t="inlineStr"><is><t xml:space="preserve">{escape(str(value))}</t></is></c>'
|
||||||
|
|
||||||
|
|
||||||
|
def export_xlsx(path: str | Path, sheet_name: str, rows: list[list[object]]) -> None:
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
max_col = 1
|
||||||
|
max_row = max(1, len(rows))
|
||||||
|
sheet_rows = []
|
||||||
|
for row_index, row in enumerate(rows, start=1):
|
||||||
|
max_col = max(max_col, len(row))
|
||||||
|
cells = "".join(_cell_xml(value, row_index, col_index) for col_index, value in enumerate(row, start=1))
|
||||||
|
sheet_rows.append(f'<row r="{row_index}">{cells}</row>')
|
||||||
|
dimension = f"A1:{_col_name(max_col)}{max_row}"
|
||||||
|
worksheet_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
f'<worksheet xmlns="{MAIN_NS}" xmlns:r="{REL_NS}">'
|
||||||
|
f'<dimension ref="{dimension}"/>'
|
||||||
|
'<sheetViews><sheetView workbookViewId="0"/></sheetViews>'
|
||||||
|
'<sheetFormatPr defaultRowHeight="15"/>'
|
||||||
|
'<sheetData>' + "".join(sheet_rows) + '</sheetData>'
|
||||||
|
'</worksheet>'
|
||||||
|
)
|
||||||
|
styles_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
f'<styleSheet xmlns="{MAIN_NS}">'
|
||||||
|
'<numFmts count="1"><numFmt numFmtId="164" formatCode="yyyy-mm-dd"/></numFmts>'
|
||||||
|
'<fonts count="1"><font><sz val="11"/><name val="Constantia"/></font></fonts>'
|
||||||
|
'<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
|
||||||
|
'<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
|
||||||
|
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
|
||||||
|
'<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="164" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/></cellXfs>'
|
||||||
|
'<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>'
|
||||||
|
'</styleSheet>'
|
||||||
|
)
|
||||||
|
content_types_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
f'<Types xmlns="{CONTENT_TYPES_NS}">'
|
||||||
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||||
|
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||||
|
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||||
|
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||||
|
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>'
|
||||||
|
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>'
|
||||||
|
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>'
|
||||||
|
'</Types>'
|
||||||
|
)
|
||||||
|
root_rels_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
f'<Relationships xmlns="{PKG_REL_NS}">'
|
||||||
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||||
|
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>'
|
||||||
|
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>'
|
||||||
|
'</Relationships>'
|
||||||
|
)
|
||||||
|
workbook_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
f'<workbook xmlns="{MAIN_NS}" xmlns:r="{REL_NS}">'
|
||||||
|
'<bookViews><workbookView xWindow="0" yWindow="0" windowWidth="24000" windowHeight="12000"/></bookViews>'
|
||||||
|
f'<sheets><sheet name="{escape(sheet_name)}" sheetId="1" r:id="rId1"/></sheets>'
|
||||||
|
'</workbook>'
|
||||||
|
)
|
||||||
|
workbook_rels_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
f'<Relationships xmlns="{PKG_REL_NS}">'
|
||||||
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||||||
|
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
||||||
|
'</Relationships>'
|
||||||
|
)
|
||||||
|
created = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
core_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
f'<cp:coreProperties xmlns:cp="{CORE_PROPS_NS}" xmlns:dc="{DC_NS}" xmlns:dcterms="{DCTERMS_NS}" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="{XSI_NS}">'
|
||||||
|
'<dc:creator>Jewelry Inventory</dc:creator>'
|
||||||
|
f'<dcterms:created xsi:type="dcterms:W3CDTF">{created}</dcterms:created>'
|
||||||
|
f'<dcterms:modified xsi:type="dcterms:W3CDTF">{created}</dcterms:modified>'
|
||||||
|
'</cp:coreProperties>'
|
||||||
|
)
|
||||||
|
app_xml = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
f'<Properties xmlns="{DOC_PROPS_NS}" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">'
|
||||||
|
'<Application>Jewelry Inventory</Application>'
|
||||||
|
'</Properties>'
|
||||||
|
)
|
||||||
|
with ZipFile(path, "w", compression=ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr("[Content_Types].xml", content_types_xml)
|
||||||
|
archive.writestr("_rels/.rels", root_rels_xml)
|
||||||
|
archive.writestr("docProps/core.xml", core_xml)
|
||||||
|
archive.writestr("docProps/app.xml", app_xml)
|
||||||
|
archive.writestr("xl/workbook.xml", workbook_xml)
|
||||||
|
archive.writestr("xl/_rels/workbook.xml.rels", workbook_rels_xml)
|
||||||
|
archive.writestr("xl/styles.xml", styles_xml)
|
||||||
|
archive.writestr("xl/worksheets/sheet1.xml", worksheet_xml)
|
||||||
Loading…
Reference in a new issue