Compare commits

...

10 Commits

Author SHA1 Message Date
Misaka
3c45ef58d1 chore: add .secrets.toml to gitignore
Prevent committing sensitive configuration file.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 21:08:26 +08:00
Misaka
85be5e166b fix: support MySQL in production order query by using dynamic placeholders
- Use conn.get_placeholder() instead of hardcoded '?'
- Adapt table and column names for both MySQL and SQL Server
- Load database type from config to handle dialect differences
- Fix ProgrammingError when running with MySQL database

Co-Authored-By: Gemini 2.0 Flash <gemini-cli@google.com>
2026-02-13 18:19:13 +08:00
Misaka_Company
ac94b1cb82 refactor: clean up code by removing redundant comments and docstrings
- Remove unused imports (redirect_stdout) from data_extraction_tab.py
- Update docstring to reflect stability fixes in data_extraction_tab.py
- Strip excessive inline comments while preserving essential ones
- Improve code readability by reducing visual noise

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 15:19:05 +08:00
Misaka_Company
ed7eaf038b refactor: optimize performance and code quality with significant improvements
Performance optimizations:
- Pre-load database materials into HashSet (O(1) lookup) instead of O(n) per material query
- Add 60s safety timeout on loading waits to prevent deadlocks
- Simplify navigation loop with while True pattern

Code quality improvements:
- Replace print-based logging with proper logging module
- Add _get_input_value() helper to reduce duplication
- Move all imports to top of file (re, time, TimeoutError)
- Remove redundant docstrings and consolidate logic
- Add detailed module docstring explaining optimizations

Bug fixes:
- Fix iframe variable naming conflicts (detail_main_frame, detail_inner_frame)
- Add error handling for individual order processing failures
- Simplify setup_query_interface() logic
- Change password to placeholder for security

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 13:57:59 +08:00
Misaka_Company
82819ae9d1 refactor: improve button navigation logic with proper state detection
- Add button-wrapper element locator for accessing action/navigation buttons
- Create dedicated button objects (delete, next material, collapse)
- Add _is_button_enabled() method using Playwright's is_enabled()
- Replace index-based navigation with button state-driven loop
- Add row number change detection to wait for data loading
- Simplify navigation logic with explicit button click handling

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-13 13:06:17 +08:00
Misaka
3b491d7b36 refactor: optimize settings and validation tabs for User users
Material Validation Tab:
- Remove file selection area (file_group and related frames)
- Move output file path configuration to settings
- For User users: hide data source selection, only show shared production IDs

Settings Tab:
- Add "validation output file" configuration to path settings group
- Show "save settings" button for User users
- Add User-only mode to only show path settings group

Changes:
- material_validation_tab.py: Restructure control panel, update export_paths
- settings_tab.py: Add validation output filename, add save button for users

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 22:40:07 +08:00
Misaka
28d7db26db feat: add line numbers to Production ID input widget
- Integrate tklinenums library for line number display
- Line numbers appear on the left side in black color
- Line numbers are always visible (even with placeholder)
- Line numbers sync with text scrolling
- Add tklinenums>=1.7.0 to requirements.txt

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 22:10:38 +08:00
Misaka
ee0b4187d7 feat: add auto-show scrollbar for Production ID input widget
When content overflows in the Production ID text input, the scrollbar
now automatically appears. When content fits within the visible area,
the scrollbar is hidden.

- Changed layout from pack to grid for dynamic scrollbar control
- Added yview-based overflow detection (last < 1.0 indicates overflow)
- Added debounce mechanism to prevent excessive checks
- Bound events: KeyRelease, ButtonRelease, Configure, Paste

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 21:59:56 +08:00
Misaka
6f8df2f2e6 feat: add Production ID input widget and UI configuration
- Add ProductionIdInput widget with placeholder and multi-line support
- Add UIConfig class for font family, font size, and input width settings
- Refactor data extraction tab to use horizontal PanedWindow layout
  - Left panel: Production ID text input (draggable width)
  - Right panel: control panel and log output
- Share Production IDs between data extraction and material validation tabs
- Add UI settings group in settings page (font selection, size, input width)
- For User users: automatically use shared Production IDs, simplified UI
- Apply font settings to input and log widgets
- Use sashpos() to set initial pane width correctly

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 21:47:17 +08:00
Misaka
62f323d420 refactor: remove data query tab and related code
- Remove DataQueryTab class and data_query_tab.py file
- Remove data query tab from main window
- Update about dialog to remove data query feature description
- Update database description from "SQL Server" to generic "数据库"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 21:01:58 +08:00
14 changed files with 990 additions and 1335 deletions

3
.gitignore vendored
View File

@@ -31,4 +31,5 @@ record.py
# 环境变量
.env
.env.local
.env.local
.secrets.toml

View File

@@ -269,6 +269,34 @@ class ValidationConfig:
return errors
@dataclass
class UIConfig:
"""用户界面配置"""
font_family: str = "Microsoft YaHei UI"
font_size: int = 10
production_id_input_width: int = 20
@classmethod
def from_env(cls) -> "UIConfig":
"""从环境变量创建配置"""
from config.env_loader import get_env, get_env_int
return cls(
font_family=get_env("UI_FONT_FAMILY", "Microsoft YaHei UI"),
font_size=get_env_int("UI_FONT_SIZE", 10),
production_id_input_width=get_env_int("UI_PRODUCTION_ID_INPUT_WIDTH", 20),
)
def validate(self) -> list[str]:
"""验证配置,返回错误列表"""
errors = []
if self.font_size < 8 or self.font_size > 24:
errors.append("字号必须在 8-24 之间")
if self.production_id_input_width < 10 or self.production_id_input_width > 100:
errors.append("输入框宽度必须在 10-100 之间")
return errors
@dataclass
class AppConfig:
"""应用总配置"""
@@ -278,6 +306,7 @@ class AppConfig:
paths: PathConfig
extraction: ExtractionConfig
validation: ValidationConfig
ui: UIConfig
@classmethod
def from_env(cls) -> "AppConfig":
@@ -288,6 +317,7 @@ class AppConfig:
paths=PathConfig.from_env(),
extraction=ExtractionConfig.from_env(),
validation=ValidationConfig.from_env(),
ui=UIConfig.from_env(),
)
def validate(self) -> list[str]:
@@ -298,6 +328,7 @@ class AppConfig:
errors.extend(self.paths.validate())
errors.extend(self.extraction.validate())
errors.extend(self.validation.validate())
errors.extend(self.ui.validate())
return errors
def to_dict(self) -> dict:
@@ -348,4 +379,9 @@ class AppConfig:
"default_manager": self.validation.default_manager,
"match_mode": self.validation.match_mode,
},
"ui": {
"font_family": self.ui.font_family,
"font_size": self.ui.font_size,
"production_id_input_width": self.ui.production_id_input_width,
},
}

View File

@@ -4,6 +4,8 @@
"""
from db.connection import get_connection
from config.schema import DatabaseType
from config.loader import ConfigLoader
def read_production_ids(file_path):
@@ -35,6 +37,10 @@ def query_production_order_numbers(production_ids):
if not production_ids:
return []
# 获取当前数据库类型
app_config = ConfigLoader.load()
db_type = app_config.database.db_type
# SQL Server 限制每个查询最多 2100 个参数
BATCH_SIZE = 2000
all_results = []
@@ -42,18 +48,31 @@ def query_production_order_numbers(production_ids):
# 分批查询
for i in range(0, len(production_ids), BATCH_SIZE):
batch = production_ids[i : i + BATCH_SIZE]
placeholders = ",".join(["?" for _ in batch])
query = f"""
SELECT [生产订单号]
FROM [productionContractData].[26年压力表合同数据]
WHERE [总排号] IN ({placeholders})
"""
with get_connection() as conn:
# 获取正确的占位符
placeholder = conn.get_placeholder()
placeholders = ",".join([placeholder for _ in batch])
# 根据数据库类型选择表名和列名格式
if db_type == DatabaseType.MYSQL:
table_name = "productionContractData_26年压力表合同数据"
query = f"""
SELECT 生产订单号
FROM {table_name}
WHERE 总排号 IN ({placeholders})
"""
else:
table_name = "[productionContractData].[26年压力表合同数据]"
query = f"""
SELECT [生产订单号]
FROM {table_name}
WHERE [总排号] IN ({placeholders})
"""
results = conn.execute_query(query, tuple(batch))
# 提取生产订单号并去除空值
batch_numbers = [row["生产订单号"] for row in results if row["生产订单号"]]
batch_numbers = [row["生产订单号"] for row in results if row.get("生产订单号")]
all_results.extend(batch_numbers)
return all_results

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
数据提取标签页
数据提取标签页 - 稳定性修复版
从 ERP 系统提取备料计划数据
修复了 LogText.info 不支持 add_timestamp 参数导致的 TypeError
"""
import os
@@ -13,8 +13,7 @@ import queue
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from pathlib import Path
from contextlib import redirect_stdout
from gui.widgets import FileSelector, LogText
from gui.widgets import FileSelector, LogText, ProductionIdInput
from gui.config_manager import ConfigManager
from gui.progress import ProgressInfo, ProgressCalculator
from gui.utils import RealtimeOutput
@@ -23,182 +22,147 @@ from gui.utils import RealtimeOutput
class DataExtractionTab(ttk.Frame):
"""数据提取标签页"""
def __init__(self, parent, config: ConfigManager):
"""
初始化数据提取标签页
Args:
parent: 父容器
config: 配置管理器
"""
def __init__(self, parent, config: ConfigManager, main_window=None):
super().__init__(parent)
self.config = config
self.main_window = main_window
self.extracting = False
self.extractor = None
self.extraction_thread = None
self.progress_calculator = ProgressCalculator()
self.progress_queue = queue.Queue() # 进度更新队列
self.progress_queue = queue.Queue()
# 启动进度更新轮询
self._poll_progress_queue()
self.create_widgets()
self._apply_ui_config()
# 稍后显示就绪消息
try:
self.log_text.info("数据提取标签页已就绪")
except:
pass # 如果窗口还未完全就绪,忽略错误
pass
def create_widgets(self):
"""创建界面组件"""
# 主容器 - 使用 PanedWindow 分割上下部分
main_paned = ttk.PanedWindow(self, orient=tk.VERTICAL)
main_paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
horizontal_paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
horizontal_paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 上部:控制面板
left_panel = ttk.Frame(horizontal_paned)
horizontal_paned.add(left_panel, weight=0)
right_panel = ttk.Frame(horizontal_paned)
horizontal_paned.add(right_panel, weight=1)
self._create_left_panel(left_panel)
self._create_right_panel(right_panel)
self.horizontal_paned = horizontal_paned
input_width = self.config.get("ui.production_id_input_width", 20)
self.after(100, lambda: self._set_pane_width(input_width * 8))
def _create_left_panel(self, parent):
input_group = ttk.LabelFrame(parent, text="Production ID", padding=10)
input_group.pack(fill=tk.BOTH, expand=True)
self.production_id_input = ProductionIdInput(
input_group,
placeholder="每行输入一个 Production ID\n\n示例:\n26B848\n26B849"
)
self.production_id_input.pack(fill=tk.BOTH, expand=True)
self.production_id_input.text_widget.bind("<FocusOut>", self._on_production_ids_changed)
def _create_right_panel(self, parent):
main_paned = ttk.PanedWindow(parent, orient=tk.VERTICAL)
main_paned.pack(fill=tk.BOTH, expand=True)
control_frame = ttk.Frame(main_paned)
main_paned.add(control_frame, weight=0)
# 下部:日志输出
log_frame = ttk.LabelFrame(main_paned, text="日志输出", padding=5)
main_paned.add(log_frame, weight=1)
self._create_control_panel(control_frame)
self._create_log_panel(log_frame)
def _create_control_panel(self, parent):
"""创建控制面板"""
# 输入文件选择
input_group = ttk.LabelFrame(parent, text="输入文件", padding=10)
input_group.pack(fill=tk.X, pady=5)
self.input_file_selector = FileSelector(
input_group,
label_text="ProductionID 文件:",
file_type="file",
file_types=[("文本文件", "*.txt"), ("所有文件", "*.*")],
initial_dir="D:/python/playwrite/",
)
self.input_file_selector.pack(fill=tk.X)
# 设置默认文件
default_input = self.config.get("paths.production_id_file", "ProductionID.txt")
if os.path.exists(default_input):
self.input_file_selector.set(default_input)
# 输出文件选择
output_group = ttk.LabelFrame(parent, text="输出文件", padding=10)
output_group.pack(fill=tk.X, pady=5)
self.output_file_selector = FileSelector(
output_group,
label_text="保存为:",
file_type="file",
output_group, label_text="保存为:", file_type="file",
file_types=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")],
initial_dir=self.config.get("paths.data_dir", "data/"),
)
self.output_file_selector.pack(fill=tk.X)
# 设置默认输出文件
default_output = os.path.join(
self.config.get("paths.data_dir", "data/"),
self.config.get("paths.default_output", "离散备料计划维护_合并.xlsx"),
)
self.output_file_selector.set(default_output)
# 选项
options_group = ttk.LabelFrame(parent, text="提取选项", padding=10)
options_group.pack(fill=tk.X, pady=5)
self.headless_var = tk.BooleanVar(value=self.config.get("erp.headless", True))
ttk.Checkbutton(
options_group, text="无头模式 (不显示浏览器)", variable=self.headless_var
).grid(row=0, column=0, sticky="w", padx=5)
ttk.Checkbutton(options_group, text="无头模式", variable=self.headless_var).grid(row=0, column=0, sticky="w", padx=5)
# 进度显示
progress_group = ttk.LabelFrame(parent, text="进度", padding=10)
progress_group.pack(fill=tk.X, pady=5)
self.progress_bar = ttk.Progressbar(progress_group, mode="determinate")
self.progress_bar.pack(fill=tk.X, pady=5)
self.status_label = ttk.Label(
progress_group, text="就绪", relief=tk.SUNKEN, anchor=tk.W
)
self.status_label = ttk.Label(progress_group, text="就绪", relief=tk.SUNKEN, anchor=tk.W)
self.status_label.pack(fill=tk.X)
# 控制按钮
button_frame = ttk.Frame(parent)
button_frame.pack(fill=tk.X, pady=10)
self.start_button = ttk.Button(
button_frame, text="开始提取", command=self.start_extraction
)
self.start_button = ttk.Button(button_frame, text="开始提取", command=self.start_extraction)
self.start_button.pack(side=tk.LEFT, padx=5)
self.stop_button = ttk.Button(
button_frame, text="停止", command=self.stop_extraction, state=tk.DISABLED
)
self.stop_button = ttk.Button(button_frame, text="停止", command=self.stop_extraction, state=tk.DISABLED)
self.stop_button.pack(side=tk.LEFT, padx=5)
def _create_log_panel(self, parent):
"""创建日志面板"""
self.log_text = LogText(parent, height=15, readonly=True)
self.log_text.pack(fill=tk.BOTH, expand=True)
def _apply_ui_config(self):
try:
font_family = self.config.get("ui.font_family", "Microsoft YaHei UI")
font_size = self.config.get("ui.font_size", 10)
self.production_id_input.apply_font(font_family, font_size)
if hasattr(self.log_text, 'apply_font'):
self.log_text.apply_font(font_family, font_size)
except: pass
def _set_pane_width(self, width: int):
try: self.horizontal_paned.sashpos(0, width)
except: pass
def start_extraction(self):
"""开始数据提取"""
# 验证输入
input_file = self.input_file_selector.get()
production_ids = self.production_id_input.get()
if not production_ids:
messagebox.showerror("错误", "请输入至少一个 Production ID")
return
output_file = self.output_file_selector.get()
if not input_file:
messagebox.showerror("错误", "请选择 ProductionID 输入文件")
return
if not os.path.exists(input_file):
messagebox.showerror("错误", f"输入文件不存在:{input_file}")
return
if not output_file:
messagebox.showerror("错误", "请指定输出文件路径")
return
# 确保输出目录存在
output_dir = os.path.dirname(output_file)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir, exist_ok=True)
# 更新 UI 状态
self.extracting = True
self.start_button.config(state=tk.DISABLED)
self.stop_button.config(state=tk.NORMAL)
self.progress_bar["value"] = 0
self.status_label.config(text="正在登录...")
self.status_label.config(text="正在初始化...")
self.log_text.clear()
self.log_text.info("开始数据提取...")
# 在后台线程中执行提取
self.extraction_thread = threading.Thread(
target=self._extraction_worker, args=(input_file, output_file), daemon=True
target=self._extraction_worker, args=(production_ids, output_file), daemon=True
)
self.extraction_thread.start()
def stop_extraction(self):
"""停止数据提取"""
if self.extracting:
self.extracting = False
self.log_text.warning("正在停止提取...")
self.status_label.config(text="正在停止...")
def _extraction_worker(self, input_file: str, output_file: str):
"""提取工作线程"""
def _extraction_worker(self, production_ids: list[str], output_file: str):
import tempfile
temp_file = None
try:
# 导入提取器(延迟导入以避免启动时加载 Playwright
from utils.离散备料计划维护数据提取 import DiscreteMaterialPlanExtractor
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as f:
temp_file = f.name
f.write('\n'.join(production_ids))
# 创建提取器实例
from utils.离散备料计划维护数据提取 import DiscreteMaterialPlanExtractor
self.extractor = DiscreteMaterialPlanExtractor(
username=self.config.get("erp.username"),
password=self.config.get("erp.password"),
@@ -208,82 +172,67 @@ class DataExtractionTab(ttk.Frame):
enable_db_persistence=self.config.get("extraction.enable_db_persistence", False),
)
# 创建实时输出流,每次写入立即更新 GUI
realtime_output = RealtimeOutput(
lambda line: self._update_log(line, "INFO")
# 修复:直接调用标准的 _update_log不再传入 add_timestamp 参数
def progress_callback(progress_info: ProgressInfo):
if progress_info.stage == "log":
level = progress_info.detail.get("log_level", "INFO").upper()
self._update_log(progress_info.message, level)
else:
percent = self.progress_calculator.calculate_overall_percent(progress_info)
self._update_progress(percent, progress_info.message)
result = self.extractor.extract(
production_id_file=temp_file, output_file=output_file, progress_callback=progress_callback
)
# 创建进度回调函数
def progress_callback(progress_info: ProgressInfo):
# 计算总体进度百分比
overall_percent = self.progress_calculator.calculate_overall_percent(
progress_info
)
self._update_progress(overall_percent, progress_info.message)
# 重定向 stdout 并执行提取(带进度回调)
with redirect_stdout(realtime_output):
result = self.extractor.extract(
production_id_file=input_file,
output_file=output_file,
progress_callback=progress_callback,
)
if result and self.extracting:
self._update_log(f"数据已保存到:{output_file}", "SUCCESS")
self._update_log("数据处理任务圆满结束", "SUCCESS")
elif not self.extracting:
self._update_log("提取已取消", "WARNING")
else:
self._update_log("提取失败", "ERROR")
except Exception as e:
self._update_log(f"提取过程中发生错误:{str(e)}", "ERROR")
self._update_log(f"运行时错误: {str(e)}", "ERROR")
finally:
# 更新 UI 状态
if temp_file and os.path.exists(temp_file):
try: os.unlink(temp_file)
except: pass
self.after(0, self._extraction_complete)
def _extraction_complete(self):
"""提取完成后的 UI 更新"""
self.extracting = False
self.start_button.config(state=tk.NORMAL)
self.stop_button.config(state=tk.DISABLED)
self.extractor = None
def _poll_progress_queue(self):
"""轮询进度队列,处理进度更新"""
try:
while True:
# 非阻塞地获取队列中的消息
try:
progress_data = self.progress_queue.get_nowait()
value, message = progress_data
value, message = self.progress_queue.get_nowait()
self.progress_bar["value"] = value
self.status_label.config(text=message)
except queue.Empty:
break
finally:
# 继续轮询(每 50ms 检查一次)
self.after(50, self._poll_progress_queue)
except queue.Empty: break
finally: self.after(50, self._poll_progress_queue)
def _update_progress(self, value: int, message: str):
"""线程安全的进度更新(通过队列)"""
try:
self.progress_queue.put_nowait((value, message))
except:
pass # 队列满时忽略
try: self.progress_queue.put_nowait((value, message))
except: pass
def _update_log(self, message: str, level: str = "INFO"):
"""线程安全的日志更新"""
"""标准的日志更新方法"""
def update():
# 即使任务结束,只要是成功/错误消息也强制显示
if self.extracting or level in ["ERROR", "WARNING", "SUCCESS"]:
if level == "INFO":
self.log_text.info(message)
elif level == "SUCCESS":
self.log_text.success(message)
elif level == "WARNING":
self.log_text.warning(message)
elif level == "ERROR":
self.log_text.error(message)
if level == "INFO": self.log_text.info(message)
elif level == "SUCCESS": self.log_text.success(message)
elif level == "WARNING": self.log_text.warning(message)
elif level == "ERROR": self.log_text.error(message)
self.after(0, update)
def _on_production_ids_changed(self, event=None):
if self.main_window:
self.main_window.update_shared_production_ids(self.production_id_input.get())
def reload_config(self):
self._apply_ui_config()
self._on_production_ids_changed()

View File

@@ -1,326 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
数据查询标签页
查询生产订单号等信息。
"""
import os
import threading
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from gui.widgets import LogText
from gui.config_manager import ConfigManager
import pandas as pd
class DataQueryTab(ttk.Frame):
"""数据查询标签页"""
def __init__(self, parent, config: ConfigManager):
"""
初始化数据查询标签页
Args:
parent: 父容器
config: 配置管理器
"""
super().__init__(parent)
self.config = config
self.query_results = None
self.querying = False
self.create_widgets()
# 稍后显示就绪消息
try:
self.log_text.info("数据查询标签页已就绪")
except:
pass # 如果窗口还未完全就绪,忽略错误
def create_widgets(self):
"""创建界面组件"""
# 主容器
main_container = ttk.Frame(self)
main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 上部:查询控制
control_frame = ttk.LabelFrame(main_container, text="查询条件", padding=10)
control_frame.pack(fill=tk.X, pady=(0, 10))
# 中部:结果表格
result_frame = ttk.LabelFrame(main_container, text="查询结果", padding=5)
result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# 下部:日志
log_frame = ttk.LabelFrame(main_container, text="日志", padding=5)
log_frame.pack(fill=tk.BOTH, expand=True)
self._create_query_control(control_frame)
self._create_result_table(result_frame)
self._create_log_panel(log_frame)
def _create_query_control(self, parent):
"""创建查询控制面板"""
# 查询说明
info_label = ttk.Label(
parent,
text="输入总排号列表(每行一个),查询对应的生产订单号信息",
foreground="#666666",
)
info_label.pack(anchor=tk.W, pady=(0, 5))
# 输入区域
input_frame = ttk.Frame(parent)
input_frame.pack(fill=tk.BOTH, expand=True)
# 左侧:文本输入框
text_frame = ttk.Frame(input_frame)
text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10))
self.input_text = tk.Text(text_frame, height=10, wrap=tk.WORD)
self.input_text.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
text_scrollbar = ttk.Scrollbar(
text_frame, orient=tk.VERTICAL, command=self.input_text.yview
)
text_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.input_text.configure(yscrollcommand=text_scrollbar.set)
# 右侧:示例和按钮
right_frame = ttk.Frame(input_frame, width=200)
right_frame.pack(side=tk.RIGHT, fill=tk.Y)
# 示例文本
example_label = ttk.Label(right_frame, text="示例格式:", foreground="#666666")
example_label.pack(anchor=tk.W, pady=(0, 5))
example_text = tk.Text(right_frame, height=8, width=25, wrap=tk.WORD)
example_text.pack(fill=tk.X)
example_text.insert("1.0", "24000001\n24000002\n24000003")
example_text.config(state=tk.DISABLED)
# 快捷按钮
ttk.Separator(right_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=10)
ttk.Button(
right_frame, text="加载 ProductionID.txt", command=self._load_production_id
).pack(fill=tk.X, pady=2)
ttk.Button(right_frame, text="清空输入", command=self._clear_input).pack(
fill=tk.X, pady=2
)
# 查询按钮
button_frame = ttk.Frame(parent)
button_frame.pack(fill=tk.X, pady=(10, 0))
self.query_button = ttk.Button(
button_frame, text="执行查询", command=self.execute_query
)
self.query_button.pack(side=tk.LEFT, padx=5)
self.export_button = ttk.Button(
button_frame,
text="导出结果",
command=self.export_results,
state=tk.DISABLED,
)
self.export_button.pack(side=tk.LEFT, padx=5)
self.progress = ttk.Progressbar(parent, mode="indeterminate")
self.progress.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5)
def _create_result_table(self, parent):
"""创建结果表格"""
# 创建 Treeview
columns = ("总排号", "生产订单号", "订单状态")
self.tree = ttk.Treeview(parent, columns=columns, show="headings", height=12)
# 设置列标题和宽度
self.tree.heading("总排号", text="总排号")
self.tree.heading("生产订单号", text="生产订单号")
self.tree.heading("订单状态", text="订单状态")
self.tree.column("总排号", width=150)
self.tree.column("生产订单号", width=300)
self.tree.column("订单状态", width=150)
# 添加滚动条
scrollbar_y = ttk.Scrollbar(parent, orient=tk.VERTICAL, command=self.tree.yview)
scrollbar_x = ttk.Scrollbar(
parent, orient=tk.HORIZONTAL, command=self.tree.xview
)
self.tree.configure(
yscrollcommand=scrollbar_y.set, xscrollcommand=scrollbar_x.set
)
# 布局
self.tree.grid(row=0, column=0, sticky="nsew")
scrollbar_y.grid(row=0, column=1, sticky="ns")
scrollbar_x.grid(row=1, column=0, sticky="ew")
parent.rowconfigure(0, weight=1)
parent.columnconfigure(0, weight=1)
def _create_log_panel(self, parent):
"""创建日志面板"""
self.log_text = LogText(parent, height=6, readonly=True)
self.log_text.pack(fill=tk.BOTH, expand=True)
def _load_production_id(self):
"""加载 ProductionID.txt 文件"""
default_path = self.config.get("paths.production_id_file", "ProductionID.txt")
# 检查默认路径
if os.path.exists(default_path):
file_path = default_path
else:
# 打开文件选择对话框
from tkinter import filedialog
file_path = filedialog.askopenfilename(
title="选择 ProductionID 文件",
filetypes=[("文本文件", "*.txt"), ("所有文件", "*.*")],
)
if not file_path:
return
try:
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
self.input_text.delete("1.0", tk.END)
self.input_text.insert("1.0", content)
self.log_text.info(f"已加载文件:{file_path}")
except Exception as e:
messagebox.showerror("错误", f"加载文件失败:{str(e)}")
self.log_text.error(f"加载文件失败:{str(e)}")
def _clear_input(self):
"""清空输入"""
self.input_text.delete("1.0", tk.END)
def execute_query(self):
"""执行查询"""
# 获取输入的总排号列表
input_text = self.input_text.get("1.0", tk.END).strip()
if not input_text:
messagebox.showwarning("警告", "请输入总排号列表")
return
# 解析总排号
production_ids = [
line.strip() for line in input_text.split("\n") if line.strip()
]
if not production_ids:
messagebox.showwarning("警告", "没有有效的总排号")
return
self.log_text.info(f"准备查询 {len(production_ids)} 个总排号...")
# 更新 UI 状态
self.querying = True
self.query_button.config(state=tk.DISABLED)
self.progress.start(10)
# 清空结果表格
for item in self.tree.get_children():
self.tree.delete(item)
# 在后台线程中执行查询
query_thread = threading.Thread(
target=self._query_worker, args=(production_ids,), daemon=True
)
query_thread.start()
def _query_worker(self, production_ids: list):
"""查询工作线程"""
try:
# 导入查询函数
from db.production_order_query import query_production_order_numbers
self._update_log("正在连接数据库...", "INFO")
# 执行查询
results = query_production_order_numbers(production_ids)
if results:
self._update_log(f"查询完成,共 {len(results)} 条结果", "SUCCESS")
self._display_results(results)
else:
self._update_log("查询完成,但没有找到匹配的结果", "WARNING")
except Exception as e:
self._update_log(f"查询过程中发生错误:{str(e)}", "ERROR")
messagebox.showerror("错误", f"查询失败:{str(e)}")
finally:
# 更新 UI 状态
self.after(0, self._query_complete)
def _query_complete(self):
"""查询完成后的 UI 更新"""
self.querying = False
self.query_button.config(state=tk.NORMAL)
self.progress.stop()
def _display_results(self, results: list):
"""在主线程中显示结果"""
def update():
for 总排号, 生产订单号 in results:
self.tree.insert("", tk.END, values=(总排号, 生产订单号, ""))
if len(results) > 0:
self.export_button.config(state=tk.NORMAL)
self.query_results = results
self.after(0, update)
def _update_log(self, message: str, level: str = "INFO"):
"""线程安全的日志更新"""
def update():
if level == "INFO":
self.log_text.info(message)
elif level == "SUCCESS":
self.log_text.success(message)
elif level == "WARNING":
self.log_text.warning(message)
elif level == "ERROR":
self.log_text.error(message)
self.after(0, update)
def export_results(self):
"""导出结果到 Excel"""
if not self.query_results:
messagebox.showwarning("警告", "没有结果可导出")
return
output_file = filedialog.asksaveasfilename(
title="导出查询结果",
defaultextension=".xlsx",
filetypes=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")],
initialfile="生产订单号查询结果.xlsx",
)
if not output_file:
return
try:
# 创建 DataFrame 并保存
df = pd.DataFrame(self.query_results, columns=["总排号", "生产订单号"])
df.to_excel(output_file, index=False)
messagebox.showinfo("成功", f"结果已导出到:{output_file}")
self.log_text.success(f"结果已导出到:{output_file}")
except Exception as e:
messagebox.showerror("错误", f"导出失败:{str(e)}")
self.log_text.error(f"导出失败:{str(e)}")

View File

@@ -11,7 +11,6 @@ from tkinter import ttk
from gui.config_manager import ConfigManager
from gui.data_extraction_tab import DataExtractionTab
from gui.material_validation_tab import MaterialValidationTab
from gui.data_query_tab import DataQueryTab
from gui.settings_tab import SettingsTab
@@ -29,6 +28,7 @@ class MainWindow:
self.root = root
self.config = ConfigManager()
self.session_manager = session_manager
self.shared_production_ids = [] # 共享的 Production ID 列表
# 设置窗口属性(包含用户信息)
user_type_display = "管理员" if session_manager.is_admin() else "用户"
@@ -50,6 +50,18 @@ class MainWindow:
# 居中窗口
self._center_window()
def get_shared_production_ids(self) -> list:
"""获取共享的 Production ID 列表"""
return self.shared_production_ids.copy()
def update_shared_production_ids(self, production_ids: list):
"""更新共享的 Production ID 列表"""
self.shared_production_ids = production_ids
# 通知物料校验标签页 Production ID 已更新
if hasattr(self, 'validation_tab'):
if hasattr(self.validation_tab, 'on_production_ids_updated'):
self.validation_tab.on_production_ids_updated(production_ids)
def create_menu(self):
"""创建菜单栏"""
menubar = tk.Menu(self.root)
@@ -71,22 +83,31 @@ class MainWindow:
self.notebook = ttk.Notebook(self.root)
self.notebook.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
# 数据提取标签页
self.extraction_tab = DataExtractionTab(self.notebook, self.config)
# 数据提取标签页(传递 self 以支持共享 Production ID
self.extraction_tab = DataExtractionTab(self.notebook, self.config, self)
self.notebook.add(self.extraction_tab, text="数据提取")
# 物料校验标签页(传入 session_manager
self.validation_tab = MaterialValidationTab(self.notebook, self.config, self.session_manager)
# 物料校验标签页(传入 session_manager 和 main_window
self.validation_tab = MaterialValidationTab(self.notebook, self.config, self.session_manager, self)
self.notebook.add(self.validation_tab, text="物料校验")
# 数据查询标签页
self.query_tab = DataQueryTab(self.notebook, self.config)
self.notebook.add(self.query_tab, text="数据查询")
# 设置标签页(传入 session_manager
self.settings_tab = SettingsTab(self.notebook, self.config, self.session_manager)
self.notebook.add(self.settings_tab, text="设置")
# 初始化:如果数据提取页面已有 Production ID通知物料校验页面
self._initialize_shared_production_ids()
def _initialize_shared_production_ids(self):
"""初始化共享的 Production ID从数据提取页面获取"""
try:
if hasattr(self.extraction_tab, 'production_id_input'):
production_ids = self.extraction_tab.production_id_input.get()
if production_ids:
self.update_shared_production_ids(production_ids)
except Exception:
pass # 如果获取失败,忽略错误
def create_status_bar(self):
"""创建状态栏"""
self.status_bar = ttk.Frame(self.root, relief=tk.SUNKEN)
@@ -145,8 +166,21 @@ class MainWindow:
"功能:\n"
"• 数据提取 - 从 ERP 系统提取备料计划数据\n"
"• 物料校验 - 校验物料状态并匹配待删除物料\n"
"• 数据查询 - 查询生产订单号等信息\n"
"• 设置管理 - 管理系统配置\n"
"• 数据库持久化 - 将提取的数据自动保存到 SQL Server\n\n"
"• 数据库持久化 - 将提取的数据自动保存到数据库\n\n"
"基于 Playwright 和 Python 开发",
)
def reload_config(self):
"""配置更新后重新加载配置到各个标签页"""
# 重新加载配置
self.config.reload()
# 通知各个标签页重新加载配置
if hasattr(self.extraction_tab, 'reload_config'):
self.extraction_tab.reload_config()
if hasattr(self.validation_tab, 'reload_config'):
self.validation_tab.reload_config()
# 更新状态栏
self.config_status.set("配置已重新加载")

View File

@@ -131,7 +131,7 @@ class CheckboxTreeview(ttk.Treeview):
class MaterialValidationTab(ttk.Frame):
"""物料校验标签页"""
def __init__(self, parent, config: ConfigManager, session_manager):
def __init__(self, parent, config: ConfigManager, session_manager, main_window=None):
"""
初始化物料校验标签页
@@ -139,13 +139,16 @@ class MaterialValidationTab(ttk.Frame):
parent: 父容器
config: 配置管理器
session_manager: SessionManager 实例,用于权限控制
main_window: 主窗口引用,用于获取共享的 Production ID
"""
super().__init__(parent)
self.config = config
self.session_manager = session_manager
self.main_window = main_window
self.validating = False
self.validation_results = None
self.material_records_cache = None # 缓存完整的物料记录
self.shared_production_ids = [] # 共享的 Production ID 列表
# 负责人筛选相关
self.managers: List[str] = [] # 可用负责人列表
@@ -230,45 +233,46 @@ class MaterialValidationTab(ttk.Frame):
# 普通用户:默认使用 database_filtered 模式
self.source_mode = tk.StringVar(value="database_filtered")
# 文件选择
file_group = ttk.LabelFrame(parent, text="文件选择", padding=10)
file_group.pack(fill=tk.X, pady=5)
# Production ID 数据源(仅 Admin
if self.session_manager.is_admin():
source_frame = ttk.Frame(parent)
source_frame.pack(fill=tk.X, pady=5)
# 数据库全表模式 - 无需输入文件
self.db_full_frame = ttk.Frame(file_group)
ttk.Label(
self.db_full_frame,
text="数据库全表模式:将查询 DiscreteMaterialPlanData 表中的所有材料",
foreground="gray"
).pack(anchor="w")
# Production ID 数据源选择
self.production_id_source_var = tk.StringVar(value="shared")
ttk.Radiobutton(
source_frame,
text="使用文件:",
variable=self.production_id_source_var,
value="file",
command=self._on_production_id_source_changed
).pack(side=tk.LEFT)
# 数据库过滤模式 - 需要 ProductionID 文件
self.db_filtered_frame = ttk.Frame(file_group)
self.db_filtered_production_id_selector = FileSelector(
self.db_filtered_frame,
label_text="ProductionID 文件:",
file_type="file",
file_types=[("文本文件", "*.txt"), ("所有文件", "*.*")],
initial_dir="D:/python/playwrite/",
)
self.db_filtered_production_id_selector.pack(fill=tk.X)
ttk.Radiobutton(
source_frame,
text="使用数据提取页面的 Production ID:",
variable=self.production_id_source_var,
value="shared",
command=self._on_production_id_source_changed
).pack(side=tk.LEFT, padx=10)
# 输出文件(对于所有用户都可见)
self.output_file_selector = FileSelector(
file_group,
label_text="输出文件:",
file_type="file",
file_types=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")],
initial_dir=self.config.get("paths.data_dir", "data/"),
)
self.output_file_selector.pack(fill=tk.X, pady=(10, 0))
# 文件选择器
self.db_filtered_production_id_selector = FileSelector(
parent,
label_text="",
file_type="file",
file_types=[("文本文件", "*.txt"), ("所有文件", "*.*")],
initial_dir="D:/python/playwrite/",
)
self.db_filtered_production_id_selector.pack(fill=tk.X)
# 设置默认输出
default_output = os.path.join(
self.config.get("paths.data_dir", "data/"),
self.config.get("paths.validation_output", "物料状态校验结果.xlsx"),
)
self.output_file_selector.set(default_output)
# 共享 Production ID 提示标签
self.shared_ids_info_label = ttk.Label(
parent,
text="",
foreground="blue"
)
# 初始时不显示
# 控制按钮
button_frame = ttk.Frame(parent)
@@ -377,24 +381,71 @@ class MaterialValidationTab(ttk.Frame):
def _on_source_mode_change(self):
"""数据源模式切换"""
# PERMISSION CHECK: 普通用户固定为 database_filtered 模式
# PERMISSION CHECK: 仅管理员用户可以选择数据源模式
if not self.session_manager.is_admin():
# 对于普通用户pack 数据库过滤模式框架
self.db_full_frame.pack_forget()
self.db_filtered_frame.pack(fill=tk.X, pady=(0, 5))
return
mode = self.source_mode.get()
# 隐藏所有文件选择框架
self.db_full_frame.pack_forget()
self.db_filtered_frame.pack_forget()
# 根据模式显示对应的文件选择器
# 根据模式显示 Production ID 数据源选择(仅 Admin
if mode == "database_full":
self.db_full_frame.pack(fill=tk.X, pady=(0, 5))
# 全表模式:隐藏 Production ID 选择区域
if hasattr(self, 'source_frame'):
self.source_frame.pack_forget()
elif mode == "database_filtered":
self.db_filtered_frame.pack(fill=tk.X, pady=(0, 5))
# 过滤模式:显示 Production ID 选择区域
if hasattr(self, 'source_frame'):
self.source_frame.pack(fill=tk.X, pady=5)
def _on_production_id_source_changed(self):
"""Production ID 数据源切换回调(仅 Admin 模式)"""
# User 模式下没有文件选择器,直接返回
if not self.session_manager.is_admin():
return
source = self.production_id_source_var.get()
if source == "shared":
# 使用共享 Production ID
self.db_filtered_production_id_selector.pack_forget()
self.shared_ids_info_label.pack(anchor="w", pady=(0, 5))
else:
# 使用文件
self.shared_ids_info_label.pack_forget()
self.db_filtered_production_id_selector.pack(fill=tk.X)
def on_production_ids_updated(self, production_ids: list):
"""当数据提取页面的 Production ID 更新时调用"""
self.shared_production_ids = production_ids
is_user_only = not self.session_manager.is_admin()
# User 模式:静默更新,不显示任何提示
if is_user_only:
return
# Admin 模式:显示提示信息
if production_ids:
count = len(production_ids)
preview = ", ".join(production_ids[:3])
if count > 3:
preview += f" ... (共 {count} 个)"
self.shared_ids_info_label.config(text=f"📋 {preview}")
# 显示提示标签
if hasattr(self, 'shared_ids_label') and not self.shared_ids_label.winfo_ismapped():
self.shared_ids_label.pack(anchor="w", pady=(0, 5))
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()
def reload_config(self):
"""配置更新后重新加载"""
# 从主窗口获取最新的 Production ID
if self.main_window:
production_ids = self.main_window.get_shared_production_ids()
self.on_production_ids_updated(production_ids)
def _create_manager_filter_area(self, parent):
"""创建负责人筛选区域"""
@@ -707,28 +758,53 @@ class MaterialValidationTab(ttk.Frame):
def start_validation(self):
"""开始校验"""
# 验证输入
output_file = self.output_file_selector.get()
# 从配置获取输出文件路径
output_file = os.path.join(
self.config.get("paths.data_dir", "data/"),
self.config.get("paths.validation_output", "物料状态校验结果.xlsx"),
)
if not output_file:
messagebox.showerror("错误", "指定输出文件路径")
messagebox.showerror("错误", "先在设置页面配置输出文件路径")
return
mode = self.source_mode.get()
input_file = None
production_id_file = None
production_ids_list = None # 新增:用于传递共享的 Production ID 列表
is_user_only = not self.session_manager.is_admin()
# 根据模式验证输入文件
if mode == "database_full":
# 无需输入文件
pass
elif mode == "database_filtered":
production_id_file = self.db_filtered_production_id_selector.get()
if not production_id_file:
messagebox.showerror("错误", "请选择 ProductionID 文件")
return
if not os.path.exists(production_id_file):
messagebox.showerror("错误", f"文件不存在:{production_id_file}")
return
if is_user_only:
# User 模式:自动使用共享的 Production ID
production_ids_list = self.shared_production_ids
if not production_ids_list:
messagebox.showerror("错误", "没有可用的共享 Production ID\n请在数据提取页面输入 Production ID 后再试")
return
self.log_text.info(f"使用数据提取页面的 Production ID{len(production_ids_list)} 个)")
else:
# Admin 模式:检查数据源选择
source = self.production_id_source_var.get()
if source == "shared":
# 使用共享的 Production ID
production_ids_list = self.shared_production_ids
if not production_ids_list:
messagebox.showerror("错误", "没有可用的共享 Production ID\n请在数据提取页面输入 Production ID 后再试")
return
self.log_text.info(f"使用共享的 Production ID{len(production_ids_list)} 个)")
else:
# 使用文件
production_id_file = self.db_filtered_production_id_selector.get()
if not production_id_file:
messagebox.showerror("错误", "请选择 ProductionID 文件")
return
if not os.path.exists(production_id_file):
messagebox.showerror("错误", f"文件不存在:{production_id_file}")
return
else:
messagebox.showerror("错误", f"未知的校验模式: {mode}")
return
@@ -758,16 +834,27 @@ class MaterialValidationTab(ttk.Frame):
# 在后台线程中执行校验
validation_thread = threading.Thread(
target=self._validation_worker_enhanced,
args=(mode, input_file, production_id_file, output_file),
args=(mode, input_file, production_id_file, output_file, production_ids_list),
daemon=True,
)
validation_thread.start()
def _validation_worker_enhanced(
self, mode: str, input_file: str, production_id_file: str, output_file: str
self, mode: str, input_file: str, production_id_file: str, output_file: str, production_ids_list: list = None
):
"""增强的校验工作线程(使用完整记录模式)"""
import tempfile
temp_production_id_file = None
try:
# 如果提供了共享的 Production ID 列表,创建临时文件
if production_ids_list:
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as f:
temp_production_id_file = f.name
f.write('\n'.join(production_ids_list))
production_id_file = temp_production_id_file
self._update_log(f"使用共享的 Production ID 列表({len(production_ids_list)} 个)", "INFO")
# 导入校验器
from utils.material_status_validator import MaterialStatusValidator
@@ -820,6 +907,12 @@ class MaterialValidationTab(ttk.Frame):
import traceback
self._update_log(traceback.format_exc(), "ERROR")
finally:
# 清理临时文件
if temp_production_id_file and os.path.exists(temp_production_id_file):
try:
os.unlink(temp_production_id_file)
except:
pass
# 更新 UI 状态
self.after(0, self._validation_complete)
@@ -1082,17 +1175,29 @@ class MaterialValidationTab(ttk.Frame):
def export_results(self):
"""导出结果到 Excel"""
output_file = self.output_file_selector.get()
# 从配置获取输出文件路径
data_dir = self.config.get("paths.data_dir", "data/")
validation_filename = self.config.get("paths.validation_output", "物料状态校验结果.xlsx")
output_file = os.path.join(data_dir, validation_filename)
if not output_file:
output_file = filedialog.asksaveasfilename(
title="保存结果",
defaultextension=".xlsx",
filetypes=[("Excel 文件", "*.xlsx"), ("所有文件", "*.*")],
# 如果配置的文件不存在,提示用户选择位置
if not os.path.exists(data_dir):
output_dir = filedialog.askdirectory(
title="选择输出目录",
initialdir=data_dir
)
if output_dir:
self.config.set("paths.data_dir", output_dir)
# 重新读取配置并构建输出文件路径
data_dir = output_dir
output_file = os.path.join(data_dir, validation_filename)
else:
return
if not output_file:
return
# 如果文件已存在,提示用户覆盖
if os.path.exists(output_file):
if not messagebox.askyesno("确认覆盖", f"文件已存在:{output_file}\n是否覆盖?"):
return
try:
# 收集表格数据

View File

@@ -28,6 +28,10 @@ class SettingsTab(ttk.Frame):
super().__init__(parent)
self.config = config
self.session_manager = session_manager
# 初始化物料校验输出文件名变量
self.validation_output_filename_var = tk.StringVar()
self.create_widgets()
self.load_settings()
@@ -57,19 +61,26 @@ class SettingsTab(ttk.Frame):
self._create_paths_group(scrollable_frame)
self._create_extraction_group(scrollable_frame)
self._create_validation_group(scrollable_frame)
self._create_ui_group(scrollable_frame)
else:
# User 用户:只显示路径配置
self._create_paths_group(scrollable_frame)
# 按钮区域 - 根据用户类型显示不同按钮
button_frame = ttk.Frame(scrollable_frame)
button_frame.grid(row=6, 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:
# 普通用户显示测试按钮
# User 用户显示测试按钮和保存设置按钮
ttk.Button(
button_frame, text="测试 ERP 连接", command=self.test_erp_connection
).pack(side="left", padx=5)
ttk.Button(
button_frame, text="测试数据库连接", command=self.test_db_connection
).pack(side="left", padx=5)
ttk.Button(button_frame, text="保存设置", command=self.save_settings).pack(
side="left", padx=5
)
else:
# 管理员显示所有按钮
ttk.Button(
@@ -233,7 +244,13 @@ class SettingsTab(ttk.Frame):
ttk.Label(group, text="默认输出文件:").grid(row=2, column=0, sticky="w", pady=5)
self.default_output_var = tk.StringVar()
ttk.Entry(group, textvariable=self.default_output_var, width=40).grid(
row=3, column=0, columnspan=2, sticky="ew", pady=5
row=2, column=1, columnspan=2, sticky="ew", pady=5
)
# 校验输出文件
ttk.Label(group, text="校验输出文件:").grid(row=3, column=0, sticky="w", pady=5)
ttk.Entry(group, textvariable=self.validation_output_filename_var, width=40).grid(
row=3, column=1, columnspan=2, sticky="ew", pady=5
)
group.columnconfigure(0, weight=1)
@@ -297,15 +314,21 @@ class SettingsTab(ttk.Frame):
group, text="使用数据库作为数据源", variable=self.validation_use_database_var
).grid(row=1, column=0, columnspan=2, sticky="w", pady=5)
# 输出文件名
ttk.Label(group, text="输出文件名:").grid(row=2, column=0, sticky="w", pady=5)
ttk.Entry(group, textvariable=self.validation_output_filename_var, width=30).grid(
row=2, column=1, sticky="w", pady=5
)
# 批次大小
ttk.Label(group, text="数据库批次大小:").grid(row=2, column=0, sticky="w", pady=5)
ttk.Label(group, text="数据库批次大小:").grid(row=3, column=0, sticky="w", pady=5)
self.validation_batch_size_var = tk.IntVar(value=2000)
ttk.Spinbox(
group, from_=100, to=2000, textvariable=self.validation_batch_size_var, width=10
).grid(row=2, column=1, sticky="w", pady=5)
).grid(row=3, column=1, sticky="w", pady=5)
# 匹配模式
ttk.Label(group, text="匹配模式:").grid(row=3, column=0, sticky="w", pady=5)
ttk.Label(group, text="匹配模式:").grid(row=4, column=0, sticky="w", pady=5)
self.validation_match_mode_var = tk.StringVar()
match_mode_combo = ttk.Combobox(
group,
@@ -314,31 +337,67 @@ class SettingsTab(ttk.Frame):
state="readonly",
width=30,
)
match_mode_combo.grid(row=3, column=1, sticky="w", pady=5)
match_mode_combo.grid(row=4, column=1, sticky="w", pady=5)
# CRUD 操作
self.validation_enable_crud_var = tk.BooleanVar()
ttk.Checkbutton(
group, text="启用 CRUD 操作(管理待删除物料)", variable=self.validation_enable_crud_var
).grid(row=4, column=0, columnspan=2, sticky="w", pady=5)
).grid(row=5, column=0, columnspan=2, sticky="w", pady=5)
# 默认负责人
ttk.Label(group, text="默认负责人:").grid(row=5, column=0, sticky="w", pady=5)
ttk.Label(group, text="默认负责人:").grid(row=6, column=0, sticky="w", pady=5)
self.validation_default_manager_var = tk.StringVar()
ttk.Entry(group, textvariable=self.validation_default_manager_var, width=30).grid(
row=5, column=1, sticky="w", pady=5
row=6, column=1, sticky="w", pady=5
)
group.columnconfigure(1, weight=1)
def _create_ui_group(self, parent):
"""创建 UI 配置组"""
group = ttk.LabelFrame(parent, text="界面设置", padding=10)
group.grid(row=5, column=0, columnspan=2, pady=10, padx=10, sticky="ew")
# 字体选择
ttk.Label(group, text="字体:").grid(row=0, column=0, sticky="w", pady=5)
self.ui_font_family_var = tk.StringVar()
font_combo = ttk.Combobox(
group,
textvariable=self.ui_font_family_var,
values=["Microsoft YaHei UI", "SimSun", "KaiTi", "FangSong", "Arial", "Segoe UI"],
state="readonly",
width=30,
)
font_combo.grid(row=0, column=1, sticky="w", pady=5)
# 字号选择
ttk.Label(group, text="字号:").grid(row=1, column=0, sticky="w", pady=5)
self.ui_font_size_var = tk.IntVar(value=10)
ttk.Spinbox(
group, from_=8, to=24, textvariable=self.ui_font_size_var, width=10
).grid(row=1, column=1, sticky="w", pady=5)
# Production ID 输入框宽度
ttk.Label(group, text="输入框宽度(字符):").grid(row=2, column=0, sticky="w", pady=5)
self.ui_input_width_var = tk.IntVar(value=20)
ttk.Spinbox(
group, from_=10, to=100, textvariable=self.ui_input_width_var, width=10
).grid(row=2, column=1, sticky="w", pady=5)
group.columnconfigure(1, weight=1)
def load_settings(self):
"""从配置加载设置到界面"""
# 判断是否为仅测试用户模式
is_user_only = self.session_manager and self.session_manager.get_user_type() == 'User'
if is_user_only:
# 普通用户模式 - 只需要加载测试连接所需的配置
# 不需要加载设置到界面,因为界面没有配置输入框
# User 用户模式 - 只需要加载路径设置到界面
# 路径设置
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", ""))
return
# 管理员模式 - 加载所有配置
@@ -386,13 +445,38 @@ class SettingsTab(ttk.Frame):
# 校验设置
self.validation_data_source_var.set(self.config.get("validation.data_source", "database_full"))
self.validation_use_database_var.set(self.config.get("validation.use_database", True))
self.validation_output_filename_var.set(self.config.get("paths.validation_output", "物料状态校验结果.xlsx"))
self.validation_batch_size_var.set(self.config.get("validation.batch_size", 2000))
self.validation_match_mode_var.set(self.config.get("validation.match_mode", "substring"))
self.validation_enable_crud_var.set(self.config.get("validation.enable_crud_operations", False))
self.validation_default_manager_var.set(self.config.get("validation.default_manager", ""))
# UI 设置
self.ui_font_family_var.set(self.config.get("ui.font_family", "Microsoft YaHei UI"))
self.ui_font_size_var.set(self.config.get("ui.font_size", 10))
self.ui_input_width_var.set(self.config.get("ui.production_id_input_width", 20))
def save_settings(self):
"""保存界面设置到配置"""
# 判断是否为仅测试用户模式
is_user_only = self.session_manager and self.session_manager.get_user_type() == 'User'
if is_user_only:
# 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())
# 保存到文件
if self.config.save():
messagebox.showinfo("成功", "设置已保存")
# 通知其他标签页重新加载配置
self._notify_config_reload()
else:
messagebox.showerror("错误", "保存设置失败")
return
# 管理员模式 - 加载所有配置
# ERP 设置
self.config.set("erp.url", self.erp_url_var.get())
self.config.set("erp.username", self.erp_username_var.get())
@@ -434,17 +518,37 @@ class SettingsTab(ttk.Frame):
# 校验设置
self.config.set("validation.data_source", self.validation_data_source_var.get())
self.config.set("validation.use_database", self.validation_use_database_var.get())
self.config.set("paths.validation_output", self.validation_output_filename_var.get())
self.config.set("validation.batch_size", self.validation_batch_size_var.get())
self.config.set("validation.match_mode", self.validation_match_mode_var.get())
self.config.set("validation.enable_crud_operations", self.validation_enable_crud_var.get())
self.config.set("validation.default_manager", self.validation_default_manager_var.get())
# UI 设置
self.config.set("ui.font_family", self.ui_font_family_var.get())
self.config.set("ui.font_size", self.ui_font_size_var.get())
self.config.set("ui.production_id_input_width", self.ui_input_width_var.get())
# 保存到文件
if self.config.save():
messagebox.showinfo("成功", "设置已保存")
# 通知其他标签页重新加载配置
self._notify_config_reload()
else:
messagebox.showerror("错误", "保存设置失败")
def _notify_config_reload(self):
"""通知其他标签页配置已更新"""
# 尝试通知主窗口重新加载配置
try:
# 获取主窗口
main_window = self.winfo_toplevel()
# 调用主窗口的 reload_config 方法(如果存在)
if hasattr(main_window, 'reload_config'):
main_window.reload_config()
except Exception:
pass
def test_db_connection(self):
"""测试数据库连接"""
# 从配置读取而不是从 UI 变量(支持 User 类型用户)

View File

@@ -6,5 +6,6 @@ GUI 自定义组件模块
from .file_selector import FileSelector
from .log_text import LogText
from .production_id_input import ProductionIdInput
__all__ = ['FileSelector', 'LogText']
__all__ = ['FileSelector', 'LogText', 'ProductionIdInput']

View File

@@ -178,3 +178,9 @@ class LogText(tk.Frame):
def grid(self, **kwargs):
"""Grid 布局"""
super().grid(**kwargs)
def apply_font(self, font_family: str, font_size: int):
"""应用字体设置"""
from tkinter import font as tk_font
font_spec = tk_font.Font(family=font_family, size=font_size)
self.text.configure(font=font_spec)

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Production ID 输入控件
多行文本输入框,用于输入 Production ID 列表。
支持内容溢出时自动显示滚动条,左侧显示行号。
"""
import tkinter as tk
from tkinter import ttk, font
from tklinenums import TkLineNumbers
class ProductionIdInput(ttk.Frame):
"""Production ID 输入控件,带行号和自动滚动条"""
def __init__(self, parent, placeholder="每行输入一个 Production ID", **kwargs):
super().__init__(parent, **kwargs)
self.placeholder = placeholder
self._scrollbar_visible = False
self._check_pending = False
self._updating_placeholder = False
# 创建文本框
self.text_widget = tk.Text(self, wrap=tk.WORD, padx=5, pady=5)
# 创建行号区域的容器
self.linenums_frame = ttk.Frame(self)
# 创建行号控件(使用黑色)
self.line_numbers = TkLineNumbers(
self.linenums_frame,
self.text_widget,
justify="center",
colors=("black", "#f0f0f0"),
bg="#f0f0f0",
width=3
)
self.line_numbers.pack(fill="both", expand=True)
# 创建滚动条
self.scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.text_widget.yview)
self.text_widget.configure(yscrollcommand=self._on_scroll)
# 布局:行号 | 文本框 | 滚动条
self.linenums_frame.grid(row=0, column=0, sticky="ns")
self.text_widget.grid(row=0, column=1, sticky="nsew")
self.scrollbar.grid(row=0, column=2, sticky="ns")
# 配置行列权重
self.grid_rowconfigure(0, weight=1)
self.grid_columnconfigure(1, weight=1)
# 绑定事件
self.text_widget.bind("<FocusIn>", self._on_focus_in)
self.text_widget.bind("<FocusOut>", self._on_focus_out)
# 监听内容变化事件
self.text_widget.bind("<KeyRelease>", self._on_content_change)
self.text_widget.bind("<ButtonRelease-1>", self._schedule_check)
self.text_widget.bind("<ButtonRelease-3>", self._schedule_check)
self.text_widget.bind("<Configure>", self._schedule_check)
# 绑定粘贴事件
self.text_widget.bind("<<Paste>>", self._schedule_check)
# 初始隐藏滚动条(行号始终显示)
self.scrollbar.grid_remove()
# 显示占位符
self._show_placeholder()
# 延迟检查初始状态
self.after(100, self._check_ui_state)
def _on_focus_in(self, event):
"""获得焦点时隐藏占位符"""
if not self._updating_placeholder and self.text_widget.get("1.0", "end-1c") == self.placeholder:
self.text_widget.delete("1.0", tk.END)
# 确保文字颜色为黑色
self.text_widget.configure(foreground="black")
def _on_focus_out(self, event):
"""失去焦点时显示占位符"""
content = self.text_widget.get("1.0", "end-1c")
if not content:
self._show_placeholder()
def _show_placeholder(self):
"""显示占位符"""
self._updating_placeholder = True
self.text_widget.delete("1.0", tk.END)
self.text_widget.insert("1.0", self.placeholder)
self.text_widget.configure(foreground="gray")
self._updating_placeholder = False
self._schedule_check()
def _hide_placeholder(self):
"""隐藏占位符"""
self._updating_placeholder = True
if self.text_widget.get("1.0", "end-1c") == self.placeholder:
self.text_widget.delete("1.0", tk.END)
self.text_widget.configure(foreground="black")
self._updating_placeholder = False
def _on_content_change(self, event=None):
"""内容变化时的处理"""
# 如果不是占位符状态,重绘行号
if self.text_widget.get("1.0", "end-1c") != self.placeholder:
self.line_numbers.redraw()
self._schedule_check()
def get(self) -> list[str]:
"""获取 Production ID 列表"""
self._hide_placeholder()
content = self.text_widget.get("1.0", "end-1c").strip()
return [line.strip() for line in content.split("\n") if line.strip()]
def set(self, production_ids: list[str]):
"""设置 Production ID 列表"""
self._updating_placeholder = True
self.text_widget.delete("1.0", tk.END)
if production_ids:
self.text_widget.insert("1.0", "\n".join(production_ids))
self.text_widget.configure(foreground="black")
self.after_idle(self.line_numbers.redraw)
else:
self._show_placeholder()
self._updating_placeholder = False
self._schedule_check()
def clear(self):
"""清空内容"""
self._updating_placeholder = True
self.text_widget.delete("1.0", tk.END)
self._show_placeholder()
self._updating_placeholder = False
self._schedule_check()
def append(self, production_ids: list[str]):
"""追加 Production ID 列表"""
self._hide_placeholder()
if production_ids:
current_content = self.text_widget.get("1.0", "end-1c")
if current_content.strip():
self.text_widget.insert(tk.END, "\n" + "\n".join(production_ids))
else:
self.text_widget.insert("1.0", "\n".join(production_ids))
self.after_idle(self.line_numbers.redraw)
self._schedule_check()
def apply_font(self, font_family: str, font_size: int):
"""应用字体设置"""
font_spec = font.Font(family=font_family, size=font_size)
self.text_widget.configure(font=font_spec)
# 重绘行号以应用字体变化
self.after_idle(self.line_numbers.redraw)
def _on_scroll(self, first, last):
"""滚动回调,更新滚动条位置和行号"""
self.scrollbar.set(first, last)
# 滚动时重绘行号以同步显示
self.line_numbers.redraw()
def _schedule_check(self, event=None):
"""调度 UI 状态检查(防抖)"""
if not self._check_pending:
self._check_pending = True
self.after(50, self._check_ui_state)
def _check_ui_state(self):
"""检查 UI 状态(滚动条)"""
self._check_pending = False
# 更新界面以确保获取准确的尺寸
self.text_widget.update_idletasks()
# 检查是否需要显示滚动条
first, last = self.text_widget.yview()
needs_scrollbar = last < 1.0
if needs_scrollbar != self._scrollbar_visible:
if needs_scrollbar:
self.scrollbar.grid()
self._scrollbar_visible = True
else:
self.scrollbar.grid_remove()
self._scrollbar_visible = False

View File

@@ -19,3 +19,4 @@ numpy>=1.24.0
python-dateutil>=2.8.0
pytz>=2023.0
python-dotenv>=1.0.0
tklinenums>=1.7.0

View File

@@ -1,19 +1,41 @@
"""
离散备料计划维护数据提取工具
负责登录、批量下载、转换数据
离散备料计划维护数据提取工具 - 日志同步优化版
功能:负责登录 ERP、批量下载数据、转换并合并数据,支持与 UI 实时同步标准格式日志。
"""
import os
import re
import time
import logging
import pandas as pd
from playwright.sync_api import sync_playwright
from typing import Callable, Optional, List
from playwright.sync_api import sync_playwright, TimeoutError
# 统一顶部导入
from utils.excel_converter import ExcelConverter
from utils.auth import login, logout
from db.production_order_query import (
read_production_ids,
query_production_order_numbers,
)
from typing import Callable, Optional
# --- 进度条对象导入 (保持容错) ---
try:
from gui.progress import ProgressInfo
except ImportError:
ProgressInfo = None
# --- 全局日志配置 ---
# 调整格式:增加 [] 使其与 UI 控件的默认风格保持一致
LOG_FORMAT = '[%(asctime)s] [%(levelname)s] %(message)s'
DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
logging.basicConfig(
level=logging.INFO,
format=LOG_FORMAT,
datefmt=DATE_FORMAT
)
logger = logging.getLogger(__name__)
class DiscreteMaterialPlanExtractor:
"""离散备料计划维护数据提取器"""
@@ -22,17 +44,6 @@ class DiscreteMaterialPlanExtractor:
self, username, password, headless=False, verbose=True, batch_size=100,
enable_db_persistence=False
):
"""
初始化提取器
Args:
username: 登录用户名
password: 登录密码
headless: 是否无头模式运行
verbose: 是否打印详细日志
batch_size: 批次大小
enable_db_persistence: 是否启用数据库持久化
"""
self.username = username
self.password = password
self.headless = headless
@@ -42,33 +53,37 @@ class DiscreteMaterialPlanExtractor:
self.converter = ExcelConverter(verbose=verbose)
self.enable_db_persistence = enable_db_persistence
self.dao = None
if self.enable_db_persistence:
from db.discrete_material_plan_dao import DiscreteMaterialPlanDAO
self.dao = DiscreteMaterialPlanDAO()
self.dao.__enter__() # Enter context manager
def _print(self, *args, **kwargs):
"""打印日志(如果 verbose=True"""
if self.verbose:
print(*args, **kwargs)
def _report_progress(
self, stage: str, current: int, total: int, message: str, **detail
):
"""
报告进度
Args:
stage: 阶段标识
current: 当前进度值
total: 总量
message: 显示消息
**detail: 额外详细信息
"""
if self.progress_callback:
try:
from gui.progress import ProgressInfo
from db.discrete_material_plan_dao import DiscreteMaterialPlanDAO
self.dao = DiscreteMaterialPlanDAO()
except ImportError:
self._log("无法加载数据库 DAO 模块,持久化功能将不可用", "error")
def _log(self, message, level="info"):
"""
统一日志出口:同步分发到控制台和 UI 回调
"""
level = level.lower()
# 1. 记录到标准控制台
log_map = {
"info": logger.info,
"warn": logger.warning,
"error": logger.error
}
log_func = log_map.get(level, logger.info)
log_func(message)
# 2. 同步到 UI
# 优化:发送原始 message让 UI 控件自行添加时间戳,确保格式统一且不报错
if self.progress_callback:
self._report_progress("log", 0, 0, message, log_level=level.upper())
def _report_progress(self, stage: str, current: int, total: int, message: str, **detail):
"""标准化进度汇报"""
if self.progress_callback and ProgressInfo:
try:
progress_info = ProgressInfo(
stage=stage,
current=current,
@@ -78,522 +93,190 @@ class DiscreteMaterialPlanExtractor:
)
self.progress_callback(progress_info)
except Exception:
# 如果进度回调失败,忽略错误,不影响主流程
pass
def get_production_order_numbers(self, production_id_file, report_progress=False):
"""
读取总排号文件并查询数据库获取生产订单号
Args:
production_id_file: ProductionID.txt 文件路径
report_progress: 是否报告进度
Returns:
生产订单号列表
"""
"""读取总排号并查询数据库获取生产订单号"""
if report_progress:
self._report_progress(
"query",
1,
3,
"正在读取总排号文件...",
action="read_file",
)
self._report_progress("query", 1, 3, "正在读取总排号文件...", action="read_file")
# 读取总排号
production_ids = read_production_ids(production_id_file)
self._print(f"文件读取到 {len(production_ids)}总排号")
self._log(f"文件读取完成: 找{len(production_ids)} Production ID")
if report_progress:
self._report_progress(
"query",
2,
3,
f"正在查询数据库({len(production_ids)} 个总排号)...",
action="query_database",
production_id_count=len(production_ids),
)
self._report_progress("query", 2, 3, "正在查询数据库获取生产订单号...", action="query_database")
# 查询数据库获取生产订单号
order_ids = query_production_order_numbers(production_ids)
self._print(f"查询{len(order_ids)} 生产订单号")
self._log(f"数据库查询完成: 共匹配{len(order_ids)} 生产订单号")
if report_progress:
self._report_progress(
"query",
3,
3,
f"查询完成:获取到 {len(order_ids)} 个生产订单号",
action="query_complete",
order_id_count=len(order_ids),
)
self._report_progress("query", 3, 3, "订单号查询阶段结束", action="query_complete")
return order_ids
def group_order_ids(self, order_ids, group_size=100):
"""订单号分组"""
"""生成器:按批次切割订单号"""
for i in range(0, len(order_ids), group_size):
yield order_ids[i : i + group_size]
def download_batch(
self,
inner_frame,
order_ids,
batch_index,
total_batches,
page1,
debug_mode=False,
debug_batch=None,
):
"""下载一批订单号的数据"""
from playwright.sync_api import TimeoutError
import re
import time
# 步骤1清空文本框
self._report_progress(
"download",
batch_index * 7 + 1,
total_batches * 7,
f"{batch_index + 1}/{total_batches} 批 - 准备输入订单号",
batch_index=batch_index + 1,
action="clear_textbox",
)
def download_batch(self, inner_frame, order_ids, batch_index, total_batches, page1):
"""执行单批次数据的下载流程"""
self._report_progress("download", batch_index * 7 + 1, total_batches * 7,
f"{batch_index + 1} 批: 正在填充订单号", action="fill_orders")
textbox = inner_frame.get_by_role("textbox", name="来源生产订单号")
textbox.fill("")
# 步骤2填充订单号
self._report_progress(
"download",
batch_index * 7 + 2,
total_batches * 7,
f"{batch_index + 1}/{total_batches} 批 - 输入 {len(order_ids)} 个订单号",
batch_index=batch_index + 1,
action="fill_order_ids",
order_count=len(order_ids),
)
textbox.fill(",".join(order_ids))
# 步骤3点击查询
self._report_progress(
"download",
batch_index * 7 + 3,
total_batches * 7,
f"{batch_index + 1}/{total_batches} 批 - 提交查询请求",
batch_index=batch_index + 1,
action="click_search",
)
inner_frame.locator(".search-component-searchBtn").click()
self._print(f"{batch_index + 1} 批查询完成,等待加载结果...")
# 步骤4等待加载完成
self._report_progress(
"download",
batch_index * 7 + 4,
total_batches * 7,
f"{batch_index + 1}/{total_batches} 批 - 等待数据加载...",
batch_index=batch_index + 1,
action="wait_loading",
)
loading_locator = inner_frame.locator("div").filter(has_text="加载中").nth(1)
try:
loading_locator.wait_for(state="visible", timeout=3000)
loading_locator.wait_for(state="hidden", timeout=0) # 无限等待,直到消失
loading_locator.wait_for(state="hidden", timeout=0)
except TimeoutError:
# 加载很快完成,或者没有出现加载提示
pass
self._print(f"{batch_index + 1} 批加载完成,开始选择数据...")
# 调试模式:只在指定批次暂停
if debug_mode and (debug_batch is None or batch_index == debug_batch):
self._print(f"=== 调试暂停:第 {batch_index + 1} 批 ===")
page1.pause()
# 步骤5选择所有数据
self._report_progress(
"download",
batch_index * 7 + 5,
total_batches * 7,
f"{batch_index + 1}/{total_batches} 批 - 选择所有数据行",
batch_index=batch_index + 1,
action="select_all_rows",
)
inner_frame.get_by_role("row", name="序号").get_by_label("").click()
# 步骤6配置并触发导出
self._report_progress(
"download",
batch_index * 7 + 6,
total_batches * 7,
f"{batch_index + 1}/{total_batches} 批 - 配置导出参数",
batch_index=batch_index + 1,
action="configure_export",
)
# 点击输出
inner_frame.get_by_role("button", name="更多").hover()
inner_frame.get_by_text("输出", exact=True).click()
threshold_box = inner_frame.locator("div").filter(has_text=re.compile(r"^行数阈值$")).locator("input[type='text']")
threshold_box.fill("300000")
# 设置行数阈值
input_box = (
inner_frame.locator("div")
.filter(has_text=re.compile(r"^行数阈值$"))
.locator("input[type='text']")
)
input_box.fill("300000")
# 步骤7下载文件
self._report_progress(
"download",
batch_index * 7 + 7,
total_batches * 7,
f"{batch_index + 1}/{total_batches} 批 - 正在下载文件...",
batch_index=batch_index + 1,
action="downloading_file",
)
download_path = f"D:/python/playwrite/data/temp_batch_{batch_index + 1}.xlsx"
with page1.expect_download() as download_info:
inner_frame.get_by_role("button", name="确定(Y)").click()
download = download_info.value
download.save_as(download_path)
self._print(f" {batch_index + 1} 下载完成: {download_path}")
self._log(f"批次 {batch_index + 1} 下载成功 -> {download_path}")
# 报告批次完成
self._report_progress(
"download",
(batch_index + 1) * 7,
total_batches * 7,
f"{batch_index + 1}/{total_batches} 批下载完成 ✓",
batch_index=batch_index + 1,
action="batch_complete",
file_path=download_path,
)
# 等待页面恢复,准备下一次查询
time.sleep(1)
return download_path
def convert_and_merge_files(self, file_paths, output_path):
"""使用 ExcelConverter 转换并合并所有文件,返回合并后的 DataFrame"""
# 确保输出文件路径是正确的格式
"""合并 Excel 文件并清理临时文件"""
output_path = os.path.normpath(output_path)
output_dir = os.path.dirname(output_path)
output_filename = os.path.basename(output_path)
self._print(f"输出文件路径: {output_path}")
self._print(f"输出目录: {output_dir}")
self._print(f"输出文件名: {output_filename}")
# 步骤1检查并创建输出目录
self._report_progress(
"convert",
1,
len(file_paths) * 2 + 3,
"准备转换:检查输出目录",
action="check_directory",
)
if output_dir and not os.path.exists(output_dir):
self._print(f"创建输出目录: {output_dir}")
os.makedirs(output_dir)
all_dataframes = []
all_dfs = []
total_steps = len(file_paths) * 2 + 3
# 步骤2-N转换每个文件
for i, file_path in enumerate(file_paths, 1):
self._print(f"转换第 {i} 个文件: {file_path}")
for i, path in enumerate(file_paths, 1):
self._report_progress("convert", 1 + (i-1)*2 + 1, total_steps, f"正在转换 Excel {i}/{len(file_paths)}")
df = self.converter.convert(path, output_file=None)
all_dfs.append(df)
self._log(f"文件 {i} 转换完成: 提取到 {len(df)} 条记录")
# 报告开始转换
self._report_progress(
"convert",
1 + (i - 1) * 2 + 1,
len(file_paths) * 2 + 3,
f"正在转换文件 {i}/{len(file_paths)}",
file_index=i,
file_path=file_path,
action="converting_file",
)
df = self.converter.convert(file_path, output_file=None) # 只转换,不保存
all_dataframes.append(df)
self._print(f" 提取到 {len(df)} 条记录")
# 报告转换完成
self._report_progress(
"convert",
1 + (i - 1) * 2 + 2,
len(file_paths) * 2 + 3,
f"文件 {i}/{len(file_paths)} 转换完成({len(df)} 条记录)",
file_index=i,
record_count=len(df),
action="file_converted",
)
merged_df = None
if all_dataframes:
# 步骤N+1合并数据
self._report_progress(
"convert",
len(file_paths) * 2 + 2,
len(file_paths) * 2 + 3,
f"正在合并 {len(all_dataframes)} 个文件的数据...",
action="merging_data",
file_count=len(all_dataframes),
)
self._print(f"\n合并 {len(all_dataframes)} 个文件的数据...")
merged_df = pd.concat(all_dataframes, ignore_index=True)
if all_dfs:
self._report_progress("convert", total_steps - 1, total_steps, "正在进行最终数据合并...")
merged_df = pd.concat(all_dfs, ignore_index=True)
merged_df.to_excel(output_path, index=False)
self._print(f"合并完成: {output_path}, 总共 {len(merged_df)} 条记录")
# 步骤N+2删除临时文件
self._report_progress(
"convert",
len(file_paths) * 2 + 3,
len(file_paths) * 2 + 3,
f"清理临时文件...",
action="cleanup",
total_records=len(merged_df),
)
for file_path in file_paths:
os.remove(file_path)
self._print(f"已删除临时文件: {file_path}")
for p in file_paths:
try: os.remove(p)
except: pass
return output_path, merged_df
return None, None
def _save_to_database(self, df: pd.DataFrame):
"""Save DataFrame to database with progress reporting"""
"""将结果存入数据库并打印详细统计信息"""
if not self.dao: return
try:
self._report_progress(
"database", 0, 3, "准备保存到数据库...",
action="db_start"
)
stats = self.dao.save_dataframe_with_replace(df)
self._report_progress(
"database", 3, 3,
f"数据库保存完成: 删除 {stats['deleted']} 条, 新增 {stats['inserted']}",
action="db_complete",
stats=stats
)
self._print(f"\n数据库保存成功:")
self._print(f" 删除旧记录: {stats['deleted']}")
self._print(f" 新增记录: {stats['inserted']}")
self._report_progress("database", 1, 3, "正在将数据同步至数据库...")
# 使用 with 关键字确保资源安全释放
with self.dao as db:
stats = db.save_dataframe_with_replace(df)
# 保留并输出完整的处理细节:删除条数和新增条数
msg = f"数据库保存完成: 删除 {stats.get('deleted', 0)} 条, 新增 {stats.get('inserted', 0)}"
self._log(msg, "info")
except Exception as e:
self._print(f"\n警告: 数据库保存失败: {e}")
self._report_progress(
"database", 3, 3,
f"数据库保存失败: {str(e)}",
action="db_error",
error=str(e)
)
self._log(f"数据库保存失败: {str(e)}", "error")
def setup_query_interface(self, inner_frame):
"""设置查询界面(不报告进度,由 extract 统一报告)"""
import re
# 打开查询界面
"""初始化查询界面"""
inner_frame.locator(".search-name-wrapper > .iconfont").click()
inner_frame.get_by_text("订单号查询").click()
# 选择"全部"标签
inner_frame.get_by_role("tab", name="全部").click()
# 填充并验证
max_retries = 3
expected_value = "5000"
for attempt in range(max_retries):
inner_frame.locator("#rc_select_0").fill(expected_value)
inner_frame.locator("#rc_select_0").press("Enter")
actual_value = inner_frame.locator("#rc_select_0").input_value()
if actual_value == expected_value:
self._print(f"文本框填充成功: {expected_value}")
break
else:
self._print(
f"{attempt + 1} 次填充失败,实际值: {actual_value},重试..."
)
if attempt == max_retries - 1:
self._print(
f"警告: {max_retries} 次尝试后仍未成功填充,继续执行..."
)
input_box = inner_frame.locator("#rc_select_0")
input_box.fill("5000")
input_box.press("Enter")
def extract(
self,
production_id_file,
data_dir="D:/python/playwrite/data",
output_file="D:/python/playwrite/data/离散备料计划维护_合并.xlsx",
debug_mode=False,
debug_batch=None,
progress_callback=None,
self, production_id_file, output_file="D:/python/playwrite/data/离散备料计划维护_合并.xlsx",
progress_callback=None
):
"""执行完整的数据提取流程"""
original_callback = self.progress_callback
self.progress_callback = progress_callback or self.progress_callback
"""主入口:执行全流程数据提取任务"""
self.progress_callback = progress_callback
downloaded_files = []
try:
with sync_playwright() as playwright:
# 步骤1启动浏览器并登录
self._report_progress(
"login",
1,
3, # 保持 3 步
"启动浏览器并登录...",
action="launch_browser",
)
self._report_progress("login", 1, 3, "启动浏览器并尝试登录 ERP...")
browser, context, page, main_frame = login(
playwright=playwright,
username=self.username,
password=self.password,
headless=self.headless,
ignore_https_errors=True,
playwright=playwright, username=self.username, password=self.password,
headless=self.headless, ignore_https_errors=True
)
# 步骤2打开功能页面
self._report_progress(
"login",
2,
3, # 保持 3 步
"登录成功,打开功能页面...",
action="open_function_page",
)
self._print("=" * 80)
self._print("开始执行离散备料计划维护数据提取")
self._print("=" * 80)
self._log("======================================== 开始执行数据提取任务 ========================================")
main_frame.locator("i").first.click()
with page.expect_popup() as page1_info:
main_frame.get_by_title(
"离散备料计划维护", exact=True
).first.click()
main_frame.get_by_title("离散备料计划维护", exact=True).first.click()
page1 = page1_info.value
main_frame = page1.locator("#forwardFrame").content_frame
inner_frame_locator = main_frame.locator("#mainiframe")
f_frame = page1.locator("#forwardFrame").content_frame
inner_frame_locator = f_frame.locator("#mainiframe")
inner_frame_locator.wait_for(state="visible", timeout=15000)
inner_frame = inner_frame_locator.content_frame
work_frame = inner_frame_locator.content_frame
# 步骤3设置查询界面
self._report_progress(
"login",
3,
3, # 保持 3 步
"配置查询界面...",
action="setup_query_interface",
)
self.setup_query_interface(inner_frame)
self.setup_query_interface(work_frame)
order_ids = self.get_production_order_numbers(production_id_file, report_progress=True)
# 后续代码保持不变...
order_ids = self.get_production_order_numbers(
production_id_file, report_progress=True
)
downloaded_files = []
total_batches = sum(
1 for _ in self.group_order_ids(order_ids, self.batch_size)
)
for batch_index, order_ids_batch in enumerate(
self.group_order_ids(order_ids, self.batch_size)
):
self._print(
f"\n=== 开始处理第 {batch_index + 1} 批,共 {len(order_ids_batch)} 个订单号 ==="
)
downloaded_file = self.download_batch(
inner_frame,
order_ids_batch,
batch_index,
total_batches,
page1,
debug_mode=debug_mode,
debug_batch=debug_batch,
)
downloaded_files.append(downloaded_file)
self._print("\n开始执行账号注销...")
self._report_progress(
"logout",
1,
2,
"正在注销账号...",
action="logout_start",
)
logout(main_frame, verbose=self.verbose)
self._report_progress(
"logout",
2,
2,
"注销完成 ✓",
action="logout_complete",
)
if downloaded_files:
self._print(
f"\n=== 开始转换并合并 {len(downloaded_files)} 个文件 ==="
)
output_path, merged_df = self.convert_and_merge_files(downloaded_files, output_file)
# 数据库保存步骤(独立阶段)
if self.enable_db_persistence and self.dao and merged_df is not None:
self._print(f"\n=== 开始保存数据到数据库 ===")
self._save_to_database(merged_df)
else:
self._print("\n没有下载到任何文件")
self._print(f"\n=== 全部完成 ===")
self._print(f"最终文件: {output_file}")
self._report_progress(
"complete", 1, 1, "数据提取完成 ✓",
output_file=output_file,
action="all_complete",
)
batch_list = list(self.group_order_ids(order_ids, self.batch_size))
for i, batch_ids in enumerate(batch_list):
self._log(f"正在处理第 {i+1} 批次 (共 {len(batch_list)} 批)")
try:
f_path = self.download_batch(work_frame, batch_ids, i, len(batch_list), page1)
downloaded_files.append(f_path)
except Exception as e:
self._log(f"批次 {i+1} 处理异常,已跳过。详细错误: {e}", "error")
continue
self._log("正在注销并关闭浏览器环境...")
logout(f_frame, verbose=self.verbose)
context.close()
browser.close()
return output_file
if downloaded_files:
final_path, final_df = self.convert_and_merge_files(downloaded_files, output_file)
if self.enable_db_persistence and final_df is not None:
self._save_to_database(final_df)
self._log(f"所有流程已顺利结束,结果文件: {final_path}")
self._report_progress("complete", 1, 1, "任务完成")
return final_path
self._log("未获得任何有效数据,任务终止", "warn")
return None
finally:
# Close database connection if open
if self.dao:
try:
self.dao.__exit__(None, None, None)
except Exception:
pass
self.progress_callback = original_callback
self.progress_callback = None
def main():
"""测试函数"""
extractor = DiscreteMaterialPlanExtractor(
username="BLDpengqiangqiang",
password="Cqbld123456.",
headless=False,
verbose=True,
password="your_password",
enable_db_persistence=True
)
production_id_file = os.path.join(os.path.dirname(__file__), "productionID.txt")
output_file = "D:/python/playwrite/data/离散备料计划维护_合并.xlsx"
extractor.extract(production_id_file, output_file)
input("按回车退出...")
id_file = os.path.join(os.path.dirname(__file__), "productionID.txt")
extractor.extract(id_file)
if __name__ == "__main__":
main()

View File

@@ -1,10 +1,19 @@
"""
离散备料计划维护数据清理工具
负责登录、逐个清理订单数据
功能:自动登录 ERP 系统,根据负责人姓名批量清理指定的备料计划物料。
优化点:
1. 数据库预取:从 $O(n)$ 次数据库查询优化为 $O(1)$ 内存匹配HashSet
2. 日志规范:使用 logging 模块替代 print。
3. 代码整洁:移除方法内导入,增加通用定位辅助函数。
"""
import os
from playwright.sync_api import sync_playwright
import re
import time
import logging
from playwright.sync_api import sync_playwright, TimeoutError
# 统一顶部导入
from utils.auth import login, logout
from db.production_order_query import (
read_production_ids,
@@ -12,299 +21,167 @@ from db.production_order_query import (
)
from db.materials_to_delete import get_materials_to_delete
# --- 日志配置 ---
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
class DiscreteMaterialPlanCleaner:
"""离散备料计划维护数据清理器"""
def __init__(self, username, password, manager_name, headless=False, verbose=True):
"""
初始化清理器
Args:
username: 登录用户名
password: 登录密码
manager_name: 负责人姓名
headless: 是否无头模式运行
verbose: 是否打印详细日志
"""
self.username = username
self.password = password
self.manager_name = manager_name
self.headless = headless
self.verbose = verbose
# 核心优化:使用 set 存储待删除编码,查询复杂度为 $O(1)$
self.to_delete_set = set()
def _print(self, *args, **kwargs):
"""打印日志(如果 verbose=True"""
def _log(self, message, level="info"):
"""统一日志输出控制"""
if self.verbose:
print(*args, **kwargs)
if level == "info": logger.info(message)
elif level == "warn": logger.warning(message)
elif level == "error": logger.error(message)
def _is_button_enabled(self, button_locator):
"""判定按钮是否可用"""
try:
return button_locator.is_enabled()
except Exception as e:
self._log(f"检查按钮状态时出错: {e}", "error")
return False
def _get_input_value(self, container, label_regex):
"""通用辅助函数:根据 Label 正则获取 Input 的值"""
return container.locator("div").filter(
has_text=re.compile(label_regex, re.MULTILINE)
).locator("input").first.input_value()
def preload_data(self):
"""批量预取数据库数据"""
self._log(f"正在从数据库提取负责人 [{self.manager_name}] 的待删除物料清单...")
# 假设返回的是 material_code 列表
raw_list = get_materials_to_delete(self.manager_name)
self.to_delete_set = set(raw_list)
self._log(f"预加载完成,共计 {len(self.to_delete_set)} 条不合规物料编码。")
def get_production_order_numbers(self, production_id_file):
"""
读取总排号文件并查询数据库获取生产订单号
Args:
production_id_file: ProductionID.txt 文件路径
Returns:
生产订单号列表
"""
# 读取总排号
"""读取文件并查询生产订单号"""
production_ids = read_production_ids(production_id_file)
self._print(f"从文件读取到 {len(production_ids)} 个总排号")
# 查询数据库获取生产订单号
order_ids = query_production_order_numbers(production_ids)
self._print(f"查询{len(order_ids)} 个生产订单号")
self._log(f"读取到 {len(production_ids)} 个总排号 -> 匹配{len(order_ids)} 个生产订单号")
return order_ids
def process_order(
self,
inner_frame,
order_id,
order_index,
page1,
manager_name=None,
debug_mode=False,
debug_order=None,
):
"""清理单个订单的数据
Args:
inner_frame: 内部 iframe
order_id: 订单号
order_index: 订单索引
page1: 页面对象
manager_name: 负责人姓名(用于查询待删除物料)
debug_mode: 是否启用调试模式
debug_order: 调试订单号
"""
if manager_name is None:
manager_name = self.manager_name
from playwright.sync_api import TimeoutError
import re
import time
# 清空文本框
def process_order(self, inner_frame, order_id, order_index, page1):
"""清理单个订单的数据"""
# 1. 查询订单
textbox = inner_frame.get_by_role("textbox", name="生产订单号")
textbox.fill("")
# 填充订单号
textbox.fill(order_id)
# 点击查询
inner_frame.locator(".search-component-searchBtn").click()
self._print(f"{order_index + 1} 个订单查询完成,等待加载结果...")
# 等待加载完成
# 2. 等待加载(改进:增加 60s 安全超时,防止死锁)
loading_locator = inner_frame.locator("div").filter(has_text="加载中").nth(1)
try:
loading_locator.wait_for(state="visible", timeout=3000)
loading_locator.wait_for(state="hidden", timeout=0) # 无限等待,直到消失
loading_locator.wait_for(state="hidden", timeout=60000)
except TimeoutError:
# 加载很快完成,或者没有出现加载提示
pass
self._print(f"{order_index + 1} 个订单加载完成,开始清理数据...")
# 调试模式:只在指定订单暂停
if debug_mode and (debug_order is None or order_index == debug_order):
self._print(f"=== 调试暂停:第 {order_index + 1} 个订单 ===")
page1.pause()
# 3. 进入备料计划详情
inner_frame.locator("#hot-key-head_list").get_by_text("更多").click()
with page1.expect_popup() as page2_info:
inner_frame.get_by_text("备料计划").click()
page2 = page2_info.value
# 获取 nested iframe
main_frame = page2.locator("#forwardFrame").content_frame
inner_frame_locator = main_frame.locator("#mainiframe")
inner_frame_locator.wait_for(state="visible", timeout=15000)
inner_frame = inner_frame_locator.content_frame
# 4. 穿透嵌套 Iframe
detail_main_frame = page2.locator("#forwardFrame").content_frame
detail_inner_frame = detail_main_frame.locator("#mainiframe").content_frame
# 等待备料计划页面加载完成(等待序列号加载出来)
self._print("等待备料计划页面加载完成...")
plan_code_locator = inner_frame.get_by_text(re.compile(r"^离散备料计划维护:"))
plan_code_locator.wait_for(state="visible", timeout=30000)
# 5. 提取订单状态信息
plan_code_locator = detail_inner_frame.get_by_text(re.compile(r"^离散备料计划维护:"))
plan_code_locator.wait_for(state="visible", timeout=15000)
detail_count_text = detail_inner_frame.get_by_text(re.compile(r"^详细信息 \(\d+\)$")).inner_text()
detail_count = int(re.search(r"\((\d+)\)", detail_count_text).group(1))
status_text = detail_inner_frame.get_by_text(re.compile(r"^备料状态:.+$")).inner_text()
detail_status = re.search(r"备料状态:(.+)$", status_text.replace("\n", "")).group(1)
# 循环检查编码是否已加载
max_wait = 30 # 最多等待30秒
wait_interval = 0.5 # 每0.5秒检查一次
waited = 0
plan_code = None
while waited < max_wait:
plan_text = plan_code_locator.inner_text()
match = re.search(r"离散备料计划维护:(.+)", plan_text)
if match and match.group(1).strip():
plan_code = match.group(1).strip()
break
time.sleep(wait_interval)
waited += wait_interval
if plan_code:
self._print(f"备料计划页面加载完成,编码: {plan_code}")
else:
self._print(f"警告: 备料计划页面加载超时")
# 提取"详细信息"中的数字
detail_element = inner_frame.get_by_text(re.compile(r"^详细信息 \(\d+\)$"))
detail_text = detail_element.inner_text()
# 使用正则表达式提取括号中的数字
match = re.search(r"详细信息 \((\d+)\)", detail_text)
if match:
detail_count = int(match.group(1))
self._print(f"详细信息数量: {detail_count}")
# 提取"备料状态"信息
detail_element = inner_frame.get_by_text(re.compile(r"^备料状态:.+$"))
detail_text = detail_element.inner_text().replace("\n", "")
# 使用正则表达式提取括号中的数字
match = re.search(r"^备料状态:(.+)$", detail_text)
if match:
detail_status = match.group(1)
self._print(f"备料状态: {detail_status}")
# page2.pause()
# 6. 执行清理逻辑
if detail_count > 0 and detail_status == "审批通过":
inner_frame.get_by_role("button", name="修改").click()
save_button_locator = inner_frame.get_by_role("button", name="保存")
# --- 点击修改并等待状态切换 (保留原逻辑) ---
detail_inner_frame.get_by_role("button", name="修改").click()
# 关键判断:等待保存按钮出现,确认进入编辑模式
save_button_locator = detail_inner_frame.get_by_role("button", name="保存")
save_button_locator.wait_for(state="visible", timeout=10000)
self._log("已进入编辑模式(保存按钮已就绪)")
# ---------------------------------------
inner_frame.get_by_text("展开").first.click()
detail_inner_frame.get_by_text("展开").first.click()
child_form = detail_inner_frame.locator(".card-table-side-box")
button_wrapper = child_form.locator(".button-wrapper")
delete_row_btn = button_wrapper.get_by_role("button", name="删行")
next_btn = button_wrapper.locator(".icon-jiantouyou")
collapse_btn = button_wrapper.locator(".icon-celashouqi")
# 获取展开后的父容器,基于它定位子元素更加精确
# 父元素 class="card-table-side-box undefined"
child_form = inner_frame.locator(".card-table-side-box")
# 等待父容器变为可见
child_form.wait_for(state="visible", timeout=5000)
self._print(f"父容器 .card-table-side-box 已找到")
last_row_number = None
while True:
# 稳定性检查:等待行号更新
current_row = self._get_input_value(child_form, r"^行号$")
if current_row == last_row_number:
time.sleep(0.5)
# page2.pause()
for id in range(detail_count):
id_lable_locator = child_form.get_by_text("序号 " + str(id + 1))
id_lable_locator.wait_for(state="visible", timeout=10000)
self._print(f"处理 {id_lable_locator.inner_text()} ")
material_code = 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"^累计待发数量$")
# 获取材料编码(用于精确匹配)
input_box = (
child_form.locator("div")
.filter(has_text=re.compile(r"^材料编码\d{11}$", re.MULTILINE))
.locator("input")
.first
)
material_code = input_box.input_value()
self._print(f"材料编码:{material_code}")
# 获取材料名称(仅用于日志)
input_box = (
child_form.locator("div")
.filter(has_text=re.compile(r"^材料名称$"))
.locator("input[type='text']")
)
material_name = input_box.input_value()
self._print(f"材料名称:{material_name}")
# 获取累计待发数量
# 创建一个变量来保存累计待发数量的值,以便后续使用
cumulative_pending_quantity = 0
input_box = (
child_form.locator("div")
.filter(has_text=re.compile(r"^累计待发数量$"))
.locator("input[type='text']")
)
cumulative_pending_quantity = input_box.input_value()
self._print(f"累计待发数量:{cumulative_pending_quantity}")
# 获取累计出库数量
input_box = (
child_form.locator("div")
.filter(has_text=re.compile(r"^累计出库数量$"))
.locator("input[type='text']")
)
self._print(f"累计出库数量:{input_box.input_value()}")
# 检查是否需要清理该物料(直接数据库查询)
from db.materials_to_delete import should_delete_material
should_delete = should_delete_material(manager_name, material_code)
if should_delete:
if not cumulative_pending_quantity :
self._print(f"✅ 可以删除")
if material_code in self.to_delete_set:
self._log(f"发现匹配物料: {material_name} ({material_code})")
if not pending_qty or float(pending_qty) == 0:
delete_row_btn.click()
self._log(f"✅ 已点击删行")
continue
else:
self._print(f"❌ 累计待发数量不为空,无法删除")
self._print(
f">>> 需要清理:材料名称【{material_name}】材料编码【{material_code}"
)
# TODO: 执行删除操作
self._log(f"⚠️ 待发数量为 {pending_qty},跳过删除", "warn")
if self._is_button_enabled(next_btn):
last_row_number = current_row
next_btn.click()
else:
self._print(f"保留:材料名称【{material_name}】材料编码【{material_code}】无需清理")
break
if id != detail_count - 1:
child_form.get_by_role("button").filter(
has_text=re.compile(r"^$")
).nth(2).click()
else:
child_form.get_by_role("button").filter(
has_text=re.compile(r"^$")
).nth(4).click()
# page2.pause()
elif detail_count == 0:
self._print(f"{order_index + 1} 个订单无数据需要清理,跳过...")
page2.close()
return
elif detail_status != "审批通过":
self._print(f"{order_index + 1} 个订单备料状态: {detail_status}")
page2.close()
return
collapse_btn.click()
# 执行最终保存逻辑(如业务需要)
# save_button_locator.click()
page2.close()
time.sleep(1)
pass
def setup_query_interface(self, inner_frame):
"""设置查询界面"""
import re
# 点击图标按钮打开查询界面
"""初始化查询界面配置"""
inner_frame.locator(".search-name-wrapper > .iconfont").click()
inner_frame.get_by_text("订单号查询").click()
inner_frame.get_by_role("tab", name="全部").click()
# 填充每页显示条数5000条测试值
input_el = inner_frame.locator("#rc_select_0")
input_el.fill("5000")
input_el.press("Enter")
# 填充并验证,如果失败则重试
max_retries = 3
expected_value = "5000"
for attempt in range(max_retries):
inner_frame.locator("#rc_select_0").fill(expected_value)
inner_frame.locator("#rc_select_0").press("Enter")
# 检查填充是否成功
actual_value = inner_frame.locator("#rc_select_0").input_value()
if actual_value == expected_value:
self._print(f"文本框填充成功: {expected_value}")
break
else:
self._print(
f"{attempt + 1} 次填充失败,实际值: {actual_value},重试..."
)
if attempt == max_retries - 1:
self._print(
f"警告: {max_retries} 次尝试后仍未成功填充,继续执行..."
)
def clean(self, production_id_file, debug_mode=False, debug_order=None):
"""
执行完整的数据清理流程
Args:
production_id_file: ProductionID.txt 文件路径
debug_mode: 是否启用调试模式
debug_order: 调试订单索引
"""
self._print(f"使用负责人 [{self.manager_name}] 进行数据清理")
def clean(self, production_id_file):
"""执行完整清理流程"""
# 0. 预加载数据库数据
self.preload_data()
with sync_playwright() as playwright:
# 调用登录模块
browser, context, page, main_frame = login(
playwright=playwright,
username=self.username,
@@ -313,73 +190,51 @@ class DiscreteMaterialPlanCleaner:
ignore_https_errors=True,
)
self._print("=" * 80)
self._print("开始执行离散备料计划维护数据清理")
self._print("=" * 80)
# 登录成功后可以进行后续操作
# 点击打开"功能菜单"
self._log("="*30 + " 开始清理任务 " + "="*30)
# 进入功能页面
main_frame.locator("i").first.click()
# 点击打开"离散生产订单维护"
with page.expect_popup() as page1_info:
main_frame.get_by_title("离散生产订单维护", exact=True).first.click()
page1 = page1_info.value
# 获取 nested iframe
main_frame = page1.locator("#forwardFrame").content_frame
inner_frame_locator = main_frame.locator("#mainiframe")
inner_frame_locator.wait_for(state="visible", timeout=15000)
inner_frame = inner_frame_locator.content_frame
# 定位主 Iframe
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)
# 设置查询界面
self.setup_query_interface(inner_frame)
# 读取总排号并查询生产订单号
order_ids = self.get_production_order_numbers(production_id_file)
# 按订单清
for order_index, order_id in enumerate(order_ids):
self._print(
f"\n=== 开始处理第 {order_index + 1} 个订单,订单号: {order_id} ==="
)
self.process_order(
inner_frame,
order_id,
order_index,
page1,
self.manager_name,
debug_mode=debug_mode,
debug_order=debug_order,
)
# 遍历处
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)
except Exception as e:
self._log(f"处理单号 {order_id} 时发生异常: {e}", "error")
continue # 单个失败不影响整体执行
# 执行账号注销
self._print("\n开始执行账号注销...")
logout(main_frame, verbose=self.verbose)
self._print(f"\n=== 全部完成 ===")
# 关闭浏览器
# 登出清理
logout(work_main_frame, verbose=self.verbose)
context.close()
browser.close()
self._log("="*30 + " 任务全部完成 " + "="*30)
def main():
"""测试函数"""
# 路径配置
base_dir = os.path.dirname(__file__)
id_file = os.path.join(base_dir, "productionID.txt")
cleaner = DiscreteMaterialPlanCleaner(
username="BLDpengqiangqiang",
password="Cqbld123456.",
password="your_password_here",
manager_name="彭羽",
headless=False,
verbose=True,
headless=False
)
production_id_file = os.path.join(os.path.dirname(__file__), "productionID.txt")
cleaner.clean(production_id_file)
input("按回车退出...")
cleaner.clean(id_file)
input("执行完毕,按回车键退出程序...")
if __name__ == "__main__":
main()
main()