orc-order-v2/启动器.py
houhuan fb12e63c4c feat(供应商管理): 新增规则引擎与词典配置支持
refactor(处理器): 重构通用供应商处理器以支持规则引擎
docs: 更新README与文档说明供应商管理功能
build: 更新打包脚本注入版本信息
test: 添加规则引擎单元测试
2025-12-12 13:46:00 +08:00

4857 lines
226 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
益选-OCR订单处理系统启动器
-----------------
提供简单的图形界面,方便用户选择功能
"""
import os
import sys
import time
import subprocess
import shutil
import tkinter as tk
from tkinter import messagebox, filedialog, scrolledtext, ttk, simpledialog
from tkinter import font as tkfont
from threading import Thread
import datetime
import time
import pandas as pd
import json
import re
import logging
from typing import Dict, List, Optional, Any
from pathlib import Path
# 导入自定义对话框工具
from app.core.utils.dialog_utils import show_custom_dialog, show_barcode_mapping_dialog, show_config_dialog
from app.core.excel.converter import UnitConverter
from app.config.settings import ConfigManager
from app.core.utils.log_utils import set_log_level
# 导入服务类
from app.services.ocr_service import OCRService
from app.services.order_service import OrderService
from app.services.tobacco_service import TobaccoService
from app.services.processor_service import ProcessorService
# 全局变量,用于跟踪任务状态
RUNNING_TASK = None
THEME_MODE = "light" # 默认浅色主题
PROCESSOR_SERVICE = None
# config_manager = ConfigManager() # 创建配置管理器实例 - 延迟初始化
# 定义浅色和深色主题颜色
THEMES = {
"light": {
"bg": "#f8f9fa",
"fg": "#212529",
"button_bg": "#ffffff",
"button_fg": "#495057",
"button_hover": "#e9ecef",
"primary_bg": "#007bff",
"primary_fg": "#ffffff",
"secondary_bg": "#6c757d",
"secondary_fg": "#ffffff",
"log_bg": "#ffffff",
"log_fg": "#212529",
"highlight_bg": "#007bff",
"highlight_fg": "#ffffff",
"border": "#dee2e6",
"success": "#28a745",
"error": "#dc3545",
"warning": "#ffc107",
"info": "#17a2b8",
"card_bg": "#ffffff",
"shadow": "#00000010"
},
"dark": {
"bg": "#1a1a1a",
"fg": "#e9ecef",
"button_bg": "#343a40",
"button_fg": "#e9ecef",
"button_hover": "#495057",
"primary_bg": "#0d6efd",
"primary_fg": "#ffffff",
"secondary_bg": "#6c757d",
"secondary_fg": "#ffffff",
"log_bg": "#212529",
"log_fg": "#e9ecef",
"highlight_bg": "#0d6efd",
"highlight_fg": "#ffffff",
"border": "#495057",
"success": "#198754",
"error": "#dc3545",
"warning": "#ffc107",
"info": "#0dcaf0",
"card_bg": "#2d3748",
"shadow": "#00000030"
}
}
def load_user_settings():
try:
path = os.path.abspath(os.path.join('data', 'user_settings.json'))
if os.path.exists(path):
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception:
pass
return {}
def save_user_settings(settings: Dict[str, Any]):
try:
os.makedirs('data', exist_ok=True)
path = os.path.abspath(os.path.join('data', 'user_settings.json'))
with open(path, 'w', encoding='utf-8') as f:
json.dump(settings, f, ensure_ascii=False, indent=2)
except Exception:
pass
RECENT_LIST_WIDGET = None
TOBACCO_PREVIEW_WINDOW = None
def get_recent_files() -> List[str]:
s = load_user_settings()
items = s.get('recent_files', [])
if not isinstance(items, list):
return []
def _allowed(p: str) -> bool:
try:
if not isinstance(p, str) or not os.path.isfile(p):
return False
ext = os.path.splitext(p)[1].lower()
return ext in {'.xlsx', '.xls', '.jpg', '.jpeg', '.png', '.bmp'}
except Exception:
return False
kept = [p for p in items if _allowed(p)]
if not kept:
candidates = []
for d in ['data/output', 'data/result']:
try:
if os.path.exists(d):
for name in os.listdir(d):
p = os.path.join(d, name)
if _allowed(p):
candidates.append(p)
except Exception:
pass
if candidates:
kept = candidates
try:
kept_sorted = sorted(kept, key=lambda p: os.path.getmtime(p), reverse=True)
except Exception:
kept_sorted = kept
if kept_sorted != items or len(kept_sorted) != len(items):
s['recent_files'] = kept_sorted[:20]
save_user_settings(s)
return kept_sorted[:10]
def refresh_recent_list_widget():
try:
global RECENT_LIST_WIDGET
if RECENT_LIST_WIDGET is None:
return
RECENT_LIST_WIDGET.delete(0, tk.END)
for i, p in enumerate(get_recent_files(), start=1):
RECENT_LIST_WIDGET.insert(tk.END, f"{i}. {p}")
except Exception:
pass
def _extract_path_from_recent_item(s: str) -> str:
try:
m = re.match(r'^(\d+)\.\s+(.*)$', s)
p = m.group(2) if m else s
return p.strip().strip('"')
except Exception:
return s.strip().strip('"')
def add_recent_file(path: str) -> None:
try:
if not path:
return
try:
if not os.path.isfile(path):
return
ext = os.path.splitext(path)[1].lower()
if ext not in {'.xlsx', '.xls', '.jpg', '.jpeg', '.png', '.bmp'}:
return
except Exception:
return
s = load_user_settings()
items = s.get('recent_files', [])
# 置顶且去重
items = [p for p in items if p != path]
items.insert(0, path)
s['recent_files'] = items[:20]
save_user_settings(s)
refresh_recent_list_widget()
except Exception:
pass
def clear_recent_files():
try:
s = load_user_settings()
s['recent_files'] = []
save_user_settings(s)
except Exception:
pass
def show_config_dialog(root, cfg: ConfigManager):
settings = load_user_settings()
dlg = tk.Toplevel(root)
dlg.title("系统设置")
dlg.geometry("560x540")
center_window(dlg)
content = ttk.Frame(dlg)
content.pack(fill=tk.BOTH, expand=True, padx=16, pady=16)
for i in range(2):
content.columnconfigure(i, weight=1)
# 当前值
log_level_val = tk.StringVar(value=settings.get('log_level', 'INFO'))
max_workers_val = tk.StringVar(value=str(settings.get('concurrency_max_workers', cfg.getint('Performance', 'max_workers', 4))))
batch_size_val = tk.StringVar(value=str(settings.get('concurrency_batch_size', cfg.getint('Performance', 'batch_size', 5))))
template_path_val = tk.StringVar(value=settings.get('template_path', os.path.join(cfg.get('Paths','template_folder','templates'), cfg.get('Templates','purchase_order','银豹-采购单模板.xls'))))
input_dir_val = tk.StringVar(value=settings.get('input_folder', cfg.get('Paths','input_folder','data/input')))
output_dir_val = tk.StringVar(value=settings.get('output_folder', cfg.get('Paths','output_folder','data/output')))
result_dir_val = tk.StringVar(value=settings.get('result_folder', 'data/result'))
def add_row(row, label_text, widget):
ttk.Label(content, text=label_text).grid(row=row, column=0, sticky='w', padx=4, pady=6)
widget.grid(row=row, column=1, sticky='ew', padx=4, pady=6)
# 日志级别
lvl = ttk.Combobox(content, textvariable=log_level_val, values=['DEBUG','INFO','WARNING','ERROR'], state='readonly')
add_row(0, "日志级别", lvl)
# 并发参数
maxw_entry = ttk.Entry(content, textvariable=max_workers_val)
add_row(1, "最大并发(max_workers)", maxw_entry)
batch_entry = ttk.Entry(content, textvariable=batch_size_val)
add_row(2, "批次大小(batch_size)", batch_entry)
# 模板路径
tpl_frame = ttk.Frame(content)
tpl_entry = ttk.Entry(tpl_frame, textvariable=template_path_val)
tpl_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
def _select_template():
p = filedialog.askopenfilename(title="选择模板文件", filetypes=[("Excel模板","*.xls *.xlsx"), ("所有文件","*.*")])
if p:
try:
template_path_val.set(os.path.relpath(p, os.getcwd()))
except Exception:
template_path_val.set(p)
ttk.Button(tpl_frame, text="选择", command=_select_template).pack(side=tk.LEFT, padx=6)
add_row(3, "采购单模板文件", tpl_frame)
# 目录
def dir_row(row_idx, label, var):
f = ttk.Frame(content)
e = ttk.Entry(f, textvariable=var)
e.pack(side=tk.LEFT, fill=tk.X, expand=True)
def _select_dir():
d = filedialog.askdirectory(title=f"选择{label}")
if d:
try:
var.set(os.path.relpath(d, os.getcwd()))
except Exception:
var.set(d)
ttk.Button(f, text="选择", command=_select_dir).pack(side=tk.LEFT, padx=6)
add_row(row_idx, label, f)
dir_row(4, "输入目录", input_dir_val)
dir_row(5, "输出目录", output_dir_val)
dir_row(6, "结果目录", result_dir_val)
api_key_val = tk.StringVar(value=settings.get('api_key', cfg.get('API','api_key','')))
secret_key_val = tk.StringVar(value=settings.get('secret_key', cfg.get('API','secret_key','')))
timeout_val = tk.StringVar(value=str(settings.get('timeout', cfg.getint('API','timeout',30))))
max_retries_val = tk.StringVar(value=str(settings.get('max_retries', cfg.getint('API','max_retries',3))))
retry_delay_val = tk.StringVar(value=str(settings.get('retry_delay', cfg.getint('API','retry_delay',2))))
api_url_val = tk.StringVar(value=settings.get('api_url', cfg.get('API','api_url','')))
api_key_entry = ttk.Entry(content, textvariable=api_key_val)
add_row(7, "API Key", api_key_entry)
secret_key_entry = ttk.Entry(content, textvariable=secret_key_val)
secret_key_entry.configure(show='*')
add_row(8, "Secret Key", secret_key_entry)
add_row(9, "Timeout", ttk.Entry(content, textvariable=timeout_val))
add_row(10, "Max Retries", ttk.Entry(content, textvariable=max_retries_val))
add_row(11, "Retry Delay", ttk.Entry(content, textvariable=retry_delay_val))
add_row(12, "API URL", ttk.Entry(content, textvariable=api_url_val))
# 操作按钮
btns = ttk.Frame(content)
btns.grid(row=13, column=0, columnspan=2, sticky='ew', pady=10)
btns.columnconfigure(0, weight=1)
def save_settings():
try:
s = load_user_settings()
s['log_level'] = log_level_val.get()
s['concurrency_max_workers'] = int(max_workers_val.get() or '4')
s['concurrency_batch_size'] = int(batch_size_val.get() or '5')
# 统一存储为相对路径(相对于当前工作目录)
tp = template_path_val.get()
inp = input_dir_val.get()
outp = output_dir_val.get()
resp = result_dir_val.get()
try:
if tp:
tp = os.path.relpath(tp, os.getcwd()) if os.path.isabs(tp) else tp
if inp:
inp = os.path.relpath(inp, os.getcwd()) if os.path.isabs(inp) else inp
if outp:
outp = os.path.relpath(outp, os.getcwd()) if os.path.isabs(outp) else outp
if resp:
resp = os.path.relpath(resp, os.getcwd()) if os.path.isabs(resp) else resp
except Exception:
pass
s['template_path'] = tp
s['input_folder'] = inp
s['output_folder'] = outp
s['result_folder'] = resp
save_user_settings(s)
# 应用到配置
try:
from app.core.utils.log_utils import set_log_level
set_log_level(s['log_level'])
except Exception:
pass
try:
tpl_path = s['template_path']
tpl_dir = os.path.dirname(tpl_path)
tpl_name = os.path.basename(tpl_path)
cfg.update('Paths','template_folder', tpl_dir)
cfg.update('Templates','purchase_order', tpl_name)
try:
cfg.update('Paths','template_file', os.path.join(tpl_dir, tpl_name))
except Exception:
pass
cfg.update('Paths','input_folder', s['input_folder'])
cfg.update('Paths','output_folder', s['output_folder'])
cfg.update('Performance','max_workers', s['concurrency_max_workers'])
cfg.update('Performance','batch_size', s['concurrency_batch_size'])
cfg.update('API','api_key', api_key_val.get())
cfg.update('API','secret_key', secret_key_val.get())
cfg.update('API','timeout', timeout_val.get())
cfg.update('API','max_retries', max_retries_val.get())
cfg.update('API','retry_delay', retry_delay_val.get())
cfg.update('API','api_url', api_url_val.get())
cfg.save_config()
except Exception:
pass
messagebox.showinfo("设置已保存","系统设置已更新并保存")
dlg.destroy()
except Exception as e:
messagebox.showerror("保存失败", str(e))
def reload_suppliers():
try:
global PROCESSOR_SERVICE
if PROCESSOR_SERVICE is None:
PROCESSOR_SERVICE = ProcessorService(ConfigManager())
PROCESSOR_SERVICE.reload_processors()
messagebox.showinfo("已重新加载", "供应商处理器已重新加载并应用最新配置")
except Exception as e:
messagebox.showerror("重新加载失败", str(e))
ttk.Button(btns, text="重新加载供应商配置", command=reload_suppliers).grid(row=0, column=0, sticky='w')
ttk.Button(btns, text="取消", command=dlg.destroy).grid(row=0, column=1, sticky='e')
ttk.Button(btns, text="保存", command=save_settings).grid(row=0, column=2, sticky='e', padx=6)
class StatusBar(tk.Frame):
"""状态栏,显示当前系统状态和进度"""
def __init__(self, master, **kwargs):
super().__init__(master, **kwargs)
self.configure(height=25, relief=tk.SUNKEN, borderwidth=1)
# 状态标签
self.status_label = tk.Label(self, text="就绪", anchor=tk.W, padx=5)
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
# 进度条
self.progress = ttk.Progressbar(self, orient=tk.HORIZONTAL, length=200, mode='determinate')
self.progress.pack(side=tk.RIGHT, padx=5, pady=2)
# 隐藏进度条(初始状态)
self.progress.pack_forget()
def set_status(self, text, progress=None):
"""设置状态栏文本和进度"""
self.status_label.config(text=text)
if progress is not None and 0 <= progress <= 100:
self.progress.pack(side=tk.RIGHT, padx=5, pady=2)
self.progress.config(value=progress)
else:
self.progress.pack_forget()
def set_running(self, is_running=True):
"""设置运行状态"""
if is_running:
self.status_label.config(text="处理中...", foreground=THEMES[THEME_MODE]["info"])
self.progress.pack(side=tk.RIGHT, padx=5, pady=2)
self.progress.config(mode='indeterminate')
self.progress.start()
else:
self.status_label.config(text="就绪", foreground=THEMES[THEME_MODE]["fg"])
self.progress.stop()
self.progress.pack_forget()
class ProgressReporter:
def __init__(self, status_bar: StatusBar):
self.status_bar = status_bar
def set(self, text: str, percent: int = None):
try:
if percent is not None:
self.status_bar.set_status(text, percent)
else:
self.status_bar.set_status(text)
except:
pass
def running(self):
try:
self.status_bar.set_running(True)
except:
pass
def done(self):
try:
self.status_bar.set_running(False)
self.status_bar.set_status("就绪")
except:
pass
def run_command_with_logging(command, log_widget, status_bar=None, on_complete=None):
"""运行命令并将输出重定向到日志窗口"""
global RUNNING_TASK
# 如果已有任务在运行,提示用户
if RUNNING_TASK is not None:
messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。")
return
def run_in_thread():
global RUNNING_TASK
RUNNING_TASK = command
# 更新状态栏
if status_bar:
status_bar.set_running(True)
# 记录命令开始执行的时间
start_time = datetime.datetime.now()
start_perf = time.perf_counter()
log_widget.configure(state=tk.NORMAL)
log_widget.delete(1.0, tk.END) # 清空之前的日志
log_widget.insert(tk.END, f"执行命令: {' '.join(command)}\n", "command")
log_widget.insert(tk.END, f"开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
log_widget.insert(tk.END, "=" * 50 + "\n\n", "separator")
log_widget.configure(state=tk.DISABLED)
# 获取原始的stdout和stderr
old_stdout = sys.stdout
old_stderr = sys.stderr
# 创建日志重定向器
log_redirector = LogRedirector(log_widget)
# 设置环境变量,使用配置中的目录
env = os.environ.copy()
try:
cfg = ConfigManager()
env["OCR_OUTPUT_DIR"] = cfg.get_path('Paths', 'output_folder', fallback='data/output', create=True)
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")
env["OCR_LOG_LEVEL"] = "DEBUG"
try:
# 重定向stdout和stderr到日志重定向器
sys.stdout = log_redirector
sys.stderr = log_redirector
# 打印一条消息,确认重定向已生效
print("日志重定向已启动现在同时输出到终端和GUI")
# 运行命令并捕获输出
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True,
env=env
)
output_data = []
# 读取并显示输出
for line in process.stdout:
output_data.append(line)
print(line.rstrip()) # 直接打印到已重定向的stdout
# 尝试从输出中提取进度信息
if status_bar:
progress = extract_progress_from_log(line)
if progress is not None:
log_widget.after(0, lambda p=progress: status_bar.set_status(f"处理中: {p}%完成", p))
# 等待进程结束
process.wait()
# 记录命令结束时间
end_time = datetime.datetime.now()
duration_sec = max(0.0, time.perf_counter() - start_perf)
print(f"\n{'=' * 50}")
print(f"执行完毕!返回码: {process.returncode}")
print(f"结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}")
print(f"耗时: {duration_sec:.2f}")
# 获取输出内容
output_text = ''.join(output_data)
# 检查是否是完整流程命令且遇到了"未找到可合并的文件"的情况
is_pipeline = "pipeline" in command
no_merge_files = "未找到采购单文件" in output_text
single_file = "只有1个采购单文件" in output_text
# 如果是完整流程且只是没有找到可合并文件或只有一个文件,则视为成功
if is_pipeline and (no_merge_files or single_file):
print("完整流程中没有需要合并的文件,但其他步骤执行成功,视为成功完成")
if status_bar:
log_widget.after(0, lambda: status_bar.set_status("处理完成", 100))
log_widget.after(0, lambda: show_result_preview(command, output_text))
else:
# 执行完成后处理结果
if on_complete:
log_widget.after(0, lambda: on_complete(process.returncode, output_text))
# 如果处理成功且没有指定on_complete回调函数则显示默认成功信息
elif process.returncode == 0:
if status_bar:
log_widget.after(0, lambda: status_bar.set_status("处理完成", 100))
log_widget.after(0, lambda: show_result_preview(command, output_text))
else:
if status_bar:
log_widget.after(0, lambda: status_bar.set_status(f"处理失败 (返回码: {process.returncode})", 0))
log_widget.after(0, lambda: messagebox.showerror("操作失败", f"处理失败,返回码:{process.returncode}"))
except Exception as e:
print(f"\n执行出错: {str(e)}")
if status_bar:
log_widget.after(0, lambda: status_bar.set_status(f"执行出错: {str(e)}", 0))
log_widget.after(0, lambda: messagebox.showerror("执行错误", f"执行命令时出错: {str(e)}"))
finally:
# 恢复原始stdout和stderr
sys.stdout = old_stdout
sys.stderr = old_stderr
# 任务完成,重置状态
RUNNING_TASK = None
if status_bar:
log_widget.after(0, lambda: status_bar.set_running(False))
# 在新线程中运行避免UI阻塞
Thread(target=run_in_thread).start()
def extract_progress_from_log(log_line):
"""从日志行中提取进度信息"""
# 尝试匹配"处理批次 x/y"格式的进度信息
batch_match = re.search(r'处理批次 (\d+)/(\d+)', log_line)
if batch_match:
current = int(batch_match.group(1))
total = int(batch_match.group(2))
return int(current / total * 100)
# 尝试匹配百分比格式
percent_match = re.search(r'(\d+)%', log_line)
if percent_match:
return int(percent_match.group(1))
return None
def show_result_preview(command, output):
"""显示处理结果预览"""
# 根据命令类型提取不同的结果信息
if "ocr" in command:
show_ocr_result_preview(output)
elif "excel" in command:
show_excel_result_preview(output)
elif "merge" in command:
show_merge_result_preview(output)
elif "pipeline" in command:
show_pipeline_result_preview(output)
else:
messagebox.showinfo("处理完成", "操作已成功完成!\n请在data/output目录查看结果。")
def show_ocr_result_preview(output):
"""显示OCR处理结果预览"""
# 提取处理的文件数量
files_match = re.search(r'找到 (\d+) 个图片文件,其中 (\d+) 个未处理', output)
processed_match = re.search(r'所有图片处理完成, 总计: (\d+), 成功: (\d+)', output)
if processed_match:
total = int(processed_match.group(1))
success = int(processed_match.group(2))
# 创建结果预览对话框
preview = tk.Toplevel()
preview.title("OCR处理结果")
preview.geometry("400x300")
preview.resizable(False, False)
# 居中显示
center_window(preview)
# 添加内容
tk.Label(preview, text="OCR处理完成", font=("Arial", 16, "bold")).pack(pady=10)
result_frame = tk.Frame(preview)
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
tk.Label(result_frame, text=f"总共处理: {total} 个文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"成功处理: {success} 个文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"失败数量: {total - success} 个文件", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
# 处理结果评估
if success == total:
result_text = "全部处理成功!"
result_color = "#28a745"
elif success > total * 0.8:
result_text = "大部分处理成功。"
result_color = "#ffc107"
else:
result_text = "处理失败较多,请检查日志。"
result_color = "#dc3545"
tk.Label(result_frame, text=result_text, font=("Arial", 12, "bold"), fg=result_color).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=10)
tk.Button(button_frame, text="关闭", command=preview.destroy).pack(side=tk.LEFT, padx=10)
else:
messagebox.showinfo("OCR处理完成", "OCR处理已完成请在data/output目录查看结果。")
def show_excel_result_preview(output):
"""显示Excel处理结果预览"""
# 提取处理的Excel信息
extract_match = re.search(r'提取到 (\d+) 个商品信息', output)
file_match = re.search(r'采购单已保存到: (.+?)(?:\n|$)', output)
if extract_match and file_match:
products_count = int(extract_match.group(1))
output_file = file_match.group(1)
# 创建结果预览对话框
preview = tk.Toplevel()
preview.title("Excel处理结果")
preview.geometry("450x320")
preview.resizable(False, False)
# 使弹窗居中显示
center_window(preview)
# 添加内容
tk.Label(preview, text="Excel处理完成", font=("Arial", 16, "bold")).pack(pady=10)
result_frame = tk.Frame(preview)
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
tk.Label(result_frame, text=f"提取商品数量: {products_count}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"输出文件: {os.path.basename(output_file)}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
# 处理成功提示
tk.Label(result_frame, text="采购单已成功生成!", font=("Arial", 12, "bold"), fg="#28a745").pack(pady=10)
# 文件信息框
file_frame = tk.Frame(result_frame, relief=tk.GROOVE, borderwidth=1)
file_frame.pack(fill=tk.X, padx=15, pady=5)
tk.Label(file_frame, text="文件信息", font=("Arial", 10, "bold")).pack(anchor=tk.W, padx=10, pady=5)
# 获取文件大小和时间
try:
file_size = os.path.getsize(output_file)
file_time = datetime.datetime.fromtimestamp(os.path.getmtime(output_file))
size_text = f"{file_size / 1024:.1f} KB" if file_size < 1024*1024 else f"{file_size / (1024*1024):.1f} MB"
tk.Label(file_frame, text=f"文件大小: {size_text}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
tk.Label(file_frame, text=f"创建时间: {file_time.strftime('%Y-%m-%d %H:%M:%S')}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
except:
tk.Label(file_frame, text="无法获取文件信息", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
# 添加按钮
button_frame = tk.Frame(preview)
button_frame.pack(pady=10)
tk.Button(button_frame, text="打开文件", command=lambda: os.startfile(output_file)).pack(side=tk.LEFT, padx=5)
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目录查看结果。")
def show_merge_result_preview(output):
"""显示合并结果预览"""
# 提取合并信息
merged_match = re.search(r'合并了 (\d+) 个采购单', output)
product_match = re.search(r'共处理 (\d+) 个商品', output)
output_match = re.search(r'已保存到: (.+?)(?:\n|$)', output)
if merged_match and output_match:
merged_count = int(merged_match.group(1))
product_count = int(product_match.group(1)) if product_match else 0
output_file = output_match.group(1)
# 创建结果预览对话框
preview = tk.Toplevel()
preview.title("采购单合并结果")
preview.geometry("450x300")
preview.resizable(False, False)
# 设置主题
apply_theme(preview)
# 添加内容
tk.Label(preview, text="采购单合并完成", font=("Arial", 16, "bold")).pack(pady=10)
result_frame = tk.Frame(preview)
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
tk.Label(result_frame, text=f"合并采购单数量: {merged_count}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"处理商品数量: {product_count}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"输出文件: {os.path.basename(output_file)}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
# 处理成功提示
tk.Label(result_frame, text="采购单已成功合并!", font=("Arial", 12, "bold"), fg=THEMES[THEME_MODE]["success"]).pack(pady=10)
# 添加按钮
button_frame = tk.Frame(preview)
button_frame.pack(pady=10)
tk.Button(button_frame, text="打开文件", command=lambda: os.startfile(output_file)).pack(side=tk.LEFT, padx=10)
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目录查看结果。")
def show_pipeline_result_preview(output):
"""显示完整流程结果预览"""
# 提取关键信息
ocr_match = re.search(r'所有图片处理完成, 总计: (\d+), 成功: (\d+)', output)
excel_match = re.search(r'提取到 (\d+) 个商品信息', output)
output_file_match = re.search(r'采购单已保存到: (.+?)(?:\n|$)', output)
# 创建结果预览对话框
preview = tk.Toplevel()
preview.title("完整流程处理结果")
preview.geometry("500x400")
preview.resizable(False, False)
# 居中显示
center_window(preview)
# 添加内容
tk.Label(preview, text="完整处理流程已完成", font=("Arial", 16, "bold")).pack(pady=10)
# 添加处理结果提示(即使没有可合并文件也显示成功)
no_files_match = re.search(r'未找到可合并的文件', output)
if no_files_match:
tk.Label(preview, text="未找到可合并的文件,但其他步骤已成功执行", font=("Arial", 12)).pack(pady=0)
result_frame = tk.Frame(preview)
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
# 创建多行结果区域
result_text = scrolledtext.ScrolledText(result_frame, wrap=tk.WORD, height=15, width=60)
result_text.pack(fill=tk.BOTH, expand=True, padx=15, pady=5)
result_text.configure(state=tk.NORMAL)
# 填充结果文本
result_text.insert(tk.END, "===== 流程执行结果 =====\n\n", "title")
# OCR处理结果
result_text.insert(tk.END, "步骤1: OCR识别\n", "step")
if ocr_match:
total = int(ocr_match.group(1))
success = int(ocr_match.group(2))
result_text.insert(tk.END, f" 处理图片: {total}\n", "info")
result_text.insert(tk.END, f" 成功识别: {success}\n", "info")
if success == total:
result_text.insert(tk.END, " 结果: 全部识别成功\n", "success")
else:
result_text.insert(tk.END, f" 结果: 部分识别成功 ({success}/{total})\n", "warning")
else:
result_text.insert(tk.END, " 结果: 无OCR处理或处理信息不完整\n", "warning")
# Excel处理结果
result_text.insert(tk.END, "\n步骤2: Excel处理\n", "step")
if excel_match:
products = int(excel_match.group(1))
result_text.insert(tk.END, f" 提取商品: {products}\n", "info")
result_text.insert(tk.END, " 结果: 成功生成采购单\n", "success")
if output_file_match:
output_file = output_file_match.group(1)
result_text.insert(tk.END, f" 输出文件: {os.path.basename(output_file)}\n", "info")
else:
result_text.insert(tk.END, " 结果: 无Excel处理或处理信息不完整\n", "warning")
# 总体评估
result_text.insert(tk.END, "\n===== 整体评估 =====\n", "title")
has_errors = "错误" in output or "失败" in output
no_files_match = re.search(r'未找到采购单文件', output)
single_file_match = re.search(r'只有1个采购单文件', output)
if no_files_match:
result_text.insert(tk.END, "没有找到可合并的文件,但处理流程已成功完成。\n", "warning")
result_text.insert(tk.END, "可以选择打开Excel文件或查看输出文件夹。\n", "info")
elif single_file_match:
result_text.insert(tk.END, "只有一个采购单文件,无需合并,处理流程已成功完成。\n", "warning")
result_text.insert(tk.END, "可以选择打开生成的Excel文件。\n", "info")
elif ocr_match and excel_match and not has_errors:
result_text.insert(tk.END, "流程完整执行成功!\n", "success")
elif ocr_match or excel_match:
result_text.insert(tk.END, "流程部分执行成功,请检查日志获取详情。\n", "warning")
else:
result_text.insert(tk.END, "流程执行可能存在问题,请查看详细日志。\n", "error")
# 设置标签样式
result_text.tag_configure("title", font=("Arial", 12, "bold"))
result_text.tag_configure("step", font=("Arial", 11, "bold"))
result_text.tag_configure("info", font=("Arial", 10))
result_text.tag_configure("success", font=("Arial", 10, "bold"), foreground="#28a745")
result_text.tag_configure("warning", font=("Arial", 10, "bold"), foreground="#ffc107")
result_text.tag_configure("error", font=("Arial", 10, "bold"), foreground="#dc3545")
result_text.configure(state=tk.DISABLED)
# 添加按钮
button_frame = tk.Frame(preview)
button_frame.pack(pady=10)
if output_file_match:
output_file = output_file_match.group(1)
tk.Button(button_frame, text="打开Excel文件", command=lambda: os.startfile(output_file)).pack(side=tk.LEFT, padx=10)
else:
# 如果没有找到合并后的文件但Excel处理成功提供打开最新Excel文件的选项
if excel_match or no_files_match or single_file_match:
# 找到输出目录中最新的采购单Excel文件
output_dir = os.path.abspath("data/output")
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)
latest_file = os.path.join(output_dir, excel_files[0])
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=preview.destroy).pack(side=tk.LEFT, padx=10)
def create_modern_button(parent, text, command, style="primary", width=None, height=None, px_width=None, px_height=None):
"""创建现代化样式的按钮"""
theme = THEMES[THEME_MODE]
if style == "primary":
bg_color = "white" # 白色背景
fg_color = theme["primary_bg"] # 蓝色文字
hover_color = "#f0f8ff" # 浅蓝色悬停
border_color = theme["primary_bg"] # 蓝色边框
elif style == "secondary":
bg_color = theme["secondary_bg"]
fg_color = theme["secondary_fg"]
hover_color = theme["button_hover"]
border_color = theme["secondary_bg"]
else: # default
bg_color = "white" # 白色背景
fg_color = theme["primary_bg"] # 蓝色文字
hover_color = "#f0f8ff" # 浅蓝色悬停
border_color = theme["primary_bg"] # 蓝色边框
# 创建一个Frame来包装按钮
button_frame = tk.Frame(parent, bg=border_color, highlightthickness=0)
button_frame.configure(relief="flat", bd=0)
if px_width or px_height:
try:
w = px_width if px_width else button_frame.winfo_reqwidth()
h = px_height if px_height else 32
button_frame.configure(width=w, height=h)
button_frame.pack_propagate(False)
except Exception:
pass
# 创建实际的按钮
button = tk.Button(
button_frame,
text=text,
command=command,
bg=bg_color,
fg=fg_color,
font=("Microsoft YaHei UI", 8),
relief="flat",
bd=0,
padx=14,
pady=4,
anchor="center",
cursor="hand2",
activebackground=hover_color,
activeforeground=fg_color
)
if width:
button.configure(width=width)
else:
button.configure(width=12)
if height is not None:
button.configure(height=height)
else:
button.configure(height=1)
if height:
button.configure(height=height)
button.pack(fill=tk.BOTH, expand=True, padx=1, pady=1)
return button_frame
# 添加悬停效果
def on_enter(e):
button.configure(bg=hover_color)
def on_leave(e):
button.configure(bg=bg_color)
button.bind("<Enter>", on_enter)
button.bind("<Leave>", on_leave)
button_frame.bind("<Enter>", on_enter)
button_frame.bind("<Leave>", on_leave)
return button_frame
def create_card_frame(parent, title=None):
"""创建卡片样式的框架"""
theme = THEMES[THEME_MODE]
card = tk.Frame(
parent,
bg=theme["card_bg"],
relief="flat",
borderwidth=1,
highlightbackground=theme["border"],
highlightthickness=1
)
if title:
title_label = tk.Label(
card,
text=title,
bg=theme["card_bg"],
fg=theme["fg"],
font=("Microsoft YaHei UI", 10, "bold")
)
title_label.pack(pady=(6, 3))
return card
def apply_theme(widget, theme_mode=None):
"""应用主题到小部件"""
global THEME_MODE
if theme_mode is None:
theme_mode = THEME_MODE
theme = THEMES[theme_mode]
try:
widget.configure(bg=theme["bg"], fg=theme["fg"])
except:
pass
# 递归应用到所有子部件
for child in widget.winfo_children():
if isinstance(child, tk.Button) and not isinstance(child, ttk.Button):
child.configure(bg=theme["button_bg"], fg=theme["button_fg"])
elif isinstance(child, scrolledtext.ScrolledText):
child.configure(bg=theme["log_bg"], fg=theme["log_fg"])
else:
try:
child.configure(bg=theme["bg"], fg=theme["fg"])
except:
pass
# 递归处理子部件的子部件
apply_theme(child, theme_mode)
def ensure_directories():
"""确保必要的目录结构存在"""
directories = ["data/input", "data/output", "data/result", "data/temp", "logs"]
for directory in directories:
if not os.path.exists(directory):
os.makedirs(directory, exist_ok=True)
print(f"创建目录: {directory}")
class LogRedirector:
"""日志重定向器,用于捕获命令输出并显示到界面"""
def __init__(self, text_widget):
self.text_widget = text_widget
self.buffer = ""
self.terminal = sys.__stdout__ # 保存原始的stdout引用
def write(self, string):
self.buffer += string
# 同时输出到终端
self.terminal.write(string)
# 在UI线程中更新文本控件
self.text_widget.after(0, self.update_text_widget)
def update_text_widget(self):
self.text_widget.configure(state=tk.NORMAL)
# 根据内容使用不同的标签
if self.buffer.strip():
# 检测不同类型的消息并应用相应样式
if any(marker in self.buffer.lower() for marker in ["错误", "error", "失败", "异常", "exception"]):
self.text_widget.insert(tk.END, self.buffer, "error")
elif any(marker in self.buffer.lower() for marker in ["警告", "warning"]):
self.text_widget.insert(tk.END, self.buffer, "warning")
elif any(marker in self.buffer.lower() for marker in ["成功", "success", "完成", "成功处理"]):
self.text_widget.insert(tk.END, self.buffer, "success")
elif any(marker in self.buffer.lower() for marker in ["info", "信息", "开始", "处理中"]):
self.text_widget.insert(tk.END, self.buffer, "info")
else:
self.text_widget.insert(tk.END, self.buffer, "normal")
else:
self.text_widget.insert(tk.END, self.buffer)
# 自动滚动到底部
self.text_widget.see(tk.END)
self.text_widget.configure(state=tk.DISABLED)
self.buffer = ""
def flush(self):
self.terminal.flush() # 确保终端也被刷新
class GUILogHandler(logging.Handler):
"""自定义日志处理器将日志输出到GUI界面"""
def __init__(self, text_widget):
super().__init__()
self.text_widget = text_widget
def emit(self, record):
try:
msg = self.format(record)
# 根据日志级别确定标签
if record.levelno >= logging.ERROR:
tag = "error"
elif record.levelno >= logging.WARNING:
tag = "warning"
elif record.levelno >= logging.INFO:
tag = "info"
else:
tag = "normal"
# 在UI线程中更新文本控件
self.text_widget.after(0, lambda: self._update_text_widget(msg + "\n", tag))
except Exception:
self.handleError(record)
def _update_text_widget(self, message, tag):
"""在UI线程中更新文本控件"""
self.text_widget.configure(state=tk.NORMAL)
self.text_widget.insert(tk.END, message, tag)
self.text_widget.see(tk.END)
self.text_widget.configure(state=tk.DISABLED)
def init_gui_logger(text_widget, level=logging.INFO):
handler = GUILogHandler(text_widget)
handler.setLevel(level)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
root_logger = logging.getLogger()
for h in root_logger.handlers[:]:
if isinstance(h, logging.StreamHandler):
root_logger.removeHandler(h)
if not any(isinstance(h, GUILogHandler) for h in root_logger.handlers):
root_logger.addHandler(handler)
root_logger.setLevel(level)
return handler
def dispose_gui_logger():
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
if isinstance(handler, GUILogHandler):
root_logger.removeHandler(handler)
try:
handler.close()
except:
pass
def create_collapsible_frame(parent, title, initial_state=True):
"""创建可折叠的面板"""
frame = tk.Frame(parent)
frame.pack(fill=tk.X, pady=5)
# 标题栏
title_frame = tk.Frame(frame)
title_frame.pack(fill=tk.X)
# 折叠指示器
state_var = tk.BooleanVar(value=initial_state)
indicator = "" if initial_state else ""
state_label = tk.Label(title_frame, text=indicator, font=("Arial", 10, "bold"))
state_label.pack(side=tk.LEFT, padx=5)
# 标题
title_label = tk.Label(title_frame, text=title, font=("Arial", 11, "bold"))
title_label.pack(side=tk.LEFT, padx=5)
# 内容区域
content_frame = tk.Frame(frame)
if initial_state:
content_frame.pack(fill=tk.X, padx=20, pady=5)
# 点击事件处理函数
def toggle_collapse(event=None):
current_state = state_var.get()
new_state = not current_state
state_var.set(new_state)
# 更新指示器
state_label.config(text="" if new_state else "")
# 显示或隐藏内容
if new_state:
content_frame.pack(fill=tk.X, padx=20, pady=5)
else:
content_frame.pack_forget()
# 绑定点击事件
title_frame.bind("<Button-1>", toggle_collapse)
state_label.bind("<Button-1>", toggle_collapse)
title_label.bind("<Button-1>", toggle_collapse)
return content_frame, state_var
def process_single_image_with_status(log_widget, status_bar):
status_bar.set_status("选择图片中...")
file_path = select_file(log_widget, [("图片文件", "*.jpg *.jpeg *.png *.bmp"), ("所有文件", "*.*")], "选择图片")
if not file_path:
status_bar.set_status("操作已取消")
add_to_log(log_widget, "未选择文件,操作已取消\n", "warning")
return
def run_in_thread():
try:
status_bar.set_running(True)
status_bar.set_status("开始处理图片...")
gui_handler = GUILogHandler(log_widget)
gui_handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
gui_handler.setFormatter(formatter)
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
if isinstance(handler, logging.StreamHandler):
root_logger.removeHandler(handler)
root_logger.addHandler(gui_handler)
root_logger.setLevel(logging.INFO)
ocr_service = OCRService()
add_to_log(log_widget, f"开始处理图片: {file_path}\n", "info")
try:
add_recent_file(file_path)
except Exception:
pass
excel_path = ocr_service.process_image(file_path)
if excel_path:
add_to_log(log_widget, "图片OCR处理完成\n", "success")
preview_output = f"采购单已保存到: {excel_path}\n"
show_excel_result_preview(preview_output)
try:
add_recent_file(excel_path)
except Exception:
pass
pass
else:
add_to_log(log_widget, "图片OCR处理失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"处理单个图片时出错: {str(e)}\n", "error")
sugg = get_error_suggestion(str(e))
if sugg:
show_error_dialog("OCR处理错误", str(e), sugg)
finally:
try:
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
if isinstance(handler, GUILogHandler):
root_logger.removeHandler(handler)
handler.close()
except:
pass
status_bar.set_running(False)
status_bar.set_status("就绪")
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def run_pipeline_directly(log_widget, status_bar):
"""直接运行完整处理流程"""
global RUNNING_TASK
# 如果已有任务在运行,提示用户
if RUNNING_TASK is not None:
messagebox.showinfo("任务进行中", "请等待当前任务完成后再执行新的操作。")
return
def run_in_thread():
global RUNNING_TASK
RUNNING_TASK = "pipeline"
# 更新状态栏
if status_bar:
status_bar.set_running(True)
status_bar.set_status("开始完整处理流程...")
# 记录开始时间
start_time = datetime.datetime.now()
start_perf = time.perf_counter()
log_widget.configure(state=tk.NORMAL)
log_widget.delete(1.0, tk.END) # 清空之前的日志
log_widget.insert(tk.END, f"执行命令: 完整处理流程\n", "command")
log_widget.insert(tk.END, f"开始时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
log_widget.insert(tk.END, "=" * 50 + "\n\n", "separator")
log_widget.configure(state=tk.DISABLED)
try:
# 直接调用main函数中的pipeline逻辑
from app.config.settings import ConfigManager
from app.services.ocr_service import OCRService
from app.services.order_service import OrderService
import logging
config = ConfigManager()
gui_handler = init_gui_logger(log_widget)
# 创建服务实例
ocr_service = OCRService(config)
order_service = OrderService(config)
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("开始OCR批量处理...", 10)
# 1. OCR批量处理
total, success = ocr_service.batch_process(progress_cb=lambda p: reporter.set("OCR处理中...", p))
if total == 0:
add_to_log(log_widget, "没有找到需要处理的图片\n", "warning")
if status_bar:
status_bar.set_status("未找到图片文件")
return
elif success == 0:
add_to_log(log_widget, "OCR处理没有成功处理任何新文件\n", "warning")
else:
add_to_log(log_widget, f"OCR处理完成共处理 {success}/{total} 个文件\n", "success")
try:
processed_map = {}
pjson = os.path.join("data", "output", "processed_files.json")
if os.path.exists(pjson):
with open(pjson, 'r', encoding='utf-8') as f:
processed_map = json.load(f)
outputs = list(processed_map.values())
for p in outputs[-10:]:
if p:
add_recent_file(os.path.abspath(p))
except Exception:
pass
reporter.set("开始Excel处理...", 92)
# 2. Excel处理
add_to_log(log_widget, "开始Excel处理...\n", "info")
result = order_service.process_excel()
if not result:
add_to_log(log_widget, "Excel处理失败\n", "error")
else:
add_to_log(log_widget, "Excel处理完成\n", "success")
try:
add_recent_file(result)
except Exception:
pass
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception:
pass
pass
# 3. 可选的合并步骤(如果有多个采购单)
reporter.set("检查是否需要合并采购单...", 80)
try:
# 先获取采购单文件列表
purchase_orders = order_service.get_purchase_orders()
if len(purchase_orders) == 0:
add_to_log(log_widget, "没有找到采购单文件,跳过合并步骤\n", "info")
elif len(purchase_orders) == 1:
add_to_log(log_widget, f"只有1个采购单文件无需合并: {os.path.basename(purchase_orders[0])}\n", "info")
else:
add_to_log(log_widget, f"找到{len(purchase_orders)}个采购单文件\n", "info")
# 弹窗询问是否进行合并
file_list = "\n".join([f"{os.path.basename(f)}" for f in purchase_orders])
merge_choice = messagebox.askyesnocancel(
"采购单合并选择",
f"发现{len(purchase_orders)}个采购单文件:\n\n{file_list}\n\n是否需要合并这些采购单?\n\n• 选择'':合并所有采购单\n• 选择'':保持文件分离\n• 选择'取消':跳过此步骤",
icon='question'
)
if merge_choice is True: # 用户选择合并
add_to_log(log_widget, "用户选择合并采购单,开始合并...\n", "info")
merge_result = order_service.merge_all_purchase_orders()
if merge_result:
add_to_log(log_widget, "采购单合并完成\n", "success")
try:
add_recent_file(merge_result)
except Exception:
pass
else:
add_to_log(log_widget, "合并失败\n", "warning")
elif merge_choice is False: # 用户选择不合并
add_to_log(log_widget, "用户选择不合并采购单,保持文件分离\n", "info")
else: # 用户选择取消
add_to_log(log_widget, "用户取消合并操作,跳过合并步骤\n", "info")
except Exception as e:
add_to_log(log_widget, f"合并过程出现问题: {str(e)}\n", "warning")
# 记录结束时间
end_time = datetime.datetime.now()
duration_sec = max(0.0, time.perf_counter() - start_perf)
add_to_log(log_widget, f"\n{'=' * 50}\n", "separator")
add_to_log(log_widget, f"完整处理流程执行完毕!\n", "success")
add_to_log(log_widget, f"结束时间: {end_time.strftime('%Y-%m-%d %H:%M:%S')}\n", "time")
add_to_log(log_widget, f"耗时: {duration_sec:.2f}\n", "time")
reporter.set("处理完成", 100)
pass
except Exception as e:
add_to_log(log_widget, f"执行过程中发生错误: {str(e)}\n", "error")
import traceback
add_to_log(log_widget, f"详细错误信息: {traceback.format_exc()}\n", "error")
finally:
# 清理日志处理器
dispose_gui_logger()
reporter.done()
RUNNING_TASK = None
if status_bar:
status_bar.set_running(False)
status_bar.set_status("就绪")
# 在新线程中运行
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def batch_ocr_with_status(log_widget, status_bar):
"""OCR批量识别"""
def run_in_thread():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("正在进行OCR批量识别...", 10)
add_to_log(log_widget, "开始OCR批量识别\n", "info")
init_gui_logger(log_widget)
# 创建OCR服务实例
ocr_service = OCRService()
# 执行批量OCR处理
result = ocr_service.batch_process()
if result:
add_to_log(log_widget, "OCR批量识别完成\n", "success")
show_ocr_result_preview("OCR批量识别成功完成")
reporter.set("批量识别完成", 100)
try:
processed_map = {}
pjson = os.path.join("data", "output", "processed_files.json")
if os.path.exists(pjson):
with open(pjson, 'r', encoding='utf-8') as f:
processed_map = json.load(f)
outputs = list(processed_map.values())
for p in outputs[-10:]:
if p:
add_recent_file(p)
inputs = list(processed_map.keys())
for p in inputs[-10:]:
if p:
add_recent_file(p)
except Exception:
pass
pass
else:
add_to_log(log_widget, "OCR批量识别失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"OCR批量识别出错: {str(e)}\n", "error")
sugg = get_error_suggestion(str(e))
if sugg:
show_error_dialog("OCR处理错误", str(e), sugg)
finally:
dispose_gui_logger()
reporter.done()
# 在新线程中运行
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def batch_process_orders_with_status(log_widget, status_bar):
"""批量处理订单仅Excel处理包含合并确认"""
def run_in_thread():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("正在批量处理订单...", 10)
add_to_log(log_widget, "开始批量处理订单\n", "info")
init_gui_logger(log_widget)
# 创建订单服务实例
order_service = OrderService()
# 执行Excel处理
add_to_log(log_widget, "开始Excel处理...\n", "info")
try:
latest_input = order_service.get_latest_excel()
if latest_input:
add_recent_file(latest_input)
except Exception:
pass
result = order_service.process_excel(progress_cb=lambda p: reporter.set("Excel处理中...", p))
if result:
add_to_log(log_widget, "Excel处理完成\n", "success")
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception:
pass
reporter.set("检查是否需要合并采购单...", 70)
add_to_log(log_widget, "检查是否需要合并采购单...\n", "info")
try:
# 先获取采购单文件列表
purchase_orders = order_service.get_purchase_orders()
if len(purchase_orders) == 0:
add_to_log(log_widget, "没有找到采购单文件,跳过合并步骤\n", "info")
elif len(purchase_orders) == 1:
add_to_log(log_widget, f"只有1个采购单文件无需合并: {os.path.basename(purchase_orders[0])}\n", "info")
else:
add_to_log(log_widget, f"找到{len(purchase_orders)}个采购单文件\n", "info")
# 弹窗询问是否进行合并
file_list = "\n".join([f"{os.path.basename(f)}" for f in purchase_orders])
merge_choice = messagebox.askyesnocancel(
"采购单合并选择",
f"发现{len(purchase_orders)}个采购单文件:\n\n{file_list}\n\n是否需要合并这些采购单?\n\n• 选择'':合并所有采购单\n• 选择'':保持文件分离\n• 选择'取消':跳过此步骤",
icon='question'
)
if merge_choice is True: # 用户选择合并
add_to_log(log_widget, "开始合并采购单...\n", "info")
merge_result = order_service.merge_all_purchase_orders()
if merge_result:
add_to_log(log_widget, "采购单合并完成\n", "success")
else:
add_to_log(log_widget, "采购单合并失败\n", "error")
elif merge_choice is False: # 用户选择不合并
add_to_log(log_widget, "用户选择保持文件分离,跳过合并步骤\n", "info")
else: # 用户选择取消
add_to_log(log_widget, "用户取消合并操作\n", "info")
except Exception as e:
add_to_log(log_widget, f"合并检查过程中出错: {str(e)}\n", "error")
add_to_log(log_widget, "批量处理订单完成\n", "success")
reporter.set("批量处理订单完成", 100)
show_excel_result_preview(f"采购单已保存到: {result}\n")
try:
add_recent_file(result)
except Exception:
pass
pass
else:
add_to_log(log_widget, "批量处理订单失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"批量处理订单时出错: {str(e)}\n", "error")
sugg = get_error_suggestion(str(e))
if sugg:
show_error_dialog("Excel处理错误", str(e), sugg)
finally:
dispose_gui_logger()
reporter.done()
# 在新线程中运行
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def merge_orders_with_status(log_widget, status_bar):
"""合并采购单"""
def run_in_thread():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("正在合并采购单...", 10)
add_to_log(log_widget, "开始合并采购单\n", "info")
init_gui_logger(log_widget)
# 创建订单服务实例
order_service = OrderService()
# 执行合并处理接入进度回调97%→100%
result = order_service.merge_all_purchase_orders(progress_cb=lambda p: reporter.set("合并处理中...", p))
if result:
add_to_log(log_widget, "采购单合并完成\n", "success")
show_merge_result_preview(f"已保存到: {result}\n")
try:
add_recent_file(result)
except Exception:
pass
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception:
pass
pass
else:
add_to_log(log_widget, "采购单合并失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"采购单合并出错: {str(e)}\n", "error")
sugg = get_error_suggestion(str(e))
if sugg:
show_error_dialog("合并错误", str(e), sugg)
finally:
dispose_gui_logger()
reporter.done()
# 在新线程中运行
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def process_tobacco_orders_with_status(log_widget, status_bar):
"""处理烟草订单"""
def run_in_thread():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("正在处理烟草订单...", 10)
add_to_log(log_widget, "开始处理烟草订单\n", "info")
init_gui_logger(log_widget)
# 创建烟草服务实例
config_manager = ConfigManager()
tobacco_service = TobaccoService(config_manager)
# 执行烟草订单处理
result = tobacco_service.process_tobacco_order()
if result:
add_to_log(log_widget, "烟草订单处理完成\n", "success")
try:
add_recent_file(result)
except Exception:
pass
pass
else:
add_to_log(log_widget, "烟草订单处理失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"烟草订单处理出错: {str(e)}\n", "error")
finally:
dispose_gui_logger()
reporter.done()
# 在新线程中运行
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def process_rongcheng_yigou_with_status(log_widget, status_bar):
def run_in_thread():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
reporter.set("正在处理蓉城易购...", 10)
add_to_log(log_widget, "开始处理蓉城易购\n", "info")
s = load_user_settings()
out_dir = os.path.abspath(s.get('output_folder', 'data/output'))
if not os.path.exists(out_dir):
os.makedirs(out_dir, exist_ok=True)
candidates = []
for f in os.listdir(out_dir):
fn = f.lower()
if re.match(r'^订单\d+\.xlsx$', fn):
p = os.path.join(out_dir, f)
try:
candidates.append((p, os.path.getmtime(p)))
except Exception:
pass
if not candidates:
add_to_log(log_widget, "未在输出目录找到蓉城易购订单文件\n", "warning")
reporter.done()
return
candidates.sort(key=lambda x: x[1], reverse=True)
src_path = candidates[0][0]
reporter.set("读取并清洗数据...", 25)
def _pick_col(df, exact_list=None, contains_list=None):
cols = list(df.columns)
if exact_list:
for name in exact_list:
for c in cols:
if str(c).strip() == str(name).strip():
return c
if contains_list:
for kw in contains_list:
for c in cols:
if kw in str(c):
return c
return None
try:
df_raw = pd.read_excel(src_path, header=2)
except Exception:
df_raw = pd.read_excel(src_path)
df_raw = df_raw.iloc[2:].reset_index(drop=True)
# 去除全空列与行
df_raw = df_raw.dropna(how='all', axis=1).dropna(how='all', axis=0)
# 选择关键列(包含关键词)
col_no = _pick_col(df_raw, contains_list=['序号'])
col_name = _pick_col(df_raw, contains_list=['商品名称','品名','名称'])
col_bc = _pick_col(df_raw, contains_list=['商品条码','条码'])
col_unit = _pick_col(df_raw, exact_list=['单位(订购单位)'], contains_list=['订购单位','小单位','单位'])
col_qty = _pick_col(df_raw, contains_list=['订购数量','订货数量','数量'])
# 新模板映射:优惠后金额(小单位)作为“单价(小单位)”,出库小计(元)作为“优惠后金额(小单位)”
col_price= _pick_col(df_raw, exact_list=['优惠后金额(小单位)'], contains_list=['单价','销售价','进货价','优惠后金额'])
col_amt = _pick_col(df_raw, exact_list=['出库小计(元)'], contains_list=['金额','优惠后金额','小计','合计','出库小计'])
selected = [c for c in [col_no,col_name,col_bc,col_unit,col_qty,col_price,col_amt] if c]
if not selected or len(selected) < 4:
# 兜底:沿用旧逻辑(索引选列)
df = pd.read_excel(src_path)
df = df.iloc[2:].reset_index(drop=True)
keep_idx = [0, 2, 3, 9, 12, 15, 17]
keep_idx = [i for i in keep_idx if i < df.shape[1]]
df2 = df.iloc[:, keep_idx].copy()
target_cols = ['序号','商品名称','商品条码','单位','数量','单价','金额']
df2.columns = target_cols[:len(df2.columns)]
else:
df2 = df_raw[selected].copy()
# 统一列名到旧配置期望的列名,便于后续映射
rename_map = {}
if col_no: rename_map[col_no] = '序号'
if col_name: rename_map[col_name] = '商品名称'
if col_bc: rename_map[col_bc] = '商品条码(小条码)'
if col_unit: rename_map[col_unit] = '单位'
if col_qty: rename_map[col_qty] = '订购数量(小单位)'
if col_price: rename_map[col_price] = '单价(小单位)'
if col_amt: rename_map[col_amt] = '优惠后金额(小单位)'
df2 = df2.rename(columns=rename_map)
# 单位清洗(与旧版一致:将“件”改为“份”,并去除空白)
if '单位' in df2.columns:
try:
df2['单位'] = df2['单位'].astype(str).str.strip().replace({'':''})
except Exception:
pass
# 保留原始订购单位
# 分裂多条码行并均分数量
try:
bc_col = '商品条码(小条码)' if '商品条码(小条码)' in df2.columns else ('商品条码' if '商品条码' in df2.columns else ('条码' if '条码' in df2.columns else None))
qty_col = '订购数量(小单位)' if '订购数量(小单位)' in df2.columns else ('订购数量' if '订购数量' in df2.columns else ('数量' if '数量' in df2.columns else None))
up_col = '单价(小单位)' if '单价(小单位)' in df2.columns else ('单价' if '单价' in df2.columns else ('销售价' if '销售价' in df2.columns else None))
amt_col = '优惠后金额(小单位)' if '优惠后金额(小单位)' in df2.columns else ('金额' if '金额' in df2.columns else ('小计' if '小计' in df2.columns else None))
if bc_col and qty_col:
rows = []
for _, row in df2.iterrows():
bc_val = str(row.get(bc_col, '')).strip()
if bc_val and (',' in bc_val or '' in bc_val or '' in bc_val or ' ' in bc_val or '/' in bc_val):
parts = []
for sep in [',','','','/',' ']:
bc_val = bc_val.replace(sep, ' ')
for token in bc_val.split():
tok = ''.join([ch for ch in token if ch.isdigit()])
if tok:
parts.append(tok)
parts = [p for p in parts if p]
if len(parts) >= 2:
try:
q_total = float(row.get(qty_col, 0) or 0)
except Exception:
q_total = 0
if q_total > 0:
n = len(parts)
base = int(q_total) // n if q_total.is_integer() else q_total / n
remainder = int(q_total) % n if q_total.is_integer() else 0
for i, bc in enumerate(parts):
new_row = row.copy()
new_row[bc_col] = bc
q_each = base + (1 if remainder > 0 and i < remainder else 0)
new_row[qty_col] = q_each
if up_col and amt_col:
try:
upv = float(new_row.get(up_col, 0) or 0)
new_row[amt_col] = upv * float(q_each)
except Exception:
pass
rows.append(new_row)
else:
# 无法分配数量,保留原行
rows.append(row)
else:
rows.append(row)
else:
rows.append(row)
df2 = pd.DataFrame(rows)
except Exception:
pass
base = os.path.basename(src_path)
inter_name = f"蓉城易购_处理后_{base}"
inter_path = os.path.join(out_dir, inter_name)
reporter.set("保存处理结果...", 45)
df2.to_excel(inter_path, index=False)
final_name = f"蓉城易购-{base}"
final_path = os.path.join(out_dir, final_name)
try:
if os.path.exists(final_path):
os.remove(final_path)
except Exception:
pass
try:
os.replace(inter_path, final_path)
except Exception:
final_path = inter_path
add_to_log(log_widget, f"蓉城易购预处理完成: {final_path}\n", "success")
reporter.set("准备进行普通Excel处理...", 60)
add_recent_file(final_path)
time.sleep(3)
order_service = OrderService()
result = order_service.process_excel(final_path, progress_cb=lambda p: reporter.set("Excel处理中...", p))
if result:
add_to_log(log_widget, "Excel普通处理完成\n", "success")
add_recent_file(result)
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception:
pass
open_result_directory_from_settings()
reporter.set("处理完成", 100)
else:
add_to_log(log_widget, "Excel普通处理失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"处理蓉城易购时出错: {str(e)}\n", "error")
msg = str(e)
suggestion = None
if 'pandas' in msg:
suggestion = "安装依赖pip install pandas openpyxl"
if suggestion:
show_error_dialog("蓉城易购处理错误", msg, suggestion)
finally:
try:
reporter.done()
except Exception:
pass
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def process_excel_file_with_status(log_widget, status_bar):
"""处理Excel文件"""
def run_in_thread():
try:
status_bar.set_running(True)
status_bar.set_status("选择Excel文件中...")
file_path = select_excel_file(log_widget)
if file_path:
status_bar.set_status("开始处理Excel文件...")
add_to_log(log_widget, f"开始处理Excel文件: {file_path}\n", "info")
else:
status_bar.set_status("操作已取消")
add_to_log(log_widget, "未选择文件,操作已取消\n", "warning")
return
init_gui_logger(log_widget)
# 创建订单服务实例
order_service = OrderService()
# 执行Excel处理
if file_path:
try:
add_recent_file(file_path)
except Exception:
pass
result = order_service.process_excel(file_path, progress_cb=lambda p: status_bar.set_status("Excel处理中...", p))
else:
try:
latest_input = order_service.get_latest_excel()
if latest_input:
add_recent_file(latest_input)
except Exception:
pass
result = order_service.process_excel(progress_cb=lambda p: status_bar.set_status("Excel处理中...", p))
if result:
add_to_log(log_widget, "Excel文件处理完成\n", "success")
show_excel_result_preview(f"采购单已保存到: {result}\n")
try:
add_recent_file(result)
except Exception:
pass
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception:
pass
pass
else:
add_to_log(log_widget, "Excel文件处理失败\n", "error")
except Exception as e:
add_to_log(log_widget, f"Excel文件处理出错: {str(e)}\n", "error")
msg = str(e)
suggestion = None
if 'openpyxl' in msg or 'engine' in msg:
suggestion = "安装依赖pip install openpyxl"
elif 'xlrd' in msg:
suggestion = "安装依赖pip install xlrd"
if suggestion:
show_error_dialog("Excel处理错误", msg, suggestion)
finally:
# 清理日志处理器
dispose_gui_logger()
status_bar.set_running(False)
status_bar.set_status("就绪")
# 在新线程中运行
thread = Thread(target=run_in_thread)
thread.daemon = True
thread.start()
def main():
"""主函数"""
try:
# 确保必要的目录结构存在并转移旧目录内容
ensure_directories()
# 创建窗口
dnd_supported = False
try:
from tkinterdnd2 import TkinterDnD, DND_FILES
root = TkinterDnD.Tk()
dnd_supported = True
except Exception:
root = tk.Tk()
settings = load_user_settings()
global THEME_MODE
THEME_MODE = settings.get('theme_mode', THEME_MODE)
try:
cfg_for_title = ConfigManager()
ver = cfg_for_title.get('App','version', fallback='dev')
root.title(f"益选-OCR订单处理系统 v{ver} by 欢欢欢")
except Exception:
root.title("益选-OCR订单处理系统 by 欢欢欢")
default_size = settings.get('window_size', "900x600")
root.geometry("900x600")
settings['window_size'] = "900x600"
root.configure(bg=THEMES[THEME_MODE]["bg"])
try:
log_level = settings.get('log_level')
if log_level:
set_log_level(log_level)
concurrency = settings.get('concurrency_max_workers')
if concurrency:
cfg = ConfigManager()
cfg.update('Performance', 'max_workers', str(concurrency))
cfg.save_config()
except Exception:
pass
# 设置窗口图标和样式
try:
root.iconbitmap(default="")
except:
pass
# 创建主容器
main_container = tk.Frame(root, bg=THEMES[THEME_MODE]["bg"])
main_container.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 隐藏主标题区域,减少间距
# 创建主内容区域
content_frame = tk.Frame(main_container, bg=THEMES[THEME_MODE]["bg"])
content_frame.pack(fill=tk.BOTH, expand=True)
# 左侧控制面板
left_panel = create_card_frame(content_frame)
left_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=False, padx=(0, 5), pady=5)
left_panel.configure(width=160)
# 中间容器(拖拽处理 + 日志)
mid_container = tk.Frame(content_frame, bg=THEMES[THEME_MODE]["bg"])
mid_container.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 5), pady=5)
# 顶部拖拽处理条高度约120px
drag_panel = create_card_frame(mid_container)
drag_panel.pack(side=tk.TOP, fill=tk.X, padx=(5, 5), pady=(0, 5))
drag_panel_content = tk.Frame(drag_panel, bg=THEMES[THEME_MODE]["card_bg"])
drag_panel_content.pack(fill=tk.X, padx=10, pady=6)
# 中间日志面板
log_panel = create_card_frame(mid_container, "处理日志")
log_panel.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=(5, 5), pady=5)
# 右侧设置与工具面板
right_panel = create_card_frame(content_frame)
right_panel.pack(side=tk.RIGHT, fill=tk.BOTH, expand=False, padx=(5, 0), pady=5)
right_panel.configure(width=380)
# 日志文本区域
log_text = scrolledtext.ScrolledText(
log_panel,
wrap=tk.WORD,
width=68,
height=26,
bg=THEMES[THEME_MODE]["log_bg"],
fg=THEMES[THEME_MODE]["log_fg"],
font=("Consolas", 9),
state=tk.DISABLED,
relief="flat",
borderwidth=0
)
log_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10))
# 配置日志文本样式
log_text.tag_configure("command", foreground=THEMES[THEME_MODE]["info"], font=("Consolas", 9, "bold"))
log_text.tag_configure("time", foreground=THEMES[THEME_MODE]["secondary_bg"], font=("Consolas", 8))
log_text.tag_configure("separator", foreground=THEMES[THEME_MODE]["border"])
log_text.tag_configure("success", foreground=THEMES[THEME_MODE]["success"], font=("Consolas", 9, "bold"))
log_text.tag_configure("error", foreground=THEMES[THEME_MODE]["error"], font=("Consolas", 9, "bold"))
log_text.tag_configure("warning", foreground=THEMES[THEME_MODE]["warning"], font=("Consolas", 9, "bold"))
log_text.tag_configure("info", foreground=THEMES[THEME_MODE]["info"], font=("Consolas", 9))
# 初始化日志内容
add_to_log(log_text, "欢迎使用 益选-OCR订单处理系统 v1.1.0\n", "success")
add_to_log(log_text, "系统已就绪,请选择相应功能进行操作。\n\n", "info")
add_to_log(log_text, "功能说明:\n", "command")
add_to_log(log_text, "• 完整处理流程一键完成OCR识别和Excel处理\n", "info")
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")
add_to_log(log_text, "=" * 50 + "\n\n", "separator")
# 创建状态栏
status_bar = StatusBar(root)
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
# 左侧面板内容区域
panel_content = tk.Frame(left_panel, bg=THEMES[THEME_MODE]["card_bg"])
panel_content.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10))
# 拖拽处理(移动到中间容器顶部)
dnd_section = tk.LabelFrame(
drag_panel_content,
bg=THEMES[THEME_MODE]["card_bg"],
fg=THEMES[THEME_MODE]["fg"],
font=("Microsoft YaHei UI", 10, "bold"),
relief="flat",
borderwidth=0
)
dnd_section.pack(fill=tk.X, pady=(0, 0))
dnd_frame = tk.Frame(dnd_section, bg=THEMES[THEME_MODE]["card_bg"], highlightthickness=1, highlightbackground=THEMES[THEME_MODE]["border"])
dnd_frame.configure(height=60)
dnd_frame.pack(fill=tk.X, padx=8, pady=6)
try:
dnd_frame.pack_propagate(False)
except Exception:
pass
def _set_highlight(active: bool):
try:
dnd_frame.configure(highlightbackground=THEMES[THEME_MODE]["info"] if active else THEMES[THEME_MODE]["border"])
except Exception:
pass
dnd_frame.bind('<Enter>', lambda e: _set_highlight(True))
dnd_frame.bind('<Leave>', lambda e: _set_highlight(False))
msg_row = tk.Frame(dnd_frame, bg=THEMES[THEME_MODE]["card_bg"])
msg_row.pack(fill=tk.X)
if dnd_supported:
tk.Label(
msg_row,
text="拖拽已启用:拖拽或点击此区域选择文件",
bg=THEMES[THEME_MODE]["card_bg"],
fg="#999999",
justify="center"
).pack(fill=tk.X)
else:
tk.Label(
msg_row,
text="点击此区域选择文件;可安装拖拽支持",
bg=THEMES[THEME_MODE]["card_bg"],
fg="#999999",
justify="center"
).pack(fill=tk.X)
if not dnd_supported:
btn_row = tk.Frame(dnd_frame, bg=THEMES[THEME_MODE]["card_bg"])
btn_row.pack(fill=tk.X)
def copy_install():
try:
root.clipboard_clear()
root.clipboard_append("pip install tkinterdnd2")
messagebox.showinfo("已复制", "已复制安装命令pip install tkinterdnd2")
except Exception as e:
messagebox.showwarning("复制失败", str(e))
create_modern_button(btn_row, "复制安装命令", copy_install, "primary", px_width=132, px_height=28).pack(side=tk.RIGHT)
def install_and_restart():
try:
add_to_log(log_text, "开始安装拖拽支持库 tkinterdnd2...\n", "info")
cmd = [sys.executable, "-m", "pip", "install", "tkinterdnd2"]
result = subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
add_to_log(log_text, result.stdout + "\n", "info")
add_to_log(log_text, "安装成功,准备重启程序以启用拖拽...\n", "success")
if messagebox.askyesno("安装完成", "已安装拖拽支持,是否立即重启应用?"):
os.execl(sys.executable, sys.executable, *sys.argv)
except subprocess.CalledProcessError as e:
add_to_log(log_text, f"安装失败: {e.stderr}\n", "error")
messagebox.showerror("安装失败", f"安装输出:\n{e.stderr}")
except Exception as e:
add_to_log(log_text, f"安装失败: {str(e)}\n", "error")
messagebox.showerror("安装失败", str(e))
create_modern_button(btn_row, "一键安装拖拽", install_and_restart, "primary", px_width=132, px_height=28).pack(side=tk.RIGHT, padx=(3,0))
# 点击拖拽框也可选择文件(替代按钮)
def _click_select(evt=None):
try:
files = filedialog.askopenfilenames(
title="选择图片或Excel文件",
filetypes=[
("支持文件", "*.xlsx *.xls *.jpg *.jpeg *.png *.bmp"),
("Excel", "*.xlsx *.xls"),
("图片", "*.jpg *.jpeg *.png *.bmp"),
("所有文件", "*.*"),
]
)
if not files:
return
for p in files:
process_dropped_file(log_text, status_bar, p)
except Exception as e:
messagebox.showerror("选择失败", str(e))
dnd_frame.bind('<Button-1>', _click_select)
msg_row.bind('<Button-1>', _click_select)
if dnd_supported:
def _on_drop(event):
try:
data = event.data
paths = []
buf = ""
in_brace = False
for ch in data:
if ch == '{':
in_brace = True
buf = ""
elif ch == '}':
in_brace = False
paths.append(buf)
buf = ""
elif ch == ' ' and not in_brace:
if buf:
paths.append(buf)
buf = ""
else:
buf += ch
if buf:
paths.append(buf)
for p in paths:
process_dropped_file(log_text, status_bar, p)
except Exception as e:
add_to_log(log_text, f"拖拽处理失败: {str(e)}\n", "error")
try:
dnd_frame.drop_target_register(DND_FILES)
dnd_frame.dnd_bind('<<Drop>>', _on_drop)
except Exception:
pass
# 右侧面板内容区域
right_panel_content = tk.Frame(right_panel, bg=THEMES[THEME_MODE]["card_bg"])
right_panel_content.pack(fill=tk.BOTH, expand=True, padx=10, pady=(5, 10))
# 完整流程区
pipeline_section = tk.LabelFrame(
panel_content,
text="完整流程",
bg=THEMES[THEME_MODE]["card_bg"],
fg=THEMES[THEME_MODE]["fg"],
font=("Microsoft YaHei UI", 10, "bold"),
relief="flat",
borderwidth=0
)
pipeline_section.pack(fill=tk.X, pady=(0, 8))
pipeline_frame = tk.Frame(pipeline_section, bg=THEMES[THEME_MODE]["card_bg"])
pipeline_frame.pack(fill=tk.X, padx=8, pady=6)
create_modern_button(pipeline_frame, "一键处理", lambda: run_pipeline_directly(log_text, status_bar), "primary", px_width=150, px_height=32).pack(anchor='w', pady=3)
# OCR处理区
core_section = tk.LabelFrame(
panel_content,
text="OCR处理",
bg=THEMES[THEME_MODE]["card_bg"],
fg=THEMES[THEME_MODE]["fg"],
font=("Microsoft YaHei UI", 10, "bold"),
relief="flat",
borderwidth=0
)
core_section.pack(fill=tk.X, pady=(0, 8))
# 核心功能按钮
core_buttons_frame = tk.Frame(core_section, bg=THEMES[THEME_MODE]["card_bg"])
core_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
# OCR处理按钮
# 核心功能按钮行1
core_row1 = tk.Frame(core_buttons_frame, bg=THEMES[THEME_MODE]["card_bg"])
core_row1.pack(fill=tk.X, pady=3)
# 批量识别
create_modern_button(
core_row1,
"批量识别",
lambda: batch_ocr_with_status(log_text, status_bar),
"primary",
px_width=72,
px_height=32
).pack(side=tk.LEFT, padx=(0, 3))
# 单个识别
create_modern_button(
core_row1,
"单个识别",
lambda: process_single_image_with_status(log_text, status_bar),
"primary",
px_width=72,
px_height=32
).pack(side=tk.LEFT, padx=(3, 0))
# OCR功能区
ocr_section = tk.LabelFrame(
panel_content,
text="Excel处理",
bg=THEMES[THEME_MODE]["card_bg"],
fg=THEMES[THEME_MODE]["fg"],
font=("Microsoft YaHei UI", 10, "bold"),
relief="flat",
borderwidth=0
)
ocr_section.pack(fill=tk.X, pady=(0, 8))
ocr_buttons_frame = tk.Frame(ocr_section, bg=THEMES[THEME_MODE]["card_bg"])
ocr_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
# OCR按钮行1
ocr_row1 = tk.Frame(ocr_buttons_frame, bg=THEMES[THEME_MODE]["card_bg"])
ocr_row1.pack(fill=tk.X, pady=3)
# 批量处理
create_modern_button(
ocr_row1,
"批量处理",
lambda: batch_process_orders_with_status(log_text, status_bar),
"primary",
px_width=72,
px_height=32
).pack(side=tk.LEFT, padx=(0, 3))
# 单个处理
create_modern_button(
ocr_row1,
"单个处理",
lambda: process_excel_file_with_status(log_text, status_bar),
"primary",
px_width=72,
px_height=32
).pack(side=tk.LEFT, padx=(3, 0))
# Excel处理区
excel_section = tk.LabelFrame(
panel_content,
text="特殊处理",
bg=THEMES[THEME_MODE]["card_bg"],
fg=THEMES[THEME_MODE]["fg"],
font=("Microsoft YaHei UI", 10, "bold"),
relief="flat",
borderwidth=0
)
excel_section.pack(fill=tk.X, pady=(0, 8))
excel_buttons_frame = tk.Frame(excel_section, bg=THEMES[THEME_MODE]["card_bg"])
excel_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
# Excel按钮行1
excel_row1 = tk.Frame(excel_buttons_frame, bg=THEMES[THEME_MODE]["card_bg"])
excel_row1.pack(fill=tk.X, pady=3)
# 蓉城易购
create_modern_button(
excel_row1,
"蓉城易购",
lambda: process_rongcheng_yigou_with_status(log_text, status_bar),
"primary",
px_width=72,
px_height=32
).pack(side=tk.LEFT, padx=(0, 3))
# 烟草公司
create_modern_button(
excel_row1,
"烟草公司",
lambda: process_tobacco_orders_with_status(log_text, status_bar),
"primary",
px_width=72,
px_height=32
).pack(side=tk.LEFT, padx=(3, 0))
# 列映射向导与模板管理入口已移至右侧系统设置区
# 工具功能区
tools_section = tk.LabelFrame(
right_panel_content,
text="快捷操作",
bg=THEMES[THEME_MODE]["card_bg"],
fg=THEMES[THEME_MODE]["fg"],
font=("Microsoft YaHei UI", 10, "bold"),
relief="flat",
borderwidth=0
)
tools_section.pack(fill=tk.X, pady=(0, 8))
tools_buttons_frame = tk.Frame(tools_section, bg=THEMES[THEME_MODE]["card_bg"])
tools_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
# 工具按钮行1
tools_row1 = tk.Frame(tools_buttons_frame, bg=THEMES[THEME_MODE]["card_bg"])
tools_row1.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)
# 拖拽/快速处理区(已移动至顶部)
# 最近文件区域
recent_section = tk.LabelFrame(
panel_content,
text="最近文件",
bg=THEMES[THEME_MODE]["card_bg"],
fg=THEMES[THEME_MODE]["fg"],
font=("Microsoft YaHei UI", 10, "bold"),
relief="flat",
borderwidth=0
)
recent_section.pack(fill=tk.BOTH, pady=(0, 12))
recent_frame = tk.Frame(recent_section, bg=THEMES[THEME_MODE]["card_bg"])
recent_frame.pack(fill=tk.BOTH, padx=8, pady=6)
recent_top = tk.Frame(recent_frame, bg=THEMES[THEME_MODE]["card_bg"])
recent_top.pack(fill=tk.X)
def _resize_recent_top(e):
try:
h = int(e.height * 0.75)
recent_top.configure(height=h)
except Exception:
pass
try:
recent_top.pack_propagate(False)
except Exception:
pass
recent_frame.bind('<Configure>', _resize_recent_top)
# 顶部操作区域移除“打开所选”按钮
# 边框矩形区域
recent_rect = tk.Frame(recent_top, bg=THEMES[THEME_MODE]["card_bg"], highlightbackground=THEMES[THEME_MODE]["border"], highlightthickness=1)
recent_rect.pack(fill=tk.BOTH, expand=True)
recent_list = tk.Listbox(recent_rect, height=12)
recent_scrollbar = tk.Scrollbar(recent_rect)
recent_list.configure(yscrollcommand=recent_scrollbar.set)
recent_scrollbar.configure(command=recent_list.yview)
recent_list.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
recent_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
global RECENT_LIST_WIDGET
RECENT_LIST_WIDGET = recent_list
def _open_selected_event(evt=None):
try:
idxs = recent_list.curselection()
if not idxs:
return
p = _extract_path_from_recent_item(recent_list.get(idxs[0]))
if os.path.exists(p):
os.startfile(p)
else:
messagebox.showwarning("文件不存在", p)
except Exception as e:
messagebox.showerror("打开失败", str(e))
recent_list.bind('<Double-Button-1>', _open_selected_event)
refresh_recent_list_widget()
rf_btns = tk.Frame(recent_frame, bg=THEMES[THEME_MODE]["card_bg"])
rf_btns.pack(fill=tk.X, pady=6)
def clear_list():
clear_recent_files()
recent_list.delete(0, tk.END)
# 打开所选按钮已移动到列表上方
create_modern_button(rf_btns, "清空列表", clear_list, "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3,0))
def purge_invalid():
try:
kept = []
for i in range(recent_list.size()):
item = recent_list.get(i)
p = _extract_path_from_recent_item(item)
if os.path.exists(p):
kept.append(p)
try:
kept_sorted = sorted(kept, key=lambda p: os.path.getmtime(p), reverse=True)
except Exception:
kept_sorted = kept
s = load_user_settings()
s['recent_files'] = kept_sorted
save_user_settings(s)
recent_list.delete(0, tk.END)
for i, p in enumerate(s['recent_files'][:recent_list.size() or len(s['recent_files'])], start=1):
recent_list.insert(tk.END, f"{i}. {p}")
refresh_recent_list_widget()
add_to_log(log_text, "已清理无效的最近文件条目\n", "success")
except Exception as e:
messagebox.showerror("清理失败", str(e))
create_modern_button(rf_btns, "清理无效", purge_invalid, "primary", px_width=72, px_height=32).pack(side=tk.LEFT, padx=(3,0))
# 清理工具改为竖排单列
create_modern_button(tools_buttons_frame, "合并订单", lambda: merge_orders_with_status(log_text, status_bar), "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)
create_modern_button(tools_buttons_frame, "清理result文件", lambda: clean_result_files(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
def open_validation_panel(log_widget):
try:
import json
import pandas as pd
import xlrd
dlg = tk.Toplevel()
dlg.title("验证匹配")
dlg.geometry("900x680")
center_window(dlg)
try:
dlg.lift()
dlg.attributes('-topmost', True)
dlg.after(200, lambda: dlg.attributes('-topmost', False))
dlg.focus_force()
except Exception:
pass
outer = ttk.Frame(dlg)
outer.pack(fill=tk.BOTH, expand=True)
canvas = tk.Canvas(outer)
vsb = ttk.Scrollbar(outer, orient='vertical', command=canvas.yview)
canvas.configure(yscrollcommand=vsb.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
vsb.pack(side=tk.RIGHT, fill=tk.Y)
frame = ttk.Frame(canvas)
canvas.create_window((0,0), window=frame, anchor='nw')
frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
cfg_path = os.path.join("config","suppliers_config.json")
suppliers = []
data_cfg = {"suppliers": []}
try:
if os.path.exists(cfg_path):
with open(cfg_path,'r',encoding='utf-8') as f:
data_cfg = json.load(f)
suppliers = [s.get('name','') for s in data_cfg.get('suppliers',[])]
except Exception:
pass
row1 = ttk.Frame(frame)
row1.pack(fill=tk.X, pady=6)
ttk.Label(row1, text="供应商").pack(side=tk.LEFT)
sup_var = tk.StringVar()
ttk.Combobox(row1, textvariable=sup_var, state='readonly', values=suppliers).pack(side=tk.LEFT, padx=6)
row2 = ttk.Frame(frame)
row2.pack(fill=tk.X, pady=6)
ttk.Label(row2, text="原始文件").pack(side=tk.LEFT)
orig_var = tk.StringVar()
ttk.Entry(row2, textvariable=orig_var).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6)
ttk.Button(row2, text="浏览", command=lambda: orig_var.set(filedialog.askopenfilename(title="选择原始Excel", filetypes=[("Excel","*.xlsx *.xls")]) or orig_var.get())).pack(side=tk.LEFT)
row3 = ttk.Frame(frame)
row3.pack(fill=tk.X, pady=6)
ttk.Label(row3, text="期望结果").pack(side=tk.LEFT)
expect_var = tk.StringVar()
ttk.Entry(row3, textvariable=expect_var).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6)
ttk.Button(row3, text="浏览", command=lambda: expect_var.set(filedialog.askopenfilename(title="选择期望结果", filetypes=[("Excel","*.xls *.xlsx")]) or expect_var.get())).pack(side=tk.LEFT)
act_row = ttk.Frame(frame)
act_row.pack(fill=tk.X, pady=8)
result_text = scrolledtext.ScrolledText(frame, height=6)
result_text.pack(fill=tk.BOTH, expand=True)
diff_box = ttk.Frame(frame)
diff_box.pack(fill=tk.BOTH, expand=True, pady=6)
diff_tree = ttk.Treeview(diff_box, show='headings', height=12)
diff_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
diff_scroll_x = ttk.Scrollbar(frame, orient='horizontal', command=diff_tree.xview)
diff_tree.configure(xscrollcommand=diff_scroll_x.set)
diff_scroll_x.pack(fill=tk.X)
suggestions_cache = []
def _read_df(path):
ap = os.path.abspath(path)
if ap.lower().endswith('.xlsx'):
return pd.read_excel(ap, engine='openpyxl')
else:
return pd.read_excel(ap, engine='xlrd')
def _run_validation():
try:
import numpy as np
from app.services.order_service import OrderService
supplier = sup_var.get()
orig = orig_var.get().strip()
expect = expect_var.get().strip()
if not supplier or not orig or not expect:
messagebox.showwarning("提示","请选择供应商、原始文件与期望结果")
return
service = OrderService()
out_path = service.process_excel(orig, progress_cb=lambda p: None)
df_actual = _read_df(out_path)
df_expect = _read_df(expect)
keys = None
if 'barcode' in df_actual.columns and 'barcode' in df_expect.columns:
keys = 'barcode'
elif '条码' in df_actual.columns and '条码' in df_expect.columns:
keys = '条码'
elif 'name' in df_actual.columns and 'name' in df_expect.columns:
keys = 'name'
records = []
mismatches = 0
if keys:
a = df_actual.set_index(keys)
e = df_expect.set_index(keys)
idx_union = list(set(a.index.tolist()) | set(e.index.tolist()))
for k in idx_union:
ra = a.loc[k] if k in a.index else None
rexp = e.loc[k] if k in e.index else None
for f in ['barcode','name','specification','quantity','unit','unit_price','total_price']:
va = ra.get(f) if (ra is not None and f in a.columns) else None
ve = rexp.get(f) if (rexp is not None and f in e.columns) else None
if va is None and ve is None:
continue
def _norm(v):
try:
return float(v)
except Exception:
return str(v) if v is not None else ''
if _norm(va) != _norm(ve):
records.append([str(k), f, str(ve), str(va)])
mismatches += 1
else:
for i in range(min(len(df_actual), len(df_expect))):
for f in ['barcode','name','specification','quantity','unit','unit_price','total_price']:
va = df_actual.iloc[i].get(f) if f in df_actual.columns else None
ve = df_expect.iloc[i].get(f) if f in df_expect.columns else None
if va is None and ve is None:
continue
def _norm(v):
try:
return float(v)
except Exception:
return str(v) if v is not None else ''
if _norm(va) != _norm(ve):
records.append([str(i), f, str(ve), str(va)])
mismatches += 1
diff_tree['columns'] = ['key','field','expected','actual']
for c in ['key','field','expected','actual']:
diff_tree.heading(c, text=c)
diff_tree.column(c, width=160, stretch=True)
for iid in diff_tree.get_children():
diff_tree.delete(iid)
for row in records:
diff_tree.insert('', tk.END, values=row)
result_text.configure(state=tk.NORMAL)
result_text.delete(1.0, tk.END)
result_text.insert(tk.END, f"差异条目: {mismatches}\n")
suggestions = []
try:
unit_vals = df_actual.get('unit') if 'unit' in df_actual.columns else pd.Series([])
bad_units = unit_vals.astype(str).isin(['','','','']).sum()
if bad_units > 0:
suggestions.append({"type":"normalize_unit","target":"unit","map":{"":"","":"","":""}})
except Exception:
pass
try:
qty = pd.to_numeric(df_actual.get('quantity'), errors='coerce') if 'quantity' in df_actual.columns else pd.Series([])
missing_qty = int(qty.isna().sum()) if not qty.empty else 0
if missing_qty > 0:
cols = df_actual.columns.tolist()
src = None
for cand in ['订单数量','订购量','订货数量']:
if cand in cols:
src = cand
break
if src:
suggestions.append({"type":"split_quantity_unit","source":src})
except Exception:
pass
try:
spec = df_actual.get('specification') if 'specification' in df_actual.columns else pd.Series([])
missing_spec = int(spec.isna().sum()) if not spec.empty else 0
if missing_spec > 0:
suggestions.append({"type":"extract_spec_from_name","source":"name"})
except Exception:
pass
try:
up = pd.to_numeric(df_actual.get('unit_price'), errors='coerce').fillna(0) if 'unit_price' in df_actual.columns else pd.Series([])
tp = pd.to_numeric(df_actual.get('total_price'), errors='coerce').fillna(0) if 'total_price' in df_actual.columns else pd.Series([])
qty = pd.to_numeric(df_actual.get('quantity'), errors='coerce').fillna(np.nan) if 'quantity' in df_actual.columns else pd.Series([])
if not qty.empty and not up.empty and not tp.empty:
need_compute = ((qty.isna()) & (up > 0) & (tp > 0)).sum()
if need_compute > 0:
suggestions.append({"type":"compute_quantity_from_total"})
except Exception:
pass
suggestions_cache.clear()
suggestions_cache.extend(suggestions)
if suggestions:
result_text.insert(tk.END, f"建议: {json.dumps(suggestions, ensure_ascii=False)}\n")
result_text.configure(state=tk.DISABLED)
except Exception as e:
messagebox.showerror("验证失败", str(e))
def _apply_suggestions():
try:
supplier = sup_var.get()
if not supplier:
return
if os.path.exists(cfg_path):
with open(cfg_path,'r',encoding='utf-8') as f:
data = json.load(f)
for s in data.get('suppliers',[]):
if s.get('name') == supplier:
rules = s.get('rules', [])
for sug in suggestions_cache:
exists = any(r.get('type') == sug.get('type') for r in rules)
if not exists:
rules.append(sug)
s['rules'] = rules
d = s.get('dictionary') or {}
d.setdefault('unit_synonyms', {"":"","":"","":"","":""})
d.setdefault('pack_multipliers', {"":24,"":24,"":12,"":10})
s['dictionary'] = d
break
with open(cfg_path,'w',encoding='utf-8') as f:
json.dump(data,f,ensure_ascii=False,indent=2)
result_text.configure(state=tk.NORMAL)
result_text.insert(tk.END, "已应用建议并保存配置\n")
result_text.configure(state=tk.DISABLED)
else:
messagebox.showwarning("提示","配置文件不存在")
except Exception as e:
messagebox.showerror("应用失败", str(e))
ttk.Button(act_row, text="运行验证", command=_run_validation).pack(side=tk.LEFT)
ttk.Button(act_row, text="应用建议", command=_apply_suggestions).pack(side=tk.LEFT, padx=6)
ttk.Button(act_row, text="关闭", command=dlg.destroy).pack(side=tk.RIGHT)
except Exception as e:
messagebox.showerror("验证匹配错误", str(e))
# 系统设置区
settings_section = tk.LabelFrame(
right_panel_content,
text="系统设置",
bg=THEMES[THEME_MODE]["card_bg"],
fg=THEMES[THEME_MODE]["fg"],
font=("Microsoft YaHei UI", 10, "bold"),
relief="flat",
borderwidth=0
)
settings_section.pack(fill=tk.X, pady=(0, 8))
settings_buttons_frame = tk.Frame(settings_section, bg=THEMES[THEME_MODE]["card_bg"])
settings_buttons_frame.pack(fill=tk.X, padx=8, pady=6)
create_modern_button(settings_buttons_frame, "系统设置", lambda: show_config_dialog(root, ConfigManager()), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "条码映射", lambda: edit_barcode_mappings(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "支持类型", lambda: show_supported_processors(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "列映射向导", lambda: open_column_mapping_wizard_alt(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "供应商管理", lambda: open_supplier_manager(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "模板管理", lambda: open_template_manager(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
create_modern_button(settings_buttons_frame, "验证匹配", lambda: open_validation_panel(log_text), "primary", px_width=132, px_height=32).pack(anchor='w', pady=3)
# 保存设置并绑定关闭事件
def on_close():
try:
w = root.winfo_width()
h = root.winfo_height()
settings['window_size'] = f"{w}x{h}"
settings['theme_mode'] = THEME_MODE
save_user_settings(settings)
except Exception:
pass
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_close)
# 绑定键盘快捷键
bind_keyboard_shortcuts(root, log_text, status_bar)
# 启动主循环
root.mainloop()
except Exception as e:
import traceback
error_msg = f"程序启动失败: {str(e)}\n详细错误信息:\n{traceback.format_exc()}"
print(error_msg)
try:
import tkinter.messagebox as mb
mb.showerror("启动错误", f"程序启动失败:\n{str(e)}")
except:
pass
def add_to_log(log_widget, text, tag="normal"):
"""向日志窗口添加文本,支持样式标签"""
log_widget.configure(state=tk.NORMAL)
log_widget.insert(tk.END, text, tag)
log_widget.see(tk.END) # 自动滚动到底部
log_widget.configure(state=tk.DISABLED)
def select_file(log_widget, file_types=[("所有文件", "*.*")], title="选择文件"):
"""通用文件选择对话框"""
file_path = filedialog.askopenfilename(title=title, filetypes=file_types)
if file_path:
add_to_log(log_widget, f"已选择文件: {file_path}\n", "info")
return file_path
def process_dropped_file(log_widget, status_bar, file_path):
try:
ext = os.path.splitext(file_path)[1].lower()
if ext in ['.jpg', '.jpeg', '.png', '.bmp']:
def _run_img():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
init_gui_logger(log_widget)
ocr_service = OCRService()
add_to_log(log_widget, f"开始处理图片: {file_path}\n", "info")
try:
add_recent_file(file_path)
except Exception:
pass
excel_path = ocr_service.process_image(file_path)
if excel_path:
add_to_log(log_widget, "图片OCR处理完成\n", "success")
add_recent_file(excel_path)
else:
add_to_log(log_widget, "图片OCR处理失败\n", "error")
finally:
dispose_gui_logger()
reporter.done()
t = Thread(target=_run_img)
t.daemon = True
t.start()
elif ext in ['.xlsx', '.xls']:
def _run_xls():
try:
reporter = ProgressReporter(status_bar)
reporter.running()
init_gui_logger(log_widget)
order_service = OrderService()
add_to_log(log_widget, f"开始处理Excel文件: {file_path}\n", "info")
try:
add_recent_file(file_path)
except Exception:
pass
result = order_service.process_excel(file_path, progress_cb=lambda p: status_bar.set_status("Excel处理中...", p))
if result:
add_to_log(log_widget, "Excel文件处理完成\n", "success")
add_recent_file(result)
try:
validate_unit_price_against_item_data(result, log_widget)
except Exception:
pass
else:
add_to_log(log_widget, "Excel文件处理失败\n", "error")
finally:
dispose_gui_logger()
reporter.done()
t = Thread(target=_run_xls)
t.daemon = True
t.start()
else:
add_to_log(log_widget, f"不支持的文件类型: {file_path}\n", "warning")
except Exception as e:
add_to_log(log_widget, f"处理拖拽文件失败: {str(e)}\n", "error")
def select_excel_file(log_widget):
"""选择Excel文件"""
return select_file(
log_widget,
[("Excel文件", "*.xlsx *.xls"), ("所有文件", "*.*")],
"选择Excel文件"
)
def validate_unit_price_against_item_data(result_path: str, log_widget=None):
try:
import pandas as pd
import os
def _read_df(path):
ap = os.path.abspath(path)
if ap.lower().endswith('.xlsx'):
return pd.read_excel(ap, engine='openpyxl')
else:
return pd.read_excel(ap, engine='xlrd')
item_path = os.path.join('templates', '商品资料.xlsx')
if not os.path.exists(item_path):
return
df_item = _read_df(item_path)
df_res = _read_df(result_path)
def _find_col(df, candidates, contains=None):
cols = list(df.columns)
for c in candidates:
if c in cols:
return c
if contains:
for c in cols:
if contains in str(c):
return c
return None
item_barcode_col = _find_col(df_item, ['商品条码','商品条码(小条码)','条码','barcode'], contains='条码')
item_price_col = _find_col(df_item, ['进货价','进货价(必填)'], contains='进货价')
res_barcode_col = _find_col(df_res, ['条码','barcode'], contains='条码')
res_price_col = _find_col(df_res, ['采购单价','unit_price','单价'], contains='单价')
if not all([item_barcode_col, item_price_col, res_barcode_col, res_price_col]):
return
item_map = df_item[[item_barcode_col, item_price_col]].dropna()
item_map[item_price_col] = pd.to_numeric(item_map[item_price_col], errors='coerce')
item_map = item_map.dropna()
imap = dict(zip(item_map[item_barcode_col].astype(str).str.strip(), item_map[item_price_col]))
df_res['_bc_'] = df_res[res_barcode_col].astype(str).str.strip()
df_res['_res_price_'] = pd.to_numeric(df_res[res_price_col], errors='coerce')
df_res['_item_price_'] = df_res['_bc_'].map(imap)
df_check = df_res.dropna(subset=['_res_price_','_item_price_'])
df_check['_diff_'] = (df_check['_res_price_'] - df_check['_item_price_']).abs()
bad = df_check[df_check['_diff_'] > 1.0]
if not bad.empty:
lines = []
for i in range(min(len(bad), 10)):
r = bad.iloc[i]
lines.append(f"条码 {r['_bc_']}: 采购单价={r['_res_price_']} vs 进货价={r['_item_price_']} 差异={r['_diff_']:.2f}")
import tkinter.messagebox as mb
mb.showwarning("单价校验提示", f"存在{len(bad)}条单价与商品资料进货价差异超过1元:\n" + "\n".join(lines))
if log_widget is not None:
add_to_log(log_widget, f"单价校验发现{len(bad)}条差异>1元\n", "warning")
else:
if log_widget is not None:
add_to_log(log_widget, "单价校验通过(差异<=1元\n", "success")
except Exception:
pass
def clean_cache(log_widget):
"""清除处理缓存"""
try:
# 清除OCR缓存文件
cache_files = [
os.path.join("data", "processed_files.json"),
os.path.join("data/output", "processed_files.json"),
os.path.join("data/output", "merged_files.json")
]
for cache_file in cache_files:
if os.path.exists(cache_file):
os.remove(cache_file)
add_to_log(log_widget, f"已清除缓存文件: {cache_file}\n", "success")
# 清除临时文件夹中所有文件
temp_dir = os.path.join("data/temp")
if os.path.exists(temp_dir):
for file in os.listdir(temp_dir):
file_path = os.path.join(temp_dir, file)
try:
if os.path.isfile(file_path):
os.remove(file_path)
add_to_log(log_widget, f"已清除临时文件: {file_path}\n", "info")
except Exception as e:
add_to_log(log_widget, f"清除文件时出错: {file_path}, 错误: {str(e)}\n", "error")
# 清除日志文件中的active标记
log_dir = "logs"
if os.path.exists(log_dir):
for file in os.listdir(log_dir):
if file.endswith(".active"):
file_path = os.path.join(log_dir, file)
try:
os.remove(file_path)
add_to_log(log_widget, f"已清除活动日志标记: {file_path}\n", "info")
except Exception as e:
add_to_log(log_widget, f"清除文件时出错: {file_path}, 错误: {str(e)}\n", "error")
# 重置全局状态
global RUNNING_TASK
RUNNING_TASK = None
add_to_log(log_widget, "缓存清除完成,系统将重新处理所有文件\n", "success")
messagebox.showinfo("缓存清除", "缓存已清除,系统将重新处理所有文件。")
except Exception as e:
add_to_log(log_widget, f"清除缓存时出错: {str(e)}\n", "error")
messagebox.showerror("错误", f"清除缓存时出错: {str(e)}")
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)
os.startfile(result_dir)
except Exception as e:
messagebox.showerror("错误", f"无法打开结果目录: {str(e)}")
def open_input_directory_from_settings():
try:
s = load_user_settings()
path = os.path.abspath(s.get('input_folder', 'data/input'))
if not os.path.exists(path):
os.makedirs(path, exist_ok=True)
os.startfile(path)
except Exception as e:
messagebox.showerror("错误", f"无法打开输入目录: {str(e)}")
def open_output_directory_from_settings():
try:
s = load_user_settings()
path = os.path.abspath(s.get('output_folder', 'data/output'))
if not os.path.exists(path):
os.makedirs(path, exist_ok=True)
os.startfile(path)
except Exception as e:
messagebox.showerror("错误", f"无法打开输出目录: {str(e)}")
def open_result_directory_from_settings():
try:
s = load_user_settings()
path = os.path.abspath(s.get('result_folder', 'data/result'))
if not os.path.exists(path):
os.makedirs(path, exist_ok=True)
os.startfile(path)
except Exception as e:
messagebox.showerror("错误", f"无法打开结果目录: {str(e)}")
def show_supported_processors(log_widget):
global PROCESSOR_SERVICE
try:
if PROCESSOR_SERVICE is None:
PROCESSOR_SERVICE = ProcessorService(ConfigManager())
info = PROCESSOR_SERVICE.get_supported_types()
dlg = tk.Toplevel()
dlg.title("支持的处理器类型")
dlg.geometry("420x360")
center_window(dlg)
frame = tk.Frame(dlg)
frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
text = scrolledtext.ScrolledText(frame, wrap=tk.WORD)
text.pack(fill=tk.BOTH, expand=True)
text.configure(state=tk.NORMAL)
text.insert(tk.END, "支持的处理器类型\n\n")
for item in info:
exts = ', '.join(item['extensions']) if item.get('extensions') else ''
text.insert(tk.END, f"{item['name']}: {item['description']}\n")
text.insert(tk.END, f" 扩展名: {exts}\n\n")
text.configure(state=tk.DISABLED)
tk.Button(dlg, text="关闭", command=dlg.destroy).pack(pady=8)
add_to_log(log_widget, "已显示支持的处理器类型\n", "info")
except Exception as e:
add_to_log(log_widget, f"显示处理器类型出错: {str(e)}\n", "error")
def safe_open_validation_panel(log_widget):
try:
if 'open_validation_panel' in globals() and callable(globals()['open_validation_panel']):
return globals()['open_validation_panel'](log_widget)
else:
messagebox.showwarning("提示", "验证面板功能尚未加载,请重启程序后重试")
except Exception as e:
messagebox.showerror("错误", str(e))
def open_supplier_manager(log_widget):
try:
import json
dlg = tk.Toplevel()
dlg.title("供应商管理")
dlg.geometry("900x700")
center_window(dlg)
try:
dlg.lift()
dlg.attributes('-topmost', True)
dlg.after(200, lambda: dlg.attributes('-topmost', False))
dlg.focus_force()
except Exception:
pass
cfg_path = os.path.join("config","suppliers_config.json")
data = {"suppliers": []}
if os.path.exists(cfg_path):
with open(cfg_path,'r',encoding='utf-8') as f:
data = json.load(f)
outer = ttk.Frame(dlg)
outer.pack(fill=tk.BOTH, expand=True)
canvas = tk.Canvas(outer)
vsb = ttk.Scrollbar(outer, orient='vertical', command=canvas.yview)
canvas.configure(yscrollcommand=vsb.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
vsb.pack(side=tk.RIGHT, fill=tk.Y)
container = ttk.Frame(canvas)
canvas.create_window((0,0), window=container, anchor='nw')
def _on_container_config(evt):
try:
canvas.configure(scrollregion=canvas.bbox("all"))
except Exception:
pass
container.bind("<Configure>", _on_container_config)
def _on_mousewheel(event):
try:
delta = -1 * int(event.delta / 120)
canvas.yview_scroll(delta, "units")
except Exception:
pass
dlg.bind("<MouseWheel>", _on_mousewheel)
# inner padding
inner = ttk.Frame(container)
inner.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
container = inner
container.columnconfigure(0, weight=1)
container.columnconfigure(1, weight=3)
left = ttk.Frame(container)
left.grid(row=0, column=0, sticky='nsew', padx=(0,8))
right = ttk.Frame(container)
right.grid(row=0, column=1, sticky='nsew')
sup_list = tk.Listbox(left, height=20)
sup_list.pack(fill=tk.BOTH, expand=True)
btns = ttk.Frame(left)
btns.pack(fill=tk.X, pady=6)
def refresh_list(selected_idx=0):
sup_list.delete(0, tk.END)
for s in data.get('suppliers',[]):
sup_list.insert(tk.END, s.get('name',''))
try:
sup_list.select_clear(0, tk.END)
sup_list.select_set(selected_idx)
except Exception:
pass
def new_supplier():
data.setdefault('suppliers', []).append({
'name': '新供应商',
'description': '',
'filename_patterns': [],
'content_indicators': [],
'column_mapping': {},
'header_row': 0,
'rules': [],
'dictionary': {
'ignore_words': [],
'unit_synonyms': {},
'pack_multipliers': {},
'name_patterns': [],
'default_unit': '',
'default_package_quantity': 1
},
'output_templates': ['templates/银豹-采购单模板.xls'],
'current_template_index': 0
})
refresh_list(len(data['suppliers'])-1)
load_selected()
def copy_supplier():
sel = sup_list.curselection()
if not sel:
return
idx = sel[0]
import copy
src = data['suppliers'][idx]
cp = copy.deepcopy(src)
cp['name'] = src.get('name','') + '_副本'
data['suppliers'].append(cp)
refresh_list(len(data['suppliers'])-1)
def delete_supplier():
sel = sup_list.curselection()
if not sel:
return
idx = sel[0]
if 0 <= idx < len(data['suppliers']):
del data['suppliers'][idx]
refresh_list(max(0, idx-1))
ttk.Button(btns, text="新建", command=new_supplier).pack(side=tk.LEFT)
ttk.Button(btns, text="复制", command=copy_supplier).pack(side=tk.LEFT, padx=6)
ttk.Button(btns, text="删除", command=delete_supplier).pack(side=tk.LEFT)
nb = ttk.Notebook(right)
nb.pack(fill=tk.BOTH, expand=True)
basic_tab = ttk.Frame(nb)
map_tab = ttk.Frame(nb)
rule_tab = ttk.Frame(nb)
tpl_tab = ttk.Frame(nb)
nb.add(basic_tab, text="基本信息")
nb.add(map_tab, text="列映射与表头")
nb.add(rule_tab, text="规则与词典")
nb.add(tpl_tab, text="模板管理")
name_var = tk.StringVar()
desc_var = tk.StringVar()
header_row_var = tk.StringVar()
ttk.Label(basic_tab, text="名称").pack(anchor='w')
ttk.Entry(basic_tab, textvariable=name_var).pack(fill=tk.X, pady=4)
ttk.Label(basic_tab, text="描述").pack(anchor='w')
ttk.Entry(basic_tab, textvariable=desc_var).pack(fill=tk.X, pady=4)
ttk.Label(basic_tab, text="文件名模式(每行一条)").pack(anchor='w')
fp_text = scrolledtext.ScrolledText(basic_tab, height=4)
fp_text.pack(fill=tk.BOTH, pady=4)
ttk.Label(basic_tab, text="内容指示词(每行一条)").pack(anchor='w')
ci_text = scrolledtext.ScrolledText(basic_tab, height=4)
ci_text.pack(fill=tk.BOTH, pady=4)
ttk.Label(basic_tab, text="表头行号(从1开始)").pack(anchor='w')
ttk.Entry(basic_tab, textvariable=header_row_var).pack(fill=tk.X, pady=4)
ttk.Button(map_tab, text="打开列映射向导", command=lambda: open_column_mapping_wizard_alt(log_widget, name_var.get())).pack(anchor='w', pady=6)
ttk.Label(rule_tab, text="忽略词(每行一条)").pack(anchor='w')
ignore_words_text = scrolledtext.ScrolledText(rule_tab, height=4)
ignore_words_text.pack(fill=tk.BOTH, pady=4)
ttk.Label(rule_tab, text="单位同义词(JSON)").pack(anchor='w')
unit_synonyms_var = tk.StringVar(value='{}')
ttk.Entry(rule_tab, textvariable=unit_synonyms_var).pack(fill=tk.X, pady=4)
ttk.Label(rule_tab, text="包装倍数(JSON)").pack(anchor='w')
pack_multipliers_var = tk.StringVar(value='{}')
ttk.Entry(rule_tab, textvariable=pack_multipliers_var).pack(fill=tk.X, pady=4)
ttk.Label(rule_tab, text="名称正则(每行一条)").pack(anchor='w')
name_patterns_text = scrolledtext.ScrolledText(rule_tab, height=4)
name_patterns_text.pack(fill=tk.BOTH, pady=4)
ttk.Label(rule_tab, text="默认单位").pack(anchor='w')
default_unit_var = tk.StringVar(value='')
ttk.Entry(rule_tab, textvariable=default_unit_var).pack(fill=tk.X, pady=4)
ttk.Label(rule_tab, text="默认包装数量").pack(anchor='w')
default_pq_var = tk.StringVar(value='1')
ttk.Entry(rule_tab, textvariable=default_pq_var).pack(fill=tk.X, pady=4)
ttk.Label(rule_tab, text="规则预设").pack(anchor='w')
rule_preset_var = tk.StringVar()
rule_presets = {
"基础拆分与推断": [
{"type": "split_quantity_unit", "source": "quantity"},
{"type": "extract_spec_from_name", "source": "name"},
{"type": "normalize_unit", "target": "unit", "map": {"": "", "": "", "": ""}},
{"type": "compute_quantity_from_total"},
{"type": "mark_gift"},
{"type": "fill_missing", "fills": {"unit": ""}}
]
}
ttk.Combobox(rule_tab, textvariable=rule_preset_var, state='readonly', values=list(rule_presets.keys())).pack(fill=tk.X, pady=4)
rule_file_row = ttk.Frame(rule_tab)
rule_file_row.pack(fill=tk.X, pady=6)
ttk.Label(rule_file_row, text="选择Excel").pack(side=tk.LEFT)
rule_excel_var = tk.StringVar()
ttk.Entry(rule_file_row, textvariable=rule_excel_var).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6)
def _browse_rule_excel():
p = filedialog.askopenfilename(title="选择Excel文件", filetypes=[("Excel","*.xlsx *.xls")])
if p:
rule_excel_var.set(p)
ttk.Button(rule_file_row, text="浏览", command=_browse_rule_excel).pack(side=tk.LEFT)
# 加载列供来源/目标下拉选择
rule_available_columns = []
def _load_rule_columns():
try:
import pandas as pd
p = rule_excel_var.get().strip()
if not p:
messagebox.showwarning("提示","请先选择Excel文件")
return
idx = None
try:
v = header_row_var.get().strip()
if v:
n = int(v)
if n > 0:
idx = n - 1
except Exception:
pass
df = pd.read_excel(p, engine='openpyxl' if p.lower().endswith('.xlsx') else 'xlrd', header=idx if idx is not None else 0, nrows=5)
cols = [str(c) for c in list(df.columns)]
rule_available_columns.clear()
rule_available_columns.extend(cols)
messagebox.showinfo("成功", f"已加载列: {', '.join(cols)}")
_refresh_rule_params()
except Exception as e:
messagebox.showerror("加载失败", str(e))
ttk.Button(rule_file_row, text="加载列", command=_load_rule_columns).pack(side=tk.LEFT, padx=6)
rule_preview_box = ttk.Frame(rule_tab)
rule_preview_box.pack(fill=tk.BOTH, expand=True, pady=6)
rule_left_tree = ttk.Treeview(rule_preview_box, show='headings', height=8)
rule_right_tree = ttk.Treeview(rule_preview_box, show='headings', height=8)
rule_left_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
rule_right_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
rule_scroll_x_row = ttk.Frame(rule_tab)
rule_scroll_x_row.pack(fill=tk.X)
rule_left_scroll_x = ttk.Scrollbar(rule_scroll_x_row, orient='horizontal', command=rule_left_tree.xview)
rule_right_scroll_x = ttk.Scrollbar(rule_scroll_x_row, orient='horizontal', command=rule_right_tree.xview)
rule_left_tree.configure(xscrollcommand=rule_left_scroll_x.set)
rule_right_tree.configure(xscrollcommand=rule_right_scroll_x.set)
rule_left_scroll_x.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0,3))
rule_right_scroll_x.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(3,0))
rule_step_row = ttk.Frame(rule_tab)
rule_step_row.pack(fill=tk.X, pady=6)
ttk.Label(rule_step_row, text="预览步骤").pack(side=tk.LEFT)
rule_step_var = tk.StringVar()
rule_step_cb = ttk.Combobox(rule_step_row, textvariable=rule_step_var, state='readonly')
rule_step_cb.pack(side=tk.LEFT, padx=6)
diff_mode_var = tk.BooleanVar(value=False)
ttk.Checkbutton(rule_step_row, text="仅显示变化列", variable=diff_mode_var).pack(side=tk.LEFT, padx=6)
err_label = ttk.Label(rule_step_row, text="错误计数: -")
err_label.pack(side=tk.LEFT, padx=12)
rule_step_box = ttk.Frame(rule_tab)
rule_step_box.pack(fill=tk.BOTH, expand=True, pady=6)
rule_step_tree = ttk.Treeview(rule_step_box, show='headings', height=8)
rule_step_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
rule_step_scroll_x = ttk.Scrollbar(rule_tab, orient='horizontal', command=rule_step_tree.xview)
rule_step_tree.configure(xscrollcommand=rule_step_scroll_x.set)
rule_step_scroll_x.pack(fill=tk.X)
def _build_rule_preview():
try:
import pandas as pd
from app.core.handlers.rule_engine import apply_rules
p = rule_excel_var.get().strip()
if not p:
return
idx = None
try:
v = header_row_var.get().strip()
if v:
n = int(v)
if n > 0:
idx = n - 1
except Exception:
pass
df = pd.read_excel(p, engine='openpyxl' if p.lower().endswith('.xlsx') else 'xlrd', header=idx if idx is not None else 0, nrows=50)
sel = sup_list.curselection()
cm = {}
rules_cur = []
if sel:
s = data['suppliers'][sel[0]]
cm = s.get('column_mapping', {})
rules_cur = s.get('rules', [])
rename_map = cm
df0 = df.rename(columns=rename_map)
dictionary = {
'ignore_words': [w.strip() for w in ignore_words_text.get(1.0, tk.END).splitlines() if w.strip()],
'unit_synonyms': json.loads(unit_synonyms_var.get() or '{}'),
'pack_multipliers': json.loads(pack_multipliers_var.get() or '{}'),
'name_patterns': [w.strip() for w in name_patterns_text.get(1.0, tk.END).splitlines() if w.strip()],
'default_unit': default_unit_var.get().strip() or '',
'default_package_quantity': int(default_pq_var.get() or '1')
}
rules_use = rule_presets.get(rule_preset_var.get(), rules_cur or rule_presets["基础拆分与推断"])
df1 = apply_rules(df0, rules_use, dictionary)
lcols = list(df0.columns)
rcols = list(df1.columns)
rule_left_tree['columns'] = lcols
rule_right_tree['columns'] = rcols
for c in lcols:
rule_left_tree.heading(c, text=c)
rule_left_tree.column(c, width=120, stretch=True)
for c in rcols:
rule_right_tree.heading(c, text=c)
rule_right_tree.column(c, width=120, stretch=True)
for iid in rule_left_tree.get_children():
rule_left_tree.delete(iid)
for iid in rule_right_tree.get_children():
rule_right_tree.delete(iid)
# Diff模式仅显示变化列
if diff_mode_var.get():
changed_cols = []
for c in set(lcols).intersection(set(rcols)):
try:
if not df0[c].equals(df1[c]):
changed_cols.append(c)
except Exception:
pass
lshow = [c for c in lcols if c in changed_cols]
rshow = [c for c in rcols if c in changed_cols]
rule_left_tree['columns'] = lshow
rule_right_tree['columns'] = rshow
for c in lshow:
rule_left_tree.heading(c, text=c)
rule_left_tree.column(c, width=120, stretch=True)
for c in rshow:
rule_right_tree.heading(c, text=c)
rule_right_tree.column(c, width=120, stretch=True)
for i in range(len(df0)):
rule_left_tree.insert('', tk.END, values=[str(df0.iloc[i].get(c, '')) for c in lshow])
for i in range(len(df1)):
rule_right_tree.insert('', tk.END, values=[str(df1.iloc[i].get(c, '')) for c in rshow])
else:
for i in range(len(df0)):
rule_left_tree.insert('', tk.END, values=[str(df0.iloc[i].get(c, '')) for c in lcols])
for i in range(len(df1)):
rule_right_tree.insert('', tk.END, values=[str(df1.iloc[i].get(c, '')) for c in rcols])
steps = [("原始", df0)]
cur = df0.copy()
for r in rules_use:
cur = apply_rules(cur, [r], dictionary)
steps.append((r.get('type','step'), cur.copy()))
rule_step_cb['values'] = [t for t,_ in steps]
if steps:
rule_step_var.set(steps[-1][0])
_show_rule_step_df(steps[-1][1])
def _on_rule_step_change(evt=None):
sel = rule_step_var.get()
for t, d in steps:
if t == sel:
_show_rule_step_df(d)
break
rule_step_cb.bind('<<ComboboxSelected>>', _on_rule_step_change)
rule_step_cb.event_generate('<<ComboboxSelected>>')
rule_step_row._steps_cache = steps
# 错误计数:统计缺失单位/规格、无法拆分数量
try:
empty_unit = int(df1.get('unit').isna().sum()) if 'unit' in df1.columns else len(df1)
empty_qty = int(pd.to_numeric(df1.get('quantity'), errors='coerce').isna().sum()) if 'quantity' in df1.columns else len(df1)
empty_spec = int(df1.get('specification').isna().sum()) if 'specification' in df1.columns else len(df1)
err_label.configure(text=f"错误计数: 单位缺失 {empty_unit},数量缺失 {empty_qty},规格缺失 {empty_spec}")
except Exception:
err_label.configure(text="错误计数: 统计失败")
except Exception as e:
messagebox.showerror("预览失败", str(e))
def _show_rule_step_df(dfshow):
cols = list(dfshow.columns)
rule_step_tree['columns'] = cols
for c in cols:
rule_step_tree.heading(c, text=c)
rule_step_tree.column(c, width=120, stretch=True)
for iid in rule_step_tree.get_children():
rule_step_tree.delete(iid)
for i in range(len(dfshow)):
rule_step_tree.insert('', tk.END, values=[str(dfshow.iloc[i].get(c, '')) for c in cols])
def _export_rule_step_csv():
try:
if not hasattr(rule_step_row, '_steps_cache'):
return
sel = rule_step_var.get()
for t, d in rule_step_row._steps_cache:
if t == sel:
save_path = filedialog.asksaveasfilename(title="导出预览为CSV", defaultextension=".csv", filetypes=[("CSV","*.csv")])
if save_path:
d.to_csv(save_path, index=False, encoding='utf-8-sig')
messagebox.showinfo("成功","已导出CSV")
break
except Exception as e:
messagebox.showerror("导出失败", str(e))
ttk.Button(rule_step_row, text="生成步骤预览", command=_build_rule_preview).pack(side=tk.LEFT, padx=6)
ttk.Button(rule_step_row, text="导出预览CSV", command=_export_rule_step_csv).pack(side=tk.LEFT, padx=6)
# 规则列表编辑器
rules_frame = ttk.LabelFrame(rule_tab, text="规则列表")
rules_frame.pack(fill=tk.BOTH, expand=True, pady=6)
rules_list = tk.Listbox(rules_frame, height=8)
rules_list.pack(fill=tk.BOTH, expand=True)
rules_btns = ttk.Frame(rules_frame)
rules_btns.pack(fill=tk.X, pady=4)
RULE_TYPES = {
'split_quantity_unit': {'label': '拆分数量单位', 'params': [('source','quantity')]},
'extract_spec_from_name': {'label': '名称提取规格', 'params': [('source','name')]},
'normalize_unit': {'label': '单位归一', 'params': [('target','unit'), ('map','{"":"","":"","":""}')]},
'compute_quantity_from_total': {'label': '金额回推数量', 'params': []},
'fill_missing': {'label': '缺省填充', 'params': [('fills','{}')]},
'mark_gift': {'label': '标记赠品', 'params': []}
}
add_row = ttk.Frame(rules_btns)
add_row.pack(fill=tk.X)
new_rule_var = tk.StringVar()
ttk.Combobox(add_row, textvariable=new_rule_var, state='readonly', values=list(RULE_TYPES.keys())).pack(side=tk.LEFT)
def _add_rule():
r = new_rule_var.get()
if not r:
return
rules_list.insert(tk.END, r)
new_rule_var.set('')
_refresh_rule_params()
ttk.Button(add_row, text="添加", command=_add_rule).pack(side=tk.LEFT, padx=6)
def _remove_rule():
sel = rules_list.curselection()
if sel:
rules_list.delete(sel[0])
_refresh_rule_params()
def _move_up():
sel = rules_list.curselection()
if sel:
idx = sel[0]
if idx > 0:
val = rules_list.get(idx)
rules_list.delete(idx)
rules_list.insert(idx-1, val)
rules_list.select_set(idx-1)
_refresh_rule_params()
def _move_down():
sel = rules_list.curselection()
if sel:
idx = sel[0]
if idx < rules_list.size()-1:
val = rules_list.get(idx)
rules_list.delete(idx)
rules_list.insert(idx+1, val)
rules_list.select_set(idx+1)
_refresh_rule_params()
ttk.Button(rules_btns, text="删除", command=_remove_rule).pack(side=tk.LEFT, padx=6)
ttk.Button(rules_btns, text="上移", command=_move_up).pack(side=tk.LEFT, padx=6)
ttk.Button(rules_btns, text="下移", command=_move_down).pack(side=tk.LEFT, padx=6)
rule_params_frame = ttk.LabelFrame(rule_tab, text="规则参数")
rule_params_frame.pack(fill=tk.BOTH, expand=True, pady=6)
rule_param_vars = {}
def _refresh_rule_params():
for w in rule_params_frame.winfo_children():
w.destroy()
sel = rules_list.curselection()
if not sel:
return
rkey = rules_list.get(sel[0])
spec = RULE_TYPES.get(rkey, {'params': []})
row = ttk.Frame(rule_params_frame)
row.pack(fill=tk.X, pady=4)
rule_param_vars.clear()
for name, default in spec['params']:
r = ttk.Frame(rule_params_frame)
r.pack(fill=tk.X, pady=4)
ttk.Label(r, text=name).pack(side=tk.LEFT)
var = tk.StringVar(value=default)
# 对 source/target 提供下拉列选择
if name in ('source','target'):
cb = ttk.Combobox(r, textvariable=var, state='readonly', values=rule_available_columns)
cb.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6)
else:
ttk.Entry(r, textvariable=var).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6)
rule_param_vars[name] = var
def _build_rules_from_ui():
rules = []
for i in range(rules_list.size()):
rkey = rules_list.get(i)
spec = RULE_TYPES.get(rkey, {'params': []})
rd = {'type': rkey}
# if selected, use edited params; else defaults
if rules_list.curselection() and rules_list.get(rules_list.curselection()[0]) == rkey:
for name, default in spec['params']:
val = rule_param_vars.get(name).get() if name in rule_param_vars else default
try:
rd[name] = json.loads(val) if (val.strip().startswith('{') or val.strip().startswith('[')) else val
except Exception:
rd[name] = val
else:
for name, default in spec['params']:
try:
rd[name] = json.loads(default) if (isinstance(default, str) and (default.strip().startswith('{') or default.strip().startswith('['))) else default
except Exception:
rd[name] = default
rules.append(rd)
return rules
def _apply_quick_template_no_unit():
try:
rules_list.delete(0, tk.END)
for r in ['split_quantity_unit','extract_spec_from_name','normalize_unit','fill_missing','mark_gift']:
rules_list.insert(tk.END, r)
unit_synonyms_var.set(json.dumps({"":"","":"","":"","":""}, ensure_ascii=False))
pack_multipliers_var.set(json.dumps({"":24,"":24,"":12,"":10}, ensure_ascii=False))
default_unit_var.set('')
default_pq_var.set('1')
messagebox.showinfo("成功","已应用无数量/单位快速模板,请在规则参数中选择来源列")
_refresh_rule_params()
except Exception as e:
messagebox.showerror("应用失败", str(e))
ttk.Button(rules_btns, text="应用无数量/单位模板", command=_apply_quick_template_no_unit).pack(side=tk.LEFT, padx=6)
rules_list.bind('<<ListboxSelect>>', lambda e: _refresh_rule_params())
def _load_rules_to_ui(rules):
rules_list.delete(0, tk.END)
for r in rules or []:
rtype = r.get('type')
if rtype in RULE_TYPES:
rules_list.insert(tk.END, rtype)
_refresh_rule_params()
tpl_list = tk.Listbox(tpl_tab, height=8)
tpl_list.pack(fill=tk.BOTH, expand=True)
tpl_btns = ttk.Frame(tpl_tab)
tpl_btns.pack(fill=tk.X, pady=6)
current_tpl_idx_var = tk.StringVar(value='0')
ttk.Label(tpl_tab, text="当前模板索引").pack(anchor='w')
ttk.Entry(tpl_tab, textvariable=current_tpl_idx_var).pack(fill=tk.X, pady=4)
def load_selected():
sel = sup_list.curselection()
if not sel:
return
idx = sel[0]
s = data['suppliers'][idx]
name_var.set(s.get('name',''))
desc_var.set(s.get('description',''))
header_row_var.set(str((s.get('header_row') or 0)+1))
fp = s.get('filename_patterns',[])
ci = s.get('content_indicators',[])
fp_text.delete(1.0, tk.END)
fp_text.insert(tk.END, '\n'.join(fp))
ci_text.delete(1.0, tk.END)
ci_text.insert(tk.END, '\n'.join(ci))
d = s.get('dictionary') or {}
ignore_words_text.delete(1.0, tk.END)
ignore_words_text.insert(tk.END, '\n'.join(d.get('ignore_words', [])))
unit_synonyms_var.set(json.dumps(d.get('unit_synonyms', {}), ensure_ascii=False))
pack_multipliers_var.set(json.dumps(d.get('pack_multipliers', {}), ensure_ascii=False))
name_patterns_text.delete(1.0, tk.END)
name_patterns_text.insert(tk.END, '\n'.join(d.get('name_patterns', [])))
default_unit_var.set(d.get('default_unit',''))
default_pq_var.set(str(d.get('default_package_quantity',1)))
tpl_list.delete(0, tk.END)
for t in s.get('output_templates', []):
tpl_list.insert(tk.END, t)
current_tpl_idx_var.set(str(s.get('current_template_index', 0)))
# 载入规则列表到UI
_load_rules_to_ui(s.get('rules', []))
def add_template():
p = filedialog.askopenfilename(title="选择模板文件", filetypes=[("Excel模板","*.xls *.xlsx")])
if p:
tpl_list.insert(tk.END, os.path.relpath(p, os.getcwd()) if os.path.isabs(p) else p)
def remove_template():
sel = tpl_list.curselection()
if sel:
tpl_list.delete(sel[0])
ttk.Button(tpl_btns, text="添加模板", command=add_template).pack(side=tk.LEFT)
ttk.Button(tpl_btns, text="删除模板", command=remove_template).pack(side=tk.LEFT, padx=6)
def save_supplier():
try:
sel = sup_list.curselection()
if not sel:
return
idx = sel[0]
s = data['suppliers'][idx]
s['name'] = name_var.get().strip() or s.get('name','')
s['description'] = desc_var.get().strip()
fp = [w.strip() for w in fp_text.get(1.0, tk.END).splitlines() if w.strip()]
ci = [w.strip() for w in ci_text.get(1.0, tk.END).splitlines() if w.strip()]
s['filename_patterns'] = fp
s['content_indicators'] = ci
hr = header_row_var.get().strip()
try:
s['header_row'] = max(0, int(hr)-1) if hr else 0
except Exception:
s['header_row'] = 0
s['dictionary'] = {
'ignore_words': [w.strip() for w in ignore_words_text.get(1.0, tk.END).splitlines() if w.strip()],
'unit_synonyms': json.loads(unit_synonyms_var.get() or '{}'),
'pack_multipliers': json.loads(pack_multipliers_var.get() or '{}'),
'name_patterns': [w.strip() for w in name_patterns_text.get(1.0, tk.END).splitlines() if w.strip()],
'default_unit': default_unit_var.get().strip() or '',
'default_package_quantity': int(default_pq_var.get() or '1')
}
# 优先使用自定义规则列表;如未编辑则使用预设或原有
rules_ui = _build_rules_from_ui()
if rules_ui:
s['rules'] = rules_ui
else:
s['rules'] = rule_presets.get(rule_preset_var.get(), s.get('rules', []))
s['output_templates'] = [tpl_list.get(i) for i in range(tpl_list.size())]
try:
s['current_template_index'] = int(current_tpl_idx_var.get() or '0')
except Exception:
s['current_template_index'] = 0
with open(cfg_path,'w',encoding='utf-8') as f:
json.dump(data,f,ensure_ascii=False,indent=2)
try:
global PROCESSOR_SERVICE
if PROCESSOR_SERVICE is None:
PROCESSOR_SERVICE = ProcessorService(ConfigManager())
PROCESSOR_SERVICE.reload_processors()
except Exception:
pass
add_to_log(log_widget, "供应商配置已保存并重载\n", "success")
refresh_list(idx)
except Exception as e:
messagebox.showerror("保存失败", str(e))
ctl_row = ttk.Frame(dlg)
ctl_row.pack(fill=tk.X, pady=8)
ttk.Button(ctl_row, text="保存", command=save_supplier).pack(side=tk.RIGHT)
def on_select(evt):
load_selected()
sup_list.bind('<<ListboxSelect>>', on_select)
refresh_list(0)
load_selected()
except Exception as e:
messagebox.showerror("供应商管理错误", str(e))
def open_column_mapping_wizard_alt(log_widget, supplier_name: str = None):
try:
import json
import pandas as pd
dlg = tk.Toplevel()
dlg.title("列映射向导")
dlg.geometry("780x660")
center_window(dlg)
try:
dlg.lift()
dlg.attributes('-topmost', True)
dlg.after(200, lambda: dlg.attributes('-topmost', False))
dlg.focus_force()
except Exception:
pass
container_scroll = ttk.Frame(dlg)
container_scroll.pack(fill=tk.BOTH, expand=True)
canvas = tk.Canvas(container_scroll)
vsb = ttk.Scrollbar(container_scroll, orient='vertical', command=canvas.yview)
canvas.configure(yscrollcommand=vsb.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
vsb.pack(side=tk.RIGHT, fill=tk.Y)
frame = ttk.Frame(canvas)
canvas.create_window((0,0), window=frame, anchor='nw')
def _on_frame_config(evt):
try:
canvas.configure(scrollregion=canvas.bbox("all"))
except Exception:
pass
frame.bind("<Configure>", _on_frame_config)
def _on_mousewheel(event):
try:
delta = -1 * int(event.delta / 120)
canvas.yview_scroll(delta, "units")
except Exception:
pass
dlg.bind("<MouseWheel>", _on_mousewheel)
# inner padding
inner_pad = ttk.Frame(frame)
inner_pad.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
frame = inner_pad
ttk.Label(frame, text="选择Excel文件").pack(anchor='w')
excel_path_var = tk.StringVar()
path_row = ttk.Frame(frame)
path_row.pack(fill=tk.X, pady=6)
ttk.Entry(path_row, textvariable=excel_path_var).pack(side=tk.LEFT, fill=tk.X, expand=True)
def _browse_excel():
p = filedialog.askopenfilename(title="选择Excel文件", filetypes=[("Excel","*.xlsx *.xls")])
if p:
excel_path_var.set(p)
try:
dlg.lift()
dlg.attributes('-topmost', True)
dlg.after(200, lambda: dlg.attributes('-topmost', False))
dlg.focus_force()
except Exception:
pass
ttk.Button(path_row, text="浏览", command=_browse_excel).pack(side=tk.LEFT, padx=6)
ttk.Label(frame, text="选择供应商").pack(anchor='w', pady=(8,0))
supplier_var = tk.StringVar()
supplier_combo = ttk.Combobox(frame, textvariable=supplier_var, state='readonly')
supplier_combo.pack(fill=tk.X)
cfg_path = os.path.join("config","suppliers_config.json")
suppliers = []
try:
if os.path.exists(cfg_path):
with open(cfg_path,'r',encoding='utf-8') as f:
data = json.load(f)
suppliers = [s.get('name','') for s in data.get('suppliers',[])]
except Exception:
pass
supplier_combo['values'] = suppliers
if supplier_name and supplier_name in suppliers:
supplier_var.set(supplier_name)
elif suppliers:
supplier_var.set(suppliers[0])
hdr_row = ttk.Frame(frame)
hdr_row.pack(fill=tk.X, pady=6)
ttk.Label(hdr_row, text="表头行号(从1开始)").pack(side=tk.LEFT)
header_row_var = tk.StringVar()
ttk.Entry(hdr_row, textvariable=header_row_var, width=8).pack(side=tk.LEFT, padx=6)
preview_frame = ttk.Frame(frame)
preview_frame.pack(fill=tk.BOTH, expand=True, pady=6)
preview_tree = ttk.Treeview(preview_frame, show='headings', height=8)
preview_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
preview_scroll_y = ttk.Scrollbar(preview_frame, orient='vertical', command=preview_tree.yview)
preview_tree.configure(yscrollcommand=preview_scroll_y.set)
preview_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
cols_box = ttk.Frame(frame)
cols_box.pack(fill=tk.BOTH, expand=True, pady=8)
ttk.Label(cols_box, text="源列 → 标准列映射").pack(anchor='w')
map_container = ttk.Frame(cols_box)
map_container.pack(fill=tk.BOTH, expand=True, pady=6)
map_canvas = tk.Canvas(map_container)
map_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
map_scroll_y = ttk.Scrollbar(map_container, orient='vertical', command=map_canvas.yview)
map_scroll_y.pack(side=tk.RIGHT, fill=tk.Y)
map_canvas.configure(yscrollcommand=map_scroll_y.set)
map_frame = ttk.Frame(map_canvas)
map_canvas.create_window((0,0), window=map_frame, anchor='nw')
def _on_map_frame_config(evt):
try:
map_canvas.configure(scrollregion=map_canvas.bbox("all"))
except Exception:
pass
map_frame.bind("<Configure>", _on_map_frame_config)
# 标准字段与中文标签
std_fields = [
"barcode","name","specification","quantity","unit",
"unit_price","total_price","category","brand","supplier"
]
friendly_labels = {
"barcode": "条码",
"name": "商品名称",
"specification": "规格",
"quantity": "数量",
"unit": "单位",
"unit_price": "采购单价",
"total_price": "金额/小计",
"category": "类别",
"brand": "品牌",
"supplier": "供应商"
}
try:
from app.core.handlers.column_mapper import ColumnMapper
cm = ColumnMapper()
# 若项目有更完整集合,替换并生成中文标签
std_fields = list(cm.STANDARD_COLUMNS.keys())
for k, v in cm.STANDARD_COLUMNS.items():
if isinstance(v, dict) and v.get('label_zh'):
friendly_labels[k] = v['label_zh']
except Exception:
pass
source_col_options = []
source_cols = []
def load_columns():
nonlocal source_cols
p = excel_path_var.get()
if not p:
return
try:
idx = None
try:
val = header_row_var.get().strip()
if val:
n = int(val)
if n > 0:
idx = n - 1
except Exception:
pass
if p.lower().endswith('.xlsx'):
df = pd.read_excel(p, engine='openpyxl', header=idx if idx is not None else 0)
else:
df = pd.read_excel(p, engine='xlrd', header=idx if idx is not None else 0)
source_cols = [str(c) for c in list(df.columns)]
for cmb in source_col_options:
cmb['values'] = source_cols
if source_cols:
cmb.set(source_cols[0])
try:
# 1) 先按已保存的映射预填
if os.path.exists(cfg_path):
with open(cfg_path,'r',encoding='utf-8') as f:
data_cfg = json.load(f)
for s in data_cfg.get('suppliers',[]):
if s.get('name') == supplier_var.get():
cm_saved = s.get('column_mapping', {})
for src, target in cm_saved.items():
if target in entries and src in source_cols:
entries[target].set(src)
break
# 2) 再用关键词建议补齐未映射项
lower = [str(c).lower() for c in source_cols]
def find_any(keys):
for i, lc in enumerate(lower):
for k in keys:
if k in lc:
return source_cols[i]
return None
mapping_suggestions = {
'barcode': find_any(['条码','barcode','条形码']),
'name': find_any(['商品名称','name','品名','商品']),
'specification': find_any(['规格','型号']),
'quantity': find_any(['数量','订购量','订货数量','采购数量']),
'unit': find_any(['单位']),
'unit_price': find_any(['单价','采购单价','进货价','价格']),
'total_price': find_any(['金额','小计','合计']),
'category': find_any(['类别','分类','品类']),
'brand': find_any(['品牌']),
'supplier': find_any(['供应商','供货商'])
}
for f, src in mapping_suggestions.items():
if f in entries and (not entries[f].get()) and src:
entries[f].set(src)
except Exception:
pass
except Exception as e:
messagebox.showerror("加载失败", str(e))
def preview_rows():
try:
p = excel_path_var.get()
if not p:
return
if p.lower().endswith('.xlsx'):
df0 = pd.read_excel(p, engine='openpyxl', header=None, nrows=30)
else:
df0 = pd.read_excel(p, engine='xlrd', header=None, nrows=30)
cols = [str(i+1) for i in range(df0.shape[1])]
preview_tree['columns'] = cols
for c in cols:
preview_tree.heading(c, text=c)
preview_tree.column(c, width=120, stretch=True)
for iid in preview_tree.get_children():
preview_tree.delete(iid)
for r in range(df0.shape[0]):
vals = [str(df0.iloc[r, c]) if c < df0.shape[1] else '' for c in range(df0.shape[1])]
preview_tree.insert('', tk.END, values=vals)
except Exception as e:
messagebox.showerror("加载失败", str(e))
def on_preview_select(evt):
try:
sel = preview_tree.selection()
if sel:
idx = preview_tree.index(sel[0])
header_row_var.set(str(idx+1))
except Exception:
pass
preview_tree.bind('<<TreeviewSelect>>', on_preview_select)
ttk.Button(path_row, text="预览前30行", command=preview_rows).pack(side=tk.LEFT, padx=6)
ttk.Button(path_row, text="加载列", command=load_columns).pack(side=tk.LEFT, padx=6)
entries = {}
for i, field in enumerate(std_fields):
row = ttk.Frame(map_frame)
row.pack(fill=tk.X, pady=4)
label_text = field + (f"{friendly_labels.get(field,'')}" if friendly_labels.get(field) else "")
ttk.Label(row, text=label_text, width=22).pack(side=tk.LEFT)
var = tk.StringVar()
cmb = ttk.Combobox(row, textvariable=var, state='readonly')
cmb.pack(side=tk.LEFT, fill=tk.X, expand=True)
source_col_options.append(cmb)
entries[field] = var
act_row = ttk.Frame(frame)
act_row.pack(fill=tk.X, pady=8)
preset_row = ttk.Frame(frame)
preset_row.pack(fill=tk.X, pady=6)
ttk.Label(preset_row, text="规则预设").pack(side=tk.LEFT)
rule_preset_var = tk.StringVar()
rule_presets = {
"基础拆分与推断": [
{"type": "split_quantity_unit", "source": "quantity"},
{"type": "extract_spec_from_name", "source": "name"},
{"type": "normalize_unit", "target": "unit", "map": {"": "", "": "", "": ""}},
{"type": "compute_quantity_from_total"},
{"type": "mark_gift"},
{"type": "fill_missing", "fills": {"unit": ""}}
]
}
preset_cb = ttk.Combobox(preset_row, textvariable=rule_preset_var, state='readonly', values=list(rule_presets.keys()))
preset_cb.pack(side=tk.LEFT, padx=6)
dict_frame = ttk.LabelFrame(frame, text="供应商规则")
dict_frame.pack(fill=tk.BOTH, expand=True, pady=6)
ttk.Label(dict_frame, text="忽略词").pack(anchor='w')
ignore_words_text = scrolledtext.ScrolledText(dict_frame, height=3)
ignore_words_text.pack(fill=tk.X, pady=4)
ttk.Label(dict_frame, text="单位同义词(JSON)").pack(anchor='w')
unit_synonyms_var = tk.StringVar(value='{}')
ttk.Entry(dict_frame, textvariable=unit_synonyms_var).pack(fill=tk.X, pady=4)
ttk.Label(dict_frame, text="包装倍数(JSON)").pack(anchor='w')
pack_multipliers_var = tk.StringVar(value='{}')
ttk.Entry(dict_frame, textvariable=pack_multipliers_var).pack(fill=tk.X, pady=4)
ttk.Label(dict_frame, text="名称正则(每行一条)").pack(anchor='w')
name_patterns_text = scrolledtext.ScrolledText(dict_frame, height=3)
name_patterns_text.pack(fill=tk.X, pady=4)
ttk.Label(dict_frame, text="默认单位").pack(anchor='w')
default_unit_var = tk.StringVar(value='')
ttk.Entry(dict_frame, textvariable=default_unit_var).pack(fill=tk.X, pady=4)
ttk.Label(dict_frame, text="默认包装数量").pack(anchor='w')
default_pq_var = tk.StringVar(value='1')
ttk.Entry(dict_frame, textvariable=default_pq_var).pack(fill=tk.X, pady=4)
def load_dictionary_for_supplier():
try:
if os.path.exists(cfg_path):
with open(cfg_path,'r',encoding='utf-8') as f:
data = json.load(f)
for s in data.get('suppliers',[]):
if s.get('name') == supplier_var.get():
d = s.get('dictionary') or {}
ignore_words = '\n'.join(d.get('ignore_words', []))
ignore_words_text.delete(1.0, tk.END)
ignore_words_text.insert(tk.END, ignore_words)
unit_synonyms_var.set(json.dumps(d.get('unit_synonyms', {}), ensure_ascii=False))
pack_multipliers_var.set(json.dumps(d.get('pack_multipliers', {}), ensure_ascii=False))
name_patterns_text.delete(1.0, tk.END)
name_patterns_text.insert(tk.END, '\n'.join(d.get('name_patterns', [])))
default_unit_var.set(d.get('default_unit',''))
default_pq_var.set(str(d.get('default_package_quantity',1)))
break
except Exception:
pass
load_dictionary_for_supplier()
def export_dictionary():
try:
save_path = filedialog.asksaveasfilename(title="导出规则", defaultextension=".json", filetypes=[("JSON","*.json")])
if not save_path:
return
payload = {
'ignore_words': [w.strip() for w in ignore_words_text.get(1.0, tk.END).splitlines() if w.strip()],
'unit_synonyms': json.loads(unit_synonyms_var.get() or '{}'),
'pack_multipliers': json.loads(pack_multipliers_var.get() or '{}'),
'name_patterns': [w.strip() for w in name_patterns_text.get(1.0, tk.END).splitlines() if w.strip()],
'default_unit': default_unit_var.get().strip() or '',
'default_package_quantity': int(default_pq_var.get() or '1')
}
with open(save_path,'w',encoding='utf-8') as f:
json.dump(payload,f,ensure_ascii=False,indent=2)
messagebox.showinfo("成功","规则已导出")
except Exception as e:
messagebox.showerror("导出失败", str(e))
def import_dictionary():
try:
open_path = filedialog.askopenfilename(title="导入规则", filetypes=[("JSON","*.json")])
if not open_path:
return
with open(open_path,'r',encoding='utf-8') as f:
payload = json.load(f)
ignore_words_text.delete(1.0, tk.END)
ignore_words_text.insert(tk.END, '\n'.join(payload.get('ignore_words', [])))
unit_synonyms_var.set(json.dumps(payload.get('unit_synonyms', {}), ensure_ascii=False))
pack_multipliers_var.set(json.dumps(payload.get('pack_multipliers', {}), ensure_ascii=False))
name_patterns_text.delete(1.0, tk.END)
name_patterns_text.insert(tk.END, '\n'.join(payload.get('name_patterns', [])))
default_unit_var.set(payload.get('default_unit',''))
default_pq_var.set(str(payload.get('default_package_quantity',1)))
messagebox.showinfo("成功","规则已导入,请保存以应用")
except Exception as e:
messagebox.showerror("导入失败", str(e))
dict_io_row = ttk.Frame(dict_frame)
dict_io_row.pack(fill=tk.X, pady=4)
ttk.Button(dict_io_row, text="导入规则", command=import_dictionary).pack(side=tk.LEFT)
ttk.Button(dict_io_row, text="导出规则", command=export_dictionary).pack(side=tk.LEFT, padx=6)
preview_box = ttk.Frame(frame)
preview_box.pack(fill=tk.BOTH, expand=True, pady=6)
left_tree = ttk.Treeview(preview_box, show='headings', height=8)
right_tree = ttk.Treeview(preview_box, show='headings', height=8)
left_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
right_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scroll_x_row = ttk.Frame(frame)
scroll_x_row.pack(fill=tk.X)
left_scroll_x = ttk.Scrollbar(scroll_x_row, orient='horizontal', command=left_tree.xview)
right_scroll_x = ttk.Scrollbar(scroll_x_row, orient='horizontal', command=right_tree.xview)
left_tree.configure(xscrollcommand=left_scroll_x.set)
right_tree.configure(xscrollcommand=right_scroll_x.set)
left_scroll_x.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0,3))
right_scroll_x.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(3,0))
def apply_rules_preview():
try:
p = excel_path_var.get()
if not p:
return
idx = None
try:
val = header_row_var.get().strip()
if val:
n = int(val)
if n > 0:
idx = n - 1
except Exception:
pass
df = pd.read_excel(p, engine='openpyxl' if p.lower().endswith('.xlsx') else 'xlrd', header=idx if idx is not None else 0, nrows=15)
rename_map = {}
for f in std_fields:
v = entries[f].get()
if v:
rename_map[v] = f
df0 = df.rename(columns=rename_map)
from app.core.handlers.rule_engine import apply_rules
rules = rule_presets.get(rule_preset_var.get(), rule_presets["基础拆分与推断"])
dictionary = {
'ignore_words': [w.strip() for w in ignore_words_text.get(1.0, tk.END).splitlines() if w.strip()],
'unit_synonyms': json.loads(unit_synonyms_var.get() or '{}'),
'pack_multipliers': json.loads(pack_multipliers_var.get() or '{}'),
'name_patterns': [w.strip() for w in name_patterns_text.get(1.0, tk.END).splitlines() if w.strip()],
'default_unit': default_unit_var.get().strip() or '',
'default_package_quantity': int(default_pq_var.get() or '1')
}
df1 = apply_rules(df0, rules, dictionary)
lcols = list(df0.columns)
rcols = list(df1.columns)
left_tree['columns'] = lcols
right_tree['columns'] = rcols
for c in lcols:
left_tree.heading(c, text=c)
left_tree.column(c, width=120, stretch=True)
for c in rcols:
right_tree.heading(c, text=c)
right_tree.column(c, width=120, stretch=True)
for iid in left_tree.get_children():
left_tree.delete(iid)
for iid in right_tree.get_children():
right_tree.delete(iid)
for i in range(len(df0)):
left_tree.insert('', tk.END, values=[str(df0.iloc[i].get(c, '')) for c in lcols])
for i in range(len(df1)):
right_tree.insert('', tk.END, values=[str(df1.iloc[i].get(c, '')) for c in rcols])
except Exception as e:
messagebox.showerror("预览失败", str(e))
ttk.Button(preset_row, text="应用规则预览", command=apply_rules_preview).pack(side=tk.LEFT, padx=6)
step_row = ttk.Frame(frame)
step_row.pack(fill=tk.X, pady=6)
ttk.Label(step_row, text="预览步骤").pack(side=tk.LEFT)
step_select_var = tk.StringVar()
step_select_cb = ttk.Combobox(step_row, textvariable=step_select_var, state='readonly')
step_select_cb.pack(side=tk.LEFT, padx=6)
step_box = ttk.Frame(frame)
step_box.pack(fill=tk.BOTH, expand=True, pady=6)
step_tree = ttk.Treeview(step_box, show='headings', height=8)
step_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
step_scroll_x = ttk.Scrollbar(frame, orient='horizontal', command=step_tree.xview)
step_tree.configure(xscrollcommand=step_scroll_x.set)
step_scroll_x.pack(fill=tk.X)
def build_step_preview():
try:
p = excel_path_var.get()
if not p:
return
idx = None
try:
val = header_row_var.get().strip()
if val:
n = int(val)
if n > 0:
idx = n - 1
except Exception:
pass
df = pd.read_excel(p, engine='openpyxl' if p.lower().endswith('.xlsx') else 'xlrd', header=idx if idx is not None else 0, nrows=50)
rename_map = {}
for f in std_fields:
v = entries[f].get()
if v:
rename_map[v] = f
df0 = df.rename(columns=rename_map)
from app.core.handlers.rule_engine import apply_rules
rules = rule_presets.get(rule_preset_var.get(), rule_presets["基础拆分与推断"])
dictionary = {
'ignore_words': [w.strip() for w in ignore_words_text.get(1.0, tk.END).splitlines() if w.strip()],
'unit_synonyms': json.loads(unit_synonyms_var.get() or '{}'),
'pack_multipliers': json.loads(pack_multipliers_var.get() or '{}'),
'name_patterns': [w.strip() for w in name_patterns_text.get(1.0, tk.END).splitlines() if w.strip()],
'default_unit': default_unit_var.get().strip() or '',
'default_package_quantity': int(default_pq_var.get() or '1')
}
steps = [("原始", df0)]
cur = df0.copy()
for r in rules:
cur = apply_rules(cur, [r], dictionary)
steps.append((r.get('type','step'), cur.copy()))
step_select_cb['values'] = [t for t,_ in steps]
if steps:
step_select_var.set(steps[-1][0])
show_step_df(steps[-1][1])
def on_step_change(evt=None):
sel = step_select_var.get()
for t, d in steps:
if t == sel:
show_step_df(d)
break
step_select_cb.bind('<<ComboboxSelected>>', on_step_change)
step_select_cb.event_generate('<<ComboboxSelected>>')
step_row._steps_cache = steps
except Exception as e:
messagebox.showerror("预览失败", str(e))
def show_step_df(dfshow):
cols = list(dfshow.columns)
step_tree['columns'] = cols
for c in cols:
step_tree.heading(c, text=c)
step_tree.column(c, width=120, stretch=True)
for iid in step_tree.get_children():
step_tree.delete(iid)
for i in range(len(dfshow)):
step_tree.insert('', tk.END, values=[str(dfshow.iloc[i].get(c, '')) for c in cols])
def export_step_csv():
try:
if not hasattr(step_row, '_steps_cache'):
return
sel = step_select_var.get()
for t, d in step_row._steps_cache:
if t == sel:
save_path = filedialog.asksaveasfilename(title="导出预览为CSV", defaultextension=".csv", filetypes=[("CSV","*.csv")])
if save_path:
d.to_csv(save_path, index=False, encoding='utf-8-sig')
messagebox.showinfo("成功","已导出CSV")
break
except Exception as e:
messagebox.showerror("导出失败", str(e))
ttk.Button(step_row, text="生成步骤预览", command=build_step_preview).pack(side=tk.LEFT, padx=6)
ttk.Button(step_row, text="导出预览CSV", command=export_step_csv).pack(side=tk.LEFT, padx=6)
def save_mapping():
try:
if not supplier_var.get():
messagebox.showwarning("提示","请选择供应商")
return
mapping = {}
for f in std_fields:
v = entries[f].get()
if v:
mapping[v] = f
if not mapping:
messagebox.showwarning("提示","请先加载列并选择映射")
return
if os.path.exists(cfg_path):
with open(cfg_path,'r',encoding='utf-8') as f:
data = json.load(f)
else:
data = {"suppliers":[]}
for s in data.get('suppliers',[]):
if s.get('name') == supplier_var.get():
s['column_mapping'] = mapping
hr = header_row_var.get().strip()
try:
if hr:
n = int(hr)
if n > 0:
s['header_row'] = n - 1
except Exception:
pass
if rule_preset_var.get():
s['rules'] = rule_presets.get(rule_preset_var.get(), [])
s['dictionary'] = {
'ignore_words': [w.strip() for w in ignore_words_text.get(1.0, tk.END).splitlines() if w.strip()],
'unit_synonyms': json.loads(unit_synonyms_var.get() or '{}'),
'pack_multipliers': json.loads(pack_multipliers_var.get() or '{}'),
'name_patterns': [w.strip() for w in name_patterns_text.get(1.0, tk.END).splitlines() if w.strip()],
'default_unit': default_unit_var.get().strip() or '',
'default_package_quantity': int(default_pq_var.get() or '1')
}
with open(cfg_path,'w',encoding='utf-8') as f:
json.dump(data,f,ensure_ascii=False,indent=2)
add_to_log(log_widget, "列映射已保存\n", "success")
messagebox.showinfo("成功","列映射已保存")
dlg.destroy()
except Exception as e:
messagebox.showerror("保存失败", str(e))
def export_mapping():
try:
save_path = filedialog.asksaveasfilename(title="导出映射", defaultextension=".json", filetypes=[("JSON","*.json")])
if not save_path:
return
mapping = {}
for f in std_fields:
v = entries[f].get()
if v:
mapping[v] = f
payload = {'supplier': supplier_var.get(), 'header_row': None, 'column_mapping': mapping}
hr = header_row_var.get().strip()
try:
if hr:
n = int(hr)
if n > 0:
payload['header_row'] = n - 1
except Exception:
pass
with open(save_path,'w',encoding='utf-8') as f:
json.dump(payload,f,ensure_ascii=False,indent=2)
messagebox.showinfo("成功","映射已导出")
except Exception as e:
messagebox.showerror("导出失败", str(e))
def import_mapping():
try:
open_path = filedialog.askopenfilename(title="导入映射", filetypes=[("JSON","*.json")])
if not open_path:
return
with open(open_path,'r',encoding='utf-8') as f:
payload = json.load(f)
cm = payload.get('column_mapping',{})
for src, target in cm.items():
if target in entries:
entries[target].set(src)
hr = payload.get('header_row')
if isinstance(hr,int) and hr >= 0:
header_row_var.set(str(hr+1))
messagebox.showinfo("成功","映射已导入,请保存以应用")
except Exception as e:
messagebox.showerror("导入失败", str(e))
ttk.Button(act_row, text="导入映射", command=import_mapping).pack(side=tk.LEFT)
ttk.Button(act_row, text="导出映射", command=export_mapping).pack(side=tk.LEFT, padx=8)
ttk.Button(act_row, text="保存映射", command=save_mapping).pack(side=tk.LEFT, padx=8)
ttk.Button(act_row, text="保存并返回", command=save_mapping).pack(side=tk.LEFT, padx=8)
ttk.Button(act_row, text="取消", command=dlg.destroy).pack(side=tk.RIGHT)
except Exception as e:
messagebox.showerror("向导错误", str(e))
def open_template_manager(log_widget):
try:
import json
import xlrd
dlg = tk.Toplevel()
dlg.title("模板管理与校验")
dlg.geometry("780x540")
center_window(dlg)
try:
dlg.lift()
dlg.attributes('-topmost', True)
dlg.after(200, lambda: dlg.attributes('-topmost', False))
dlg.focus_force()
except Exception:
pass
frame = ttk.Frame(dlg)
frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
cfg_path = os.path.join("config","suppliers_config.json")
suppliers = []
data = {"suppliers":[]}
try:
if os.path.exists(cfg_path):
with open(cfg_path,'r',encoding='utf-8') as f:
data = json.load(f)
suppliers = [s.get('name','') for s in data.get('suppliers',[])]
except Exception:
pass
ttk.Label(frame, text="选择供应商").pack(anchor='w')
supplier_var = tk.StringVar()
supplier_combo = ttk.Combobox(frame, textvariable=supplier_var, state='readonly', values=suppliers)
supplier_combo.pack(fill=tk.X)
if suppliers:
supplier_var.set(suppliers[0])
ttk.Label(frame, text="模板文件").pack(anchor='w', pady=(8,0))
tpl_var = tk.StringVar()
tpl_row = ttk.Frame(frame)
tpl_row.pack(fill=tk.X, pady=6)
ttk.Entry(tpl_row, textvariable=tpl_var).pack(side=tk.LEFT, fill=tk.X, expand=True)
def choose_tpl():
p = filedialog.askopenfilename(title="选择模板文件", filetypes=[("Excel模板","*.xls *.xlsx")])
if p:
tpl_var.set(os.path.relpath(p, os.getcwd()) if os.path.isabs(p) else p)
try:
dlg.lift()
dlg.attributes('-topmost', True)
dlg.after(200, lambda: dlg.attributes('-topmost', False))
dlg.focus_force()
except Exception:
pass
ttk.Button(tpl_row, text="选择", command=choose_tpl).pack(side=tk.LEFT, padx=6)
ttk.Label(frame, text="校验结果").pack(anchor='w', pady=(8,0))
result_text = scrolledtext.ScrolledText(frame, height=12)
result_text.pack(fill=tk.BOTH, expand=True)
def validate_tpl():
try:
p = tpl_var.get()
if not p:
messagebox.showwarning("提示","请先选择模板文件")
return
ap = os.path.abspath(p)
wb = xlrd.open_workbook(ap, formatting_info=True)
sh = wb.sheet_by_index(0)
headers = []
try:
headers = [str(sh.cell_value(0, c)).strip() for c in range(sh.ncols)]
except Exception:
pass
required = ["条码","采购量","赠送量","采购单价"]
missing = [h for h in required if h not in headers]
result_text.configure(state=tk.NORMAL)
result_text.delete(1.0, tk.END)
result_text.insert(tk.END, f"模板列: {headers}\n")
if missing:
result_text.insert(tk.END, f"缺少列: {missing}\n")
else:
result_text.insert(tk.END, "模板校验通过\n")
result_text.configure(state=tk.DISABLED)
except Exception as e:
messagebox.showerror("校验失败", str(e))
def save_tpl():
try:
if not supplier_var.get() or not tpl_var.get():
messagebox.showwarning("提示","请选择供应商并选择模板文件")
return
for s in data.get('suppliers',[]):
if s.get('name') == supplier_var.get():
s['output_template'] = tpl_var.get()
with open(cfg_path,'w',encoding='utf-8') as f:
json.dump(data,f,ensure_ascii=False,indent=2)
add_to_log(log_widget, "模板路径已保存\n", "success")
messagebox.showinfo("成功","模板路径已保存")
dlg.destroy()
except Exception as e:
messagebox.showerror("保存失败", str(e))
act = ttk.Frame(frame)
act.pack(fill=tk.X, pady=8)
ttk.Button(act, text="校验模板", command=validate_tpl).pack(side=tk.LEFT)
ttk.Button(act, text="保存", command=save_tpl).pack(side=tk.LEFT, padx=8)
ttk.Button(act, text="取消", command=dlg.destroy).pack(side=tk.RIGHT)
except Exception as e:
messagebox.showerror("模板管理错误", str(e))
def clean_data_files(log_widget):
"""清理数据文件仅清理input和output目录"""
try:
# 确认清理
if not messagebox.askyesno("确认清理", "确定要清理input和output目录的文件吗这将删除所有输入和输出数据。"):
add_to_log(log_widget, "操作已取消\n", "info")
return
files_cleaned = 0
# 清理输入目录
input_dir = "data/input"
if os.path.exists(input_dir):
for file in os.listdir(input_dir):
file_path = os.path.join(input_dir, file)
if os.path.isfile(file_path):
os.remove(file_path)
files_cleaned += 1
add_to_log(log_widget, f"已清理input目录\n", "info")
# 清理输出目录
output_dir = "data/output"
if os.path.exists(output_dir):
for file in os.listdir(output_dir):
file_path = os.path.join(output_dir, file)
if os.path.isfile(file_path):
os.remove(file_path)
files_cleaned += 1
add_to_log(log_widget, f"已清理output目录\n", "info")
# 不清理result目录仅input/output
add_to_log(log_widget, f"清理完成,共清理 {files_cleaned} 个文件\n", "success")
messagebox.showinfo("清理完成", f"已成功清理 {files_cleaned} 个文件")
except Exception as e:
add_to_log(log_widget, f"清理数据文件时出错: {str(e)}\n", "error")
messagebox.showerror("错误", f"清理数据文件时出错: {str(e)}")
def clean_result_files(log_widget):
try:
if not messagebox.askyesno("确认清理", "确定要清理result目录的文件吗这将删除所有已生成的采购单文件。"):
add_to_log(log_widget, "操作已取消\n", "info")
return
count = 0
result_dir = "data/result"
if os.path.exists(result_dir):
for file in os.listdir(result_dir):
file_path = os.path.join(result_dir, file)
if os.path.isfile(file_path):
os.remove(file_path)
count += 1
add_to_log(log_widget, f"已清理result目录{count} 个文件\n", "success")
messagebox.showinfo("清理完成", f"已清理result目录 {count} 个文件")
except Exception as e:
add_to_log(log_widget, f"清理result目录时出错: {str(e)}\n", "error")
messagebox.showerror("错误", f"清理result目录时出错: {str(e)}")
def center_window(window):
"""使窗口居中显示"""
window.update_idletasks()
width = window.winfo_width()
height = window.winfo_height()
x = (window.winfo_screenwidth() // 2) - (width // 2)
y = (window.winfo_screenheight() // 2) - (height // 2)
window.geometry('{}x{}+{}+{}'.format(width, height, x, y))
def show_tobacco_result_preview(returncode, output):
"""显示烟草订单处理结果预览"""
# 只在成功时显示结果预览
if returncode != 0:
return
try:
global TOBACCO_PREVIEW_WINDOW
try:
if TOBACCO_PREVIEW_WINDOW and TOBACCO_PREVIEW_WINDOW.winfo_exists():
TOBACCO_PREVIEW_WINDOW.lift()
return
except:
TOBACCO_PREVIEW_WINDOW = None
# 查找输出文件路径
result_file = None
order_time = "(未知)"
total_amount = "(未知)"
items_count = 0
# 先使用更可靠的方式查找文件路径
abs_path_match = re.search(r'烟草订单处理完成,绝对路径: (.+)(?:\n|$)', output)
if abs_path_match:
result_file = abs_path_match.group(1).strip()
# 提取处理结果信息
for line in output.split('\n'):
# 提取订单时间和金额
if "烟草公司订单处理成功" in line and "订单时间" in line:
time_match = re.search(r'订单时间: ([^,]+)', line)
amount_match = re.search(r'总金额: ([^,]+)', line)
items_match = re.search(r'处理条目: (\d+)', line)
if time_match:
order_time = time_match.group(1).strip()
if amount_match:
total_amount = amount_match.group(1).strip()
if items_match:
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")
if os.path.exists(default_path):
result_file = default_path
# 创建结果预览对话框
preview = tk.Toplevel()
preview.title("烟草订单处理结果")
preview.geometry("450x320")
preview.resizable(False, False)
TOBACCO_PREVIEW_WINDOW = preview
def _close_preview():
global TOBACCO_PREVIEW_WINDOW
try:
TOBACCO_PREVIEW_WINDOW = None
except:
pass
try:
preview.destroy()
except:
pass
preview.protocol("WM_DELETE_WINDOW", _close_preview)
# 使弹窗居中显示
center_window(preview)
# 添加内容
tk.Label(preview, text="烟草订单处理完成", font=("Arial", 16, "bold")).pack(pady=10)
result_frame = tk.Frame(preview)
result_frame.pack(pady=10, fill=tk.BOTH, expand=True)
tk.Label(result_frame, text=f"订单时间: {order_time}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"订单总金额: {total_amount}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
tk.Label(result_frame, text=f"处理商品数量: {items_count}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
# 文件信息
if result_file and os.path.exists(result_file):
tk.Label(result_frame, text=f"输出文件: {os.path.basename(result_file)}", font=("Arial", 12)).pack(anchor=tk.W, padx=20, pady=5)
# 处理成功提示
tk.Label(result_frame, text="银豹采购单已成功生成!", font=("Arial", 12, "bold"), fg="#28a745").pack(pady=10)
# 文件信息框
file_frame = tk.Frame(result_frame, relief=tk.GROOVE, borderwidth=1)
file_frame.pack(fill=tk.X, padx=15, pady=5)
tk.Label(file_frame, text="文件信息", font=("Arial", 10, "bold")).pack(anchor=tk.W, padx=10, pady=5)
# 获取文件大小和时间
try:
file_size = os.path.getsize(result_file)
file_time = datetime.datetime.fromtimestamp(os.path.getmtime(result_file))
size_text = f"{file_size / 1024:.1f} KB" if file_size < 1024*1024 else f"{file_size / (1024*1024):.1f} MB"
tk.Label(file_frame, text=f"文件大小: {size_text}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
tk.Label(file_frame, text=f"创建时间: {file_time.strftime('%Y-%m-%d %H:%M:%S')}", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
except:
tk.Label(file_frame, text="无法获取文件信息", font=("Arial", 10)).pack(anchor=tk.W, padx=10, pady=2)
# 添加按钮
button_frame = tk.Frame(preview)
button_frame.pack(pady=10)
tk.Button(button_frame, text="打开文件", command=lambda: os.startfile(result_file)).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="打开所在文件夹", command=lambda: os.startfile(os.path.dirname(result_file))).pack(side=tk.LEFT, padx=5)
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)
# 添加按钮
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=_close_preview).pack(side=tk.LEFT, padx=5)
# 确保窗口显示在最前
preview.lift()
preview.attributes('-topmost', True)
preview.after_idle(lambda: preview.attributes('-topmost', False))
except Exception as e:
# 发生异常,显示错误消息
messagebox.showerror(
"处理异常",
f"显示预览时发生错误: {e}\n请检查日志了解详细信息。"
)
def edit_barcode_mappings(log_widget):
"""编辑条码映射配置"""
try:
add_to_log(log_widget, "正在加载条码映射配置...\n", "info")
# 创建单位转换器实例,用于加载和保存条码映射
unit_converter = UnitConverter()
# 加载现有的映射配置
current_mappings = unit_converter.special_barcodes
# 回调函数,保存更新后的映射
def save_mappings(new_mappings):
success = unit_converter.update_barcode_mappings(new_mappings)
if success:
add_to_log(log_widget, f"成功保存条码映射配置,共{len(new_mappings)}\n", "success")
else:
add_to_log(log_widget, "保存条码映射配置失败\n", "error")
# 显示条码映射编辑对话框
show_barcode_mapping_dialog(None, save_mappings, current_mappings)
except Exception as e:
add_to_log(log_widget, f"编辑条码映射时出错: {str(e)}\n", "error")
messagebox.showerror("错误", f"编辑条码映射时出错: {str(e)}")
def open_column_mapping_wizard(log_widget):
try:
import json
from tkinter import ttk
from app.services.order_service import OrderService
from app.core.handlers.column_mapper import ColumnMapper
suppliers = []
config_path = os.path.abspath(os.path.join('config','suppliers_config.json'))
if os.path.exists(config_path):
with open(config_path,'r',encoding='utf-8') as f:
data = json.load(f)
suppliers = data.get('suppliers', [])
file_path = select_excel_file(log_widget)
if not file_path:
try:
svc = OrderService()
file_path = svc.get_latest_excel()
except Exception:
file_path = None
if not file_path:
messagebox.showwarning("未选择文件","请选择一个用于分析的Excel文件")
return
import pandas as pd
df = pd.read_excel(file_path, nrows=5)
detected_cols = list(df.columns)
dlg = tk.Toplevel()
dlg.title("列映射向导")
dlg.geometry("520x560")
center_window(dlg)
frame = tk.Frame(dlg, bg=THEMES[THEME_MODE]["card_bg"])
frame.pack(fill=tk.BOTH, expand=True, padx=12, pady=12)
tk.Label(frame, text="供应商", bg=THEMES[THEME_MODE]["card_bg"], fg=THEMES[THEME_MODE]["fg"]).pack(anchor='w')
supplier_names = [s.get('name','') for s in suppliers]
supplier_var = tk.StringVar(value=supplier_names[0] if supplier_names else '')
supplier_cb = ttk.Combobox(frame, values=supplier_names, textvariable=supplier_var, state='readonly')
supplier_cb.pack(fill=tk.X, pady=6)
std_cols = list(ColumnMapper.STANDARD_COLUMNS.keys())
mapper = ColumnMapper()
suggestions = mapper.suggest_column_mapping(df)
mapping_vars = {}
for sc in std_cols:
row = tk.Frame(frame, bg=THEMES[THEME_MODE]["card_bg"])
row.pack(fill=tk.X, pady=4)
tk.Label(row, text=sc, width=14, anchor='w', bg=THEMES[THEME_MODE]["card_bg"], fg=THEMES[THEME_MODE]["fg"]).pack(side=tk.LEFT)
var = tk.StringVar()
values = ['未映射'] + detected_cols
cb = ttk.Combobox(row, values=values, textvariable=var, state='readonly')
pre = None
for col, stds in suggestions.items():
if sc in stds and col in detected_cols:
pre = col
break
var.set(pre if pre else '未映射')
cb.pack(side=tk.LEFT, fill=tk.X, expand=True)
mapping_vars[sc] = var
btn_row = tk.Frame(frame, bg=THEMES[THEME_MODE]["card_bg"])
btn_row.pack(fill=tk.X, pady=12)
def save_mapping():
try:
supplier_name = supplier_var.get()
if not supplier_name:
messagebox.showwarning("未选择供应商","请选择一个供应商")
return
updated = False
for s in suppliers:
if s.get('name') == supplier_name:
cm = {}
for sc, var in mapping_vars.items():
src = var.get()
if src and src != '未映射':
cm[src] = sc
s['column_mapping'] = cm
updated = True
break
if not updated:
messagebox.showerror("错误","未找到供应商配置")
return
with open(config_path,'w',encoding='utf-8') as f:
json.dump({'suppliers': suppliers}, f, ensure_ascii=False, indent=2)
try:
global PROCESSOR_SERVICE
if PROCESSOR_SERVICE is None:
PROCESSOR_SERVICE = ProcessorService(ConfigManager())
PROCESSOR_SERVICE.reload_processors()
except Exception:
pass
add_to_log(log_widget, "列映射已保存并重新加载供应商配置\n", "success")
dlg.destroy()
except Exception as e:
messagebox.showerror("保存失败", str(e))
create_modern_button(btn_row, "保存", save_mapping, "primary", px_width=92, px_height=32).pack(side=tk.RIGHT)
except Exception as e:
messagebox.showerror("向导错误", str(e))
def bind_keyboard_shortcuts(root, log_widget, status_bar):
"""绑定键盘快捷键"""
# Ctrl+O - 处理单个图片
root.bind('<Control-o>', lambda e: process_single_image_with_status(log_widget, status_bar))
# Ctrl+E - 处理Excel文件
root.bind('<Control-e>', lambda e: process_excel_file_with_status(log_widget, status_bar))
# Ctrl+B - 批量处理
root.bind('<Control-b>', lambda e: batch_ocr_with_status(log_widget, status_bar))
# Ctrl+P - 完整流程
root.bind('<Control-p>', lambda e: run_pipeline_directly(log_widget, status_bar))
# Ctrl+M - 合并采购单
root.bind('<Control-m>', lambda e: merge_orders_with_status(log_widget, status_bar))
# Ctrl+T - 处理烟草订单
root.bind('<Control-t>', lambda e: process_tobacco_orders_with_status(log_widget, status_bar))
# F5 - 刷新/清除缓存
root.bind('<F5>', lambda e: clean_cache(log_widget))
# Escape - 退出
root.bind('<Escape>', lambda e: root.quit() if messagebox.askyesno("确认退出", "确定要退出程序吗?") else None)
# F1 - 显示快捷键帮助
root.bind('<F1>', lambda e: show_shortcuts_help())
def show_shortcuts_help():
"""显示快捷键帮助对话框"""
help_dialog = tk.Toplevel()
help_dialog.title("快捷键帮助")
help_dialog.geometry("400x450")
center_window(help_dialog)
tk.Label(help_dialog, text="键盘快捷键", font=("Arial", 16, "bold")).pack(pady=10)
help_text = tk.Text(help_dialog, wrap=tk.WORD, width=50, height=20)
help_text.pack(padx=20, pady=10, fill=tk.BOTH, expand=True)
shortcuts = """
Ctrl+O: 处理单个图片
Ctrl+E: 处理Excel文件
Ctrl+B: OCR批量识别
Ctrl+P: 完整处理流程
Ctrl+M: 合并采购单
Ctrl+T: 处理烟草订单
F5: 清除处理缓存
Esc: 退出程序
"""
help_text.insert(tk.END, shortcuts)
help_text.configure(state=tk.DISABLED)
# 按钮
tk.Button(help_dialog, text="确定", command=help_dialog.destroy).pack(pady=10)
# 确保窗口显示在最前
help_dialog.lift()
help_dialog.attributes('-topmost', True)
help_dialog.after_idle(lambda: help_dialog.attributes('-topmost', False))
if __name__ == "__main__":
main()
def show_error_dialog(title: str, message: str, suggestion: Optional[str] = None):
try:
full_msg = message
if suggestion:
full_msg = f"{message}\n\n建议操作:\n- {suggestion}"
messagebox.showerror(title, full_msg)
except Exception:
pass
def get_error_suggestion(message: str) -> Optional[str]:
msg = (message or "").lower()
if 'openpyxl' in msg or ('engine' in msg and 'xlsx' in msg):
return '安装依赖pip install openpyxl'
if 'xlrd' in msg or ('engine' in msg and 'xls' in msg):
return '安装依赖pip install xlrd'
if 'timeout' in msg or 'timed out' in msg:
return '检查网络增大API超时时间或稍后重试'
if 'invalid access_token' in msg or 'access token' in msg:
return '刷新百度OCR令牌或检查api_key/secret_key'
if '429' in msg or 'too many requests' in msg:
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 'permission denied' in msg:
return '以管理员权限运行或更改目录写入权限'
return None
def _extract_path_from_recent_item(s: str) -> str:
try:
m = re.match(r'^(\d+)\.\s+(.*)$', s)
p = m.group(2) if m else s
return p.strip().strip('"')
except Exception:
return s.strip().strip('"')
def open_validation_panel(log_widget):
try:
import json
import pandas as pd
import xlrd
dlg = tk.Toplevel()
dlg.title("验证匹配")
dlg.geometry("900x680")
center_window(dlg)
try:
dlg.lift()
dlg.attributes('-topmost', True)
dlg.after(200, lambda: dlg.attributes('-topmost', False))
dlg.focus_force()
except Exception:
pass
outer = ttk.Frame(dlg)
outer.pack(fill=tk.BOTH, expand=True)
canvas = tk.Canvas(outer)
vsb = ttk.Scrollbar(outer, orient='vertical', command=canvas.yview)
canvas.configure(yscrollcommand=vsb.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
vsb.pack(side=tk.RIGHT, fill=tk.Y)
frame = ttk.Frame(canvas)
canvas.create_window((0,0), window=frame, anchor='nw')
frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
cfg_path = os.path.join("config","suppliers_config.json")
suppliers = []
data_cfg = {"suppliers": []}
try:
if os.path.exists(cfg_path):
with open(cfg_path,'r',encoding='utf-8') as f:
data_cfg = json.load(f)
suppliers = [s.get('name','') for s in data_cfg.get('suppliers',[])]
except Exception:
pass
row1 = ttk.Frame(frame)
row1.pack(fill=tk.X, pady=6)
ttk.Label(row1, text="供应商").pack(side=tk.LEFT)
sup_var = tk.StringVar()
ttk.Combobox(row1, textvariable=sup_var, state='readonly', values=suppliers).pack(side=tk.LEFT, padx=6)
row2 = ttk.Frame(frame)
row2.pack(fill=tk.X, pady=6)
ttk.Label(row2, text="原始文件").pack(side=tk.LEFT)
orig_var = tk.StringVar()
ttk.Entry(row2, textvariable=orig_var).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6)
ttk.Button(row2, text="浏览", command=lambda: orig_var.set(filedialog.askopenfilename(title="选择原始Excel", filetypes=[("Excel","*.xlsx *.xls")]) or orig_var.get())).pack(side=tk.LEFT)
row3 = ttk.Frame(frame)
row3.pack(fill=tk.X, pady=6)
ttk.Label(row3, text="期望结果").pack(side=tk.LEFT)
expect_var = tk.StringVar()
ttk.Entry(row3, textvariable=expect_var).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6)
ttk.Button(row3, text="浏览", command=lambda: expect_var.set(filedialog.askopenfilename(title="选择期望结果", filetypes=[("Excel","*.xls *.xlsx")]) or expect_var.get())).pack(side=tk.LEFT)
act_row = ttk.Frame(frame)
act_row.pack(fill=tk.X, pady=8)
result_text = scrolledtext.ScrolledText(frame, height=6)
result_text.pack(fill=tk.BOTH, expand=True)
diff_box = ttk.Frame(frame)
diff_box.pack(fill=tk.BOTH, expand=True, pady=6)
diff_tree = ttk.Treeview(diff_box, show='headings', height=12)
diff_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
diff_scroll_x = ttk.Scrollbar(frame, orient='horizontal', command=diff_tree.xview)
diff_tree.configure(xscrollcommand=diff_scroll_x.set)
diff_scroll_x.pack(fill=tk.X)
suggestions_cache = []
def _run_validation():
try:
import numpy as np
from app.services.order_service import OrderService
supplier = sup_var.get()
orig = orig_var.get().strip()
expect = expect_var.get().strip()
if not supplier or not orig or not expect:
messagebox.showwarning("提示","请选择供应商、原始文件与期望结果")
return
service = OrderService()
out_path = service.process_excel(orig, progress_cb=lambda p: None)
result_text.configure(state=tk.NORMAL)
result_text.delete(1.0, tk.END)
result_text.insert(tk.END, f"生成结果: {out_path}\n")
def _read_df(path):
ap = os.path.abspath(path)
if ap.lower().endswith('.xlsx'):
return pd.read_excel(ap, engine='openpyxl')
else:
return pd.read_excel(ap, engine='xlrd')
df_actual = _read_df(out_path)
df_expect = _read_df(expect)
keys = None
if 'barcode' in df_actual.columns and 'barcode' in df_expect.columns:
keys = 'barcode'
elif '条码' in df_actual.columns and '条码' in df_expect.columns:
keys = '条码'
elif 'name' in df_actual.columns and 'name' in df_expect.columns:
keys = 'name'
else:
keys = None
fields = ['barcode','name','specification','quantity','unit','unit_price','total_price']
for c in ['条码','商品名称','规格','采购量','单位','采购单价','金额','小计']:
if c in df_expect.columns and c not in fields:
fields.append(c)
records = []
mismatches = 0
if keys:
a = df_actual.set_index(keys)
e = df_expect.set_index(keys)
idx_union = list(set(a.index.tolist()) | set(e.index.tolist()))
for k in idx_union:
ra = a.loc[k] if k in a.index else None
rexp = e.loc[k] if k in e.index else None
for f in ['barcode','name','specification','quantity','unit','unit_price','total_price']:
va = None
ve = None
if ra is not None and f in a.columns:
va = ra.get(f)
if rexp is not None and f in e.columns:
ve = rexp.get(f)
if va is None and ve is None:
continue
def _norm(v):
try:
return float(v)
except Exception:
return str(v) if v is not None else ''
if _norm(va) != _norm(ve):
records.append([str(k), f, str(ve), str(va)])
mismatches += 1
else:
for i in range(min(len(df_actual), len(df_expect))):
for f in ['barcode','name','specification','quantity','unit','unit_price','total_price']:
va = df_actual.iloc[i].get(f) if f in df_actual.columns else None
ve = df_expect.iloc[i].get(f) if f in df_expect.columns else None
if va is None and ve is None:
continue
def _norm(v):
try:
return float(v)
except Exception:
return str(v) if v is not None else ''
if _norm(va) != _norm(ve):
records.append([str(i), f, str(ve), str(va)])
mismatches += 1
diff_tree['columns'] = ['key','field','expected','actual']
for c in ['key','field','expected','actual']:
diff_tree.heading(c, text=c)
diff_tree.column(c, width=160, stretch=True)
for iid in diff_tree.get_children():
diff_tree.delete(iid)
for row in records:
diff_tree.insert('', tk.END, values=row)
result_text.insert(tk.END, f"差异条目: {mismatches}\n")
result_text.configure(state=tk.DISABLED)
suggestions = []
try:
unit_vals = df_actual.get('unit') if 'unit' in df_actual.columns else pd.Series([])
bad_units = unit_vals.astype(str).isin(['','','','']).sum()
if bad_units > 0:
suggestions.append({"type":"normalize_unit","target":"unit","map":{"":"","":"","":""}})
except Exception:
pass
try:
qty = pd.to_numeric(df_actual.get('quantity'), errors='coerce') if 'quantity' in df_actual.columns else pd.Series([])
missing_qty = int(qty.isna().sum()) if not qty.empty else 0
if missing_qty > 0:
cols = df_actual.columns.tolist()
src = None
for cand in ['订单数量','订购量','订货数量']:
if cand in cols:
src = cand
break
if src:
suggestions.append({"type":"split_quantity_unit","source":src})
except Exception:
pass
try:
spec = df_actual.get('specification') if 'specification' in df_actual.columns else pd.Series([])
missing_spec = int(spec.isna().sum()) if not spec.empty else 0
if missing_spec > 0:
suggestions.append({"type":"extract_spec_from_name","source":"name"})
except Exception:
pass
try:
up = pd.to_numeric(df_actual.get('unit_price'), errors='coerce').fillna(0) if 'unit_price' in df_actual.columns else pd.Series([])
tp = pd.to_numeric(df_actual.get('total_price'), errors='coerce').fillna(0) if 'total_price' in df_actual.columns else pd.Series([])
qty = pd.to_numeric(df_actual.get('quantity'), errors='coerce').fillna(np.nan) if 'quantity' in df_actual.columns else pd.Series([])
if not qty.empty and not up.empty and not tp.empty:
need_compute = ((qty.isna()) & (up > 0) & (tp > 0)).sum()
if need_compute > 0:
suggestions.append({"type":"compute_quantity_from_total"})
except Exception:
pass
suggestions_cache.clear()
suggestions_cache.extend(suggestions)
if suggestions:
result_text.configure(state=tk.NORMAL)
result_text.insert(tk.END, f"建议: {json.dumps(suggestions, ensure_ascii=False)}\n")
result_text.configure(state=tk.DISABLED)
except Exception as e:
messagebox.showerror("验证失败", str(e))
def _apply_suggestions():
try:
supplier = sup_var.get()
if not supplier:
return
if os.path.exists(cfg_path):
with open(cfg_path,'r',encoding='utf-8') as f:
data = json.load(f)
for s in data.get('suppliers',[]):
if s.get('name') == supplier:
rules = s.get('rules', [])
for sug in suggestions_cache:
exists = any(r.get('type') == sug.get('type') for r in rules)
if not exists:
rules.append(sug)
s['rules'] = rules
d = s.get('dictionary') or {}
d.setdefault('unit_synonyms', {"":"","":"","":"","":""})
d.setdefault('pack_multipliers', {"":24,"":24,"":12,"":10})
s['dictionary'] = d
break
with open(cfg_path,'w',encoding='utf-8') as f:
json.dump(data,f,ensure_ascii=False,indent=2)
result_text.configure(state=tk.NORMAL)
result_text.insert(tk.END, "已应用建议并保存配置\n")
result_text.configure(state=tk.DISABLED)
else:
messagebox.showwarning("提示","配置文件不存在")
except Exception as e:
messagebox.showerror("应用失败", str(e))
ttk.Button(act_row, text="运行验证", command=_run_validation).pack(side=tk.LEFT)
ttk.Button(act_row, text="应用建议", command=_apply_suggestions).pack(side=tk.LEFT, padx=6)
ttk.Button(act_row, text="关闭", command=dlg.destroy).pack(side=tk.RIGHT)
except Exception as e:
messagebox.showerror("验证匹配错误", str(e))