From 6f96bf50acee6a257fd628134a1c50cd4e43ceee Mon Sep 17 00:00:00 2001 From: houhuan Date: Mon, 4 May 2026 23:05:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E9=97=AE=E9=A2=98=20=E2=80=94=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E8=A7=A3=E6=9E=90=E6=94=B9=E4=B8=BA=E5=9F=BA=E4=BA=8E?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E7=9B=AE=E5=BD=95=E8=80=8C=E9=9D=9ECWD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当从外部目录(如D:\ccc)拖入文件时,输出文件会错误地写入源目录。 根因是所有路径使用相对路径 + os.getcwd() 解析,CWD不同则路径错误。 修复方案: - ConfigManager.get_path() 改为使用 app_root (exe所在目录/脚本所在目录) - 将 22 处裸硬编码 "data/result"/"data/output" 替换为 config.get_path() - 添加 result_folder 到默认配置和 config.ini - 修复 error_utils.py 中的路径匹配字符串 Co-Authored-By: Claude Opus 4.7 --- app/config/defaults.py | 1 + app/config/settings.py | 15 +- app/core/excel/merger.py | 10 +- app/core/excel/processor.py | 6 +- app/core/ocr/table_ocr.py | 6 +- app/core/processors/tobacco_processor.py | 4 +- app/core/utils/dialog_utils.py | 2 +- app/services/ocr_service.py | 2 +- app/services/tobacco_service.py | 4 +- app/ui/command_runner.py | 8 +- app/ui/error_utils.py | 4 +- app/ui/file_operations.py | 42 +++-- app/ui/main_window.py | 9 +- app/ui/result_previews.py | 26 +-- app/ui/user_settings.py | 4 +- config.ini | 3 +- config/config.ini | 1 + docs/SYSTEM_ARCHITECTURE.md | 208 ----------------------- 18 files changed, 84 insertions(+), 271 deletions(-) delete mode 100644 docs/SYSTEM_ARCHITECTURE.md diff --git a/app/config/defaults.py b/app/config/defaults.py index 9904a87..1ca613b 100644 --- a/app/config/defaults.py +++ b/app/config/defaults.py @@ -19,6 +19,7 @@ DEFAULT_CONFIG = { 'Paths': { 'input_folder': 'data/input', 'output_folder': 'data/output', + 'result_folder': 'data/result', 'temp_folder': 'data/temp', 'template_folder': 'templates', 'template_file': '银豹-采购单模板.xls', diff --git a/app/config/settings.py b/app/config/settings.py index 9717513..4836fa1 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -33,7 +33,16 @@ class ConfigManager: def _init(self, config_file): """初始化配置管理器""" - self.config_file = config_file or 'config.ini' + # 计算应用根目录(不依赖 os.getcwd()) + import sys + if getattr(sys, 'frozen', False): + # PyInstaller 打包后,根目录是 exe 所在目录 + self.app_root = os.path.dirname(sys.executable) + else: + # 源码运行,根目录是 app/config/ 的上两级 + self.app_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + self.config_file = config_file or os.path.join(self.app_root, 'config.ini') self.config = configparser.ConfigParser() self.load_config() @@ -153,8 +162,8 @@ class ConfigManager: path = Path(path_str) if not path.is_absolute(): - # 相对路径,转为绝对路径(相对于项目根目录) - path = Path(os.getcwd()) / path + # 相对路径,转为绝对路径(相对于应用根目录) + path = Path(self.app_root) / path if create: try: diff --git a/app/core/excel/merger.py b/app/core/excel/merger.py index f325a09..53efc6e 100644 --- a/app/core/excel/merger.py +++ b/app/core/excel/merger.py @@ -49,7 +49,7 @@ class PurchaseOrderMerger: # 修复ConfigParser对象没有get_path方法的问题 try: # 获取输出目录 - self.output_dir = config.get('Paths', 'output_folder', fallback='data/output') + self.output_dir = config.get_path('Paths', 'output_folder', fallback='data/output', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/output') # 确保目录存在 os.makedirs(self.output_dir, exist_ok=True) @@ -96,8 +96,8 @@ class PurchaseOrderMerger: Returns: 采购单文件路径列表 """ - # 采购单文件保存在data/result目录 - result_dir = "data/result" + # 采购单文件保存在result目录 + result_dir = self.config.get_path('Paths', 'result_folder', fallback='data/result', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/result') logger.info(f"搜索目录 {result_dir} 中的采购单Excel文件") # 确保目录存在 @@ -354,9 +354,9 @@ class PurchaseOrderMerger: # 采购单价(必填)- E列(4) output_sheet.write(r, price_col, float(row['采购单价']), price_style) - # 生成输出文件名,保存到data/result目录 + # 生成输出文件名,保存到result目录 timestamp = datetime.now().strftime("%Y%m%d%H%M%S") - result_dir = "data/result" + result_dir = self.config.get_path('Paths', 'result_folder', fallback='data/result', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/result') os.makedirs(result_dir, exist_ok=True) output_file = os.path.join(result_dir, f"合并采购单_{timestamp}.xls") diff --git a/app/core/excel/processor.py b/app/core/excel/processor.py index b2a9a58..8c589b3 100644 --- a/app/core/excel/processor.py +++ b/app/core/excel/processor.py @@ -52,7 +52,7 @@ class ExcelProcessor: # 修复ConfigParser对象没有get_path方法的问题 try: # 获取输入和输出目录 - self.output_dir = config.get('Paths', 'output_folder', fallback='data/output') + self.output_dir = config.get_path('Paths', 'output_folder', fallback='data/output', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/output') self.temp_dir = config.get('Paths', 'temp_folder', fallback='data/temp') # 获取模板文件路径 @@ -591,9 +591,9 @@ class ExcelProcessor: logger.warning("未提取到有效商品信息") return None - # 生成输出文件名,保存到data/result目录 + # 生成输出文件名,保存到result目录 file_name = os.path.splitext(os.path.basename(file_path))[0] - result_dir = "data/result" + result_dir = self.config.get_path('Paths', 'result_folder', fallback='data/result', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/result') os.makedirs(result_dir, exist_ok=True) output_file = os.path.join(result_dir, f"采购单_{file_name}.xls") diff --git a/app/core/ocr/table_ocr.py b/app/core/ocr/table_ocr.py index c61f6c8..3757293 100644 --- a/app/core/ocr/table_ocr.py +++ b/app/core/ocr/table_ocr.py @@ -114,9 +114,9 @@ class OCRProcessor: # 修复ConfigParser对象没有get_path方法的问题 try: # 获取输入和输出目录 - self.input_folder = config.get('Paths', 'input_folder', fallback='data/input') - self.output_folder = config.get('Paths', 'output_folder', fallback='data/output') - self.temp_folder = config.get('Paths', 'temp_folder', fallback='data/temp') + self.input_folder = config.get_path('Paths', 'input_folder', fallback='data/input', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/input') + self.output_folder = config.get_path('Paths', 'output_folder', fallback='data/output', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/output') + self.temp_folder = config.get_path('Paths', 'temp_folder', fallback='data/temp', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/temp') # 确保目录存在 os.makedirs(self.input_folder, exist_ok=True) diff --git a/app/core/processors/tobacco_processor.py b/app/core/processors/tobacco_processor.py index 3641ad5..fc8079e 100644 --- a/app/core/processors/tobacco_processor.py +++ b/app/core/processors/tobacco_processor.py @@ -39,7 +39,7 @@ class TobaccoProcessor(BaseProcessor): self.template_file = config.get('Paths', 'template_file', fallback='templates/银豹-采购单模板.xls') # 输出目录配置 - self.result_dir = Path("data/result") + self.result_dir = Path(config.get_path('Paths', 'result_folder', fallback='data/result', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/result')) self.result_dir.mkdir(exist_ok=True) # 默认输出文件名 @@ -316,7 +316,7 @@ class TobaccoProcessor(BaseProcessor): today_start = datetime.datetime.combine(today, datetime.time.min).timestamp() # 查找订单明细文件 - result_dir = Path("data/output") + result_dir = Path(self.config.get_path('Paths', 'output_folder', fallback='data/output') if hasattr(self.config, 'get_path') else os.path.abspath('data/output')) if not result_dir.exists(): return None diff --git a/app/core/utils/dialog_utils.py b/app/core/utils/dialog_utils.py index 78d8746..2508d84 100644 --- a/app/core/utils/dialog_utils.py +++ b/app/core/utils/dialog_utils.py @@ -107,7 +107,7 @@ def create_custom_dialog(title="提示", message="", result_file=None, time_info button_frame = tk.Frame(dialog) button_frame.pack(pady=10) - tk.Button(button_frame, text="打开输出目录", command=lambda: os.startfile(os.path.abspath("data/output"))).pack(side=tk.LEFT, padx=5) + tk.Button(button_frame, text="打开输出目录", command=lambda: os.startfile(ConfigManager().get_path('Paths', 'output_folder', fallback='data/output', create=True))).pack(side=tk.LEFT, padx=5) tk.Button(button_frame, text="关闭", command=dialog.destroy).pack(side=tk.LEFT, padx=5) # 确保窗口显示在最前 diff --git a/app/services/ocr_service.py b/app/services/ocr_service.py index 1a4ce59..f119ab5 100644 --- a/app/services/ocr_service.py +++ b/app/services/ocr_service.py @@ -154,7 +154,7 @@ class OCRService: # 获取文件名(不含扩展名) base_name = os.path.splitext(os.path.basename(image_path))[0] # 生成Excel文件路径 - output_dir = self.config.get('Paths', 'output_folder', fallback='data/output') + output_dir = self.config.get_path('Paths', 'output_folder', fallback='data/output', create=True) if hasattr(self.config, 'get_path') else os.path.abspath('data/output') excel_path = os.path.join(output_dir, f"{base_name}.xlsx") return excel_path diff --git a/app/services/tobacco_service.py b/app/services/tobacco_service.py index 8564079..b9107a4 100644 --- a/app/services/tobacco_service.py +++ b/app/services/tobacco_service.py @@ -36,10 +36,10 @@ class TobaccoService: """ self.config = config # 修复配置获取方式,使用fallback机制 - self.output_dir = config.get('Paths', 'output_folder', fallback='data/output') + self.output_dir = config.get_path('Paths', 'output_folder', fallback='data/output', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/output') self.template_file = config.get('Paths', 'template_file', fallback='templates/银豹-采购单模板.xls') # 将烟草订单保存到result目录 - result_dir = "data/result" + result_dir = config.get_path('Paths', 'result_folder', fallback='data/result', create=True) if hasattr(config, 'get_path') else os.path.abspath('data/result') os.makedirs(result_dir, exist_ok=True) self.output_file = os.path.join(result_dir, '银豹采购单_烟草公司.xls') diff --git a/app/ui/command_runner.py b/app/ui/command_runner.py index 12e5853..b6abe0d 100644 --- a/app/ui/command_runner.py +++ b/app/ui/command_runner.py @@ -63,9 +63,11 @@ def run_command_with_logging(command, log_widget, status_bar=None, on_complete=N env["OCR_INPUT_DIR"] = cfg.get_path('Paths', 'input_folder', fallback='data/input', create=True) env["OCR_TEMP_DIR"] = cfg.get_path('Paths', 'temp_folder', fallback='data/temp', create=True) except Exception: - env["OCR_OUTPUT_DIR"] = os.path.abspath("data/output") - env["OCR_INPUT_DIR"] = os.path.abspath("data/input") - env["OCR_TEMP_DIR"] = os.path.abspath("data/temp") + # 回退:使用 exe/脚本所在目录 + app_root = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + env["OCR_OUTPUT_DIR"] = os.path.join(app_root, "data", "output") + env["OCR_INPUT_DIR"] = os.path.join(app_root, "data", "input") + env["OCR_TEMP_DIR"] = os.path.join(app_root, "data", "temp") env["OCR_LOG_LEVEL"] = "DEBUG" try: diff --git a/app/ui/error_utils.py b/app/ui/error_utils.py index fdd1ff3..e3352b3 100644 --- a/app/ui/error_utils.py +++ b/app/ui/error_utils.py @@ -34,8 +34,8 @@ def get_error_suggestion(message: str) -> Optional[str]: return '降低识别频率或稍后重试' if '模板文件不存在' in msg or ('no such file' in msg and '模板' in msg): return '在系统设置中选择正确的模板文件路径' - if '没有找到采购单' in msg or '未在 data/result 目录下找到采购单' in msg: - return '确认data/result目录内存在采购单文件' + if '没有找到采购单' in msg or '未在' in msg and '找到采购单' in msg: + return '确认result目录内存在采购单文件' if 'permission denied' in msg: return '以管理员权限运行或更改目录写入权限' return None diff --git a/app/ui/file_operations.py b/app/ui/file_operations.py index 3e0075b..5507292 100644 --- a/app/ui/file_operations.py +++ b/app/ui/file_operations.py @@ -35,11 +35,11 @@ def ensure_directories(): """确保必要的目录结构存在""" config = ConfigManager() directories = [ - config.get('Paths', 'input_folder', fallback='data/input'), - config.get('Paths', 'output_folder', fallback='data/output'), - 'data/result', - config.get('Paths', 'temp_folder', fallback='data/temp'), - 'logs' + config.get_path('Paths', 'input_folder', fallback='data/input', create=True), + config.get_path('Paths', 'output_folder', fallback='data/output', create=True), + config.get_path('Paths', 'result_folder', fallback='data/result', create=True), + config.get_path('Paths', 'temp_folder', fallback='data/temp', create=True), + os.path.join(config.app_root, 'logs') ] for directory in directories: if not os.path.exists(directory): @@ -52,8 +52,8 @@ def clean_cache(log_widget): from .command_runner import set_running_task try: config = ConfigManager() - processed_record = config.get('Paths', 'processed_record', fallback='data/processed_files.json') - output_folder = config.get('Paths', 'output_folder', fallback='data/output') + processed_record = config.get_path('Paths', 'processed_record', fallback='data/processed_files.json') + output_folder = config.get_path('Paths', 'output_folder', fallback='data/output') cache_files = [ processed_record, os.path.join(output_folder, "processed_files.json"), @@ -65,7 +65,7 @@ def clean_cache(log_widget): os.remove(cache_file) add_to_log(log_widget, f"已清除缓存文件: {cache_file}\n", "success") - temp_dir = os.path.join("data/temp") + temp_dir = config.get_path('Paths', 'temp_folder', fallback='data/temp') if os.path.exists(temp_dir): for file in os.listdir(temp_dir): file_path = os.path.join(temp_dir, file) @@ -76,7 +76,7 @@ def clean_cache(log_widget): except Exception as e: add_to_log(log_widget, f"清除文件时出错: {file_path}, 错误: {str(e)}\n", "error") - log_dir = "logs" + log_dir = os.path.join(config.app_root, 'logs') if os.path.exists(log_dir): for file in os.listdir(log_dir): if file.endswith(".active"): @@ -98,22 +98,18 @@ def clean_cache(log_widget): def open_result_directory(): try: - result_dir = os.path.abspath("data/result") - if not os.path.exists(result_dir): - os.makedirs(result_dir, exist_ok=True) + config = ConfigManager() + result_dir = config.get_path('Paths', 'result_folder', fallback='data/result', create=True) os.startfile(result_dir) except Exception as e: messagebox.showerror("错误", f"无法打开结果目录: {str(e)}") -def _open_directory_from_settings(settings_key, default_path, label): - """通用的从用户设置读取路径并打开目录""" - from .user_settings import load_user_settings +def _open_directory_from_settings(config_key, default_path, label): + """通用的从配置读取路径并打开目录""" try: - s = load_user_settings() - path = os.path.abspath(s.get(settings_key, default_path)) - if not os.path.exists(path): - os.makedirs(path, exist_ok=True) + config = ConfigManager() + path = config.get_path('Paths', config_key, fallback=default_path, create=True) os.startfile(path) except Exception as e: messagebox.showerror("错误", f"无法打开{label}: {str(e)}") @@ -138,9 +134,10 @@ def clean_data_files(log_widget): add_to_log(log_widget, "操作已取消\n", "info") return + config = ConfigManager() files_cleaned = 0 - input_dir = "data/input" + input_dir = config.get_path('Paths', 'input_folder', fallback='data/input') if os.path.exists(input_dir): for file in os.listdir(input_dir): file_path = os.path.join(input_dir, file) @@ -149,7 +146,7 @@ def clean_data_files(log_widget): files_cleaned += 1 add_to_log(log_widget, "已清理input目录\n", "info") - output_dir = "data/output" + output_dir = config.get_path('Paths', 'output_folder', fallback='data/output') if os.path.exists(output_dir): for file in os.listdir(output_dir): file_path = os.path.join(output_dir, file) @@ -170,8 +167,9 @@ def clean_result_files(log_widget): if not messagebox.askyesno("确认清理", "确定要清理result目录的文件吗?这将删除所有已生成的采购单文件。"): add_to_log(log_widget, "操作已取消\n", "info") return + config = ConfigManager() count = 0 - result_dir = "data/result" + result_dir = config.get_path('Paths', 'result_folder', fallback='data/result') if os.path.exists(result_dir): for file in os.listdir(result_dir): file_path = os.path.join(result_dir, file) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 9a0734d..3e28fdc 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -238,8 +238,8 @@ def _create_right_panel(content_frame, theme, log_text, root): tk.Frame(tools_buttons_frame, bg=theme["card_bg"]).pack(fill=tk.X, pady=3) create_modern_button(tools_buttons_frame, "打开结果目录", lambda: open_result_directory(), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) - create_modern_button(tools_buttons_frame, "打开输出目录", lambda: os.startfile(os.path.abspath("data/output")), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) - create_modern_button(tools_buttons_frame, "打开输入目录", lambda: os.startfile(os.path.abspath("data/input")), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) + create_modern_button(tools_buttons_frame, "打开输出目录", lambda: os.startfile(ConfigManager().get_path('Paths', 'output_folder', fallback='data/output', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) + create_modern_button(tools_buttons_frame, "打开输入目录", lambda: os.startfile(ConfigManager().get_path('Paths', 'input_folder', fallback='data/input', create=True)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) create_modern_button(tools_buttons_frame, "合并订单", lambda: merge_orders_with_status(log_text, StatusBar(root)), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) create_modern_button(tools_buttons_frame, "清除缓存", lambda: clean_cache(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) create_modern_button(tools_buttons_frame, "清理input/out文件", lambda: clean_data_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3) @@ -433,8 +433,9 @@ def _create_log_panel(mid_container, theme): add_to_log(log_text, "• 批量处理订单:批量处理多个订单文件\n", "info") add_to_log(log_text, "• 处理烟草订单:专门处理烟草类订单\n", "info") add_to_log(log_text, "• 合并订单:将多个订单合并为一个文件\n\n", "info") - add_to_log(log_text, "请将需要处理的图片文件放入 data/input 目录中。\n", "warning") - add_to_log(log_text, "OCR识别结果保存在 data/output 目录,处理完成的订单保存在 result 目录中。\n\n", "warning") + cfg = ConfigManager() + add_to_log(log_text, f"请将需要处理的图片文件放入 {cfg.get_path('Paths', 'input_folder', fallback='data/input')} 目录中。\n", "warning") + add_to_log(log_text, f"OCR识别结果保存在 {cfg.get_path('Paths', 'output_folder', fallback='data/output')} 目录,处理完成的订单保存在 {cfg.get_path('Paths', 'result_folder', fallback='data/result')} 目录中。\n\n", "warning") add_to_log(log_text, "=" * 50 + "\n\n", "separator") return log_text diff --git a/app/ui/result_previews.py b/app/ui/result_previews.py index 6ee53ca..8be9957 100644 --- a/app/ui/result_previews.py +++ b/app/ui/result_previews.py @@ -11,10 +11,16 @@ from tkinter import messagebox, scrolledtext from .theme import THEMES, get_theme_mode, apply_theme from .ui_widgets import center_window from app.core.utils.file_utils import format_file_size +from app.config.settings import ConfigManager TOBACCO_PREVIEW_WINDOW = None +def _get_output_dir(): + """获取输出目录的绝对路径""" + return ConfigManager().get_path('Paths', 'output_folder', fallback='data/output', create=True) + + def show_result_preview(command, output): """显示处理结果预览""" if "ocr" in command: @@ -26,7 +32,7 @@ def show_result_preview(command, output): elif "pipeline" in command: show_pipeline_result_preview(output) else: - messagebox.showinfo("处理完成", "操作已成功完成!\n请在data/output目录查看结果。") + messagebox.showinfo("处理完成", f"操作已成功完成!\n请在{_get_output_dir()}目录查看结果。") def show_ocr_result_preview(output): @@ -68,10 +74,10 @@ def show_ocr_result_preview(output): button_frame = tk.Frame(preview) button_frame.pack(pady=10) - tk.Button(button_frame, text="查看输出文件", command=lambda: os.startfile(os.path.abspath("data/output"))).pack(side=tk.LEFT, padx=10) + tk.Button(button_frame, text="查看输出文件", command=lambda: os.startfile(_get_output_dir())).pack(side=tk.LEFT, padx=10) tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=10) else: - messagebox.showinfo("OCR处理完成", "OCR处理已完成,请在data/output目录查看结果。") + messagebox.showinfo("OCR处理完成", f"OCR处理已完成,请在{_get_output_dir()}目录查看结果。") def show_excel_result_preview(output): @@ -120,7 +126,7 @@ def show_excel_result_preview(output): tk.Button(button_frame, text="打开所在文件夹", command=lambda: os.startfile(os.path.dirname(output_file))).pack(side=tk.LEFT, padx=5) tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=5) else: - messagebox.showinfo("Excel处理完成", "Excel处理已完成,请在data/output目录查看结果。") + messagebox.showinfo("Excel处理完成", f"Excel处理已完成,请在{_get_output_dir()}目录查看结果。") def show_merge_result_preview(output): @@ -159,7 +165,7 @@ def show_merge_result_preview(output): tk.Button(button_frame, text="打开所在文件夹", command=lambda: os.startfile(os.path.dirname(output_file))).pack(side=tk.LEFT, padx=10) tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=10) else: - messagebox.showinfo("采购单合并完成", "采购单合并已完成,请在data/output目录查看结果。") + messagebox.showinfo("采购单合并完成", f"采购单合并已完成,请在{_get_output_dir()}目录查看结果。") def show_pipeline_result_preview(output): @@ -250,7 +256,7 @@ def show_pipeline_result_preview(output): tk.Button(button_frame, text="打开Excel文件", command=lambda: os.startfile(output_file)).pack(side=tk.LEFT, padx=10) else: if excel_match or no_files_match or single_file_match: - output_dir = os.path.abspath("data/output") + output_dir = _get_output_dir() excel_files = [f for f in os.listdir(output_dir) if f.startswith('采购单_') and (f.endswith('.xls') or f.endswith('.xlsx'))] if excel_files: excel_files.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True) @@ -258,7 +264,7 @@ def show_pipeline_result_preview(output): tk.Button(button_frame, text="打开最新Excel文件", command=lambda: os.startfile(latest_file)).pack(side=tk.LEFT, padx=10) - tk.Button(button_frame, text="查看输出文件夹", command=lambda: os.startfile(os.path.abspath("data/output"))).pack(side=tk.LEFT, padx=10) + tk.Button(button_frame, text="查看输出文件夹", command=lambda: os.startfile(_get_output_dir())).pack(side=tk.LEFT, padx=10) tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=10) @@ -299,7 +305,7 @@ def show_tobacco_result_preview(returncode, output): items_count = int(items_match.group(1).strip()) if not result_file or not os.path.exists(result_file): - default_path = os.path.abspath("data/output/银豹采购单_烟草公司.xls") + default_path = os.path.join(_get_output_dir(), "银豹采购单_烟草公司.xls") if os.path.exists(default_path): result_file = default_path @@ -353,11 +359,11 @@ def show_tobacco_result_preview(returncode, output): tk.Button(button_frame, text="关闭", command=_close_preview).pack(side=tk.LEFT, padx=5) else: tk.Label(result_frame, text="未找到输出文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5) - tk.Label(result_frame, text="请检查data/output目录", font=("Arial", 12, "bold"), fg="#dc3545").pack(pady=10) + tk.Label(result_frame, text=f"请检查{_get_output_dir()}目录", font=("Arial", 12, "bold"), fg="#dc3545").pack(pady=10) button_frame = tk.Frame(preview) button_frame.pack(pady=10) - tk.Button(button_frame, text="打开输出目录", command=lambda: os.startfile(os.path.abspath("data/output"))).pack(side=tk.LEFT, padx=5) + tk.Button(button_frame, text="打开输出目录", command=lambda: os.startfile(_get_output_dir())).pack(side=tk.LEFT, padx=5) tk.Button(button_frame, text="关闭", command=_close_preview).pack(side=tk.LEFT, padx=5) preview.lift() diff --git a/app/ui/user_settings.py b/app/ui/user_settings.py index 4a1911b..2c0c965 100644 --- a/app/ui/user_settings.py +++ b/app/ui/user_settings.py @@ -9,6 +9,7 @@ import tkinter as tk from typing import Dict, List, Any from app.core.utils.log_utils import get_logger +from app.config.settings import ConfigManager logger = get_logger(__name__) @@ -54,7 +55,8 @@ def get_recent_files() -> List[str]: kept = [p for p in items if _allowed(p)] if not kept: candidates = [] - for d in ['data/output', 'data/result']: + cfg = ConfigManager() + for d in [cfg.get_path('Paths', 'output_folder', fallback='data/output'), cfg.get_path('Paths', 'result_folder', fallback='data/result')]: try: if os.path.exists(d): for name in os.listdir(d): diff --git a/config.ini b/config.ini index 50193f9..fae61b7 100644 --- a/config.ini +++ b/config.ini @@ -17,6 +17,7 @@ template_file = templates\银豹-采购单模板.xls processed_record = data/processed_files.json data_dir = data product_db = data/product_cache.db +result_folder = data/result [Performance] max_workers = 4 @@ -33,7 +34,7 @@ purchase_order = 银豹-采购单模板.xls item_data = 商品资料.xlsx [App] -version = 2026.03.30.1036 +version = 2026.05.04.2128 [Gitea] base_url = https://gitea.94kan.cn diff --git a/config/config.ini b/config/config.ini index 453df88..2d61c05 100644 --- a/config/config.ini +++ b/config/config.ini @@ -11,6 +11,7 @@ form_ocr_url = https://aip.baidubce.com/rest/2.0/solution/v1/form_ocr/get_reques [Paths] input_folder = data/input output_folder = data/output +result_folder = data/result temp_folder = data/temp template_folder = templates template_file = 银豹-采购单模板.xls diff --git a/docs/SYSTEM_ARCHITECTURE.md b/docs/SYSTEM_ARCHITECTURE.md deleted file mode 100644 index c6d3521..0000000 --- a/docs/SYSTEM_ARCHITECTURE.md +++ /dev/null @@ -1,208 +0,0 @@ -# OCR 订单处理系统 - 系统架构文档 (v2.2) - -本文件详述了“OCR 订单处理系统”的技术架构、业务流向、数据模型及部署方案。 - -## 1. 系统整体架构图 (System Overall Architecture) - -```mermaid -graph TB - subgraph 用户交互层 - UI[启动器.py / Tkinter GUI] - CLI[headless_api.py / CLI] - end - - subgraph 核心业务逻辑层 - OS[OrderService / 订单调度] - OCR[OCRService / 图片识别] - SSS[SpecialSuppliersService / 特殊供应商处理] - TS[TobaccoService / 烟草处理] - EP[ExcelProcessor / 标准化转换] - end - - subgraph 基础设施与存储 - CONFIG[ConfigManager / JSON 配置] - FS[FileSystem / Excel 数据存储] - LOG[QueueLogger / 异步日志队列] - end - - subgraph 第三方集成 - OPENCLAW[OpenClaw 自动化平台] - POSPAL[银豹 POS 系统 (导出模板)] - end - - UI --> OS - CLI --> OS - OPENCLAW -- 调用 --> CLI - OS --> OCR - OS --> SSS - OS --> TS - OS --> EP - EP --> FS - EP --> CONFIG - SSS --> EP - TS --> EP - OS -- 验证 --> FS -``` - -### 图例说明 -- **用户交互层**:支持桌面 GUI 操作及专为 OpenClaw 设计的无界面 API 接入。 -- **核心业务层**:各服务高度解耦,通过 `OrderService` 进行智能路由分发。 -- **存储层**:系统采用“文件即数据库”的设计,利用 Excel 存储模板和商品资料,JSON 存储映射关系。 -- **第三方集成**:与 OpenClaw 平台通过 CLI 接口对接,最终生成符合银豹 POS 要求的采购单。 - ---- - -## 2. 核心业务逻辑流程图 (Core Business Logic) - -以“智能订单识别与预处理”为例: - -```mermaid -sequenceDiagram - participant User as 用户/OpenClaw - participant OS as OrderService - participant SSS as SpecialSuppliersService - participant TS as TobaccoService - participant EP as ExcelProcessor - - User->>OS: 提交 Excel 文件 - OS->>OS: 扫描前50行内容特征 - - alt 包含 "RCDH" - OS->>SSS: 路由至蓉城易购预处理 - SSS->>SSS: 按 E, N, Q, S 列强制清洗 - SSS-->>OS: 返回清洗后的临时文件 - else 包含 "专卖证号" - OS->>TS: 路由至烟草专用预处理 - TS->>TS: 数量*10 / 单价/10 / B,E,G,H列映射 - TS-->>OS: 返回清洗后的临时文件 - else 包含 "杨碧月" - OS->>SSS: 路由至杨碧月列对齐流程 - SSS-->>OS: 返回标准列临时文件 - else 通用格式 - OS->>OS: 直接跳过预处理 - end - - OS->>EP: 执行标准条码映射与模板填充 - EP->>EP: 校验单价 (与商品资料比对) - EP-->>User: 输出最终银豹采购单 (data/result) -``` - -### 技术注解 -- **智能指纹识别**:通过 `header=None` 读取前 50 行,避免了因标题行位置不固定导致的识别失败。 -- **原子化预处理**:每个供应商逻辑独立,预处理结果均为统一格式的中间文件,确立了系统的可扩展性。 - ---- - -## 3. 技术架构分层图 (Layered Architecture) - -| 分层 | 技术栈 / 组件 | 功能描述 | -| :--- | :--- | :--- | -| **表现层 (Presentation)** | Tkinter, headless_api.py | 桌面 GUI 交互与 OpenClaw 命令行接口 | -| **业务逻辑层 (Business)** | Python 3.x, Pandas, OCRService | 核心数据清洗、条码分裂、供应商特征识别 | -| **数据访问层 (Data)** | Pandas (Excel Engine), Json | 对 Excel 模板、映射表、用户设置的读写 | -| **基础设施层 (Infrastructure)** | Queue, Logging, PyInstaller | 异步日志分发、全局错误处理、EXE 打包工具 | - ---- - -## 4. 数据架构设计 (Data Architecture) - -系统未采用传统关系型数据库,而是基于 **JSON + Excel** 的混合存储架构。 - -### 4.1 表间关系示意 (JSON Mapping) -```mermaid -erDiagram - CONFIG_JSON ||--o{ BARCODE_MAPPING_JSON : "存储映射" - BARCODE_MAPPING_JSON { - string original_barcode "OCR识别出的原始条码" - string target_barcode "系统目标条码" - } - ITEM_DATA_EXCEL ||--o{ PURCHASE_ORDER_EXCEL : "验证单价" - ITEM_DATA_EXCEL { - string barcode "条码 (主键)" - float cost_price "进货价" - } -``` - -### 4.2 存储方案 -- **映射关系**:`barcode_mappings.json`。支持运行时动态更新,通过 `headless_api.py --update-mapping` 修改。 -- **业务数据**:`templates/商品资料.xlsx`。作为单价校验的权威数据源。 - ---- - -## 5. 微服务与模块化设计 (Microservices & Modularity) - -虽然系统目前采用单体架构(Monolithic Architecture)以适配桌面部署环境,但在逻辑上采用了**微服务式的模块化设计**: - -- **服务拆分**:每个供应商逻辑(Rongcheng, Tobacco, YangBiyue)都是独立的类,具备高度自治性。 -- **解耦机制**:通过统一的 `preprocess` 契约(输入:原始文件,输出:清洗后文件)进行交互,未来可轻松迁移至独立服务。 -- **进程隔离**:GUI 主进程与业务处理线程通过 `queue.Queue` 进行解耦,确保处理逻辑不阻塞用户界面。 - ---- - -## 6. 部署架构图 (Deployment) - -```mermaid -graph LR - subgraph 生产服务器 (Windows) - APP[orc-order-v2.exe] - DATA[data/ 目录] - LOGS[logs/ 目录] - end - - subgraph 自动化平台 - OC[OpenClaw] - end - - OC -- 命令行调用 --> APP - APP -- 读写 --> DATA - APP -- 记录 --> LOGS -``` - -### 部署要点 -- **便携化**:通过 PyInstaller 将 Python 运行环境与依赖打包,实现单文件/单目录部署。 -- **路径无关性**:系统内部通过 `os.path.abspath` 动态计算路径,支持安装在任意盘符。 - ---- - -## 6. 安全架构图 (Security) - -```mermaid -graph TD - A[外部输入] --> B{文件类型校验} - B -- 非图片/Excel --> C[拒绝处理] - B -- 图片/Excel --> D[清洗逻辑] - D --> E{单价偏差校验} - E -- 差值 > 1.0 --> F[生成警告日志/弹窗] - E -- 正常 --> G[生成采购单] - G --> H[日志埋点与审计] -``` - -### 安全策略 -- **数据隔离**:所有处理后的文件存放在 `data/output` 和 `data/result`,不修改原始输入文件。 -- **权限控制**:系统运行于用户权限下,利用 Windows 文件系统权限保护配置文件。 - ---- - -## 7. 技术债务与优化建议 (Tech Debt & Optimization) - -### 7.1 当前技术债务 -1. **并发限制**:目前为单进程串行处理,面对超大规模订单(万行级)可能存在阻塞。 -2. **持久化局限**:使用 JSON 存储映射关系在条码量达到万级时,查询性能会下降。 -3. **环境依赖**:OCR 引擎高度依赖 Tesseract/PaddleOCR 等本地二进制库,部署复杂。 - -### 7.2 单点故障风险 (SPOF Analysis) -1. **本地环境强依赖**:所有 OCR 与 Excel 处理均在单一 Windows 节点,若该节点故障,OpenClaw 对接将完全中断。 -2. **核心模板丢失**:`templates/` 下的商品资料或采购单模板缺失会导致全流程崩溃。 -3. **OCR 精度波动**:OCR 结果受图片质量影响,若 OCR 识别条码错误且无映射表,则该行数据将丢失。 - -### 7.3 架构优化建议方案 -- **容灾备份**:建议将 `templates/` 和 `barcode_mappings.json` 定期备份至远程 Git 仓库(如 Gitea)。 -- **分布式识别**:引入 PaddleOCR 服务端,支持多节点并发 OCR 识别,减少本地算力依赖。 -- **配置热更新**:支持从远程 URL 加载 `barcode_mappings.json`,实现多机条码库同步。 -- **数据回退机制**:增加中间文件持久化策略,允许在处理失败时手动干预已清洗的 Excel。 - ---- -*附注:本文档图表均采用 Mermaid 标准编写,可直接在 VS Code (需安装 Mermaid 插件) 或 [Mermaid Live Editor](https://mermaid.live/) 中实时渲染并导出为高清 PNG/SVG 格式。* - ---- -*文档版本:2.2.0 | 生成日期:2026-03-31*