import tkinter as tk from tkinter import filedialog, messagebox, ttk import pandas as pd from openpyxl import load_workbook import os import sys import shutil from collections import Counter from datetime import datetime class ExcelMapperApp: def __init__(self, root): self.root = root self.root.title("Excel 自动填表工具") self.root.geometry("800x600") self.root.resizable(True, True) if getattr(sys, 'frozen', False): self.base_dir = os.path.dirname(sys.executable) else: self.base_dir = os.path.dirname(os.path.abspath(__file__)) self.source_file = tk.StringVar() self.header_row = tk.StringVar(value="4") self.source_header_row = tk.StringVar(value="2") self.df = None self.valid_values = {} # 存储 Valid Values 工作表的数据 self.status_text = tk.StringVar(value="请选择来源Excel文件") self.setup_ui() def setup_ui(self): title_label = tk.Label(self.root, text="Excel 自动填表工具", font=("Arial", 16, "bold")) title_label.pack(pady=10) file_frame = tk.LabelFrame(self.root, text="文件选择", font=("Arial", 10, "bold")) file_frame.pack(pady=10, padx=20, fill="x") tk.Label(file_frame, text="来源数据Excel:").grid(row=0, column=0, sticky="w", pady=5) tk.Entry(file_frame, textvariable=self.source_file, width=50).grid(row=0, column=1, padx=5, pady=5) tk.Button(file_frame, text="浏览", command=self.select_source_file).grid(row=0, column=2, pady=5) tk.Label(file_frame, text="来源表头行号:").grid(row=1, column=0, sticky="w", pady=5) tk.Entry(file_frame, textvariable=self.source_header_row, width=10).grid(row=1, column=1, sticky="w", padx=5, pady=5) tk.Label(file_frame, text="(第2行是字段名)", fg="gray").grid(row=1, column=1, sticky="e", padx=5, pady=5) tk.Label(file_frame, text="模板表头行号:").grid(row=2, column=0, sticky="w", pady=5) tk.Entry(file_frame, textvariable=self.header_row, width=10).grid(row=2, column=1, sticky="w", padx=5, pady=5) tk.Label(file_frame, text="(第4行是字段名)", fg="gray").grid(row=2, column=1, sticky="e", padx=5, pady=5) template_path = os.path.join(self.base_dir, "模板.xlsx") tk.Label(file_frame, text=f"目标模板: {template_path}", fg="blue").grid(row=3, column=0, columnspan=3, sticky="w", pady=3) tk.Label(file_frame, text="(数据将直接追加到模板文件末尾)", fg="green").grid(row=4, column=0, columnspan=3, sticky="w", pady=3) btn_frame = tk.Frame(self.root) btn_frame.pack(pady=10) tk.Button(btn_frame, text="🔍 查看来源结构", command=self.inspect_source, bg="#9C27B0", fg="white", font=("Arial", 10), width=14).pack(side="left", padx=5) tk.Button(btn_frame, text="🔍 检查模板表头", command=self.check_template_header, bg="#FF9800", fg="white", font=("Arial", 10), width=14).pack(side="left", padx=5) tk.Button(btn_frame, text="🚀 追加到模板", command=self.generate_output, bg="#4CAF50", fg="white", font=("Arial", 10), width=14).pack(side="left", padx=5) tk.Button(btn_frame, text="清空日志", command=self.clear_log, bg="#f44336", fg="white", font=("Arial", 10), width=14).pack(side="left", padx=5) log_frame = tk.LabelFrame(self.root, text="运行日志", font=("Arial", 10, "bold")) log_frame.pack(pady=10, padx=20, fill="both", expand=True) self.log_text = tk.Text(log_frame, height=16, font=("Courier", 9)) self.log_text.pack(side="left", fill="both", expand=True) scrollbar = tk.Scrollbar(log_frame, command=self.log_text.yview) scrollbar.pack(side="right", fill="y") self.log_text.config(yscrollcommand=scrollbar.set) status_bar = tk.Label(self.root, textvariable=self.status_text, relief="sunken", anchor="w", font=("Arial", 9), bg="#f0f0f0") status_bar.pack(side="bottom", fill="x") def log(self, msg): self.log_text.insert(tk.END, msg + "\n") self.log_text.see(tk.END) self.root.update() print(msg) def clear_log(self): self.log_text.delete(1.0, tk.END) def select_source_file(self): file_path = filedialog.askopenfilename( title="选择来源Excel文件", filetypes=[("Excel files", "*.xlsx *.xls"), ("All files", "*.*")] ) if file_path: self.source_file.set(file_path) self.status_text.set(f"已选择: {os.path.basename(file_path)}") self.log(f"📁 已选择来源文件: {file_path}") def inspect_source(self): source_path = self.source_file.get() if not source_path: messagebox.showwarning("警告", "请先选择来源Excel文件!") return self.log("\n" + "="*60) self.log("🔍 分析来源Excel结构...") try: wb = load_workbook(source_path) ws = wb.active self.log(f"📊 共 {ws.max_row} 行, {ws.max_column} 列") self.log("\n📌 前3行 × 前20列:") for row in range(1, min(4, ws.max_row + 1)): values = [] for col in range(1, min(21, ws.max_column + 1)): val = ws.cell(row=row, column=col).value if val: values.append(f"列{col}:{str(val)[:30]}") if values: self.log(f" 第{row}行: {' | '.join(values)}") self.log(f"\n📌 第2行(列名):") row2_vals = [] for col in range(1, min(ws.max_column + 1, 30)): val = ws.cell(row=2, column=col).value if val: row2_vals.append(f"列{col}:{str(val)[:25]}") if row2_vals: self.log(f" {' | '.join(row2_vals)}") except Exception as e: self.log(f"❌ 分析失败: {str(e)}") def check_template_header(self): template_path = os.path.join(self.base_dir, "模板.xlsx") if not os.path.exists(template_path): self.log(f"❌ 找不到模板文件: {template_path}") return self.log("\n" + "="*60) self.log("🔍 检查模板表头...") try: wb = load_workbook(template_path) ws = wb.active self.log(f"📊 共 {ws.max_row} 行, {ws.max_column} 列") # 显示前5行 for row in range(1, min(6, ws.max_row + 1)): values = [] for col in range(1, min(31, ws.max_column + 1)): val = ws.cell(row=row, column=col).value if val: values.append(f"列{col}:{str(val)[:30]}") if values: self.log(f"\n📌 第{row}行: {' | '.join(values[:10])}") if len(values) > 10: self.log(f" ... 还有 {len(values)-10} 个有值单元格") else: self.log(f"\n📌 第{row}行: (全空)") # 检查 Valid Values 工作表 if "Valid Values" in wb.sheetnames: self.log(f"\n✅ 找到 'Valid Values' 工作表") ws_valid = wb["Valid Values"] self.log(f" 共 {ws_valid.max_row} 行, {ws_valid.max_column} 列") # 读取前几行 for row in range(1, min(6, ws_valid.max_row + 1)): values = [] for col in range(1, min(10, ws_valid.max_column + 1)): val = ws_valid.cell(row=row, column=col).value if val: values.append(str(val)[:20]) if values: self.log(f" Valid Values 第{row}行: {' | '.join(values)}") else: self.log(f"\n⚠️ 未找到 'Valid Values' 工作表") except Exception as e: self.log(f"❌ 检查失败: {str(e)}") def load_valid_values(self, wb): """从 Valid Values 工作表读取有效值""" valid_data = {} if "Valid Values" not in wb.sheetnames: self.log("⚠️ 未找到 'Valid Values' 工作表,将使用硬编码默认值") return valid_data ws_valid = wb["Valid Values"] # 读取第1行作为列名 headers = [] for col in range(1, ws_valid.max_column + 1): val = ws_valid.cell(row=1, column=col).value if val: headers.append(str(val).strip()) else: headers.append(f"Column_{col}") # 读取数据行 for row in range(2, ws_valid.max_row + 1): for col in range(1, len(headers) + 1): val = ws_valid.cell(row=row, column=col).value if val: header = headers[col - 1] if header not in valid_data: valid_data[header] = [] if str(val).strip() not in valid_data[header]: valid_data[header].append(str(val).strip()) return valid_data def generate_output(self): self.log("\n" + "="*60) self.log("🚀 开始处理...") source_path = self.source_file.get() template_path = os.path.join(self.base_dir, "模板.xlsx") if not source_path: messagebox.showwarning("警告", "请选择来源Excel文件!") return if not os.path.exists(template_path): self.log(f"❌ 找不到模板文件: {template_path}") messagebox.showerror("错误", f"找不到模板文件:\n{template_path}") return try: header_row = int(self.header_row.get()) source_header = int(self.source_header_row.get()) # ========== 1. 备份模板 ========== backup_path = template_path.replace(".xlsx", f"_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx") shutil.copy2(template_path, backup_path) self.log(f"📦 已备份模板到: {backup_path}") # ========== 2. 读取来源数据 ========== self.log(f"📖 读取来源数据: {source_path}") self.log(f"📌 使用第 {source_header} 行作为列名") self.df = pd.read_excel(source_path, header=source_header - 1) self.log(f"✅ 成功读取 {len(self.df)} 行数据") # ========== 3. 加载模板并读取 Valid Values ========== self.log(f"\n📖 加载模板: {template_path}") wb = load_workbook(template_path) ws = wb.active # 加载 Valid Values self.valid_values = self.load_valid_values(wb) self.log(f"📋 从 Valid Values 读取到 {len(self.valid_values)} 个字段的有效值") # ========== 4. 验证模板表头 ========== col_indices = {} for col in range(1, ws.max_column + 1): cell_value = ws.cell(row=header_row, column=col).value if cell_value: col_name = str(cell_value).strip() if col_name and not col_name.startswith("Unnamed"): col_indices[col_name] = col self.log(f"📊 模板中共有 {len(col_indices)} 个有名称的列") # ========== 5. 找到已有数据的最后一行 ========== last_row = 5 for row in range(5, ws.max_row + 2): is_empty = True for col in range(1, min(20, ws.max_column + 1)): if ws.cell(row=row, column=col).value is not None: is_empty = False break if is_empty: last_row = row break else: last_row = ws.max_row + 1 self.log(f"📌 已有数据到第 {last_row - 1} 行,从第 {last_row} 行开始追加") # ========== 6. 定义默认值映射 ========== # 格式: 目标列名 -> (值来源, 处理方式) # 值来源: 'fixed' 固定值, 'valid' 从Valid Values取第一个, 'source' 从来源取, 'computed' 计算 default_mapping = { # 固定值 "Variant Type": ("fixed", "Not Variant"), "Minimum Order Quantity": ("fixed", 1), "Force Quantity Multiplier": ("fixed", 1), "Display Set Quantity": ("fixed", 1), "Ship Type": ("valid", "Ship Type"), # 从Valid Values取第一个 "Lead Time": ("fixed", 48), "Replacement Lead Time": ("fixed", 120), "Flat Pack": ("fixed", "Yes"), "Assembly Required": ("fixed", "Yes"), "Canada Product Restriction": ("fixed", "Yes"), "CARB Phase II Compliant (formaldehyde emissions)": ("fixed", "Yes"), "Commercial Warranty": ("fixed", "Yes"), "CANFER Compliant": ("fixed", "Yes"), "Commercial Warranty Length": ("fixed", "1 Years"), "Composite Wood Product (CWP)": ("fixed", "Yes"), "Country Of Manufacturer": ("fixed", "China"), "General Certificate of Conformity (GCC)": ("fixed", "Yes"), "Hazard Class(es)": ("fixed", "Does Not Apply"), "Hazardous Material / Dangerous Goods": ("fixed", "No"), "Hazardous Material Weight": ("fixed", "Does Not Apply"), "ISTA Certified": ("popup", ""), # 弹出输入框 "Level of Assembly": ("fixed", "Full Assembly Needed"), "Packing Group": ("fixed", "Does Not Apply"), "Reason for Restriction": ("fixed", "Does Not Apply"), "Supplier Intended and Approved Use": ("fixed", "Residential Use"), "TSCA Title VI Compliant (formaldehyde emissions)": ("fixed", "Yes"), "UN or ID number": ("fixed", "Does Not Apply"), "Uniform Packaging and Labeling Regulations (UPLR) Compliant": ("fixed", "Yes"), "Warning Required": ("fixed", "No"), "Warranty Length": ("fixed", "1 Years"), "Battery or Batteries Included": ("fixed", "No"), # 动态值(从来源取) "Product Type": ("source", "产品类目"), "Brand": ("valid", "Brand"), # 从Valid Values取第一个 } # ========== 7. 匹配来源列 ========== source_mapping = { "Item Code": "Item Code", "产品名称": "产品名称", "优惠单价": "优惠单价", "产品重量": "产品重量(磅)", "包装尺寸-重量": "包装尺寸-重量(磅)", "包装尺寸-高度": "包装尺寸-高度(英寸)", "包装尺寸-宽度": "包装尺寸-宽度(英寸)", "包装尺寸-长度": "包装尺寸-长度(英寸)", "颜色": "颜色", "材质": "材质", "组装长度": "组装长度(英寸)", "组装高度": "组装高度(英寸)", "组装宽度": "组装宽度(英寸)", "产品类目": "产品类目", } self.log(f"\n🔍 匹配来源列:") source_map = {} for key, expected in source_mapping.items(): found = False for col in self.df.columns: if col == expected: source_map[key] = col found = True break if found: self.log(f" ✅ '{key}' -> '{source_map[key]}'") else: self.log(f" ❌ '{key}' -> 未找到") source_map[key] = None # ========== 8. 填充数据 ========== def get_key(row): item = row.get("Item Code", "") name = row.get("产品名称", "") return f"{item}_{name}" keys = [get_key(row) for _, row in self.df.iterrows()] key_count = Counter(keys) key_index = Counter() row_num = last_row filled_count = 0 self.log(f"\n✍️ 开始追加数据...") for idx, (_, src_row) in enumerate(self.df.iterrows()): item_code = src_row.get("Item Code", "") product_name = src_row.get("产品名称", "") if pd.isna(item_code): item_code = "" if pd.isna(product_name): product_name = "" key = get_key(src_row) if key_count[key] > 1: key_index[key] += 1 suffix = f".{key_index[key] - 1}" mfr_suffix = f".{key_index[key]}" else: suffix = "" mfr_suffix = "" self.log(f"\n--- 第 {idx+1}/{len(self.df)} 行 ---") self.log(f" Item Code: '{item_code}'") self.log(f" 产品名称: '{str(product_name)[:50]}...'") fill_count = 0 # ===== Supplier Part Number ===== if "Supplier Part Number" in col_indices: val = f"{item_code}{product_name}{suffix}" ws.cell(row=row_num, column=col_indices["Supplier Part Number"], value=val) fill_count += 1 # ===== Manufacturer Part Number ===== if "Manufacturer Part Number" in col_indices: val = f"{item_code}{product_name}{mfr_suffix}" ws.cell(row=row_num, column=col_indices["Manufacturer Part Number"], value=val) fill_count += 1 # ===== Base Cost ===== if "Base Cost" in col_indices and source_map.get("优惠单价"): col = source_map["优惠单价"] val = src_row.get(col, "") if pd.notna(val): ws.cell(row=row_num, column=col_indices["Base Cost"], value=val) fill_count += 1 # ===== MSRP ===== if "Manufacturer Suggested Retail Price" in col_indices and source_map.get("优惠单价"): col = source_map["优惠单价"] val = src_row.get(col, 0) try: num = float(val) if pd.notna(val) else 0 ws.cell(row=row_num, column=col_indices["Manufacturer Suggested Retail Price"], value=num + 70) fill_count += 1 except: pass # ===== Product Weight ===== if "Product Weight" in col_indices and source_map.get("产品重量"): col = source_map["产品重量"] val = src_row.get(col, "") if pd.notna(val): ws.cell(row=row_num, column=col_indices["Product Weight"], value=val) fill_count += 1 # ===== Carton 尺寸 ===== carton_map = [ ("Carton Weight 1", "包装尺寸-重量"), ("Carton Height 1", "包装尺寸-高度"), ("Carton Width 1", "包装尺寸-宽度"), ("Carton Depth 1", "包装尺寸-长度"), ] for target, src_name in carton_map: if target in col_indices and source_map.get(src_name): col = source_map[src_name] val = src_row.get(col, "") if pd.notna(val): ws.cell(row=row_num, column=col_indices[target], value=val) fill_count += 1 # ===== Color/Material ===== cm_map = [ ("Base Color", "颜色"), ("Base Material", "材质"), ("Color", "颜色"), ("Material", "材质"), ("Top Color", "颜色"), ("Top Material", "材质"), ] for target, src_name in cm_map: if target in col_indices and source_map.get(src_name): col = source_map[src_name] val = src_row.get(col, "") if pd.notna(val): ws.cell(row=row_num, column=col_indices[target], value=val) fill_count += 1 # ===== Overall 尺寸 ===== overall_map = [ ("Overall Depth - Front to Back", "组装长度"), ("Overall Height - Top to Bottom", "组装高度"), ("Overall Product Weight", "产品重量"), ("Overall Width - Side to Side", "组装宽度"), ] for target, src_name in overall_map: if target in col_indices and source_map.get(src_name): col = source_map[src_name] val = src_row.get(col, "") if pd.notna(val): ws.cell(row=row_num, column=col_indices[target], value=val) fill_count += 1 # ===== 默认值字段 ===== variant_type = "Not Variant" # 默认值 for target_col, (source_type, source_val) in default_mapping.items(): if target_col not in col_indices: continue col_idx = col_indices[target_col] if source_type == "fixed": # 固定值 ws.cell(row=row_num, column=col_idx, value=source_val) fill_count += 1 self.log(f" ✅ {target_col} = '{source_val}' (固定值)") elif source_type == "valid": # 从 Valid Values 取第一个值 if source_val in self.valid_values and self.valid_values[source_val]: val = self.valid_values[source_val][0] ws.cell(row=row_num, column=col_idx, value=val) fill_count += 1 self.log(f" ✅ {target_col} = '{val}' (从Valid Values)") else: self.log(f" ⚠️ {target_col}: Valid Values中无数据,使用默认值'Not Variant'") ws.cell(row=row_num, column=col_idx, value="Not Variant") fill_count += 1 elif source_type == "source": # 从来源取 if source_map.get(source_val): col = source_map[source_val] val = src_row.get(col, "") if pd.notna(val): ws.cell(row=row_num, column=col_idx, value=val) fill_count += 1 self.log(f" ✅ {target_col} = '{val}' (从来源)") else: self.log(f" ⚠️ {target_col}: 来源为空") else: self.log(f" ⚠️ {target_col}: 找不到来源列'{source_val}'") elif source_type == "popup": # 弹出输入框(ISTA Certified) self.log(f" ⏳ 请为 {target_col} 输入值...") # 这里用简单方式:如果10秒没输入就跳过 result = self.show_input_dialog(f"请输入 {target_col}", "包装认证 (ISTA Certified):") if result: ws.cell(row=row_num, column=col_idx, value=result) fill_count += 1 self.log(f" ✅ {target_col} = '{result}' (用户输入)") else: self.log(f" ⏭️ {target_col}: 用户跳过") # ===== 特殊逻辑:Variant Grouping 1/2 ===== # Variant Grouping 1: 如果 Variant Type 是 Not Variant 则不填,否则填 "Color" if "Variant Grouping 1" in col_indices: if variant_type != "Not Variant": ws.cell(row=row_num, column=col_indices["Variant Grouping 1"], value="Color") fill_count += 1 if "Variant Attribute Name On Site 1" in col_indices: if variant_type != "Not Variant": ws.cell(row=row_num, column=col_indices["Variant Attribute Name On Site 1"], value="Finish") fill_count += 1 if "Variant Grouping 2" in col_indices: if variant_type != "Not Variant": ws.cell(row=row_num, column=col_indices["Variant Grouping 2"], value="Size") fill_count += 1 if "Variant Attribute Name On Site 2" in col_indices: if variant_type != "Not Variant": ws.cell(row=row_num, column=col_indices["Variant Attribute Name On Site 2"], value="Dimensions") fill_count += 1 self.log(f" 📊 本行填充 {fill_count} 个字段") row_num += 1 filled_count += 1 # ========== 9. 保存 ========== wb.save(template_path) self.log(f"\n✅ 成功追加到模板: {template_path}") self.log(f"📊 共追加 {filled_count} 行数据") self.log(f"📦 备份文件: {backup_path}") self.status_text.set(f"✅ 已追加 {filled_count} 行到模板") messagebox.showinfo("成功", f"已追加 {filled_count} 行数据到模板文件:\n{template_path}\n\n" f"备份文件保存在: {backup_path}" ) except Exception as e: self.log(f"\n❌ 错误: {str(e)}") import traceback self.log(traceback.format_exc()) messagebox.showerror("错误", f"生成失败:\n{str(e)}") def show_input_dialog(self, title, prompt): """显示输入对话框,10秒超时自动跳过""" result = tk.StringVar() dialog = tk.Toplevel(self.root) dialog.title(title) dialog.geometry("400x120") dialog.transient(self.root) dialog.grab_set() tk.Label(dialog, text=prompt, font=("Arial", 11)).pack(pady=10) entry = tk.Entry(dialog, width=40) entry.pack(pady=5) entry.focus() # 超时计数器 timeout_seconds = 10 time_label = tk.Label(dialog, text=f"剩余 {timeout_seconds} 秒...", fg="gray") time_label.pack(pady=5) def on_ok(): result.set(entry.get()) dialog.destroy() def on_cancel(): dialog.destroy() def countdown(count): if count <= 0: dialog.destroy() return time_label.config(text=f"剩余 {count} 秒...") dialog.after(1000, countdown, count - 1) tk.Button(dialog, text="确定", command=on_ok, width=10).pack(side="left", padx=20, pady=10) tk.Button(dialog, text="跳过", command=on_cancel, width=10).pack(side="right", padx=20, pady=10) dialog.after(1000, countdown, timeout_seconds - 1) self.root.wait_window(dialog) return result.get() if __name__ == "__main__": root = tk.Tk() app = ExcelMapperApp(root) root.mainloop()