Загрузка данных
"""Магазин игрушек (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):
# Диалог модальный: о