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 0000000..766dde8 Binary files /dev/null and b/data_exports/metal_types_export.xlsx differ diff --git a/data_exports/order_items_export.xlsx b/data_exports/order_items_export.xlsx new file mode 100644 index 0000000..c584063 Binary files /dev/null and b/data_exports/order_items_export.xlsx differ diff --git a/data_exports/order_statuses_export.xlsx b/data_exports/order_statuses_export.xlsx new file mode 100644 index 0000000..6acced1 Binary files /dev/null and b/data_exports/order_statuses_export.xlsx differ diff --git a/data_exports/orders_export..xlsx b/data_exports/orders_export..xlsx new file mode 100644 index 0000000..147eacb Binary files /dev/null and b/data_exports/orders_export..xlsx differ diff --git a/data_exports/partners_export.xlsx b/data_exports/partners_export.xlsx new file mode 100644 index 0000000..923f4e8 Binary files /dev/null and b/data_exports/partners_export.xlsx differ diff --git a/data_exports/products_export.xlsx b/data_exports/products_export.xlsx new file mode 100644 index 0000000..a318f7f Binary files /dev/null and b/data_exports/products_export.xlsx differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e3e9a71 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..0aa985e --- /dev/null +++ b/schema.sql @@ -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 +); diff --git a/seed_data.py b/seed_data.py new file mode 100644 index 0000000..9a51b9b --- /dev/null +++ b/seed_data.py @@ -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("Демо-данные успешно загружены.") diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..352cd48 --- /dev/null +++ b/static/app.js @@ -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(); +}); 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)