Загрузка данных


"""Магазин игрушек (PyQt6, данные хранятся в коде, без БД). Запуск: python main.py
Рядом можно положить папку resources с logo.png, icon.png, picture.png (необязательно)."""
import sys
import uuid
from pathlib import Path

from PyQt6.QtCore import QDate, Qt, pyqtSignal
from PyQt6.QtGui import QIcon, QImage, QPixmap
from PyQt6.QtWidgets import (QApplication, QComboBox, QDateEdit, QDialog, QDoubleSpinBox, QFileDialog,
                             QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox,
                             QPushButton, QScrollArea, QSpinBox, QStackedWidget, QTextEdit, QVBoxLayout,
                             QWidget)

BASE_DIR = Path(__file__).resolve().parent
RESOURCES_DIR = BASE_DIR / "resources"
IMAGES_DIR = BASE_DIR / "images"

FONT_FAMILY = "Times New Roman"
COLOR_MAIN_BG = "#FFFFFF"
COLOR_SECOND_BG = "#7FFF00"
COLOR_ACCENT = "#00FA9A"

APP_STYLE = f"""
* {{ font-family: '{FONT_FAMILY}'; font-size: 14px; }}
QMainWindow, QDialog, QScrollArea, #page {{ background-color: {COLOR_MAIN_BG}; }}
#header {{ background-color: {COLOR_SECOND_BG}; }}
QPushButton {{ background-color: {COLOR_ACCENT}; border: 1px solid black; padding: 6px 14px; }}
QPushButton:hover {{ background-color: {COLOR_SECOND_BG}; }}
"""

# ===================== ДАННЫЕ (временно в коде) =====================
USERS = [
    {"user_id": 1, "surname": "Иванов", "first_name": "Иван", "patronymic": "Иванович",
     "login": "client", "password": "client123", "role_code": "client"},
    {"user_id": 2, "surname": "Петрова", "first_name": "Мария", "patronymic": "Сергеевна",
     "login": "manager", "password": "manager123", "role_code": "manager"},
    {"user_id": 3, "surname": "Сидоров", "first_name": "Алексей", "patronymic": "Петрович",
     "login": "admin", "password": "admin123", "role_code": "admin"},
]

CATEGORIES = {1: "Конструкторы", 2: "Мягкие игрушки", 3: "Куклы", 4: "Настольные игры", 5: "Машинки"}
SUPPLIERS = {1: "ООО «Игрушки-Опт»", 2: "ИП Смирнов", 3: "ТД «Детство»"}
ORDER_STATUSES = {1: "Новый", 2: "В обработке", 3: "Выдан", 4: "Отменён"}

PRODUCTS = [
    {"product_id": 1, "product_name": "Конструктор «Город»", "category_id": 1, "description": "Набор из 500 деталей",
     "manufacturer_name": "ToyWorld", "supplier_id": 1, "price": 2500.00, "unit_name": "набор",
     "stock_quantity": 15, "discount": 0, "image_path": None},
    {"product_id": 2, "product_name": "Мишка плюшевый", "category_id": 2, "description": "Мягкий медведь, 40 см",
     "manufacturer_name": "Весёлый мир", "supplier_id": 2, "price": 1200.50, "unit_name": "шт.",
     "stock_quantity": 30, "discount": 10, "image_path": None},
    {"product_id": 3, "product_name": "Кукла «Принцесса»", "category_id": 3, "description": "Кукла с аксессуарами",
     "manufacturer_name": "KidsPlay", "supplier_id": 3, "price": 1800.00, "unit_name": "шт.",
     "stock_quantity": 8, "discount": 20, "image_path": None},
    {"product_id": 4, "product_name": "Настольная игра «Лабиринт»", "category_id": 4, "description": "Для 2–4 игроков",
     "manufacturer_name": "ToyWorld", "supplier_id": 1, "price": 990.00, "unit_name": "шт.",
     "stock_quantity": 0, "discount": 0, "image_path": None},
    {"product_id": 5, "product_name": "Машинка гоночная", "category_id": 5, "description": "Металлическая, 1:32",
     "manufacturer_name": "Весёлый мир", "supplier_id": 2, "price": 450.00, "unit_name": "шт.",
     "stock_quantity": 50, "discount": 25, "image_path": None},
    {"product_id": 6, "product_name": "Конструктор «Космос»", "category_id": 1, "description": "Набор из 300 деталей",
     "manufacturer_name": "KidsPlay", "supplier_id": 3, "price": 2100.00, "unit_name": "набор",
     "stock_quantity": 12, "discount": 5, "image_path": None},
    {"product_id": 7, "product_name": "Зайка мягкий", "category_id": 2, "description": "Плюшевый заяц, 30 см",
     "manufacturer_name": "Весёлый мир", "supplier_id": 1, "price": 800.00, "unit_name": "шт.",
     "stock_quantity": 0, "discount": 18, "image_path": None},
    {"product_id": 8, "product_name": "Кукла «Малышка»", "category_id": 3, "description": "Пупс с бутылочкой",
     "manufacturer_name": "ToyWorld", "supplier_id": 2, "price": 1500.00, "unit_name": "шт.",
     "stock_quantity": 20, "discount": 17, "image_path": None},
]

ORDERS = [
    {"order_id": 1, "order_article": "A-1001", "status_id": 1, "pickup_address": "г. Москва, ул. Ленина, 10",
     "order_date": "2026-10-01", "delivery_date": "2026-10-05"},
    {"order_id": 2, "order_article": "A-1002", "status_id": 3, "pickup_address": "г. Казань, ул. Баумана, 5",
     "order_date": "2026-09-20", "delivery_date": "2026-09-25"},
]
# Товары в заказах: (id заказа, id товара)
ORDER_ITEMS = [(1, 1), (1, 2), (2, 5)]


# ===================== РАБОТА С ДАННЫМИ =====================
def authorize(login, password):
    for user in USERS:
        if user["login"] == login and user["password"] == password:
            return user
    return None


def with_names(product):
    """Добавляет к товару название категории и поставщика."""
    result = dict(product)
    result["category_name"] = CATEGORIES[product["category_id"]]
    result["supplier_name"] = SUPPLIERS[product["supplier_id"]]
    return result


def fetch_products():
    return [with_names(product) for product in PRODUCTS]


def fetch_product(product_id):
    for product in PRODUCTS:
        if product["product_id"] == product_id:
            return with_names(product)
    return None


def next_product_id():
    return max((product["product_id"] for product in PRODUCTS), default=0) + 1


def save_product(product_id, data):
    """product_id=None -> добавление нового товара."""
    values = {
        "product_name": data["name"], "category_id": data["category_id"],
        "description": data["description"], "manufacturer_name": data["manufacturer"],
        "supplier_id": data["supplier_id"], "price": data["price"], "unit_name": data["unit"],
        "stock_quantity": data["quantity"], "discount": data["discount"],
        "image_path": data["image_path"],
    }
    if product_id is None:
        PRODUCTS.append({"product_id": next_product_id(), **values})  # ID = максимальный + 1
        return
    for product in PRODUCTS:
        if product["product_id"] == product_id:
            product.update(values)


def product_in_orders(product_id):
    return any(item[1] == product_id for item in ORDER_ITEMS)


def delete_product(product_id):
    PRODUCTS[:] = [product for product in PRODUCTS if product["product_id"] != product_id]


def fetch_dictionary(name):
    """Возвращает список пар (id, название)."""
    source = {"categories": CATEGORIES, "suppliers": SUPPLIERS, "order_statuses": ORDER_STATUSES}[name]
    return sorted(source.items(), key=lambda pair: pair[1])


def fetch_orders():
    return [{**order, "status_name": ORDER_STATUSES[order["status_id"]]} for order in ORDERS]


def fetch_order(order_id):
    for order in ORDERS:
        if order["order_id"] == order_id:
            return dict(order)
    return None


def article_exists(article, exclude_id=None):
    return any(order["order_article"] == article and order["order_id"] != exclude_id for order in ORDERS)


def save_order(order_id, data):
    values = {"order_article": data["article"], "status_id": data["status_id"],
              "pickup_address": data["address"], "order_date": data["order_date"],
              "delivery_date": data["delivery_date"]}
    if order_id is None:
        new_id = max((order["order_id"] for order in ORDERS), default=0) + 1
        ORDERS.append({"order_id": new_id, **values})
        return
    for order in ORDERS:
        if order["order_id"] == order_id:
            order.update(values)


def delete_order(order_id):
    ORDERS[:] = [order for order in ORDERS if order["order_id"] != order_id]
    ORDER_ITEMS[:] = [item for item in ORDER_ITEMS if item[0] != order_id]


# ===================== ЛОГИКА СПИСКА ТОВАРОВ =====================
DISCOUNT_LIMIT = 17             # порог скидки из задания (в ведомости экспертов указано 15%)
COLOR_BIG_DISCOUNT = "#FFDEAD"  # фон при большой скидке (в ведомости: #2E8B57)
COLOR_NO_STOCK = "#ADD8E6"      # голубой — товара нет на складе
COLOR_DEFAULT = "#FFFFFF"

SORT_OPTIONS = {
    "Без сортировки": None,
    "Количество на складе ↑": ("stock_quantity", False),
    "Количество на складе ↓": ("stock_quantity", True),
    "Цена ↑": ("price", False),
    "Цена ↓": ("price", True),
}
ALL_SUPPLIERS = "Все поставщики"


def final_price(price, discount):
    return round(price * (100 - discount) / 100, 2)


def row_color(quantity, discount):
    if quantity == 0:
        return COLOR_NO_STOCK
    if discount > DISCOUNT_LIMIT:
        return COLOR_BIG_DISCOUNT
    return COLOR_DEFAULT


def filter_products(products, text, supplier_id, sort_key):
    """Поиск (по всем текстовым полям), фильтр по поставщику и сортировка применяются вместе."""
    text = text.strip().lower()
    result = []
    for product in products:
        if supplier_id is not None and product["supplier_id"] != supplier_id:
            continue
        haystack = " ".join(str(product[key]) for key in (
            "product_name", "category_name", "description",
            "manufacturer_name", "supplier_name", "unit_name")).lower()
        if text and text not in haystack:
            continue
        result.append(product)
    rule = SORT_OPTIONS.get(sort_key)
    if rule:
        field, reverse = rule
        result.sort(key=lambda item: item[field], reverse=reverse)
    return result


# ===================== ОБЩИЕ ЭЛЕМЕНТЫ ИНТЕРФЕЙСА =====================
ROLE_GUEST = "guest"
ROLE_CLIENT = "client"
ROLE_MANAGER = "manager"
ROLE_ADMIN = "admin"


def make_user(row=None):
    """Пользователь сессии: словарь с ФИО и ролью. row=None -> гость."""
    if row is None:
        return {"fio": "Гость", "role": ROLE_GUEST}
    fio = " ".join(part for part in (row["surname"], row["first_name"], row["patronymic"]) if part)
    return {"fio": fio, "role": row["role_code"]}


def load_pixmap(path, width, height):
    pixmap = QPixmap(str(path))
    return pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatio,
                         Qt.TransformationMode.SmoothTransformation)


def make_header(title, user, on_back=None, on_logout=None):
    """Шапка: логотип, заголовок, ФИО в правом верхнем углу, кнопки."""
    header = QWidget()
    header.setObjectName("header")
    layout = QHBoxLayout(header)
    logo = QLabel()
    logo.setPixmap(load_pixmap(RESOURCES_DIR / "logo.png", 48, 48))
    layout.addWidget(logo)
    title_label = QLabel(title)
    title_label.setStyleSheet("font-size: 20px; font-weight: bold;")
    layout.addWidget(title_label)
    layout.addStretch()
    if on_back:
        back_button = QPushButton("Назад")
        back_button.clicked.connect(on_back)
        layout.addWidget(back_button)
    fio_label = QLabel(user["fio"])
    fio_label.setStyleSheet("font-weight: bold;")
    layout.addWidget(fio_label)
    if on_logout:
        logout_button = QPushButton("Выйти")
        logout_button.clicked.connect(on_logout)
        layout.addWidget(logout_button)
    return header


# ===================== ОКНО ВХОДА =====================
class LoginPage(QWidget):
    logged_in = pyqtSignal(dict)

    def __init__(self):
        super().__init__()
        self.setObjectName("page")
        layout = QVBoxLayout(self)
        layout.setAlignment(Qt.AlignmentFlag.AlignCenter)

        logo = QLabel()
        logo.setPixmap(load_pixmap(RESOURCES_DIR / "logo.png", 120, 120))
        logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(logo)

        title = QLabel("Вход в систему")
        title.setStyleSheet("font-size: 22px; font-weight: bold;")
        title.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(title)

        self.login_edit = QLineEdit()
        self.login_edit.setPlaceholderText("Логин")
        self.login_edit.setFixedWidth(280)
        self.password_edit = QLineEdit()
        self.password_edit.setPlaceholderText("Пароль")
        self.password_edit.setEchoMode(QLineEdit.EchoMode.Password)
        self.password_edit.setFixedWidth(280)
        self.password_edit.returnPressed.connect(self.sign_in)
        login_button = QPushButton("Войти")
        login_button.setFixedWidth(280)
        login_button.clicked.connect(self.sign_in)
        guest_button = QPushButton("Войти как гость")
        guest_button.setFixedWidth(280)
        guest_button.clicked.connect(lambda: self.logged_in.emit(make_user()))

        for widget in (self.login_edit, self.password_edit, login_button, guest_button):
            layout.addWidget(widget, alignment=Qt.AlignmentFlag.AlignCenter)

    def sign_in(self):
        login = self.login_edit.text().strip()
        password = self.password_edit.text()
        if not login or not password:
            QMessageBox.warning(self, "Ошибка входа", "Введите логин и пароль.")
            return
        row = authorize(login, password)
        if row is None:
            QMessageBox.warning(self, "Ошибка входа",
                                "Неверный логин или пароль. Проверьте данные и повторите ввод.")
            return
        self.logged_in.emit(make_user(row))


# ===================== СПИСОК ТОВАРОВ =====================
MAX_IMAGE_WIDTH = 300
MAX_IMAGE_HEIGHT = 200
INVALID_STYLE = "border: 2px solid red;"


def image_file(image_path):
    """Файл картинки товара или заглушка picture.png."""
    if image_path:
        path = BASE_DIR / image_path
        if path.exists():
            return path
    return RESOURCES_DIR / "picture.png"


def remove_image(image_path):
    if image_path:
        path = BASE_DIR / image_path
        if path.exists():
            path.unlink()


class ProductCard(QFrame):
    clicked = pyqtSignal(int)
    delete_requested = pyqtSignal(int)

    def __init__(self, product, can_edit):
        super().__init__()
        self.product_id = product["product_id"]
        self.can_edit = can_edit
        color = row_color(product["stock_quantity"], product["discount"])
        self.setObjectName("card")
        self.setStyleSheet(f"#card {{ background-color: {color}; border: 1px solid black; }}")

        layout = QHBoxLayout(self)
        photo = QLabel()
        photo.setPixmap(load_pixmap(image_file(product["image_path"]), 150, 100))
        photo.setFixedSize(160, 110)
        photo.setAlignment(Qt.AlignmentFlag.AlignCenter)
        layout.addWidget(photo)

        info = QVBoxLayout()
        title = QLabel(f"{product['category_name']} | {product['product_name']}")
        title.setStyleSheet("font-weight: bold; font-size: 16px;")
        info.addWidget(title)
        for text in (f"Описание товара: {product['description'] or ''}",
                     f"Производитель: {product['manufacturer_name']}",
                     f"Поставщик: {product['supplier_name']}"):
            label = QLabel(text)
            label.setWordWrap(True)
            info.addWidget(label)
        info.addWidget(self.make_price_label(product))
        info.addWidget(QLabel(f"Единица измерения: {product['unit_name']}"))
        info.addWidget(QLabel(f"Количество на складе: {product['stock_quantity']}"))
        layout.addLayout(info, stretch=1)

        side = QVBoxLayout()
        discount = QLabel(f"Действующая скидка\n{product['discount']}%")
        discount.setAlignment(Qt.AlignmentFlag.AlignCenter)
        discount.setStyleSheet("border: 1px solid black; padding: 8px;")
        discount.setFixedWidth(150)
        side.addWidget(discount, stretch=1)
        if can_edit:
            delete_button = QPushButton("Удалить")
            delete_button.clicked.connect(lambda: self.delete_requested.emit(self.product_id))
            side.addWidget(delete_button)
        layout.addLayout(side)

    @staticmethod
    def make_price_label(product):
        price, discount = product["price"], product["discount"]
        label = QLabel()
        label.setTextFormat(Qt.TextFormat.RichText)
        if discount > 0:
            total = final_price(price, discount)
            label.setText(f"Цена: <span style='color:red'><s>{price:.2f}</s></span> "
                          f"<span style='color:black'>{total:.2f}</span>")
        else:
            label.setText(f"Цена: {price:.2f}")
        return label

    def mousePressEvent(self, event):
        # Переход на форму редактирования по нажатию на карточку (только администратор)
        if self.can_edit and event.button() == Qt.MouseButton.LeftButton:
            self.clicked.emit(self.product_id)
        super().mousePressEvent(event)


class ProductsPage(QWidget):
    def __init__(self, window, user):
        super().__init__()
        self.setObjectName("page")
        self.window_ref = window
        self.user = user
        self.is_admin = user["role"] == ROLE_ADMIN
        self.has_tools = user["role"] in (ROLE_MANAGER, ROLE_ADMIN)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(make_header("Список товаров", user, on_logout=window.show_login))

        body = QVBoxLayout()
        body.setContentsMargins(12, 8, 12, 8)
        layout.addLayout(body)

        if self.has_tools:
            body.addLayout(self.build_toolbar())

        self.cards_layout = QVBoxLayout()
        self.cards_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
        container = QWidget()
        container.setObjectName("page")
        container.setLayout(self.cards_layout)
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setWidget(container)
        body.addWidget(scroll)

        self.products = []
        self.reload()

    def build_toolbar(self):
        toolbar = QHBoxLayout()
        self.search_edit = QLineEdit()
        self.search_edit.setPlaceholderText("Поиск по тексту...")
        self.search_edit.textChanged.connect(self.refresh)
        self.sort_combo = QComboBox()
        self.sort_combo.addItems(list(SORT_OPTIONS))
        self.sort_combo.currentIndexChanged.connect(self.refresh)
        self.supplier_combo = QComboBox()
        # Первый элемент сбрасывает фильтр
        self.supplier_combo.addItem(ALL_SUPPLIERS, None)
        for row in fetch_dictionary("suppliers"):
            self.supplier_combo.addItem(row[1], row[0])
        self.supplier_combo.currentIndexChanged.connect(self.refresh)
        orders_button = QPushButton("Заказы")
        orders_button.clicked.connect(lambda: self.window_ref.show_orders(self.user))

        toolbar.addWidget(self.search_edit, stretch=1)
        toolbar.addWidget(self.sort_combo)
        toolbar.addWidget(self.supplier_combo)
        toolbar.addWidget(orders_button)
        if self.is_admin:
            add_button = QPushButton("Добавить товар")
            add_button.clicked.connect(lambda: self.open_form(None))
            toolbar.addWidget(add_button)
        return toolbar

    def reload(self):
        """Перечитывает товары и обновляет список."""
        self.products = fetch_products()
        self.refresh()

    def refresh(self, *_):
        """Поиск, фильтр и сортировка применяются одновременно, в реальном времени."""
        if self.has_tools:
            shown = filter_products(self.products, self.search_edit.text(),
                                    self.supplier_combo.currentData(),
                                    self.sort_combo.currentText())
        else:
            shown = self.products
        while self.cards_layout.count():
            item = self.cards_layout.takeAt(0)
            if item.widget():
                item.widget().deleteLater()
        for product in shown:
            card = ProductCard(product, can_edit=self.is_admin)
            card.clicked.connect(self.open_form)
            card.delete_requested.connect(self.remove_product)
            self.cards_layout.addWidget(card)

    def open_form(self, product_id):
        # Диалог модальный: о