feat: add delete execution feature with progress tracking and dryrun mode

- Add ExecutionConfig for dryrun settings in config schema
- Create DeleteProgressWindow widget for real-time progress display
- Integrate delete execution flow in MaterialValidationTab with threading
- Add dryrun checkbox for admin users in settings
- Add progress callback support to DiscreteMaterialPlanCleaner
- Add markdown report generation with statistics
- Include tkinterweb and markdown2 dependencies for report rendering

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-02-26 13:02:48 +08:00
parent 63601a994f
commit 8d51c5b368
8 changed files with 957 additions and 18 deletions

View File

@@ -297,6 +297,26 @@ class UIConfig:
return errors return errors
@dataclass
class ExecutionConfig:
"""执行配置(用于删除操作等)"""
dryrun: bool = False # 预览模式,不保存更改
@classmethod
def from_env(cls) -> "ExecutionConfig":
"""从环境变量创建配置"""
from config.env_loader import get_env_bool
return cls(
dryrun=get_env_bool("EXECUTION_DRYRUN", False),
)
def validate(self) -> list[str]:
"""验证配置,返回错误列表"""
return [] # dryrun 是布尔值,无需验证
@dataclass @dataclass
class AppConfig: class AppConfig:
"""应用总配置""" """应用总配置"""
@@ -307,6 +327,7 @@ class AppConfig:
extraction: ExtractionConfig extraction: ExtractionConfig
validation: ValidationConfig validation: ValidationConfig
ui: UIConfig ui: UIConfig
execution: ExecutionConfig
@classmethod @classmethod
def from_env(cls) -> "AppConfig": def from_env(cls) -> "AppConfig":
@@ -318,6 +339,7 @@ class AppConfig:
extraction=ExtractionConfig.from_env(), extraction=ExtractionConfig.from_env(),
validation=ValidationConfig.from_env(), validation=ValidationConfig.from_env(),
ui=UIConfig.from_env(), ui=UIConfig.from_env(),
execution=ExecutionConfig.from_env(),
) )
def validate(self) -> list[str]: def validate(self) -> list[str]:
@@ -329,6 +351,7 @@ class AppConfig:
errors.extend(self.extraction.validate()) errors.extend(self.extraction.validate())
errors.extend(self.validation.validate()) errors.extend(self.validation.validate())
errors.extend(self.ui.validate()) errors.extend(self.ui.validate())
errors.extend(self.execution.validate())
return errors return errors
def to_dict(self) -> dict: def to_dict(self) -> dict:
@@ -384,4 +407,7 @@ class AppConfig:
"font_size": self.ui.font_size, "font_size": self.ui.font_size,
"production_id_input_width": self.ui.production_id_input_width, "production_id_input_width": self.ui.production_id_input_width,
}, },
"execution": {
"dryrun": self.execution.dryrun,
},
} }

View File

@@ -134,6 +134,11 @@ class ConfigManager:
"""获取提取配置""" """获取提取配置"""
return self.config.extraction return self.config.extraction
@property
def execution(self):
"""获取执行配置"""
return self.config.execution
# 为了向后兼容,保留旧版本的导入 # 为了向后兼容,保留旧版本的导入
DEFAULT_SETTINGS = DEFAULT_SETTINGS_DICT DEFAULT_SETTINGS = DEFAULT_SETTINGS_DICT

View File

@@ -18,12 +18,13 @@ from pathlib import Path
from io import StringIO from io import StringIO
from contextlib import redirect_stdout from contextlib import redirect_stdout
from typing import List, Dict from typing import List, Dict
from gui.widgets import FileSelector, LogText, GuiTextHandler from gui.widgets import FileSelector, LogText, GuiTextHandler, DeleteProgressWindow
from gui.config_manager import ConfigManager from gui.config_manager import ConfigManager
from gui.log_config import setup_gui_logging, get_logger from gui.log_config import setup_gui_logging, get_logger
from gui.material_type_management_dialog import MaterialTypeManagementDialog from gui.material_type_management_dialog import MaterialTypeManagementDialog
from db.materials_to_be_deleted_dao import MaterialsTypeToBeDeletedDAO from db.materials_to_be_deleted_dao import MaterialsTypeToBeDeletedDAO
import pandas as pd import pandas as pd
import tempfile
class CheckboxTreeview(ttk.Treeview): class CheckboxTreeview(ttk.Treeview):
@@ -437,6 +438,25 @@ class MaterialValidationTab(ttk.Frame):
) )
self.confirm_delete_button.pack(side=tk.LEFT, padx=5) self.confirm_delete_button.pack(side=tk.LEFT, padx=5)
# 执行删除按钮
self.execute_delete_button = ttk.Button(
button_frame,
text="执行删除",
command=self.start_delete_execution,
state=tk.DISABLED
)
self.execute_delete_button.pack(side=tk.LEFT, padx=5)
# Admin 用户:显示 dryrun 复选框
if self.session_manager.is_admin():
self.dryrun_var = tk.BooleanVar(value=False)
self.dryrun_checkbox = ttk.Checkbutton(
button_frame,
text="预览模式 (不保存)",
variable=self.dryrun_var
)
self.dryrun_checkbox.pack(side=tk.LEFT, padx=5)
self.export_button = ttk.Button( self.export_button = ttk.Button(
button_frame, button_frame,
text="导出结果", text="导出结果",
@@ -556,6 +576,9 @@ class MaterialValidationTab(ttk.Frame):
# User 模式:静默更新,不显示任何提示 # User 模式:静默更新,不显示任何提示
if is_user_only: if is_user_only:
# 启用执行删除按钮(如果有 Production ID
if production_ids and hasattr(self, 'execute_delete_button'):
self.execute_delete_button.config(state=tk.NORMAL)
return return
# Admin 模式:显示提示信息 # Admin 模式:显示提示信息
@@ -568,12 +591,18 @@ class MaterialValidationTab(ttk.Frame):
# 显示提示标签 # 显示提示标签
if hasattr(self, 'shared_ids_label') and not self.shared_ids_label.winfo_ismapped(): if hasattr(self, 'shared_ids_label') and not self.shared_ids_label.winfo_ismapped():
self.shared_ids_label.pack(anchor="w", pady=(0, 5)) self.shared_ids_label.pack(anchor="w", pady=(0, 5))
# 启用执行删除按钮
if hasattr(self, 'execute_delete_button'):
self.execute_delete_button.config(state=tk.NORMAL)
else: else:
self.shared_ids_info_label.config(text="") self.shared_ids_info_label.config(text="")
# 隐藏提示标签 # 隐藏提示标签
if hasattr(self, 'shared_ids_label'): if hasattr(self, 'shared_ids_label'):
if self.shared_ids_label.winfo_ismapped(): if self.shared_ids_label.winfo_ismapped():
self.shared_ids_label.pack_forget() self.shared_ids_label.pack_forget()
# 禁用执行删除按钮
if hasattr(self, 'execute_delete_button'):
self.execute_delete_button.config(state=tk.DISABLED)
def reload_config(self): def reload_config(self):
"""配置更新后重新加载""" """配置更新后重新加载"""
@@ -1062,6 +1091,14 @@ class MaterialValidationTab(ttk.Frame):
self.deselect_all_button.config(state=tk.NORMAL) self.deselect_all_button.config(state=tk.NORMAL)
self.confirm_delete_button.config(state=tk.NORMAL) self.confirm_delete_button.config(state=tk.NORMAL)
# 启用执行删除按钮(需要 Production ID
if self.shared_production_ids or (
self.session_manager.is_admin() and
hasattr(self, 'db_filtered_production_id_selector') and
self.db_filtered_production_id_selector.get()
):
self.execute_delete_button.config(state=tk.NORMAL)
# 恢复筛选状态 # 恢复筛选状态
if hasattr(self, 'previously_selected_managers') and self.previously_selected_managers: if hasattr(self, 'previously_selected_managers') and self.previously_selected_managers:
self._restore_manager_filter_state(self.previously_selected_managers) self._restore_manager_filter_state(self.previously_selected_managers)
@@ -1380,3 +1417,175 @@ class MaterialValidationTab(ttk.Frame):
def open_type_management(self): def open_type_management(self):
"""打开类型管理窗口""" """打开类型管理窗口"""
dialog = MaterialTypeManagementDialog(self, self.session_manager, title="物料类型管理") dialog = MaterialTypeManagementDialog(self, self.session_manager, title="物料类型管理")
def start_delete_execution(self):
"""开始执行删除"""
# 1. 获取 Production ID
production_ids = None
is_admin = self.session_manager.is_admin()
if is_admin:
# Admin 用户:检查数据源选择
if hasattr(self, 'production_id_source_var'):
source = self.production_id_source_var.get()
if source == "shared":
production_ids = self.shared_production_ids
else:
production_id_file = self.db_filtered_production_id_selector.get()
if production_id_file and os.path.exists(production_id_file):
from db.production_order_query import read_production_ids
production_ids = read_production_ids(production_id_file)
else:
# 普通用户:使用共享的 Production ID
production_ids = self.shared_production_ids
# 验证 Production ID
if not production_ids:
messagebox.showerror("错误", "没有可用的 Production ID\n请先在校验页面获取数据")
return
# 2. 获取负责人
if is_admin:
manager_names = self._get_selected_managers()
if not manager_names:
messagebox.showwarning("警告", "请至少选择一个负责人")
return
else:
manager_names = [self.session_manager.get_username()]
# 3. 获取 dryrun 设置
dryrun = False
if is_admin and hasattr(self, 'dryrun_var'):
# Admin 用户:使用界面上的 dryrun 复选框
dryrun = self.dryrun_var.get()
else:
# User 用户:从配置读取 dryrun 设置
dryrun = self.config.get("execution.dryrun", False)
# 4. 确认执行
mode_text = "预览模式(不会保存更改)" if dryrun else "正式执行(将保存更改)"
manager_text = "".join(manager_names)
confirm_msg = (
f"确认执行删除操作?\n\n"
f"模式: {mode_text}\n"
f"负责人: {manager_text}\n"
f"Production ID 数量: {len(production_ids)}\n\n"
f"此操作可能需要较长时间,是否继续?"
)
if not messagebox.askyesno("确认执行", confirm_msg):
return
# 5. 创建进度窗口
self.progress_window = DeleteProgressWindow(
self,
title="执行删除",
managers=manager_text,
dryrun=dryrun,
on_cancel=self._cancel_delete_execution
)
self.log_text.info(f"开始执行删除(模式: {'预览' if dryrun else '正式'}...")
# 6. 启动后台线程执行
delete_thread = threading.Thread(
target=self._delete_worker,
args=(production_ids, manager_names, dryrun),
daemon=True
)
delete_thread.start()
def _cancel_delete_execution(self):
"""取消删除执行"""
self.log_text.info("用户取消了执行操作")
if hasattr(self, 'progress_window') and self.progress_window:
self.progress_window.append_log("正在取消...", "warning")
def _delete_worker(self, production_ids: list, manager_names: list, dryrun: bool):
"""
后台线程执行删除
Args:
production_ids: Production ID 列表
manager_names: 负责人列表
dryrun: 是否为预览模式
"""
progress_window = self.progress_window
cancelled = False
try:
from utils.discrete_material_plan_cleaner import DiscreteMaterialPlanCleaner
from db.production_order_query import query_production_order_numbers
# 创建临时文件保存 Production ID
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as f:
temp_file = f.name
f.write('\n'.join(production_ids))
# 进度回调函数
def progress_callback(current: int, total: int, message: str):
if progress_window and progress_window.is_cancelled():
return
progress_window.update_progress(current, total, message)
progress_window.append_log(message, "info")
# 创建 Cleaner 实例
cleaner = DiscreteMaterialPlanCleaner(
username=self.config.get("erp.username"),
password=self.config.get("erp.password"),
manager_names=manager_names,
headless=self.config.get("erp.headless", True),
verbose=True,
dryrun=dryrun,
progress_callback=progress_callback
)
# 执行清理
progress_window.append_log("正在连接 ERP 系统...", "info")
cleaner.clean(temp_file)
# 检查是否被取消
if progress_window.is_cancelled():
cancelled = True
self.after(0, lambda: self._update_log("执行已取消", "WARNING"))
else:
# 生成报告
report = cleaner.generate_report()
# 在主线程中更新 UI
self.after(0, lambda: self._delete_complete(report, cleaner.stats))
except Exception as e:
error_msg = f"执行过程中发生错误: {str(e)}"
self.after(0, lambda: self._update_log(error_msg, "ERROR"))
if progress_window:
progress_window.append_log(error_msg, "error")
progress_window.set_completed()
import traceback
self.after(0, lambda: self._update_log(traceback.format_exc(), "ERROR"))
finally:
# 清理临时文件
try:
if 'temp_file' in locals() and os.path.exists(temp_file):
os.unlink(temp_file)
except:
pass
def _delete_complete(self, report: str, stats: dict):
"""
删除完成回调
Args:
report: Markdown 格式的报告
stats: 统计信息字典
"""
# 更新日志
self.log_text.info("执行完成!")
self.log_text.info(f"处理订单: {stats.get('processed_orders', 0)}/{stats.get('total_orders', 0)}")
self.log_text.info(f"删除物料: {len(stats.get('deleted_materials', []))}")
self.log_text.info(f"跳过物料: {len(stats.get('skipped_materials', []))}")
self.log_text.info(f"错误数量: {len(stats.get('errors', []))}")
# 显示报告
if hasattr(self, 'progress_window') and self.progress_window:
self.progress_window.show_report(report)

View File

@@ -63,11 +63,17 @@ class SettingsTab(ttk.Frame):
self._create_validation_group(scrollable_frame) self._create_validation_group(scrollable_frame)
self._create_ui_group(scrollable_frame) self._create_ui_group(scrollable_frame)
else: else:
# User 用户:显示路径配置 # User 用户:显示路径配置和执行设置
self._create_paths_group(scrollable_frame) self._create_paths_group(scrollable_frame)
self._create_user_execution_group(scrollable_frame)
# 按钮区域 - 根据用户类型显示不同按钮 # 按钮区域 - 根据用户类型显示不同按钮
button_frame = ttk.Frame(scrollable_frame) button_frame = ttk.Frame(scrollable_frame)
if is_user_only:
# User 用户显示测试按钮和保存设置按钮row=4 因为有执行设置组)
button_frame.grid(row=4, column=0, columnspan=2, pady=20, sticky="ew")
else:
# 管理员显示所有按钮
button_frame.grid(row=7, column=0, columnspan=2, pady=20, sticky="ew") button_frame.grid(row=7, column=0, columnspan=2, pady=20, sticky="ew")
if is_user_only: if is_user_only:
@@ -225,7 +231,13 @@ class SettingsTab(ttk.Frame):
def _create_paths_group(self, parent): def _create_paths_group(self, parent):
"""创建路径配置组""" """创建路径配置组"""
# 根据用户类型调整 grid 位置
is_user_only = self.session_manager and self.session_manager.get_user_type() == 'User'
group = ttk.LabelFrame(parent, text="路径设置", padding=10) group = ttk.LabelFrame(parent, text="路径设置", padding=10)
if is_user_only:
group.grid(row=2, column=1, pady=10, padx=10, sticky="nsew")
else:
group.grid(row=2, column=1, pady=10, padx=10, sticky="nsew") group.grid(row=2, column=1, pady=10, padx=10, sticky="nsew")
from gui.widgets import FileSelector from gui.widgets import FileSelector
@@ -255,6 +267,27 @@ class SettingsTab(ttk.Frame):
group.columnconfigure(0, weight=1) group.columnconfigure(0, weight=1)
def _create_user_execution_group(self, parent):
"""创建 User 用户的执行设置组"""
group = ttk.LabelFrame(parent, text="执行设置", padding=10)
group.grid(row=3, column=0, columnspan=2, pady=10, padx=10, sticky="ew")
# dryrun 模式设置
self.user_dryrun_var = tk.BooleanVar(value=False)
ttk.Checkbutton(
group,
text="预览模式 (执行删除时不保存更改)",
variable=self.user_dryrun_var
).grid(row=0, column=0, sticky="w", pady=5)
# 说明文字
hint_label = ttk.Label(
group,
text="提示:勾选后,执行删除操作时将只预览不实际保存,用于测试流程。",
foreground="gray"
)
hint_label.grid(row=1, column=0, sticky="w", pady=(0, 5))
def _create_extraction_group(self, parent): def _create_extraction_group(self, parent):
"""创建处理配置组""" """创建处理配置组"""
group = ttk.LabelFrame(parent, text="数据提取设置", padding=10) group = ttk.LabelFrame(parent, text="数据提取设置", padding=10)
@@ -398,6 +431,8 @@ class SettingsTab(ttk.Frame):
self.data_dir_selector.set(self.config.get("paths.data_dir", "")) self.data_dir_selector.set(self.config.get("paths.data_dir", ""))
self.default_output_var.set(self.config.get("paths.default_output", "")) self.default_output_var.set(self.config.get("paths.default_output", ""))
self.validation_output_filename_var.set(self.config.get("paths.validation_output", "")) self.validation_output_filename_var.set(self.config.get("paths.validation_output", ""))
# 执行设置
self.user_dryrun_var.set(self.config.get("execution.dryrun", False))
return return
# 管理员模式 - 加载所有配置 # 管理员模式 - 加载所有配置
@@ -462,10 +497,12 @@ class SettingsTab(ttk.Frame):
is_user_only = self.session_manager and self.session_manager.get_user_type() == 'User' is_user_only = self.session_manager and self.session_manager.get_user_type() == 'User'
if is_user_only: if is_user_only:
# User 用户模式 - 只保存路径设置 # User 用户模式 - 只保存路径设置和执行设置
self.config.set("paths.data_dir", self.data_dir_selector.get()) self.config.set("paths.data_dir", self.data_dir_selector.get())
self.config.set("paths.default_output", self.default_output_var.get()) self.config.set("paths.default_output", self.default_output_var.get())
self.config.set("paths.validation_output", self.validation_output_filename_var.get()) self.config.set("paths.validation_output", self.validation_output_filename_var.get())
# 保存执行设置
self.config.set("execution.dryrun", self.user_dryrun_var.get())
# 保存到文件 # 保存到文件
if self.config.save(): if self.config.save():

View File

@@ -8,5 +8,6 @@ from .file_selector import FileSelector
from .log_text import LogText from .log_text import LogText
from .production_id_input import ProductionIdInput from .production_id_input import ProductionIdInput
from .log_handler import GuiTextHandler from .log_handler import GuiTextHandler
from .delete_progress_window import DeleteProgressWindow
__all__ = ['FileSelector', 'LogText', 'ProductionIdInput', 'GuiTextHandler'] __all__ = ['FileSelector', 'LogText', 'ProductionIdInput', 'GuiTextHandler', 'DeleteProgressWindow']

View File

@@ -0,0 +1,447 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
删除进度窗口组件
显示删除操作的进度和日志,完成后显示 Markdown 格式的报告。
"""
import tkinter as tk
from tkinter import ttk, scrolledtext
from typing import Optional, Callable
from datetime import datetime
# 尝试导入 tkinterweb 和 markdown2
try:
from tkinterweb import HtmlFrame
HAS_TKINTERWEB = True
except ImportError:
HAS_TKINTERWEB = False
try:
import markdown2
HAS_MARKDOWN2 = True
except ImportError:
HAS_MARKDOWN2 = False
class DeleteProgressWindow:
"""删除进度窗口"""
def __init__(
self,
parent,
title: str = "执行删除",
managers: str = "",
dryrun: bool = False,
on_cancel: Optional[Callable] = None
):
"""
初始化删除进度窗口
Args:
parent: 父窗口
title: 窗口标题
managers: 负责人列表字符串
dryrun: 是否为预览模式
on_cancel: 取消回调函数
"""
self.parent = parent
self.on_cancel = on_cancel
self.cancelled = False
self.managers = managers
self.dryrun = dryrun
# 创建窗口
self.window = tk.Toplevel(parent)
self.window.title(title)
self.window.resizable(True, True)
self.window.transient(parent)
# 设置窗口大小
self.window.geometry("700x600")
# 创建内容
self._create_widgets()
# 居中显示
self._center()
def _center(self):
"""将窗口居中显示"""
self.window.update_idletasks()
width = 700
height = 600
x = (self.window.winfo_screenwidth() // 2) - (width // 2)
y = (self.window.winfo_screenheight() // 2) - (height // 2)
self.window.geometry(f"{width}x{height}+{x}+{y}")
def _create_widgets(self):
"""创建窗口组件"""
# 主容器
self.main_frame = ttk.Frame(self.window, padding=10)
self.main_frame.pack(fill=tk.BOTH, expand=True)
# 信息区域
info_frame = ttk.Frame(self.main_frame)
info_frame.pack(fill=tk.X, pady=(0, 10))
# 负责人信息
if self.managers:
ttk.Label(info_frame, text=f"负责人: {self.managers}").pack(anchor="w")
# 模式信息
mode_text = "预览模式 (不保存)" if self.dryrun else "正常执行"
mode_label = ttk.Label(info_frame, text=f"模式: {mode_text}")
mode_label.pack(anchor="w")
# 进度区域
self.progress_frame = ttk.LabelFrame(self.main_frame, text="进度", padding=5)
self.progress_frame.pack(fill=tk.X, pady=(0, 10))
self.progress_var = tk.StringVar(value="准备中...")
self.progress_label = ttk.Label(self.progress_frame, textvariable=self.progress_var)
self.progress_label.pack(anchor="w")
self.progress_bar = ttk.Progressbar(
self.progress_frame,
mode='determinate',
length=660,
maximum=100
)
self.progress_bar.pack(fill=tk.X, pady=5)
# 日志区域(执行过程中显示)
self.log_frame = ttk.LabelFrame(self.main_frame, text="日志", padding=5)
self.log_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
self.log_text = scrolledtext.ScrolledText(
self.log_frame,
height=10,
wrap=tk.WORD,
state=tk.DISABLED,
font=('Consolas', 9)
)
self.log_text.pack(fill=tk.BOTH, expand=True)
# 配置日志标签颜色
self.log_text.tag_configure('info', foreground='black')
self.log_text.tag_configure('success', foreground='green')
self.log_text.tag_configure('warning', foreground='orange')
self.log_text.tag_configure('error', foreground='red')
# 报告区域(完成后显示)- 初始隐藏
self.report_frame = ttk.LabelFrame(self.main_frame, text="执行报告", padding=5)
# 根据 tkinterweb 可用性选择渲染方式
if HAS_TKINTERWEB:
# 使用 HtmlFrame 渲染 HTML
self.report_html = HtmlFrame(self.report_frame)
self.report_html.pack(fill=tk.BOTH, expand=True)
else:
# 降级为文本显示
self.report_text = scrolledtext.ScrolledText(
self.report_frame,
height=20,
wrap=tk.WORD,
state=tk.DISABLED,
font=('Consolas', 9)
)
self.report_text.pack(fill=tk.BOTH, expand=True)
# 按钮区域
button_frame = ttk.Frame(self.main_frame)
button_frame.pack(fill=tk.X)
self.cancel_button = ttk.Button(
button_frame,
text="取消执行",
command=self._on_cancel
)
self.cancel_button.pack(side=tk.RIGHT)
# 关闭按钮(初始隐藏)
self.close_button = ttk.Button(
button_frame,
text="关闭",
command=self.close
)
def _on_cancel(self):
"""处理取消操作"""
self.cancelled = True
self.cancel_button.config(state=tk.DISABLED, text="正在取消...")
if self.on_cancel:
self.on_cancel()
else:
self.append_log("用户取消了操作", "warning")
def update_progress(self, current: int, total: int, message: str):
"""
更新进度
Args:
current: 当前进度值
total: 总数
message: 进度消息
"""
if total > 0:
percentage = int((current / total) * 100)
self.progress_bar['value'] = percentage
self.progress_var.set(message)
else:
self.progress_var.set(message)
self.window.update_idletasks()
def append_log(self, message: str, level: str = "info"):
"""
追加日志
Args:
message: 日志消息
level: 日志级别 (info, success, warning, error)
"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_entry = f"[{timestamp}] {message}\n"
self.log_text.config(state=tk.NORMAL)
self.log_text.insert(tk.END, log_entry, level)
self.log_text.see(tk.END)
self.log_text.config(state=tk.DISABLED)
self.window.update_idletasks()
def show_report(self, markdown_content: str):
"""
显示报告
Args:
markdown_content: Markdown 格式的报告内容
"""
# 隐藏进度区域和日志区域
self.progress_frame.pack_forget()
self.log_frame.pack_forget()
# 显示报告区域
self.report_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# 根据可用库选择渲染方式
if HAS_TKINTERWEB and HAS_MARKDOWN2:
# 使用 tkinterweb 渲染 HTML
html_content = self._markdown_to_html(markdown_content)
self.report_html.load_html(html_content)
elif HAS_TKINTERWEB:
# 只有 tkinterweb使用简单 HTML
html_content = self._markdown_to_simple_html(markdown_content)
self.report_html.load_html(html_content)
else:
# 降级为文本显示
text_content = self._markdown_to_text(markdown_content)
self.report_text.config(state=tk.NORMAL)
self.report_text.delete(1.0, tk.END)
self.report_text.insert(tk.END, text_content)
self.report_text.config(state=tk.DISABLED)
# 更新标题
self.window.title("执行报告")
# 隐藏取消按钮,显示关闭按钮
self.cancel_button.pack_forget()
self.close_button.pack(side=tk.RIGHT)
# 更新进度标签
self.progress_var.set("执行完成")
def _markdown_to_html(self, markdown_content: str) -> str:
"""
将 Markdown 转换为 HTML
Args:
markdown_content: Markdown 内容
Returns:
HTML 内容
"""
# 使用 markdown2 转换
html_body = markdown2.markdown(
markdown_content,
extras=['tables', 'fenced-code-blocks']
)
# 添加样式
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{
font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
font-size: 12px;
padding: 10px;
line-height: 1.6;
background-color: #ffffff;
}}
h1 {{
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
font-size: 18px;
}}
h2 {{
color: #34495e;
border-bottom: 1px solid #bdc3c7;
padding-bottom: 5px;
margin-top: 20px;
font-size: 14px;
}}
table {{
border-collapse: collapse;
width: 100%;
margin: 10px 0;
}}
th, td {{
border: 1px solid #bdc3c7;
padding: 8px;
text-align: left;
}}
th {{
background-color: #3498db;
color: white;
}}
tr:nth-child(even) {{
background-color: #f2f2f2;
}}
ul {{
list-style-type: disc;
padding-left: 20px;
}}
li {{
margin: 5px 0;
}}
.success {{ color: #27ae60; }}
.warning {{ color: #f39c12; }}
.error {{ color: #e74c3c; }}
</style>
</head>
<body>
{html_body}
</body>
</html>
"""
return html_content
def _markdown_to_simple_html(self, markdown_content: str) -> str:
"""
将 Markdown 转换为简单 HTML不依赖 markdown2
Args:
markdown_content: Markdown 内容
Returns:
HTML 内容
"""
lines = markdown_content.split('\n')
html_parts = ['<!DOCTYPE html><html><head><meta charset="UTF-8">',
'<style>',
'body { font-family: "Microsoft YaHei", Arial, sans-serif; font-size: 12px; padding: 10px; }',
'h1 { color: #2c3e50; border-bottom: 2px solid #3498db; padding-bottom: 10px; }',
'h2 { color: #34495e; border-bottom: 1px solid #bdc3c7; margin-top: 20px; }',
'table { border-collapse: collapse; width: 100%; margin: 10px 0; }',
'th, td { border: 1px solid #bdc3c7; padding: 8px; text-align: left; }',
'th { background-color: #3498db; color: white; }',
'</style></head><body>']
in_table = False
for line in lines:
if line.startswith('# '):
html_parts.append(f'<h1>{line[2:]}</h1>')
elif line.startswith('## '):
html_parts.append(f'<h2>{line[3:]}</h2>')
elif line.startswith('| '):
if not in_table:
html_parts.append('<table>')
in_table = True
# 检查是否是表头分隔行
if '|--' in line or '|-' in line:
continue
cells = [cell.strip() for cell in line.split('|')[1:-1]]
if cells:
# 第一行作为表头
if html_parts[-1] == '<table>':
html_parts.append('<tr>' + ''.join(f'<th>{c}</th>' for c in cells) + '</tr>')
else:
html_parts.append('<tr>' + ''.join(f'<td>{c}</td>' for c in cells) + '</tr>')
elif line.startswith('- '):
if in_table:
html_parts.append('</table>')
in_table = False
html_parts.append(f'<li>{line[2:]}</li>')
elif line.strip() == '':
if in_table:
html_parts.append('</table>')
in_table = False
html_parts.append('<br>')
else:
if in_table:
html_parts.append('</table>')
in_table = False
if line.strip():
html_parts.append(f'<p>{line}</p>')
if in_table:
html_parts.append('</table>')
html_parts.append('</body></html>')
return '\n'.join(html_parts)
def _markdown_to_text(self, markdown_content: str) -> str:
"""
将 Markdown 转换为简单的文本格式
Args:
markdown_content: Markdown 内容
Returns:
格式化后的文本
"""
lines = markdown_content.split('\n')
result = []
for line in lines:
# 标题
if line.startswith('# '):
result.append('=' * 60)
result.append(line[2:])
result.append('=' * 60)
elif line.startswith('## '):
result.append('')
result.append(line[3:])
result.append('-' * 40)
elif line.startswith('| '):
# 表格行 - 保持原样
result.append(line)
elif line.startswith('|--') or line.startswith('|-'):
# 表格分隔线 - 跳过
continue
elif line.startswith('- '):
# 列表项
result.append(' ' + line)
elif line.strip() == '':
result.append('')
else:
result.append(line)
return '\n'.join(result)
def close(self):
"""关闭窗口"""
self.window.destroy()
def is_cancelled(self) -> bool:
"""检查是否已取消"""
return self.cancelled
def set_completed(self):
"""设置为完成状态"""
self.cancel_button.pack_forget()
self.close_button.pack(side=tk.RIGHT)

View File

@@ -20,3 +20,7 @@ python-dateutil>=2.8.0
pytz>=2023.0 pytz>=2023.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
tklinenums>=1.7.0 tklinenums>=1.7.0
# --- Markdown Rendering ---
markdown2>=2.4.0
tkinterweb>=3.23

View File

@@ -11,7 +11,8 @@ import os
import re import re
import time import time
import logging import logging
from typing import Union, List, Optional from typing import Union, List, Optional, Callable, Dict, Any
from datetime import datetime
from playwright.sync_api import sync_playwright, TimeoutError from playwright.sync_api import sync_playwright, TimeoutError
# 统一顶部导入 # 统一顶部导入
@@ -42,12 +43,14 @@ class DiscreteMaterialPlanCleaner:
headless=False, headless=False,
verbose=True, verbose=True,
dryrun=False, dryrun=False,
progress_callback: Optional[Callable[[int, int, str], None]] = None,
): ):
self.username = username self.username = username
self.password = password self.password = password
self.headless = headless self.headless = headless
self.verbose = verbose self.verbose = verbose
self.dryrun = dryrun self.dryrun = dryrun
self.progress_callback = progress_callback
# 参数规范化:支持 str、List[str]、None # 参数规范化:支持 str、List[str]、None
if manager_names is None: if manager_names is None:
@@ -60,6 +63,19 @@ class DiscreteMaterialPlanCleaner:
# 核心优化:使用 set 存储待删除编码,查询复杂度为 $O(1)$ # 核心优化:使用 set 存储待删除编码,查询复杂度为 $O(1)$
self.to_delete_set = set() self.to_delete_set = set()
# 统计信息
self.stats = {
'total_orders': 0,
'processed_orders': 0,
'total_materials': 0, # 总物料数
'processed_materials': 0, # 已处理物料数
'deleted_materials': [], # [{order_id, material_code, material_name}]
'skipped_materials': [], # [{order_id, material_code, material_name, reason}]
'errors': [], # [{order_id, error_message}]
'start_time': None,
'end_time': None,
}
def _log(self, message, level="info"): def _log(self, message, level="info"):
"""统一日志输出控制""" """统一日志输出控制"""
if self.verbose: if self.verbose:
@@ -70,6 +86,47 @@ class DiscreteMaterialPlanCleaner:
elif level == "error": elif level == "error":
logger.error(message) logger.error(message)
def _report_progress(self, current: int, total: int, message: str):
"""报告进度"""
if self.progress_callback:
self.progress_callback(current, total, message)
def _report_material_progress(
self,
order_idx: int,
total_orders: int,
material_idx: int,
total_materials: int,
order_id: str,
material_name: str,
action: str
):
"""报告物料处理进度
进度计算逻辑:
- 每个订单占 1/total_orders 的固定进度配额
- 订单内物料进度按比例分配material_idx / total_materials
- 总体进度 = (order_idx + material_idx / total_materials) / total_orders
"""
if self.progress_callback:
order_progress = f"订单 [{order_idx + 1}/{total_orders}]"
if total_materials > 0:
material_progress = f"物料 [{material_idx}/{total_materials}]"
message = f"{order_progress} {material_progress} - {order_id} - {action}: {material_name}"
# 计算总体进度比例0.0 到 1.0
order_internal_ratio = material_idx / total_materials
overall_ratio = (order_idx + order_internal_ratio) / total_orders
# 使用固定精度整数表示进度(范围 0-10000显示时除以 100 即为百分比)
PROGRESS_SCALE = 10000
overall_current = int(overall_ratio * PROGRESS_SCALE)
overall_total = PROGRESS_SCALE
self.progress_callback(overall_current, overall_total, message)
else:
message = f"{order_progress} - {order_id} - {action}: {material_name}"
self.progress_callback(order_idx + 1, total_orders, message)
def _is_button_enabled(self, button_locator): def _is_button_enabled(self, button_locator):
"""判定按钮是否可用""" """判定按钮是否可用"""
try: try:
@@ -108,8 +165,19 @@ class DiscreteMaterialPlanCleaner:
) )
return order_ids return order_ids
def process_order(self, inner_frame, order_id, order_index, page1): def process_order(self, inner_frame, order_id, order_index, page1, total_orders: int = 1):
"""清理单个订单的数据""" """清理单个订单的数据
Args:
inner_frame: 内层 iframe
order_id: 订单 ID
order_index: 订单索引(从 0 开始)
page1: 页面对象
total_orders: 总订单数(用于进度报告)
"""
# 报告订单进度
# self._report_progress(order_index + 1, total_orders, f"正在打开订单: {order_id}")
# 1. 查询订单 # 1. 查询订单
textbox = inner_frame.get_by_role("textbox", name="生产订单号") textbox = inner_frame.get_by_role("textbox", name="生产订单号")
textbox.fill(order_id) textbox.fill(order_id)
@@ -137,7 +205,7 @@ class DiscreteMaterialPlanCleaner:
plan_code_locator = detail_inner_frame.get_by_text( plan_code_locator = detail_inner_frame.get_by_text(
re.compile(r"^离散备料计划维护:") re.compile(r"^离散备料计划维护:")
) )
plan_code_locator.wait_for(state="visible", timeout=15000) plan_code_locator.wait_for(state="visible", timeout=30000)
detail_count_text = detail_inner_frame.get_by_text( detail_count_text = detail_inner_frame.get_by_text(
re.compile(r"^详细信息 \(\d+\)$") re.compile(r"^详细信息 \(\d+\)$")
@@ -154,6 +222,9 @@ class DiscreteMaterialPlanCleaner:
# 6. 执行清理逻辑 # 6. 执行清理逻辑
if detail_status == "审批通过": if detail_status == "审批通过":
if detail_count > 0: if detail_count > 0:
# 更新总物料数统计
self.stats['total_materials'] += detail_count
# --- 点击修改并等待状态切换 (保留原逻辑) --- # --- 点击修改并等待状态切换 (保留原逻辑) ---
detail_inner_frame.get_by_role("button", name="修改").click() detail_inner_frame.get_by_role("button", name="修改").click()
@@ -161,7 +232,7 @@ class DiscreteMaterialPlanCleaner:
save_button_locator = detail_inner_frame.get_by_role( save_button_locator = detail_inner_frame.get_by_role(
"button", name="保存" "button", name="保存"
) )
save_button_locator.wait_for(state="visible", timeout=10000) save_button_locator.wait_for(state="visible", timeout=30000)
self._log("已进入编辑模式(保存按钮已就绪)") self._log("已进入编辑模式(保存按钮已就绪)")
# --------------------------------------- # ---------------------------------------
@@ -175,8 +246,12 @@ class DiscreteMaterialPlanCleaner:
collapse_btn = button_wrapper.locator(".icon-celashouqi") collapse_btn = button_wrapper.locator(".icon-celashouqi")
last_row_number = None last_row_number = None
material_idx = 0 # 物料计数器
# page2.pause() # 调试用,正式运行时可删除 # page2.pause() # 调试用,正式运行时可删除
while True: while True:
material_idx += 1
self.stats['processed_materials'] += 1
# 稳定性检查:等待行号更新 # 稳定性检查:等待行号更新
current_row = self._get_input_value(child_form, r"^行号$") current_row = self._get_input_value(child_form, r"^行号$")
row_num_int = int(current_row) row_num_int = int(current_row)
@@ -187,11 +262,24 @@ class DiscreteMaterialPlanCleaner:
material_name = self._get_input_value(child_form, r"^材料名称") material_name = self._get_input_value(child_form, r"^材料名称")
pending_qty = self._get_input_value(child_form, r"^累计待发数量$") pending_qty = self._get_input_value(child_form, r"^累计待发数量$")
# 报告物料进度
self._report_material_progress(
order_index, total_orders,
material_idx, detail_count,
order_id, material_name, "检查"
)
if material_code in self.to_delete_set: if material_code in self.to_delete_set:
self._log(f"发现匹配物料: {material_name} ({material_code})") self._log(f"发现匹配物料: {material_name} ({material_code})")
if (not pending_qty) and not ( if (not pending_qty) and not (
row_num_int >= 7000 and row_num_int < 8000 row_num_int >= 7000 and row_num_int < 8000
): ):
# 报告删除进度
self._report_material_progress(
order_index, total_orders,
material_idx, detail_count,
order_id, material_name, "删除"
)
# 记录删除前的行号 # 记录删除前的行号
old_row_number = current_row old_row_number = current_row
delete_row_btn.click() delete_row_btn.click()
@@ -200,6 +288,7 @@ class DiscreteMaterialPlanCleaner:
# 等待行号变化(表示删除完成且新数据已加载) # 等待行号变化(表示删除完成且新数据已加载)
max_wait_time = 10 # 最大等待10秒 max_wait_time = 10 # 最大等待10秒
start_time = time.time() start_time = time.time()
delete_success = False
while time.time() - start_time < max_wait_time: while time.time() - start_time < max_wait_time:
try: try:
new_row_number = self._get_input_value( new_row_number = self._get_input_value(
@@ -209,6 +298,7 @@ class DiscreteMaterialPlanCleaner:
self._log( self._log(
f"✓ 删除完成,行号已从 {old_row_number} 变更为 {new_row_number}" f"✓ 删除完成,行号已从 {old_row_number} 变更为 {new_row_number}"
) )
delete_success = True
break break
time.sleep(0.2) # 每200ms检查一次 time.sleep(0.2) # 每200ms检查一次
except Exception as e: except Exception as e:
@@ -219,20 +309,61 @@ class DiscreteMaterialPlanCleaner:
f"⚠️ 等待删除完成超时({max_wait_time}秒)", "warn" f"⚠️ 等待删除完成超时({max_wait_time}秒)", "warn"
) )
# 记录删除统计
if delete_success:
self.stats['deleted_materials'].append({
'order_id': order_id,
'material_code': material_code,
'material_name': material_name
})
continue continue
elif row_num_int >= 7000 and row_num_int < 8000: elif row_num_int >= 7000 and row_num_int < 8000:
self._log( reason = f"行号 {row_num_int} 在 7000-8000 范围内"
f"⚠️ 行号 {row_num_int} 在 7000-8000 范围内,跳过删除", self._report_material_progress(
"warn", order_index, total_orders,
material_idx, detail_count,
order_id, material_name, "跳过"
) )
self._log(f"⚠️ {reason},跳过删除", "warn")
self.stats['skipped_materials'].append({
'order_id': order_id,
'material_code': material_code,
'material_name': material_name,
'reason': reason
})
elif pending_qty: elif pending_qty:
self._log(f"⚠️ 待发数量为 {pending_qty},跳过删除", "warn") reason = f"待发数量为 {pending_qty}"
self._report_material_progress(
order_index, total_orders,
material_idx, detail_count,
order_id, material_name, "跳过"
)
self._log(f"⚠️ {reason},跳过删除", "warn")
self.stats['skipped_materials'].append({
'order_id': order_id,
'material_code': material_code,
'material_name': material_name,
'reason': reason
})
else: else:
reason = "不满足删除条件"
self._report_material_progress(
order_index, total_orders,
material_idx, detail_count,
order_id, material_name, "跳过"
)
self._log( self._log(
f"⚠️ 不满足删除条件,跳过物料 {material_name} ({material_code})", f"⚠️ {reason},跳过物料 {material_name} ({material_code})",
"warn", "warn",
) )
self.stats['skipped_materials'].append({
'order_id': order_id,
'material_code': material_code,
'material_name': material_name,
'reason': reason
})
else: else:
self._log( self._log(
@@ -276,6 +407,9 @@ class DiscreteMaterialPlanCleaner:
def clean(self, production_id_file): def clean(self, production_id_file):
"""执行完整清理流程""" """执行完整清理流程"""
# 初始化统计
self.stats['start_time'] = datetime.now()
# 0. 预加载数据库数据 # 0. 预加载数据库数据
self.preload_data() self.preload_data()
@@ -300,19 +434,27 @@ class DiscreteMaterialPlanCleaner:
work_main_frame = page1.locator("#forwardFrame").content_frame work_main_frame = page1.locator("#forwardFrame").content_frame
inner_frame = work_main_frame.locator("#mainiframe").content_frame inner_frame = work_main_frame.locator("#mainiframe").content_frame
inner_frame.locator("#hot-key-head_list").wait_for( inner_frame.locator("#hot-key-head_list").wait_for(
state="visible", timeout=15000 state="visible", timeout=30000
) )
self.setup_query_interface(inner_frame) self.setup_query_interface(inner_frame)
order_ids = self.get_production_order_numbers(production_id_file) order_ids = self.get_production_order_numbers(production_id_file)
# 设置总订单数
self.stats['total_orders'] = len(order_ids)
# 遍历处理 # 遍历处理
for index, order_id in enumerate(order_ids): for index, order_id in enumerate(order_ids):
self._log(f"进度: [{index+1}/{len(order_ids)}] 处理单号: {order_id}") self._log(f"进度: [{index+1}/{len(order_ids)}] 处理单号: {order_id}")
try: try:
self.process_order(inner_frame, order_id, index, page1) self.process_order(inner_frame, order_id, index, page1, len(order_ids))
self.stats['processed_orders'] += 1
except Exception as e: except Exception as e:
self._log(f"处理单号 {order_id} 时发生异常: {e}", "error") self._log(f"处理单号 {order_id} 时发生异常: {e}", "error")
self.stats['errors'].append({
'order_id': order_id,
'error_message': str(e)
})
continue # 单个失败不影响整体执行 continue # 单个失败不影响整体执行
# 登出清理 # 登出清理
@@ -321,6 +463,74 @@ class DiscreteMaterialPlanCleaner:
browser.close() browser.close()
self._log("=" * 30 + " 任务全部完成 " + "=" * 30) self._log("=" * 30 + " 任务全部完成 " + "=" * 30)
# 记录结束时间
self.stats['end_time'] = datetime.now()
def generate_report(self) -> str:
"""生成 Markdown 格式的执行报告
Returns:
Markdown 格式的报告字符串
"""
report_lines = []
# 标题
report_lines.append("# 执行报告")
report_lines.append("")
# 概述
report_lines.append("## 概述")
report_lines.append("")
start_time = self.stats.get('start_time')
end_time = self.stats.get('end_time')
duration = None
if start_time and end_time:
duration = end_time - start_time
report_lines.append(f"- 开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
report_lines.append(f"- 结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
report_lines.append(f"- 执行时长: {duration}")
report_lines.append(f"- 处理订单: {self.stats['processed_orders']}/{self.stats['total_orders']}")
report_lines.append(f"- 处理物料: {self.stats['processed_materials']}/{self.stats['total_materials']}")
report_lines.append(f"- 删除物料: {len(self.stats['deleted_materials'])}")
report_lines.append(f"- 跳过物料: {len(self.stats['skipped_materials'])}")
report_lines.append(f"- 错误数量: {len(self.stats['errors'])}")
report_lines.append(f"- 执行模式: {'预览模式 (dryrun)' if self.dryrun else '正常执行'}")
if self.manager_names:
report_lines.append(f"- 负责人: {', '.join(self.manager_names)}")
report_lines.append("")
# 删除明细
if self.stats['deleted_materials']:
report_lines.append("## 删除明细")
report_lines.append("")
report_lines.append("| 订单号 | 物料编码 | 物料名称 |")
report_lines.append("|--------|----------|----------|")
for item in self.stats['deleted_materials']:
report_lines.append(f"| {item['order_id']} | {item['material_code']} | {item['material_name']} |")
report_lines.append("")
# 跳过明细
if self.stats['skipped_materials']:
report_lines.append("## 跳过明细")
report_lines.append("")
report_lines.append("| 订单号 | 物料编码 | 物料名称 | 跳过原因 |")
report_lines.append("|--------|----------|----------|----------|")
for item in self.stats['skipped_materials']:
report_lines.append(f"| {item['order_id']} | {item['material_code']} | {item['material_name']} | {item['reason']} |")
report_lines.append("")
# 错误明细
if self.stats['errors']:
report_lines.append("## 错误明细")
report_lines.append("")
report_lines.append("| 订单号 | 错误信息 |")
report_lines.append("|--------|----------|")
for item in self.stats['errors']:
report_lines.append(f"| {item['order_id']} | {item['error_message']} |")
report_lines.append("")
return "\n".join(report_lines)
def main(): def main():
# 路径配置 # 路径配置