refactor(处理器): 重构通用供应商处理器以支持规则引擎 docs: 更新README与文档说明供应商管理功能 build: 更新打包脚本注入版本信息 test: 添加规则引擎单元测试
4857 lines
226 KiB
Python
4857 lines
226 KiB
Python
#!/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))
|