diff --git a/config/schema.py b/config/schema.py index 0c172a7..2c342be 100644 --- a/config/schema.py +++ b/config/schema.py @@ -297,6 +297,26 @@ class UIConfig: 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 class AppConfig: """应用总配置""" @@ -307,6 +327,7 @@ class AppConfig: extraction: ExtractionConfig validation: ValidationConfig ui: UIConfig + execution: ExecutionConfig @classmethod def from_env(cls) -> "AppConfig": @@ -318,6 +339,7 @@ class AppConfig: extraction=ExtractionConfig.from_env(), validation=ValidationConfig.from_env(), ui=UIConfig.from_env(), + execution=ExecutionConfig.from_env(), ) def validate(self) -> list[str]: @@ -329,6 +351,7 @@ class AppConfig: errors.extend(self.extraction.validate()) errors.extend(self.validation.validate()) errors.extend(self.ui.validate()) + errors.extend(self.execution.validate()) return errors def to_dict(self) -> dict: @@ -384,4 +407,7 @@ class AppConfig: "font_size": self.ui.font_size, "production_id_input_width": self.ui.production_id_input_width, }, + "execution": { + "dryrun": self.execution.dryrun, + }, } diff --git a/gui/config_manager.py b/gui/config_manager.py index 0a41d28..1824d3b 100644 --- a/gui/config_manager.py +++ b/gui/config_manager.py @@ -134,6 +134,11 @@ class ConfigManager: """获取提取配置""" return self.config.extraction + @property + def execution(self): + """获取执行配置""" + return self.config.execution + # 为了向后兼容,保留旧版本的导入 DEFAULT_SETTINGS = DEFAULT_SETTINGS_DICT diff --git a/gui/material_validation_tab.py b/gui/material_validation_tab.py index e1b31fc..f42f18b 100644 --- a/gui/material_validation_tab.py +++ b/gui/material_validation_tab.py @@ -18,12 +18,13 @@ from pathlib import Path from io import StringIO from contextlib import redirect_stdout 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.log_config import setup_gui_logging, get_logger from gui.material_type_management_dialog import MaterialTypeManagementDialog from db.materials_to_be_deleted_dao import MaterialsTypeToBeDeletedDAO import pandas as pd +import tempfile class CheckboxTreeview(ttk.Treeview): @@ -437,6 +438,25 @@ class MaterialValidationTab(ttk.Frame): ) 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( button_frame, text="导出结果", @@ -556,6 +576,9 @@ class MaterialValidationTab(ttk.Frame): # User 模式:静默更新,不显示任何提示 if is_user_only: + # 启用执行删除按钮(如果有 Production ID) + if production_ids and hasattr(self, 'execute_delete_button'): + self.execute_delete_button.config(state=tk.NORMAL) return # Admin 模式:显示提示信息 @@ -568,12 +591,18 @@ class MaterialValidationTab(ttk.Frame): # 显示提示标签 if hasattr(self, 'shared_ids_label') and not self.shared_ids_label.winfo_ismapped(): 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: self.shared_ids_info_label.config(text="") # 隐藏提示标签 if hasattr(self, 'shared_ids_label'): if self.shared_ids_label.winfo_ismapped(): self.shared_ids_label.pack_forget() + # 禁用执行删除按钮 + if hasattr(self, 'execute_delete_button'): + self.execute_delete_button.config(state=tk.DISABLED) def reload_config(self): """配置更新后重新加载""" @@ -1062,6 +1091,14 @@ class MaterialValidationTab(ttk.Frame): self.deselect_all_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: self._restore_manager_filter_state(self.previously_selected_managers) @@ -1380,3 +1417,175 @@ class MaterialValidationTab(ttk.Frame): def open_type_management(self): """打开类型管理窗口""" 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) diff --git a/gui/settings_tab.py b/gui/settings_tab.py index 637a7b2..866a1c7 100644 --- a/gui/settings_tab.py +++ b/gui/settings_tab.py @@ -63,12 +63,18 @@ class SettingsTab(ttk.Frame): self._create_validation_group(scrollable_frame) self._create_ui_group(scrollable_frame) else: - # User 用户:只显示路径配置 + # User 用户:显示路径配置和执行设置 self._create_paths_group(scrollable_frame) + self._create_user_execution_group(scrollable_frame) # 按钮区域 - 根据用户类型显示不同按钮 button_frame = ttk.Frame(scrollable_frame) - button_frame.grid(row=7, column=0, columnspan=2, pady=20, sticky="ew") + 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") if is_user_only: # User 用户:显示测试按钮和保存设置按钮 @@ -225,8 +231,14 @@ class SettingsTab(ttk.Frame): 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.grid(row=2, column=1, pady=10, padx=10, sticky="nsew") + 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") from gui.widgets import FileSelector @@ -255,6 +267,27 @@ class SettingsTab(ttk.Frame): 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): """创建处理配置组""" 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.default_output_var.set(self.config.get("paths.default_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 # 管理员模式 - 加载所有配置 @@ -462,10 +497,12 @@ class SettingsTab(ttk.Frame): is_user_only = self.session_manager and self.session_manager.get_user_type() == 'User' if is_user_only: - # User 用户模式 - 只保存路径设置 + # User 用户模式 - 只保存路径设置和执行设置 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.validation_output", self.validation_output_filename_var.get()) + # 保存执行设置 + self.config.set("execution.dryrun", self.user_dryrun_var.get()) # 保存到文件 if self.config.save(): diff --git a/gui/widgets/__init__.py b/gui/widgets/__init__.py index ca2a72a..89fd103 100644 --- a/gui/widgets/__init__.py +++ b/gui/widgets/__init__.py @@ -8,5 +8,6 @@ from .file_selector import FileSelector from .log_text import LogText from .production_id_input import ProductionIdInput from .log_handler import GuiTextHandler +from .delete_progress_window import DeleteProgressWindow -__all__ = ['FileSelector', 'LogText', 'ProductionIdInput', 'GuiTextHandler'] +__all__ = ['FileSelector', 'LogText', 'ProductionIdInput', 'GuiTextHandler', 'DeleteProgressWindow'] diff --git a/gui/widgets/delete_progress_window.py b/gui/widgets/delete_progress_window.py new file mode 100644 index 0000000..7f0b1be --- /dev/null +++ b/gui/widgets/delete_progress_window.py @@ -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""" + + + + + + + + {html_body} + + + """ + 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 = ['', + ''] + + in_table = False + for line in lines: + if line.startswith('# '): + html_parts.append(f'

{line[2:]}

') + elif line.startswith('## '): + html_parts.append(f'

{line[3:]}

') + elif line.startswith('| '): + if not in_table: + html_parts.append('') + 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] == '
': + html_parts.append('' + ''.join(f'' for c in cells) + '') + else: + html_parts.append('' + ''.join(f'' for c in cells) + '') + elif line.startswith('- '): + if in_table: + html_parts.append('
{c}
{c}
') + in_table = False + html_parts.append(f'
  • {line[2:]}
  • ') + elif line.strip() == '': + if in_table: + html_parts.append('') + in_table = False + html_parts.append('
    ') + else: + if in_table: + html_parts.append('') + in_table = False + if line.strip(): + html_parts.append(f'

    {line}

    ') + + if in_table: + html_parts.append('') + + html_parts.append('') + 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) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f95887d..d43879b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,7 @@ python-dateutil>=2.8.0 pytz>=2023.0 python-dotenv>=1.0.0 tklinenums>=1.7.0 + +# --- Markdown Rendering --- +markdown2>=2.4.0 +tkinterweb>=3.23 diff --git a/utils/discrete_material_plan_cleaner.py b/utils/discrete_material_plan_cleaner.py index e102d33..50cc996 100644 --- a/utils/discrete_material_plan_cleaner.py +++ b/utils/discrete_material_plan_cleaner.py @@ -11,7 +11,8 @@ import os import re import time 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 # 统一顶部导入 @@ -42,12 +43,14 @@ class DiscreteMaterialPlanCleaner: headless=False, verbose=True, dryrun=False, + progress_callback: Optional[Callable[[int, int, str], None]] = None, ): self.username = username self.password = password self.headless = headless self.verbose = verbose self.dryrun = dryrun + self.progress_callback = progress_callback # 参数规范化:支持 str、List[str]、None if manager_names is None: @@ -60,6 +63,19 @@ class DiscreteMaterialPlanCleaner: # 核心优化:使用 set 存储待删除编码,查询复杂度为 $O(1)$ 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"): """统一日志输出控制""" if self.verbose: @@ -70,6 +86,47 @@ class DiscreteMaterialPlanCleaner: elif level == "error": 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): """判定按钮是否可用""" try: @@ -108,8 +165,19 @@ class DiscreteMaterialPlanCleaner: ) 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. 查询订单 textbox = inner_frame.get_by_role("textbox", name="生产订单号") textbox.fill(order_id) @@ -137,7 +205,7 @@ class DiscreteMaterialPlanCleaner: plan_code_locator = detail_inner_frame.get_by_text( 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( re.compile(r"^详细信息 \(\d+\)$") @@ -154,6 +222,9 @@ class DiscreteMaterialPlanCleaner: # 6. 执行清理逻辑 if detail_status == "审批通过": if detail_count > 0: + # 更新总物料数统计 + self.stats['total_materials'] += detail_count + # --- 点击修改并等待状态切换 (保留原逻辑) --- detail_inner_frame.get_by_role("button", name="修改").click() @@ -161,7 +232,7 @@ class DiscreteMaterialPlanCleaner: save_button_locator = detail_inner_frame.get_by_role( "button", name="保存" ) - save_button_locator.wait_for(state="visible", timeout=10000) + save_button_locator.wait_for(state="visible", timeout=30000) self._log("已进入编辑模式(保存按钮已就绪)") # --------------------------------------- @@ -175,8 +246,12 @@ class DiscreteMaterialPlanCleaner: collapse_btn = button_wrapper.locator(".icon-celashouqi") last_row_number = None + material_idx = 0 # 物料计数器 # page2.pause() # 调试用,正式运行时可删除 while True: + material_idx += 1 + self.stats['processed_materials'] += 1 + # 稳定性检查:等待行号更新 current_row = self._get_input_value(child_form, r"^行号$") row_num_int = int(current_row) @@ -187,11 +262,24 @@ class DiscreteMaterialPlanCleaner: material_name = 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: self._log(f"发现匹配物料: {material_name} ({material_code})") if (not pending_qty) and not ( 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 delete_row_btn.click() @@ -200,6 +288,7 @@ class DiscreteMaterialPlanCleaner: # 等待行号变化(表示删除完成且新数据已加载) max_wait_time = 10 # 最大等待10秒 start_time = time.time() + delete_success = False while time.time() - start_time < max_wait_time: try: new_row_number = self._get_input_value( @@ -209,6 +298,7 @@ class DiscreteMaterialPlanCleaner: self._log( f"✓ 删除完成,行号已从 {old_row_number} 变更为 {new_row_number}" ) + delete_success = True break time.sleep(0.2) # 每200ms检查一次 except Exception as e: @@ -219,20 +309,61 @@ class DiscreteMaterialPlanCleaner: 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 elif row_num_int >= 7000 and row_num_int < 8000: - self._log( - f"⚠️ 行号 {row_num_int} 在 7000-8000 范围内,跳过删除", - "warn", + reason = f"行号 {row_num_int} 在 7000-8000 范围内" + 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 + }) 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: + reason = "不满足删除条件" + self._report_material_progress( + order_index, total_orders, + material_idx, detail_count, + order_id, material_name, "跳过" + ) self._log( - f"⚠️ 不满足删除条件,跳过物料 {material_name} ({material_code})", + f"⚠️ {reason},跳过物料 {material_name} ({material_code})", "warn", ) + self.stats['skipped_materials'].append({ + 'order_id': order_id, + 'material_code': material_code, + 'material_name': material_name, + 'reason': reason + }) else: self._log( @@ -276,6 +407,9 @@ class DiscreteMaterialPlanCleaner: def clean(self, production_id_file): """执行完整清理流程""" + # 初始化统计 + self.stats['start_time'] = datetime.now() + # 0. 预加载数据库数据 self.preload_data() @@ -300,19 +434,27 @@ class DiscreteMaterialPlanCleaner: work_main_frame = page1.locator("#forwardFrame").content_frame inner_frame = work_main_frame.locator("#mainiframe").content_frame inner_frame.locator("#hot-key-head_list").wait_for( - state="visible", timeout=15000 + state="visible", timeout=30000 ) self.setup_query_interface(inner_frame) 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): self._log(f"进度: [{index+1}/{len(order_ids)}] 处理单号: {order_id}") 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: self._log(f"处理单号 {order_id} 时发生异常: {e}", "error") + self.stats['errors'].append({ + 'order_id': order_id, + 'error_message': str(e) + }) continue # 单个失败不影响整体执行 # 登出清理 @@ -321,6 +463,74 @@ class DiscreteMaterialPlanCleaner: browser.close() 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(): # 路径配置