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

@@ -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():
# 路径配置