From 71defa5161b3c7dd7b912d62a2aaf22f436c381a Mon Sep 17 00:00:00 2001 From: user_2i Date: Fri, 27 Mar 2026 12:02:41 +0500 Subject: [PATCH] demo --- app.py | 794 ++++++++++++++++++++++++ data_exports/metal_types_export.xlsx | Bin 0 -> 5844 bytes data_exports/order_items_export.xlsx | Bin 0 -> 5973 bytes data_exports/order_statuses_export.xlsx | Bin 0 -> 14973 bytes data_exports/orders_export..xlsx | Bin 0 -> 6068 bytes data_exports/partners_export.xlsx | Bin 0 -> 6405 bytes data_exports/products_export.xlsx | Bin 0 -> 6380 bytes requirements.txt | 1 + schema.sql | 55 ++ seed_data.py | 111 ++++ static/app.js | 143 +++++ static/favicon.svg | 5 + static/logo.svg | 6 + static/style.css | 71 +++ templates/base.html | 192 ++++++ templates/index.html | 88 +++ templates/metal_form.html | 27 + templates/metal_types.html | 45 ++ templates/order_form.html | 216 +++++++ templates/partner_form.html | 86 +++ templates/partners.html | 60 ++ templates/product_form.html | 48 ++ templates/products.html | 53 ++ tests/test_app.py | 63 ++ xlsx_utils.py | 139 +++++ 25 files changed, 2203 insertions(+) create mode 100644 app.py create mode 100644 data_exports/metal_types_export.xlsx create mode 100644 data_exports/order_items_export.xlsx create mode 100644 data_exports/order_statuses_export.xlsx create mode 100644 data_exports/orders_export..xlsx create mode 100644 data_exports/partners_export.xlsx create mode 100644 data_exports/products_export.xlsx create mode 100644 requirements.txt create mode 100644 schema.sql create mode 100644 seed_data.py create mode 100644 static/app.js create mode 100644 static/favicon.svg create mode 100644 static/logo.svg create mode 100644 static/style.css create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/metal_form.html create mode 100644 templates/metal_types.html create mode 100644 templates/order_form.html create mode 100644 templates/partner_form.html create mode 100644 templates/partners.html create mode 100644 templates/product_form.html create mode 100644 templates/products.html create mode 100644 tests/test_app.py create mode 100644 xlsx_utils.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..2c5e86b --- /dev/null +++ b/app.py @@ -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//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//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//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//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//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//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//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//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) diff --git a/data_exports/metal_types_export.xlsx b/data_exports/metal_types_export.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..766dde84bd971bc12668d24aeaf79e54f242656c GIT binary patch literal 5844 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 DYZ48j literal 0 HcmV?d00001 diff --git a/data_exports/order_statuses_export.xlsx b/data_exports/order_statuses_export.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6acced10bfef2ff71776ebb9c3bd2262903f0fb1 GIT binary patch literal 14973 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 literal 0 HcmV?d00001 diff --git a/data_exports/orders_export..xlsx b/data_exports/orders_export..xlsx new file mode 100644 index 0000000000000000000000000000000000000000..147eacb65ae9f970175b6e303aef1510a0e1cafc GIT binary patch literal 6068 zcmaJ_1yq#p@}^U|d+Cr`QM#nNTe`cJUP@9L2_+RIm(Hbg5ox4bN~A@kCA{G8p1)l0 z|2lil?tXi|neW|spP6}|M@$H^x=CbyP`{TSroq06kI_`PjTqJnRdSTK<#YOeS)y!9iI$7{ zwr9xRCcGbrkbSynn&+UwgZv>-lsRlrrN^;9H$u*BsE5WmtK^tRYR-V z3Xh0UmD6#y#`6!|K4zIZT79&xe3o(~Y%;(<9lRr>>1dDarbz-ul)|)Cy%yGr3)Xd zG!>I3xN(<27_U#G_OIkL+zLYoW|W9JG9K_a=sSO)tNEOiLPKa{+W&+_hp-5Z?;Xbn zm|B&o*|Y1h-I=A)+ENSFRuNlRYOLHW)(g}mNbI1p)TbrJY~W#R!J|A0`4@fY51KN> zGO1EN6)HH1v~np+VK|gR4k&cQ<&|}{0l=h(MjA1NpUw-%#d$R0W;M>+0Hha;8%dAR zG_S=-*+vCI3(Q;a^PR*WquIY~WA(A>^M3_`KU0>ZE-#s>lbQz-#hxF@&b!pV)_^W) zqg%@|6m`S#8Wh7ak43m+sMKj+yjI6tWgx9%4H9RGN!LTku3vux>$-}cDU@v0$p}&# z*QmBOK*z%?^?q$HH|c&_+1E@xNu{j3D=g}o4v~~{l`dl53>6anHbmUmsU9S38!*$! z7arHgpLP7g#?J2<4jUZ|u5!bWVmF$fFgkk=es_wP_p{h`-+tM(-#FIs)yCDy=H^ku zQNqz#2Dgm!qq2iZbD-HMmMZf1Y0Y@}faDV@XUqs?o<)R)9H5wvP?S`6Mta0m>Hx0! zbKBSF>(QL2_+bTd^%O{ESeUYx<9cUpTkP)`&h1Pyw=Ida+bVLYK(Wp;K}-w2=Mp2St4->FC(D=AXF? zzA_fl5CZZgC6=edgIZg{xy$X&%Umqg*d7=)lW8C64g5L+8$t>ms?i><&U4b{hJ&V( zfP-xmd&>H`r(k_MLVlNwtO69{*0Ll=!StbYgj_oUmq6zBp+{YAn%|kj=cnjU82R5e zb4XdSj^+5rn;^)|Y%+++3ro)?hbq>t(;j66;DSTTgO72u1Izl)XbNaY0R)2RI99tv zEpX#lP3*#0GjQ8xKQcbmO;fmww86n`wx_JljUlYbFII6B8^?df9X-eyY&c$r$8n1l zxjr9Q#y$@(0{>{x4dh+9Sfc7!l*cT2eS{ZCEHMH#{%12n?(=SKi zy_p_>tjZRGId%xUM(@*7G7g$e}fm+Kkx#1_&QpF?trzgH|CtlO&GAOs~2eU z{IUE3`c|_F*}#Hc1!A900KWM8#A?|og8f6fNegU^264`;1F zbU-O9LpJhKcm>(ok71QgR>50(r%%F^P*HLK!sEUKNv7{M-Kv!q9ZW^SGvOxcRL6v0 z@Q<#CZj&Npe$Dz|Fo0~W2f)ad4Q0MwIb#}v?1lw)&y_49832wq5E~`6Cfe8@4pi@( zn3lf)Eq*P`g(;G(R4JKR7=0=4!KSty$3!JDwbjgL*yFdvmnt$V4q#&0 zcrdn07mWT6A4dG6??4|hLiByqcoecc&jgz>KU0*3@C**thZJb~0;-W{cZzYk^6?Y> zYd>9PBc{xlH`-Kfd~v5g#;i+(idk$0)|up==o6o-g&*heC}_M|ukTdsB8t~K!7g6; zD60(UYXsm3)~9#YeFA++&6TqRT!LxUd?4+v;f}=R+r~NO6IPl=UT}eRovOXcO*Lr|*uNq2VniUYW)~Qk?#OnqbD_Ll zxv#Wb>vSc&5E#(iaE0SHUnhsCCp^LPYc4*130lvMa6mGSL>(6bVH|E%H0B^pvfL^GWDwKLwJ8sM?=0{!sCFH zI$VU>Vn+d4k&$y&mC9+i1n*mVeDc_L2A@gNAO5h>`jY!E29EDRn@Smae9Rdy~V4hu0 ze1T1R=1U03#BN%yw&QOH8s6}BxE``=N~9nnH5oQSG~Q=#_VY86B_SFoQVOG!kZF8l zi+{t8^mLYTAdTe5&FmoV@KVyMN^4{&GkRQq#)qQV&m|0aVYJBGgDJ!{*5Iy#H5h`? zN{OdvIpIt}(JJOUY!exaE=JW!z!&{ggR@sWE?%Hf61*F}LQ^DN5ltgV71Q9_+2SL~ zwF@nOxUUE)f@(5TrA<{WSrj)e-SRA&QvR!?(OCc{^m7ak2JpLflHSTU=X~6g&MpFH zC9pw(LN%Q1bb7fOS21Mo1v&Z$E2;{dH79$0DJHy$%TcuY6J+Z;YFBa~1RX||)I*#= zeYaDxx?o->p&L4YffMd=nnXkbgF04$M5KrrgAP)|=)Owy)5L2Buaz>|PKKwx{R+ zX}$;${@8#ga23_3&Yc!}EbS?X-s!ls zAg;N|fXZh%JWL0*;$1vTF5^IRAVit~e1om>Ke$Gl#UIh`7<7O_ zG~p#%0p_a9rb(mXo73H<&@%f)lqm-uGb35ey|AeG3&tP?`F2F79BpJ|yCL#cS4_?n zal+|PM9IUWBFdj~5PM=i)K{OTlL%ooRcq;=iV8k~zGrHbVPXvDYnD2=*7vmL^u!W1 z8bJnn(N;_|7}Sakor)7@)Z%<24`CNSE;u$R*WJ59q4pv$i+;U~%uO$DAEz5K{Tsx) zrW2NEjl_in2lw&8-^Wj&NKY=l1Rhs#cfwQ(@7IU1CeNA8vcwy6(UY9) zT9=3Tj`oMbBDrdSS;gJ?P4Q9-FA-9xKf|GN*~l_pYVcCDyh(YU$p|6l?kk#? z>E1<8+d1|rp}x9Vjp2W6N9inwJ;XqU%O?#i;a8{YP0(9?^6G@$!-=vP>)7NQGUu+o z$cHD(8zN0ZZvBWsB_`^EGmS`BA&kc20nuWv;mes^i`N1s%N8i}O~CX~)_#nRvCC9^ zQTjC_Ht{W^rb-#^5bkB0dqGnVkJPolGAqh(x53?9bDUy*|I-eRBaQnWef4%3h5C@|ny43tdvI35F% zoZ)LIxMvO3yY-9(*Gvm2n<6cjEWu$;Mka_qEw@fzKu$l|e!7g)i98E?T?N^diS+(# zj36U0wx;_ok>iX-hKr{aPjysEQ#*%TrNxoU`vRa8smv zwiv-?Sj7!Upb37k@fp7hSyp?n|fg3IF~X$}IpAeIisw_R~_R6ULIUMoCF91T>_GX!e$} zQS1UM8fBCLaVaHH1Xyf zUYHemb<{WAUJ5@BHlbzvzJl_VJ@nfJyZWVjXrgis23l(fR;FQ8`Ew0hrQlJDl<>ac z&eSI#-w`0yS*spTJVO;#K+~s7`}j^W)>&E(7xkm?$|Ns@$Dq5@uVi7S(}B#kw`zYwZz z(5=an9{ah?Asiku77yHjZZ=3&do0Y8F-2HDc>5lzBlXP+RuyXb=(eib*0vX+< zu1!l(^)_|w2fA8K?|3Ot!_-=+d??NE*JCBtpk_|^jB~R$A}SaYq+o8RwejNWj!2`K z3)?Z?e#)|B10u`B6M2Ma2U>0I#Mk|je#$a1V^Mu_SM)Dzx{32(kR)XNN!^gud~mlM z#A#5teLkAJx2O!kZ|=ZEy&@HLLz9NTkGmr@X_)-&BG%I(MAH1H%+?oX7>2aL}?WWRcyj?Uw8c@jB{TKy3-GT8_VsJZ{yro6aRF+PxSBb z^4q*`m8DzfKeeMj1KejtcY45Yo4%b)w*h{u1An^SKik~Vrr)-6D?9vq;r~UV{)}>e z5Z;}Gew)eddhQnR=c(w=2=^z(-N5{9KDYV2H$MM#yx+d=2Fh=HdE2x8YrOpFe!q(E udfaal!N2GJ?~eDU_kC#n=gxWFUj9FIucnBMa`y;~+gI>y!=)y=yZRRneM~g~ literal 0 HcmV?d00001 diff --git a/data_exports/partners_export.xlsx b/data_exports/partners_export.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..923f4e8cf38f8a33f49ae2a4b73199be21aba85f GIT binary patch literal 6405 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 valhpmEENA%|5wKQQ~N%&{xfnFFgN@^`(8^G1r { + 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(); +}); diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..320031d --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/logo.svg b/static/logo.svg new file mode 100644 index 0000000..66490f3 --- /dev/null +++ b/static/logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..4854251 --- /dev/null +++ b/static/style.css @@ -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; + } +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..953fb25 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,192 @@ + + + + + + Ювелирный Склад + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endif %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..227a0fc --- /dev/null +++ b/templates/index.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% block content %} +

📋 Заявки партнеров

+ + + +
+
+ + + {% if selected_partner %} + Сбросить фильтр + {% endif %} +
+
+ +
+
+

Всего заявок

+
{{ orders|length }}
+
+
+

Общая сумма (без скидки)

+
+ {% set total = namespace(value=0) %} + {% for order in orders %} + {% set total.value = total.value + order.subtotal_amount %} + {% endfor %} + {{ "%.2f"|format(total.value) }} ₽ +
+
+
+

Общая сумма (со скидкой)

+
+ {% 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) }} ₽ +
+
+
+ + + + + + + + + + + + + + + + {% for order in orders %} + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
НомерПартнерДатаСумма (без скидки)СкидкаСумма (со скидкой)ПримечаниеДействия
{{ order.order_number }}{{ order.partner_name }}{{ order.order_date }}{{ "%.2f"|format(order.subtotal_amount) }} ₽{{ order.discount_percent }}%{{ "%.2f"|format(order.total_amount) }} ₽{{ order.notes or '-' }} + ✏️ +
+ +
+
Нет заявок. Создайте первую!
+{% endblock %} diff --git a/templates/metal_form.html b/templates/metal_form.html new file mode 100644 index 0000000..2b6a8be --- /dev/null +++ b/templates/metal_form.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block content %} +

{% if metal %}✏️ Редактирование металла{% else %}➕ Добавление металла{% endif %}

+ +
+
+ + +
+ +
+ + + Например: 585, 750, 925, 950 +
+ +
+ + +
+ +
+ + ❌ Отмена +
+
+{% endblock %} diff --git a/templates/metal_types.html b/templates/metal_types.html new file mode 100644 index 0000000..ac46b02 --- /dev/null +++ b/templates/metal_types.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block content %} +

🔩 Типы металлов

+ + + +
+
+

Виды металлов

+
{{ metals|length }}
+
+
+ + + + + + + + + + + + {% for metal in metals %} + + + + + + + {% else %} + + + + {% endfor %} + +
МеталлПробаЦена за граммДействия
{{ metal.name }}{{ metal.purity }}{{ "%.2f"|format(metal.price_per_gram) }} ₽ + ✏️ +
+ +
+
Нет данных
+{% endblock %} diff --git a/templates/order_form.html b/templates/order_form.html new file mode 100644 index 0000000..ebdcb52 --- /dev/null +++ b/templates/order_form.html @@ -0,0 +1,216 @@ +{% extends "base.html" %} +{% block content %} +

{% if order %}✏️ Редактирование заявки{% else %}➕ Новая заявка{% endif %}

+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +

📦 Товары в заявке

+
+ {% if order_items %} + {% for item in order_items %} +
+ + + +
+ {% endfor %} + {% else %} +
+ + + +
+ {% endif %} +
+ + + +
+

💰 Расчет стоимости

+ +
+ 📊 Шкала скидок от суммы заявки:
+ • до 500 000 ₽ → 0%
+ • от 500 000 до 1 000 000 ₽ → 5%
+ • от 1 000 000 до 2 000 000 ₽ → 10%
+ • свыше 2 000 000 ₽ → 15% +
+ +
+ Сумма заявки: + 0.00 ₽ +
+
+ Скидка: + 0% + +
+
+ Сумма к оплате: + 0.00 ₽ +
+
+ Вы экономите: + 0.00 ₽ +
+
+ +
+ + ❌ Отмена +
+
+ + + + +{% endblock %} +EOF diff --git a/templates/partner_form.html b/templates/partner_form.html new file mode 100644 index 0000000..a367ca0 --- /dev/null +++ b/templates/partner_form.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block content %} +

{% if partner %}✏️ Редактирование партнера{% else %}➕ Добавление партнера{% endif %}

+ +
+
+ + + Минимум 2 символа +
+ +
+ + + Полное имя (фамилия и имя, минимум 5 символов) +
+ +
+ + + example@domain.ru +
+ +
+ + + Формат: +7 (999) 123-45-67 или 89123456789 (минимум 10 цифр) +
+
+ +
+ + + Полный адрес, минимум 5 символов +
+ +
+ + + 10 или 12 цифр, только цифры +
+ + {% if partner %} +
+ + + Изменение этой суммы повлияет на скидку партнера +
+ {% endif %} + +
+ + ❌ Отмена +
+
+ + +{% endblock %} +EOF diff --git a/templates/partners.html b/templates/partners.html new file mode 100644 index 0000000..18964d5 --- /dev/null +++ b/templates/partners.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block content %} +

🤝 Партнеры

+ + + +
+
+

Всего партнеров

+
{{ partners|length }}
+
+
+ + + + + + + + + + + + + + + + + {% for partner in partners %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
КомпанияДиректорТелефонEmailАдресИННПродажиСкидкаДействия
{{ partner.company_name }}{{ partner.director_name }}{{ partner.phone }}{{ partner.email }}{{ partner.legal_address }}{{ partner.inn }}{{ "%.2f"|format(partner.total_sales) }} ₽ + {% if partner.total_sales > 2000000 %}15% + {% elif partner.total_sales > 1000000 %}10% + {% elif partner.total_sales > 500000 %}5% + {% else %}0%{% endif %} + + ✏️ +
+ +
+
Нет партнеров
+{% endblock %} diff --git a/templates/product_form.html b/templates/product_form.html new file mode 100644 index 0000000..ecf98b7 --- /dev/null +++ b/templates/product_form.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block content %} +

{% if product %}✏️ Редактирование товара{% else %}➕ Добавление товара{% endif %}

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + 1.0 - простые, 1.5 - сложные, 2.0 - эксклюзивные +
+ +
+ + +
+ +
+ + ❌ Отмена +
+
+{% endblock %} diff --git a/templates/products.html b/templates/products.html new file mode 100644 index 0000000..5cd1744 --- /dev/null +++ b/templates/products.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block content %} +

💎 Ювелирные изделия

+ + + +
+
+

Всего товаров

+
{{ products|length }}
+
+
+ + + + + + + + + + + + + + + + {% for product in products %} + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
АртикулНазваниеМеталлВес (г)Коэф.ЦенаОписаниеДействия
{{ product.sku }}{{ product.name }}{{ product.metal_name }} ({{ product.purity }}){{ product.weight_grams }}{{ product.complexity_factor }}{{ "%.2f"|format(product.calculated_price) }} ₽{{ product.description or '-' }} + ✏️ +
+ +
+
Нет товаров
+{% endblock %} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..7b1f0ce --- /dev/null +++ b/tests/test_app.py @@ -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() diff --git a/xlsx_utils.py b/xlsx_utils.py new file mode 100644 index 0000000..a09978d --- /dev/null +++ b/xlsx_utils.py @@ -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'' + if isinstance(value, bool): + return f'{1 if value else 0}' + if isinstance(value, (int, float)): + return f'{value}' + if isinstance(value, (date, datetime)): + return f'{date_to_excel_serial(value)}' + return f'{escape(str(value))}' + + +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'{cells}') + dimension = f"A1:{_col_name(max_col)}{max_row}" + worksheet_xml = ( + '' + f'' + f'' + '' + '' + '' + "".join(sheet_rows) + '' + '' + ) + styles_xml = ( + '' + f'' + '' + '' + '' + '' + '' + '' + '' + '' + ) + content_types_xml = ( + '' + f'' + '' + '' + '' + '' + '' + '' + '' + '' + ) + root_rels_xml = ( + '' + f'' + '' + '' + '' + '' + ) + workbook_xml = ( + '' + f'' + '' + f'' + '' + ) + workbook_rels_xml = ( + '' + f'' + '' + '' + '' + ) + created = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + core_xml = ( + '' + f'' + 'Jewelry Inventory' + f'{created}' + f'{created}' + '' + ) + app_xml = ( + '' + f'' + 'Jewelry Inventory' + '' + ) + 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)