feat: add material deletion confirmation with checkbox UI

- Add MaterialsToBeDeletedDAO for managing material deletion records by MaterialCode
- Add MaterialValidationResult dataclass for enhanced validation results
- Add CheckboxTreeview component with selectable checkbox functionality
- Refactor material validation UI with new columns: select, material name, code, spec, model, manager
- Add double-click to edit manager name functionality
- Add select all/deselect all buttons
- Add confirm deletion button to write selected records to database
- Prioritize MaterialsToBeDeleted.ManagerName over type-based matching when displaying
- Export results to Excel with selection state

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-02-06 16:40:28 +08:00
parent 0ef1b86f12
commit cd21e54abf
4 changed files with 1117 additions and 35 deletions

View File

@@ -12,7 +12,7 @@
import os
import threading
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from tkinter import ttk, messagebox, filedialog, simpledialog
from pathlib import Path
from io import StringIO
from contextlib import redirect_stdout
@@ -21,6 +21,102 @@ from gui.config_manager import ConfigManager
import pandas as pd
class CheckboxTreeview(ttk.Treeview):
"""支持 checkbox 的 Treeview 组件
使用 Unicode 字符模拟 checkbox
- ☐ 未选中
- ☑ 选中
"""
def __init__(self, parent, **kwargs):
"""初始化 CheckboxTreeview
Args:
parent: 父容器
**kwargs: 传递给 Treeview 的参数
"""
super().__init__(parent, **kwargs)
self.checkboxes = {} # item_id -> bool
self.checkbox_column = "选择"
# 绑定点击事件
self.bind("<Button-1>", self._on_click)
def _on_click(self, event):
"""处理点击事件,切换 checkbox 状态"""
# 获取点击位置对应的 item 和 column
region = self.identify_region(event.x, event.y)
if region == "cell":
column = self.identify_column(event.x)
item = self.identify_row(event.y)
# 检查是否点击了 checkbox 列(第一列)
if column == "#1" and item:
# 切换 checkbox 状态
current_state = self.checkboxes.get(item, False)
self.set_checked(item, not current_state)
return "break" # 阻止默认行为
def set_checked(self, item, checked: bool):
"""设置指定 item 的 checkbox 状态
Args:
item: Treeview item ID
checked: 是否选中
"""
self.checkboxes[item] = checked
# 更新显示
checkbox_char = "" if checked else ""
values = list(self.item(item, "values"))
if values:
values[0] = checkbox_char
self.item(item, values=values)
def get_checked_items(self) -> list:
"""获取所有选中的 item
Returns:
List of item IDs
"""
return [item for item, checked in self.checkboxes.items() if checked]
def check_all(self, checked: bool = True):
"""全选或取消全选
Args:
checked: True 为全选False 为取消全选
"""
for item in self.get_children():
self.set_checked(item, checked)
def insert(self, parent, index, values=None, **kwargs):
"""重写 insert 方法,初始化 checkbox 状态"""
if values is None:
values = []
# 确保第一个值是 checkbox
if not values or values[0] not in ["", ""]:
values = [""] + list(values)
item = super().insert(parent, index, values=values, **kwargs)
# 初始化 checkbox 状态为未选中
checkbox_char = values[0] if values else ""
self.checkboxes[item] = (checkbox_char == "")
return item
def delete(self, *items):
"""重写 delete 方法,清理 checkbox 状态"""
for item in items:
if item in self.checkboxes:
del self.checkboxes[item]
super().delete(*items)
class MaterialValidationTab(ttk.Frame):
"""物料校验标签页"""
@@ -36,6 +132,7 @@ class MaterialValidationTab(ttk.Frame):
self.config = config
self.validating = False
self.validation_results = None
self.material_records_cache = None # 缓存完整的物料记录
self.create_widgets()
@@ -147,6 +244,33 @@ class MaterialValidationTab(ttk.Frame):
)
self.start_button.pack(side=tk.LEFT, padx=5)
# 全选按钮
self.select_all_button = ttk.Button(
button_frame,
text="全选",
command=lambda: self._select_all(check=True),
state=tk.DISABLED
)
self.select_all_button.pack(side=tk.LEFT, padx=5)
# 取消全选按钮
self.deselect_all_button = ttk.Button(
button_frame,
text="取消全选",
command=lambda: self._select_all(check=False),
state=tk.DISABLED
)
self.deselect_all_button.pack(side=tk.LEFT, padx=5)
# 确认删除按钮
self.confirm_delete_button = ttk.Button(
button_frame,
text="确认删除",
command=self.confirm_deletion,
state=tk.DISABLED
)
self.confirm_delete_button.pack(side=tk.LEFT, padx=5)
self.export_button = ttk.Button(
button_frame,
text="导出结果",
@@ -157,20 +281,24 @@ class MaterialValidationTab(ttk.Frame):
def _create_result_table(self, parent):
"""创建结果表格"""
# 创建 Treeview
columns = ("材料名称", "匹配的MaterialName", "负责人", "匹配状态")
self.tree = ttk.Treeview(parent, columns=columns, show="headings", height=10)
# 新列定义:选择、材料名称、材料代码、规格、型号、负责人
columns = ("选择", "材料名称", "材料代码", "规格", "型号", "负责人")
self.tree = CheckboxTreeview(parent, columns=columns, show="headings", height=10)
# 设置列标题和宽度
self.tree.heading("选择", text="选择")
self.tree.heading("材料名称", text="材料名称")
self.tree.heading("匹配的MaterialName", text="匹配的MaterialName")
self.tree.heading("负责人", text="负责人")
self.tree.heading("匹配状态", text="匹配状态")
self.tree.heading("材料代码", text="材料代码")
self.tree.heading("规格", text="规格")
self.tree.heading("型号", text="型号")
self.tree.heading("负责人", text="负责人(双击编辑)")
self.tree.column("材料名称", width=300)
self.tree.column("匹配的MaterialName", width=300)
self.tree.column("选择", width=50, anchor="center")
self.tree.column("材料名称", width=250)
self.tree.column("材料代码", width=150)
self.tree.column("规格", width=150)
self.tree.column("型号", width=150)
self.tree.column("负责人", width=150)
self.tree.column("匹配状态", width=100)
# 添加滚动条
scrollbar_y = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.tree.yview)
@@ -190,6 +318,9 @@ class MaterialValidationTab(ttk.Frame):
parent.rowconfigure(0, weight=1)
parent.columnconfigure(0, weight=1)
# 绑定双击事件用于编辑负责人
self.tree.bind("<Double-1>", self._on_cell_double_click)
def _create_log_panel(self, parent):
"""创建日志面板"""
self.log_text = LogText(parent, height=8, readonly=True)
@@ -209,6 +340,45 @@ class MaterialValidationTab(ttk.Frame):
elif mode == "database_filtered":
self.db_filtered_frame.grid(row=0, column=0, columnspan=2, sticky="ew")
def _on_cell_double_click(self, event):
"""处理单元格双击事件,编辑负责人"""
# 获取点击位置
region = self.tree.identify_region(event.x, event.y)
if region == "cell":
column = self.tree.identify_column(event.x)
item = self.tree.identify_row(event.y)
# 检查是否点击了"负责人"列第6列
if column == "#6" and item:
values = self.tree.item(item, "values")
current_value = values[5] if len(values) > 5 else ""
# 弹出编辑对话框
new_value = simpledialog.askstring(
"编辑负责人",
f"请输入负责人姓名:",
initialvalue=current_value
)
if new_value is not None: # 用户没有取消
# 更新单元格值
new_values = list(values)
new_values[5] = new_value
self.tree.item(item, values=new_values)
self.log_text.info(f"已更新负责人: {current_value} -> {new_value}")
def _select_all(self, check: bool = True):
"""全选或取消全选
Args:
check: True 为全选False 为取消全选
"""
self.tree.check_all(check)
action = "全选" if check else "取消全选"
self.log_text.info(f"{action}完成")
def start_validation(self):
"""开始校验"""
# 验证输入
@@ -245,6 +415,9 @@ class MaterialValidationTab(ttk.Frame):
# 更新 UI 状态
self.validating = True
self.start_button.config(state=tk.DISABLED)
self.confirm_delete_button.config(state=tk.DISABLED)
self.select_all_button.config(state=tk.DISABLED)
self.deselect_all_button.config(state=tk.DISABLED)
self.log_text.clear()
self.log_text.info(f"开始物料校验(模式: {mode}...")
@@ -254,21 +427,21 @@ class MaterialValidationTab(ttk.Frame):
# 在后台线程中执行校验
validation_thread = threading.Thread(
target=self._validation_worker,
target=self._validation_worker_enhanced,
args=(mode, input_file, production_id_file, output_file),
daemon=True,
)
validation_thread.start()
def _validation_worker(
def _validation_worker_enhanced(
self, mode: str, input_file: str, production_id_file: str, output_file: str
):
"""校验工作线程"""
"""增强的校验工作线程(使用完整记录模式)"""
try:
# 导入校验器
from utils.material_status_validator import MaterialStatusValidator
# 创建校验器实例(需要 ERP 凭据,因为可能需要登录系统)
# 创建校验器实例
validator = MaterialStatusValidator(
username=self.config.get("erp.username"),
password=self.config.get("erp.password"),
@@ -279,15 +452,15 @@ class MaterialValidationTab(ttk.Frame):
# 捕获 stdout 输出
captured_output = StringIO()
# 根据模式执行校验
# 根据模式执行增强校验
with redirect_stdout(captured_output):
if mode == "database_full":
result = validator.validate_from_database(
result_file, results = validator.validate_from_database_enhanced(
full_table=True,
output_file=output_file
)
elif mode == "database_filtered":
result = validator.validate_from_database(
result_file, results = validator.validate_from_database_enhanced(
production_id_file=production_id_file,
full_table=False,
output_file=output_file
@@ -295,6 +468,9 @@ class MaterialValidationTab(ttk.Frame):
else:
raise ValueError(f"未知的校验模式: {mode}")
# 缓存结果记录
self.material_records_cache = results
# 获取捕获的输出并显示到日志
output_text = captured_output.getvalue()
if output_text:
@@ -302,15 +478,17 @@ class MaterialValidationTab(ttk.Frame):
if line.strip():
self._update_log(line, "INFO")
if result:
if result_file and results:
self._update_log(f"校验完成,结果已保存到:{output_file}", "SUCCESS")
# 加载结果显示
self._load_results(output_file)
# 加载结果显示(带删除状态)
self._load_results_with_deletion_status(result_file)
else:
self._update_log("校验失败", "ERROR")
except Exception as e:
self._update_log(f"校验过程中发生错误:{str(e)}", "ERROR")
import traceback
self._update_log(traceback.format_exc(), "ERROR")
finally:
# 更新 UI 状态
self.after(0, self._validation_complete)
@@ -319,35 +497,153 @@ class MaterialValidationTab(ttk.Frame):
"""校验完成后的 UI 更新"""
self.validating = False
self.start_button.config(state=tk.NORMAL)
self.select_all_button.config(state=tk.NORMAL)
self.deselect_all_button.config(state=tk.NORMAL)
self.confirm_delete_button.config(state=tk.NORMAL)
def _load_results(self, file_path: str):
"""加载校验结果到表格"""
def _load_results_with_deletion_status(self, file_path: str):
"""加载结果并设置 checkbox 选中状态"""
try:
df = pd.read_excel(file_path)
# 在主线程中更新表格
def update_table():
# 从数据库获取已标记删除的 MaterialCode -> ManagerName 映射
from db.materials_to_be_deleted_records_dao import MaterialsToBeDeletedDAO
record_dao = MaterialsToBeDeletedDAO()
marked_records = record_dao.get_all_records()
# Build dictionary: MaterialCode -> ManagerName
marked_codes_dict = {
r['MaterialCode']: r['ManagerName']
for r in marked_records
if r.get('MaterialCode') and r.get('ManagerName')
}
for _, row in df.iterrows():
self.tree.insert(
"",
tk.END,
values=(
row.get("材料名称", ""),
row.get("匹配的MaterialName", ""),
row.get("负责人", ""),
row.get("匹配状态", ""),
),
material_code = str(row.get("材料代码", ""))
# 优先使用 MaterialsToBeDeleted 中的 ManagerName
manager_name = marked_codes_dict.get(material_code)
is_marked = manager_name is not None
# 如果没有在 MaterialsToBeDeleted 中找到,使用 Excel 中的负责人
if not manager_name:
manager_name = str(row.get("负责人", ""))
# 准备显示值
checkbox = "" if is_marked else ""
values = (
checkbox,
str(row.get("材料名称", "")),
material_code,
str(row.get("规格", "")),
str(row.get("型号", "")),
manager_name,
)
item = self.tree.insert("", tk.END, values=values)
# 设置 checkbox 状态
if is_marked:
self.tree.set_checked(item, True)
if len(df) > 0:
self.export_button.config(state=tk.NORMAL)
self._update_log(f"已加载 {len(df)} 条结果", "INFO")
self._update_log(f"已加载 {len(df)} 条结果(其中 {len(marked_codes_dict)} 条已标记删除)", "INFO")
self.after(0, update_table)
except Exception as e:
self._update_log(f"加载结果失败:{str(e)}", "ERROR")
def confirm_deletion(self):
"""确认删除主流程"""
# 获取选中的 item
checked_items = self.tree.get_checked_items()
if not checked_items:
messagebox.showwarning("警告", "请先选择要删除的记录")
return
# 收集数据
materials = []
missing_manager = []
for item in checked_items:
values = self.tree.item(item, "values")
material_code = values[2] if len(values) > 2 else ""
manager_name = values[5] if len(values) > 5 else ""
if not material_code or not material_code.strip():
continue
if not manager_name or not manager_name.strip():
missing_manager.append(material_code)
else:
materials.append({
"material_code": material_code.strip(),
"manager_name": manager_name.strip()
})
# 验证负责人
if missing_manager:
msg = f"以下选中的记录缺少负责人信息,无法删除:\n\n" + \
"\n".join(missing_manager[:10]) # 最多显示10个
if len(missing_manager) > 10:
msg += f"\n... 共 {len(missing_manager)}"
messagebox.showwarning("警告", msg)
return
if not materials:
messagebox.showwarning("警告", "没有有效的记录可删除")
return
# 显示确认对话框
confirm_msg = f"确认要将 {len(materials)} 条记录写入删除表吗?\n\n"
confirm_msg += "这些记录将被标记为待删除状态。"
if not messagebox.askyesno("确认删除", confirm_msg):
return
# 执行删除操作(后台线程)
self._update_log(f"开始处理 {len(materials)} 条记录...", "INFO")
deletion_thread = threading.Thread(
target=self._execute_deletion_in_background,
args=(materials,),
daemon=True,
)
deletion_thread.start()
def _execute_deletion_in_background(self, materials: list):
"""后台线程执行删除操作"""
try:
from db.materials_to_be_deleted_records_dao import MaterialsToBeDeletedDAO
dao = MaterialsToBeDeletedDAO()
stats = dao.upsert_batch(materials)
self.after(0, lambda: self._deletion_complete(stats))
except Exception as e:
self.after(0, lambda: self._update_log(f"删除操作失败:{str(e)}", "ERROR"))
def _deletion_complete(self, stats: dict):
"""删除完成回调,更新 UI"""
total = stats.get('total', 0)
success = stats.get('success', 0)
failed = stats.get('failed', 0)
msg = f"删除操作完成!\n\n总计:{total}\n成功:{success}\n失败:{failed}"
if failed > 0:
messagebox.showwarning("完成", msg)
self._update_log(msg, "WARNING")
else:
messagebox.showinfo("成功", msg)
self._update_log(msg, "SUCCESS")
def _update_log(self, message: str, level: str = "INFO"):
"""线程安全的日志更新"""
@@ -382,7 +678,18 @@ class MaterialValidationTab(ttk.Frame):
data = []
for item in self.tree.get_children():
values = self.tree.item(item)["values"]
data.append(values)
# 转换 checkbox 字符为布尔值
checkbox = values[0] if values else ""
is_checked = checkbox == ""
data.append([
"" if is_checked else "", # 选择状态
values[1] if len(values) > 1 else "", # 材料名称
values[2] if len(values) > 2 else "", # 材料代码
values[3] if len(values) > 3 else "", # 规格
values[4] if len(values) > 4 else "", # 型号
values[5] if len(values) > 5 else "", # 负责人
])
if not data:
messagebox.showwarning("警告", "没有数据可导出")
@@ -390,7 +697,7 @@ class MaterialValidationTab(ttk.Frame):
# 创建 DataFrame 并保存
df = pd.DataFrame(
data, columns=["材料名称", "匹配的MaterialName", "负责人", "匹配状态"]
data, columns=["选择", "材料名称", "材料代码", "规格", "型号", "负责人"]
)
df.to_excel(output_file, index=False)