fix: 修复输出路径问题 — 路径解析改为基于应用目录而非CWD

当从外部目录(如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 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 23:05:10 +08:00
parent 6fd14b4e49
commit 6f96bf50ac
18 changed files with 84 additions and 271 deletions
+1
View File
@@ -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',
+12 -3
View File
@@ -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:
+5 -5
View File
@@ -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")
+3 -3
View File
@@ -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")
+3 -3
View File
@@ -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)
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)
# 确保窗口显示在最前
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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')
+5 -3
View File
@@ -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:
+2 -2
View File
@@ -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
+20 -22
View File
@@ -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)
+5 -4
View File
@@ -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
+16 -10
View File
@@ -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()
+3 -1
View File
@@ -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):
+2 -1
View File
@@ -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
+1
View File
@@ -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
-208
View File
@@ -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*