Kaynağa Gözat

Готовая программа для ПМ-04

Firioo 1 hafta önce
işleme
ca00aa36bb
4 değiştirilmiş dosya ile 670 ekleme ve 0 silme
  1. 30 0
      .gitignore
  2. 103 0
      init_db.sql
  3. 536 0
      programm.py
  4. 1 0
      requirements.txt

+ 30 - 0
.gitignore

@@ -0,0 +1,30 @@
+# Виртуальное окружение
+venv/
+env/
+.venv/
+
+# Файлы Python (байт-код)
+__pycache__/
+*.pyc
+*.pyo
+
+# Файлы PyInstaller (сборка)
+build/
+dist/
+*.spec
+
+# Файлы IDE (PyCharm, VS Code)
+.idea/
+.vscode/
+*.swp
+*.swo
+
+# Файлы, создаваемые приложением (экспортированные CSV)
+*.csv
+
+# Логи
+*.log
+
+# Файлы ОС
+Thumbs.db
+.DS_Store

+ 103 - 0
init_db.sql

@@ -0,0 +1,103 @@
+CREATE TABLE Category (
+    id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+    name VARCHAR(100) NOT NULL UNIQUE
+);
+
+DROP TABLE equipment CASCADE;
+CREATE TABLE Equipment (
+    id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+    name VARCHAR(200) NOT NULL,
+    manufacturer VARCHAR(200),
+    passport_data TEXT,
+    category_id INT NOT NULL REFERENCES Category(id) ON DELETE RESTRICT
+);
+DROP TABLE Employee CASCADE;
+CREATE TABLE Employee (
+    id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+    full_name VARCHAR(200) NOT NULL,
+    position VARCHAR(100),
+    phone VARCHAR(20)
+);
+
+DROP TABLE Supplier CASCADE;
+CREATE TABLE Supplier (
+    id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+    address TEXT NOT NULL
+);
+
+CREATE TABLE Warehouse (
+    id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+    address TEXT NOT NULL
+);
+
+-- Фактические таблицы
+DROP TABLE Purchase CASCADE;
+CREATE TABLE Purchase (
+    id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+    equipment_id INT NOT NULL REFERENCES Equipment(id) ON DELETE RESTRICT,
+    supplier_id INT NOT NULL REFERENCES Supplier(id),
+    warehouse_id INT NOT NULL REFERENCES Warehouse(id),
+    quantity INT NOT NULL CHECK (quantity > 0),
+    price_per_unit NUMERIC(12,2) NOT NULL CHECK (price_per_unit >= 0),
+    purchase_date DATE NOT NULL,
+    notes TEXT
+);
+
+DROP TABLE WriteOff CASCADE;
+CREATE TABLE WriteOff (
+    id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+    equipment_id INT NOT NULL REFERENCES Equipment(id) ON DELETE RESTRICT,
+    employee_id INT NOT NULL REFERENCES Employee(id),
+    quantity INT NOT NULL CHECK (quantity > 0),
+    reason VARCHAR(255),
+    write_off_date DATE NOT NULL
+);
+
+-- Индексы
+CREATE INDEX idx_purchase_equip ON Purchase(equipment_id);
+CREATE INDEX idx_writeoff_equip ON WriteOff(equipment_id);
+
+-- Представление с расчётным атрибутом: текущий остаток каждого оборудования
+CREATE VIEW EquipmentStock AS
+SELECT 
+    e.id,
+    e.name,
+    e.manufacturer,
+    e.passport_data,
+    c.name AS category,
+    COALESCE(SUM(p.quantity), 0) - COALESCE(SUM(w.quantity), 0) AS current_quantity
+FROM Equipment e
+LEFT JOIN Category c ON e.category_id = c.id
+LEFT JOIN Purchase p ON e.id = p.equipment_id
+LEFT JOIN WriteOff w ON e.id = w.equipment_id
+GROUP BY e.id, c.name;
+
+-- Детализация закупок (для отчётов)
+CREATE VIEW PurchaseDetails AS
+SELECT 
+    p.id,
+    e.name AS equipment,
+    s.address AS supplier,
+    w.address AS warehouse,
+    p.quantity,
+    p.price_per_unit,
+    p.quantity * p.price_per_unit AS total_cost,
+    p.purchase_date,
+    p.notes
+FROM Purchase p
+JOIN Equipment e ON p.equipment_id = e.id
+JOIN Supplier s ON p.supplier_id = s.id
+JOIN Warehouse w ON p.warehouse_id = w.id;
+
+-- Детализация списаний
+CREATE VIEW WriteOffDetails AS
+SELECT 
+    wo.id,
+    e.name AS equipment,
+    emp.full_name AS employee,
+    wo.quantity,
+    wo.reason,
+    wo.write_off_date
+FROM WriteOff wo
+JOIN Equipment e ON wo.equipment_id = e.id
+JOIN Employee emp ON wo.employee_id = emp.id;

+ 536 - 0
programm.py

@@ -0,0 +1,536 @@
+import psycopg2
+import tkinter as tk
+from tkinter import ttk, messagebox, simpledialog
+from datetime import datetime
+import csv
+
+# ========== НАСТРОЙКИ БД ==========
+DB_CONFIG = {
+    "dbname": "dbwork",
+    "user": "zhelezkov_fv",
+    "password": "0&6&I#u#s#B",      
+    "host": "edu-pg.itiscaf.ru",
+    "port": 5432
+}
+
+def get_conn():
+    return psycopg2.connect(**DB_CONFIG)
+
+# ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ДЛЯ СПРАВОЧНИКОВ ==========
+def get_categories():
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("SELECT id, name FROM Category ORDER BY name")
+        return cur.fetchall()
+
+def get_equipment_list():
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("SELECT id, name FROM Equipment ORDER BY name")
+        return cur.fetchall()
+
+def get_employees():
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("SELECT id, full_name FROM Employee ORDER BY full_name")
+        return cur.fetchall()
+
+def get_suppliers():
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("SELECT id, address FROM Supplier ORDER BY address")
+        return cur.fetchall()
+
+def get_warehouses():
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("SELECT id, address FROM Warehouse ORDER BY address")
+        return cur.fetchall()
+
+# ========== ОБНОВЛЕНИЕ ТАБЛИЦ ==========
+def refresh_stock(tree):
+    for row in tree.get_children():
+        tree.delete(row)
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("""
+            SELECT id, name, manufacturer, category, current_quantity
+            FROM EquipmentStock
+            ORDER BY name
+        """)
+        for row in cur.fetchall():
+            tree.insert("", tk.END, values=row)
+
+def refresh_purchases(tree):
+    for row in tree.get_children():
+        tree.delete(row)
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("""
+            SELECT id, equipment, supplier, warehouse, quantity,
+                   price_per_unit, total_cost, purchase_date, notes
+            FROM PurchaseDetails
+            ORDER BY purchase_date DESC
+        """)
+        for row in cur.fetchall():
+            tree.insert("", tk.END, values=row)
+
+def refresh_writeoffs(tree):
+    for row in tree.get_children():
+        tree.delete(row)
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("""
+            SELECT id, equipment, employee, quantity, reason, write_off_date
+            FROM WriteOffDetails
+            ORDER BY write_off_date DESC
+        """)
+        for row in cur.fetchall():
+            tree.insert("", tk.END, values=row)
+
+# ========== ПОИСК ==========
+def search_stock(tree, search_entry):
+    keyword = search_entry.get().strip()
+    for row in tree.get_children():
+        tree.delete(row)
+    if not keyword:
+        refresh_stock(tree)
+        return
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("""
+            SELECT e.id, e.name, e.manufacturer, c.name AS category,
+                   COALESCE(SUM(p.quantity), 0) - COALESCE(SUM(w.quantity), 0) AS current_quantity
+            FROM Equipment e
+            LEFT JOIN Category c ON e.category_id = c.id
+            LEFT JOIN Purchase p ON e.id = p.equipment_id
+            LEFT JOIN WriteOff w ON e.id = w.equipment_id
+            WHERE e.name ILIKE %s OR e.manufacturer ILIKE %s
+            GROUP BY e.id, c.name
+            ORDER BY e.name
+        """, (f'{keyword}%', f'{keyword}%'))   # изменено: только % в конце
+        for row in cur.fetchall():
+            tree.insert("", tk.END, values=row)
+
+# ========== CRUD ДЛЯ ЗАКУПОК ==========
+def add_purchase(parent, refresh_callback):
+    win = tk.Toplevel(parent)
+    win.title("Новая закупка")
+    win.geometry("500x400")
+    win.grab_set()
+
+    # Поля
+    tk.Label(win, text="Оборудование:").grid(row=0, column=0, sticky="e", padx=5, pady=5)
+    equip_var = tk.StringVar()
+    equip_combo = ttk.Combobox(win, textvariable=equip_var, values=[f"{e[0]} - {e[1]}" for e in get_equipment_list()], width=40)
+    equip_combo.grid(row=0, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Поставщик:").grid(row=1, column=0, sticky="e")
+    supp_var = tk.StringVar()
+    supp_combo = ttk.Combobox(win, textvariable=supp_var, values=[f"{s[0]} - {s[1]}" for s in get_suppliers()], width=40)
+    supp_combo.grid(row=1, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Склад:").grid(row=2, column=0, sticky="e")
+    wh_var = tk.StringVar()
+    wh_combo = ttk.Combobox(win, textvariable=wh_var, values=[f"{w[0]} - {w[1]}" for w in get_warehouses()], width=40)
+    wh_combo.grid(row=2, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Количество:").grid(row=3, column=0, sticky="e")
+    qty_entry = tk.Entry(win)
+    qty_entry.grid(row=3, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Цена за ед. (руб):").grid(row=4, column=0, sticky="e")
+    price_entry = tk.Entry(win)
+    price_entry.grid(row=4, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Дата закупки (ГГГГ-ММ-ДД):").grid(row=5, column=0, sticky="e")
+    date_entry = tk.Entry(win)
+    date_entry.insert(0, datetime.now().strftime("%Y-%m-%d"))
+    date_entry.grid(row=5, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Примечание:").grid(row=6, column=0, sticky="ne")
+    notes_text = tk.Text(win, height=4, width=30)
+    notes_text.grid(row=6, column=1, padx=5, pady=5)
+
+    def save():
+        if not equip_var.get() or not supp_var.get() or not wh_var.get():
+            messagebox.showerror("Ошибка", "Заполните все обязательные поля")
+            return
+        try:
+            eq_id = int(equip_var.get().split(" - ")[0])
+            sup_id = int(supp_var.get().split(" - ")[0])
+            wh_id = int(wh_var.get().split(" - ")[0])
+            qty = int(qty_entry.get())
+            price = float(price_entry.get())
+            date = date_entry.get()
+            datetime.strptime(date, "%Y-%m-%d")
+            notes = notes_text.get("1.0", tk.END).strip()
+        except Exception:
+            messagebox.showerror("Ошибка", "Проверьте введённые данные (количество – целое, цена – число, дата в формате ГГГГ-ММ-ДД)")
+            return
+        try:
+            with get_conn() as conn, conn.cursor() as cur:
+                cur.execute("""
+                    INSERT INTO Purchase (equipment_id, supplier_id, warehouse_id, quantity, price_per_unit, purchase_date, notes)
+                    VALUES (%s, %s, %s, %s, %s, %s, %s)
+                """, (eq_id, sup_id, wh_id, qty, price, date, notes))
+                conn.commit()
+                messagebox.showinfo("Успех", "Закупка добавлена")
+                win.destroy()
+                refresh_callback()
+        except Exception as e:
+            messagebox.showerror("Ошибка БД", str(e))
+
+    tk.Button(win, text="Сохранить", command=save, bg="lightgreen").grid(row=7, column=0, columnspan=2, pady=15)
+
+def delete_purchase(tree, refresh_callback):
+    selected = tree.selection()
+    if not selected:
+        messagebox.showwarning("Выбор", "Выберите запись для удаления")
+        return
+    item = tree.item(selected[0])
+    purchase_id = item['values'][0]
+    if messagebox.askyesno("Удаление", f"Удалить закупку ID {purchase_id}?"):
+        try:
+            with get_conn() as conn, conn.cursor() as cur:
+                cur.execute("DELETE FROM Purchase WHERE id = %s", (purchase_id,))
+                conn.commit()
+                refresh_callback()
+        except Exception as e:
+            messagebox.showerror("Ошибка", str(e))
+
+def edit_purchase(tree, refresh_callback):
+    selected = tree.selection()
+    if not selected:
+        messagebox.showwarning("Выбор", "Выберите закупку для редактирования")
+        return
+    item = tree.item(selected[0])
+    purchase_id = item['values'][0]
+    # Получаем текущие данные
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("SELECT equipment_id, supplier_id, warehouse_id, quantity, price_per_unit, purchase_date, notes FROM Purchase WHERE id = %s", (purchase_id,))
+        data = cur.fetchone()
+    if not data:
+        return
+    win = tk.Toplevel(tree)
+    win.title("Редактирование закупки")
+    win.geometry("500x400")
+    win.grab_set()
+
+    # Заполняем поля (аналогично add, но с предзаполнением)
+    # ... (для краткости покажу принцип, полный код аналогичен add)
+    # Для экономии места, в рабочей версии нужно реализовать, но здесь ограничение символов.
+    # Я дам полный код в конце ответа отдельным блоком.
+    messagebox.showinfo("Уведомление", "Функция редактирования аналогична добавлению. Полный код предоставлен в итоговом приложении.")
+
+# ========== CRUD ДЛЯ СПИСАНИЙ ==========
+def add_writeoff(parent, refresh_callback):
+    win = tk.Toplevel(parent)
+    win.title("Новое списание")
+    win.geometry("450x350")
+    win.grab_set()
+
+    tk.Label(win, text="Оборудование:").grid(row=0, column=0, sticky="e")
+    equip_var = tk.StringVar()
+    equip_combo = ttk.Combobox(win, textvariable=equip_var, values=[f"{e[0]} - {e[1]}" for e in get_equipment_list()], width=40)
+    equip_combo.grid(row=0, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Сотрудник:").grid(row=1, column=0, sticky="e")
+    emp_var = tk.StringVar()
+    emp_combo = ttk.Combobox(win, textvariable=emp_var, values=[f"{e[0]} - {e[1]}" for e in get_employees()], width=40)
+    emp_combo.grid(row=1, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Количество:").grid(row=2, column=0, sticky="e")
+    qty_entry = tk.Entry(win)
+    qty_entry.grid(row=2, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Причина списания:").grid(row=3, column=0, sticky="ne")
+    reason_text = tk.Text(win, height=3, width=30)
+    reason_text.grid(row=3, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Дата списания (ГГГГ-ММ-ДД):").grid(row=4, column=0, sticky="e")
+    date_entry = tk.Entry(win)
+    date_entry.insert(0, datetime.now().strftime("%Y-%m-%d"))
+    date_entry.grid(row=4, column=1, padx=5, pady=5)
+
+    def save():
+        if not equip_var.get() or not emp_var.get():
+            messagebox.showerror("Ошибка", "Заполните оборудование и сотрудника")
+            return
+        try:
+            eq_id = int(equip_var.get().split(" - ")[0])
+            emp_id = int(emp_var.get().split(" - ")[0])
+            qty = int(qty_entry.get())
+            reason = reason_text.get("1.0", tk.END).strip()
+            date = date_entry.get()
+            datetime.strptime(date, "%Y-%m-%d")
+        except Exception:
+            messagebox.showerror("Ошибка", "Неверные данные")
+            return
+        try:
+            with get_conn() as conn, conn.cursor() as cur:
+                cur.execute("""
+                    INSERT INTO WriteOff (equipment_id, employee_id, quantity, reason, write_off_date)
+                    VALUES (%s, %s, %s, %s, %s)
+                """, (eq_id, emp_id, qty, reason, date))
+                conn.commit()
+                messagebox.showinfo("Успех", "Списание добавлено")
+                win.destroy()
+                refresh_callback()
+        except Exception as e:
+            messagebox.showerror("Ошибка", str(e))
+
+    tk.Button(win, text="Сохранить", command=save, bg="lightcoral").grid(row=5, column=0, columnspan=2, pady=15)
+
+def delete_writeoff(tree, refresh_callback):
+    selected = tree.selection()
+    if not selected:
+        messagebox.showwarning("Выбор", "Выберите списание для удаления")
+        return
+    item = tree.item(selected[0])
+    wo_id = item['values'][0]
+    if messagebox.askyesno("Удаление", f"Удалить списание ID {wo_id}?"):
+        with get_conn() as conn, conn.cursor() as cur:
+            cur.execute("DELETE FROM WriteOff WHERE id = %s", (wo_id,))
+            conn.commit()
+            refresh_callback()
+
+# ========== ЭКСПОРТ ==========
+def export_to_csv(tree, filename_prefix):
+    data = []
+    for child in tree.get_children():
+        vals = tree.item(child)['values']
+        data.append(vals)
+    if not data:
+        messagebox.showwarning("Нет данных", "Нечего экспортировать")
+        return
+    filename = f"{filename_prefix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
+    with open(filename, 'w', newline='', encoding='utf-8-sig') as f:
+        writer = csv.writer(f)
+        cols = [tree.heading(c)['text'] for c in tree['columns']]
+        writer.writerow(cols)
+        writer.writerows(data)
+    messagebox.showinfo("Экспорт", f"Сохранён файл {filename}")
+
+# ========== ГЛАВНОЕ ОКНО (ВКЛАДКИ) ==========
+class App:
+    def __init__(self):
+        self.root = tk.Tk()
+        self.root.title("Учёт оборудования (партионный)")
+        self.root.geometry("1300x650")
+        self.root.configure(bg="#f0f0f0")
+
+        style = ttk.Style()
+        style.theme_use("clam")
+        style.configure("TNotebook.Tab", font=("Arial", 10, "bold"), padding=[10, 5])
+        style.configure("TButton", font=("Arial", 9), padding=4)
+        style.configure("Treeview.Heading", font=("Arial", 10, "bold"))
+
+        self.notebook = ttk.Notebook(self.root)
+        self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
+
+        # Вкладка "Остатки оборудования"
+        self.stock_frame = ttk.Frame(self.notebook)
+        self.notebook.add(self.stock_frame, text="📦 Остатки оборудования")
+        self.create_stock_tab()
+
+        # Вкладка "Закупки"
+        self.purchase_frame = ttk.Frame(self.notebook)
+        self.notebook.add(self.purchase_frame, text="📥 Закупки")
+        self.create_purchase_tab()
+
+        # Вкладка "Списания"
+        self.writeoff_frame = ttk.Frame(self.notebook)
+        self.notebook.add(self.writeoff_frame, text="📤 Списания")
+        self.create_writeoff_tab()
+
+        self.root.mainloop()
+
+    def create_stock_tab(self):
+        # Поиск
+        search_frame = ttk.Frame(self.stock_frame)
+        search_frame.pack(fill=tk.X, padx=5, pady=5)
+        ttk.Label(search_frame, text="Поиск:").pack(side=tk.LEFT, padx=5)
+        self.stock_search = ttk.Entry(search_frame, width=30)
+        self.stock_search.pack(side=tk.LEFT, padx=5)
+        ttk.Button(search_frame, text="🔍 Найти", command=lambda: search_stock(self.stock_tree, self.stock_search)).pack(side=tk.LEFT, padx=2)
+        ttk.Button(search_frame, text="🔄 Сброс", command=lambda: refresh_stock(self.stock_tree)).pack(side=tk.LEFT, padx=2)
+
+        columns = ("ID", "Наименование", "Производитель", "Категория", "Остаток (шт)")
+        self.stock_tree = ttk.Treeview(self.stock_frame, columns=columns, show="headings")
+        for col in columns:
+            self.stock_tree.heading(col, text=col)
+            self.stock_tree.column(col, width=150)
+        self.stock_tree.pack(fill=tk.BOTH, expand=True)
+
+        btn_frame = ttk.Frame(self.stock_frame)
+        btn_frame.pack(fill=tk.X, padx=5, pady=5)
+        ttk.Button(btn_frame, text="🔄 Обновить", command=lambda: refresh_stock(self.stock_tree)).pack(side=tk.LEFT, padx=5)
+        ttk.Button(btn_frame, text="📎 Экспорт CSV", command=lambda: export_to_csv(self.stock_tree, "stock")).pack(side=tk.LEFT, padx=5)
+
+        refresh_stock(self.stock_tree)
+
+    def create_purchase_tab(self):
+        # Панель поиска (можно по оборудованию или поставщику)
+        search_frame = ttk.Frame(self.purchase_frame)
+        search_frame.pack(fill=tk.X, padx=5, pady=5)
+        ttk.Label(search_frame, text="Поиск (оборудование/поставщик):").pack(side=tk.LEFT, padx=5)
+        self.purchase_search = ttk.Entry(search_frame, width=30)
+        self.purchase_search.pack(side=tk.LEFT, padx=5)
+        def search_purchases():
+            kw = self.purchase_search.get().strip()
+            for item in self.purchase_tree.get_children():
+                self.purchase_tree.delete(item)
+            if not kw:
+                refresh_purchases(self.purchase_tree)
+                return
+            with get_conn() as conn, conn.cursor() as cur:
+                cur.execute("""
+                    SELECT id, equipment, supplier, warehouse, quantity, price_per_unit, total_cost, purchase_date, notes
+                    FROM PurchaseDetails
+                    WHERE equipment ILIKE %s OR supplier ILIKE %s
+                    ORDER BY purchase_date DESC
+                """, (f'{kw}%', f'{kw}%'))   # поиск с начала
+                for row in cur.fetchall():
+                    self.purchase_tree.insert("", tk.END, values=row)
+        ttk.Button(search_frame, text="🔍 Найти", command=search_purchases).pack(side=tk.LEFT, padx=2)
+        ttk.Button(search_frame, text="🔄 Сброс", command=lambda: refresh_purchases(self.purchase_tree)).pack(side=tk.LEFT, padx=2)
+
+        columns = ("ID", "Оборудование", "Поставщик", "Склад", "Кол-во", "Цена за ед.", "Итого", "Дата", "Примечания")
+        self.purchase_tree = ttk.Treeview(self.purchase_frame, columns=columns, show="headings")
+        for col in columns:
+            self.purchase_tree.heading(col, text=col)
+            self.purchase_tree.column(col, width=100)
+        self.purchase_tree.pack(fill=tk.BOTH, expand=True)
+
+        btn_frame = ttk.Frame(self.purchase_frame)
+        btn_frame.pack(fill=tk.X, padx=5, pady=5)
+        ttk.Button(btn_frame, text="➕ Добавить закупку", command=lambda: add_purchase(self.root, lambda: refresh_purchases(self.purchase_tree))).pack(side=tk.LEFT, padx=5)
+        ttk.Button(btn_frame, text="✏️ Редактировать", command=lambda: edit_purchase(self.purchase_tree, lambda: refresh_purchases(self.purchase_tree))).pack(side=tk.LEFT, padx=5)
+        ttk.Button(btn_frame, text="❌ Удалить", command=lambda: delete_purchase(self.purchase_tree, lambda: refresh_purchases(self.purchase_tree))).pack(side=tk.LEFT, padx=5)
+        ttk.Button(btn_frame, text="🔄 Обновить", command=lambda: refresh_purchases(self.purchase_tree)).pack(side=tk.LEFT, padx=5)
+        ttk.Button(btn_frame, text="📎 Экспорт CSV", command=lambda: export_to_csv(self.purchase_tree, "purchases")).pack(side=tk.LEFT, padx=5)
+
+        refresh_purchases(self.purchase_tree)
+
+    def create_writeoff_tab(self):
+        # Поиск по оборудованию/сотруднику
+        search_frame = ttk.Frame(self.writeoff_frame)
+        search_frame.pack(fill=tk.X, padx=5, pady=5)
+        ttk.Label(search_frame, text="Поиск (оборудование/сотрудник):").pack(side=tk.LEFT, padx=5)
+        self.writeoff_search = ttk.Entry(search_frame, width=30)
+        self.writeoff_search.pack(side=tk.LEFT, padx=5)
+        def search_writeoffs():
+            kw = self.writeoff_search.get().strip()
+            for item in self.writeoff_tree.get_children():
+                self.writeoff_tree.delete(item)
+            if not kw:
+                refresh_writeoffs(self.writeoff_tree)
+                return
+            with get_conn() as conn, conn.cursor() as cur:
+                cur.execute("""
+                SELECT id, equipment, employee, quantity, reason, write_off_date
+                FROM WriteOffDetails
+                WHERE equipment ILIKE %s OR employee ILIKE %s
+                ORDER BY write_off_date DESC
+            """, (f'{kw}%', f'{kw}%'))
+            for row in cur.fetchall():
+                self.writeoff_tree.insert("", tk.END, values=row)
+        ttk.Button(search_frame, text="🔍 Найти", command=search_writeoffs).pack(side=tk.LEFT, padx=2)
+        ttk.Button(search_frame, text="🔄 Сброс", command=lambda: refresh_writeoffs(self.writeoff_tree)).pack(side=tk.LEFT, padx=2)
+
+        columns = ("ID", "Оборудование", "Сотрудник", "Кол-во", "Причина", "Дата списания")
+        self.writeoff_tree = ttk.Treeview(self.writeoff_frame, columns=columns, show="headings")
+        for col in columns:
+            self.writeoff_tree.heading(col, text=col)
+            self.writeoff_tree.column(col, width=140)
+        self.writeoff_tree.pack(fill=tk.BOTH, expand=True)
+
+        btn_frame = ttk.Frame(self.writeoff_frame)
+        btn_frame.pack(fill=tk.X, padx=5, pady=5)
+        ttk.Button(btn_frame, text="➕ Добавить списание", command=lambda: add_writeoff(self.root, lambda: refresh_writeoffs(self.writeoff_tree))).pack(side=tk.LEFT, padx=5)
+        ttk.Button(btn_frame, text="❌ Удалить", command=lambda: delete_writeoff(self.writeoff_tree, lambda: refresh_writeoffs(self.writeoff_tree))).pack(side=tk.LEFT, padx=5)
+        ttk.Button(btn_frame, text="🔄 Обновить", command=lambda: refresh_writeoffs(self.writeoff_tree)).pack(side=tk.LEFT, padx=5)
+        ttk.Button(btn_frame, text="📎 Экспорт CSV", command=lambda: export_to_csv(self.writeoff_tree, "writeoffs")).pack(side=tk.LEFT, padx=5)
+
+        refresh_writeoffs(self.writeoff_tree)
+
+# Функция редактирования закупки (дополнение к классу, но вынесем отдельно)
+def edit_purchase(tree, refresh_callback):
+    selected = tree.selection()
+    if not selected:
+        messagebox.showwarning("Выбор", "Выберите закупку")
+        return
+    item = tree.item(selected[0])
+    purchase_id = item['values'][0]
+    with get_conn() as conn, conn.cursor() as cur:
+        cur.execute("SELECT equipment_id, supplier_id, warehouse_id, quantity, price_per_unit, purchase_date, notes FROM Purchase WHERE id = %s", (purchase_id,))
+        data = cur.fetchone()
+    if not data:
+        return
+    eq_id, sup_id, wh_id, qty, price, date, notes = data
+    win = tk.Toplevel()
+    win.title("Редактирование закупки")
+    win.geometry("500x400")
+    win.grab_set()
+
+    # Получаем названия для предзаполнения
+    eq_name = next((f"{e[0]} - {e[1]}" for e in get_equipment_list() if e[0] == eq_id), "")
+    sup_name = next((f"{s[0]} - {s[1]}" for s in get_suppliers() if s[0] == sup_id), "")
+    wh_name = next((f"{w[0]} - {w[1]}" for w in get_warehouses() if w[0] == wh_id), "")
+
+    tk.Label(win, text="Оборудование:").grid(row=0, column=0, sticky="e")
+    equip_var = tk.StringVar(value=eq_name)
+    equip_combo = ttk.Combobox(win, textvariable=equip_var, values=[f"{e[0]} - {e[1]}" for e in get_equipment_list()], width=40)
+    equip_combo.grid(row=0, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Поставщик:").grid(row=1, column=0, sticky="e")
+    supp_var = tk.StringVar(value=sup_name)
+    supp_combo = ttk.Combobox(win, textvariable=supp_var, values=[f"{s[0]} - {s[1]}" for s in get_suppliers()], width=40)
+    supp_combo.grid(row=1, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Склад:").grid(row=2, column=0, sticky="e")
+    wh_var = tk.StringVar(value=wh_name)
+    wh_combo = ttk.Combobox(win, textvariable=wh_var, values=[f"{w[0]} - {w[1]}" for w in get_warehouses()], width=40)
+    wh_combo.grid(row=2, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Количество:").grid(row=3, column=0, sticky="e")
+    qty_entry = tk.Entry(win)
+    qty_entry.insert(0, str(qty))
+    qty_entry.grid(row=3, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Цена за ед.:").grid(row=4, column=0, sticky="e")
+    price_entry = tk.Entry(win)
+    price_entry.insert(0, str(price))
+    price_entry.grid(row=4, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Дата (ГГГГ-ММ-ДД):").grid(row=5, column=0, sticky="e")
+    date_entry = tk.Entry(win)
+    date_entry.insert(0, date.strftime("%Y-%m-%d") if isinstance(date, datetime) else date)
+    date_entry.grid(row=5, column=1, padx=5, pady=5)
+
+    tk.Label(win, text="Примечание:").grid(row=6, column=0, sticky="ne")
+    notes_text = tk.Text(win, height=4, width=30)
+    notes_text.insert("1.0", notes or "")
+    notes_text.grid(row=6, column=1, padx=5, pady=5)
+
+    def save_edit():
+        try:
+            eq_id_new = int(equip_var.get().split(" - ")[0])
+            sup_id_new = int(supp_var.get().split(" - ")[0])
+            wh_id_new = int(wh_var.get().split(" - ")[0])
+            new_qty = int(qty_entry.get())
+            new_price = float(price_entry.get())
+            new_date = date_entry.get()
+            datetime.strptime(new_date, "%Y-%m-%d")
+            new_notes = notes_text.get("1.0", tk.END).strip()
+        except Exception:
+            messagebox.showerror("Ошибка", "Проверьте данные")
+            return
+        with get_conn() as conn, conn.cursor() as cur:
+            cur.execute("""
+                UPDATE Purchase
+                SET equipment_id=%s, supplier_id=%s, warehouse_id=%s, quantity=%s,
+                    price_per_unit=%s, purchase_date=%s, notes=%s
+                WHERE id=%s
+            """, (eq_id_new, sup_id_new, wh_id_new, new_qty, new_price, new_date, new_notes, purchase_id))
+            conn.commit()
+        win.destroy()
+        refresh_callback()
+        messagebox.showinfo("Успех", "Закупка обновлена")
+
+    tk.Button(win, text="Сохранить", command=save_edit, bg="lightblue").grid(row=7, column=0, columnspan=2, pady=15)
+
+if __name__ == "__main__":
+    App()

+ 1 - 0
requirements.txt

@@ -0,0 +1 @@
+psycopg2-binary