import sys import os import time import threading import re import urllib.parse import webbrowser import json from datetime import datetime import tkinter as tk from tkinter import filedialog, messagebox import requests import pandas as pd from lxml import html # ================= 1. 导入 UI 库 ================= import ttkbootstrap as ttk from ttkbootstrap.constants import * from ttkbootstrap.dialogs import Messagebox # 兼容导入 try: from ttkbootstrap.widgets import ScrolledText, Tableview, ToastNotification except ImportError: from ttkbootstrap.scrolled import ScrolledText from ttkbootstrap.tableview import Tableview from ttkbootstrap.toast import ToastNotification # ================= 2. 后端核心逻辑 ================= class CRMCrawler: def __init__(self, log_callback, data_callback): self.log = log_callback self.on_data = data_callback self.stop_flag = False self.session = requests.Session() self.base_url = "http://111.198.24.44:88/index.php" self.http_headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "X-Requested-With": "XMLHttpRequest", "Accept": "application/json, text/javascript, */*; q=0.01" } def login(self, username, password): self.log(f"🔑 正在登录... 用户: {username}") login_payload = { "module": "Users", "action": "Authenticate", "return_module": "Users", "return_action": "Login", "user_name": username, "user_password": password, "login_theme": "newskin" } try: self.session.get(self.base_url, headers=self.http_headers) self.session.post(self.base_url, data=login_payload, headers=self.http_headers) if 'PHPSESSID' in self.session.cookies: self.log("✅ 登录成功!") return True else: self.log("❌ 登录失败:请检查账号密码") return False except Exception as e: self.log(f"❌ 网络错误: {str(e)}") return False def get_timestamp(self): return int(time.time() * 1000) def clean_num(self, val): """将 1.0000 转换为 1,保留必要的小数,为空则返回空字符串""" if val is None or val == "": return "" try: f_val = float(val) if f_val.is_integer(): return str(int(f_val)) else: return str(f_val) except: return str(val) def _safe_float(self, val): try: return float(val) except: return 0.0 def fetch_product_details(self, record_id, contract_no, sales_person, outsourced_desc_from_html): """ 修正后的价格逻辑: 1. 销售单价/销售总价 -> 永远不填 (代码中置空) 2. 厂家是"外购" -> 报价单价/总价置空,总金额填入"外购"列 3. 厂家非"外购" -> 金额填入报价单价/总价,"外购"列置空 """ detail_payload = { "module": "Plugins", "pluginName": "DetailProductTable", "action": "getTableData", "moduleName": "SalesOrder", "record": record_id, "actionId": self.get_timestamp(), "isTool": "1" } product_rows = [] try: res = self.session.post(self.base_url, data=detail_payload, headers=self.http_headers) try: detail_json = res.json() except: return [] products = [] raw_data = detail_json.get('data') if isinstance(raw_data, list): products = raw_data elif isinstance(raw_data, dict): if 'rows' in raw_data: products = raw_data['rows'] else: for v in raw_data.values(): if isinstance(v, dict) and ('productid' in v or 'productname' in v): products.append(v) for prod in products: # 1. 基础信息 manufacturer = self._get_nested_val(prod, 'cf_2128') or self._get_nested_val(prod, 'manufacturer') prod_desc_text = prod.get('productname', '') unit = self._get_nested_val(prod, 'usageunit') qty_raw = self._get_nested_val(prod, 'qty') discount = self.clean_num(self._get_nested_val(prod, 'discount_percent')) currency = self._get_nested_val(prod, 'cf_534') # 2. 价格获取 list_price_raw = self._get_nested_val(prod, 'listPrice') f_qty = self._safe_float(qty_raw) f_list_price = self._safe_float(list_price_raw) f_total_val = f_list_price * f_qty # 计算总价 # 3. 判断外购逻辑 is_outsourced = False if manufacturer and "外购" in manufacturer: is_outsourced = True # 4. 处理产品描述 final_desc = prod_desc_text if is_outsourced and outsourced_desc_from_html: final_desc = outsourced_desc_from_html # 5. 分配金额到指定列 col_quote_unit = "" col_quote_total = "" col_sales_unit = "" col_sales_total = "" col_outsourced = "" if is_outsourced: # 外购:报价列为空,金额填入外购列(总价) col_outsourced = self.clean_num(f_total_val) else: # 非外购:金额填入报价列,外购列为空 col_quote_unit = self.clean_num(f_list_price) col_quote_total = self.clean_num(f_total_val) # 构建行数据 row = { "合同编号": contract_no, "销售员": sales_person, "厂家": manufacturer, "货号": prod.get('productcode', ''), "产品描述": final_desc, "数量": self.clean_num(qty_raw), "单位": unit, "币种": currency, "报价单价": col_quote_unit, "报价总价": col_quote_total, "销售单价": col_sales_unit, # 留空 "销售总价": col_sales_total, # 留空 "折扣率": discount, "外购": col_outsourced, # 预留列 "合同币种/美元": "", "外购转美元": "", "报价总价美元": "", "净合同额美元": "" } product_rows.append(row) except Exception: pass return product_rows def _get_nested_val(self, item, key): if not item or key not in item: return "" val = item[key] if isinstance(val, dict) and 'value' in val: return val['value'] return val def fetch_detail_html(self, record_id): try: url = f"{self.base_url}?module=SalesOrder&action=DetailView&record={record_id}" resp = self.session.get(url, headers=self.http_headers, timeout=10) if resp.status_code == 200: tree = html.fromstring(resp.content) target = tree.xpath("/html/body/div[1]/div/div[2]/div[2]/form/div[1]/div[1]/div[2]") if target: import copy el = copy.deepcopy(target[0]) for bad in el.xpath('.//script | .//style'): bad.drop_tree() for br in el.xpath('.//br'): br.tail = "\n" + (br.tail if br.tail else "") return "\n".join([line.strip() for line in el.text_content().splitlines() if line.strip()]) except Exception: pass return "" def parse_data(self, text, cid): if not text: return None data = { "系统ID": cid, "合同编号": "", "内贸合同号": "", "外贸合同号": "", "签署公司": "", "收款情况": "", "签订日期": "", "销售员": "", "最终用户单位": "", "最终用户信息联系人": "", "最终用户信息电话": "", "最终用户信息邮箱": "", "最终用户所在地": "", "买方单位": "", "买方信息联系人": "", "买方信息电话": "", "买方信息邮箱": "", "厂家": "", "厂家型号": "", "合同标的": "", "数量": "", "单位": "台/套", "折扣率(%)": "", "合同额": "", "合同总额": "", "外购付款方式": "", "最晚发货期": "", "已收款": "", "未收款": "", "收款日期": "", "IS_ASD": False, "_temp_second_code": "", "OUTSOURCED_DESC_HTML": "", "product_list": [] } lines = [line.strip() for line in text.split('\n') if line.strip()] key_map = { "收款账户": "签署公司", "收款状态": "收款情况", "签约日期": "签订日期", "负责人": "销售员", "客户名称": "最终用户单位", "联系人姓名": "最终用户信息联系人", "合同总额": "合同总额", "最新收款日期": "收款日期", "最晚发货期": "最晚发货期", "付款比例及期限": "外购付款方式", "地址": "最终用户所在地", "厂家": "厂家", "外购产品明细": "OUTSOURCED_DESC_HTML" } for i, line in enumerate(lines): if line == "合同订单编号" and i + 1 < len(lines): parts = lines[i + 1].strip().split() if len(parts) >= 1: data["合同编号"] = parts[0] if len(parts) >= 2: data["_temp_second_code"] = parts[1] elif line in key_map and i + 1 < len(lines): target = key_map[line] if not data[target]: data[target] = lines[i + 1] elif "合同标的" in line and "品名/型号" in line and i + 1 < len(lines): parts = lines[i + 1].split('/') if len(parts) >= 1: data["合同标的"] = parts[0] if len(parts) >= 2: data["厂家型号"] = parts[1] if len(parts) >= 3: data["数量"] = self.clean_num(parts[2]) if len(parts) >= 5: data["合同额"] = parts[4] if not data["买方单位"]: buyer_match = re.search(r"(?:买方|The Buyer)[::]\s*(.*?)(?:\n|$)", text) if buyer_match and len(buyer_match.group(1)) > 1: data["买方单位"] = buyer_match.group(1).strip() try: total = float(data["合同总额"]) if data["合同总额"] else 0 if "已收" in data["收款情况"]: data["已收款"] = self.clean_num(total); data["未收款"] = "0" elif "未" in data["收款情况"]: data["已收款"] = "0"; data["未收款"] = self.clean_num(total) except: pass factory_val = data.get("厂家", "") if factory_val and "ASD" in factory_val.upper(): data["IS_ASD"] = True else: data["IS_ASD"] = False c_no = data.get("合同编号", "").strip().upper() sec_code = data.pop("_temp_second_code", "") if c_no.startswith('W'): data["外贸合同号"] = sec_code elif c_no.startswith('N'): data["内贸合同号"] = sec_code else: data["内贸合同号"] = sec_code if not c_no: return None return data def extract_time(self, text): matches = re.findall(r"(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})", text) if matches: dt_objects = [datetime.strptime(m, "%Y-%m-%d %H:%M:%S") for m in matches] return max(dt_objects) return None def run_task(self, mode, **kwargs): crmids = [] if mode == 'search': query = kwargs.get('query') self.log(f"🔍 正在搜索: {query}") url = f"{self.base_url}?module=Home&action=UnifiedSearch&selectedmodule=undefined&query_string={urllib.parse.quote(query)}" resp = self.session.get(url, headers=self.http_headers) tree = html.fromstring(resp.content) links = tree.xpath('//a[contains(@onclick, "record=")]') for link in links: match = re.search(r"record=(\d+)", link.get('onclick', '')) if match: crmids.append(match.group(1)) crmids = list(set(crmids)) elif mode == 'date': s_date = kwargs.get('start'); e_date = kwargs.get('end') self.log(f"📅 时间筛选: {s_date} ~ {e_date}") self._process_date_range(s_date, e_date) return self.log(f" 共找到 {len(crmids)} 条记录,开始解析详情...") for i, cid in enumerate(crmids): if self.stop_flag: break self._process_single_id(cid) self.log(f" 进度: {i + 1}/{len(crmids)}") def _process_date_range(self, s_str, e_str): try: t_start = datetime.strptime(s_str, "%Y-%m-%d") t_end = datetime.strptime(e_str, "%Y-%m-%d").replace(hour=23, minute=59, second=59) except: self.log("❌ 日期格式错误"); return page = 1 while not self.stop_flag: ts = int(time.time() * 1000) url = f"{self.base_url}?module=SalesOrder&action=SalesOrderAjax&file=ListViewData&sorder=DESC&order_by=modifiedtime&start={page}&pagesize=50&actionId={ts}&isFilter=true&search%5Bviewscope%5D=all_to_me&search%5Bviewname%5D=476" try: resp = self.session.get(url, headers=self.http_headers) data = resp.json() entries = data.get('data', []) or data.get('entries', []) if not entries: break page_ids = [x.get('crmid') or x.get('id') for x in entries if isinstance(x, dict)] self.log(f" 🔎 正在检查第 {page} 页 ({len(page_ids)} 条)...") valid_cnt = 0 for cid in page_ids: if self.stop_flag: break text_html = self.fetch_detail_html(cid) r_time = self.extract_time(text_html) if r_time: if r_time > t_end: continue if r_time < t_start: self.log(f" 🛑 遇到旧数据 ({r_time}),停止爬取") self.stop_flag = True; break self._process_data_payload(text_html, cid) valid_cnt += 1 if valid_cnt > 0: self.log(f" ✅ 第 {page} 页入库 {valid_cnt} 条") page += 1 if self.stop_flag: break except Exception as e: self.log(f"❌ 错误: {e}"); break def _process_single_id(self, cid): text_html = self.fetch_detail_html(cid) self._process_data_payload(text_html, cid) def _process_data_payload(self, text_html, cid): parsed = self.parse_data(text_html, cid) if parsed: c_no = parsed.get("合同编号", "") s_person = parsed.get("销售员", "") outsourced_html_val = parsed.get("OUTSOURCED_DESC_HTML", "") detail_rows = self.fetch_product_details(cid, c_no, s_person, outsourced_html_val) parsed['product_list'] = detail_rows self.on_data(parsed) # ================= 3. 界面显示类 ================= class CRMGUI(ttk.Window): def __init__(self): super().__init__(themename="cosmo") self.title("CRM 智能数据助手 测试版") self.geometry("1400x900") self.crawler = CRMCrawler(self.log_msg, self.add_record_to_table) self.is_running = False self.stored_data = { 'ASD': {'Domestic': [], 'Foreign': [], 'Other': []}, 'NON_ASD': {'Domestic': [], 'Foreign': [], 'Other': []} } self.treeviews = {} # 1. 主表字段 self.base_cols = [ "合同编号", "签署公司", "收款情况", "签订日期", "销售员", "厂家", "最终用户单位", "最终用户信息联系人", "最终用户信息电话", "买方单位", "厂家型号", "合同标的", "数量", "合同额", "合同总额", "最晚发货期", "已收款", "未收款", "收款日期" ] self.cols_domestic = ["内贸合同号"] + self.base_cols + ["系统ID"] self.cols_foreign = ["外贸合同号"] + self.base_cols + ["系统ID"] self.cols_other = self.base_cols + ["系统ID"] # 2. 明细表字段 self.cols_detail = [ "合同编号", "销售员", "厂家", "货号", "产品描述", "数量", "单位", "币种", "报价单价", "报价总价", "销售单价", "销售总价", "折扣率", "外购", "合同币种/美元", "外购转美元", "报价总价美元", "净合同额美元" ] self.create_widgets() def create_widgets(self): # --- 1. 顶部控制 --- control_frame = ttk.Frame(self, padding=10, bootstyle="light") control_frame.pack(fill=X) login_grp = ttk.Labelframe(control_frame, text="身份验证", padding=10) login_grp.pack(side=LEFT, padx=5, fill=Y) ttk.Label(login_grp, text="用户:").pack(side=LEFT) self.user_ent = ttk.Entry(login_grp, width=10); self.user_ent.insert(0, "TEST"); self.user_ent.pack(side=LEFT, padx=5) ttk.Label(login_grp, text="密码:").pack(side=LEFT) self.pass_ent = ttk.Entry(login_grp, width=10, show="*"); self.pass_ent.insert(0, "***"); self.pass_ent.pack(side=LEFT, padx=5) mode_grp = ttk.Labelframe(control_frame, text="任务类型", padding=10) mode_grp.pack(side=LEFT, padx=10, fill=Y, expand=True) self.nb_mode = ttk.Notebook(mode_grp, bootstyle="primary") self.nb_mode.pack(fill=BOTH, expand=True) f_date = ttk.Frame(self.nb_mode, padding=10) self.nb_mode.add(f_date, text="📅 按时间范围") self.ent_start = ttk.Entry(f_date, width=12); self.ent_start.insert(0, "2026-01-14"); self.ent_start.pack(side=LEFT, padx=5) ttk.Label(f_date, text="至").pack(side=LEFT) self.ent_end = ttk.Entry(f_date, width=12); self.ent_end.insert(0, "2026-01-15"); self.ent_end.pack(side=LEFT, padx=5) f_search = ttk.Frame(self.nb_mode, padding=10) self.nb_mode.add(f_search, text="🔍 关键词搜索") self.ent_query = ttk.Entry(f_search, width=25); self.ent_query.pack(fill=X) self.nb_mode.select(f_date) btn_grp = ttk.Frame(control_frame, padding=10) btn_grp.pack(side=RIGHT, fill=Y) self.btn_run = ttk.Button(btn_grp, text="▶ 开始", bootstyle="success", command=self.start_thread, width=10) self.btn_run.pack(side=TOP, pady=2) self.btn_stop = ttk.Button(btn_grp, text="⏹ 停止", bootstyle="danger", command=self.stop_task, state=DISABLED, width=10) self.btn_stop.pack(side=TOP, pady=2) # --- 2. 核心展示区 --- toggle_frame = ttk.Frame(self, padding=(10, 5)) toggle_frame.pack(fill=X) self.curr_view = tk.StringVar(value="ASD") self.btn_view_asd = ttk.Button(toggle_frame, text="ASD 产品列表", command=lambda: self.switch_view("ASD"), width=20) self.btn_view_asd.pack(side=LEFT, padx=5) self.btn_view_non = ttk.Button(toggle_frame, text="非 ASD 产品列表", command=lambda: self.switch_view("NON_ASD"), width=20) self.btn_view_non.pack(side=LEFT, padx=5) self.container = ttk.Frame(self) self.container.pack(fill=BOTH, expand=True, padx=10) self.frame_asd = ttk.Frame(self.container) self.frame_non = ttk.Frame(self.container) self._init_inner_tabs(self.frame_asd, "ASD") self._init_inner_tabs(self.frame_non, "NON_ASD") self.switch_view("ASD") # --- 3. 底部区 --- bottom_frame = ttk.Frame(self, padding=5) bottom_frame.pack(fill=X, padx=10, pady=5) log_frame = ttk.Labelframe(bottom_frame, text="系统日志", padding=5) log_frame.pack(side=LEFT, fill=BOTH, expand=True) self.txt_log = ScrolledText(log_frame, height=5); self.txt_log.text.configure(state=DISABLED); self.txt_log.pack(fill=BOTH, expand=True) export_frame = ttk.Frame(bottom_frame, padding=10) export_frame.pack(side=RIGHT, fill=Y) ttk.Button(export_frame, text="📂 导出完整 Excel", bootstyle="primary", command=self.export_data).pack(fill=X, pady=10) def _init_inner_tabs(self, parent_frame, prefix): nb = ttk.Notebook(parent_frame, bootstyle="info") nb.pack(fill=BOTH, expand=True) # 汇总 Tab f_dom = ttk.Frame(nb); nb.add(f_dom, text="📜 内贸汇总"); self._create_treeview(f_dom, self.cols_domestic, f"{prefix}_Domestic") f_for = ttk.Frame(nb); nb.add(f_for, text="📜 外贸汇总"); self._create_treeview(f_for, self.cols_foreign, f"{prefix}_Foreign") f_oth = ttk.Frame(nb); nb.add(f_oth, text="📜 其他汇总"); self._create_treeview(f_oth, self.cols_other, f"{prefix}_Other") # 明细 Tab f_detail_dom = ttk.Frame(nb); nb.add(f_detail_dom, text="📦 内贸明细清单") self._create_treeview(f_detail_dom, self.cols_detail, f"{prefix}_Detail_Domestic") f_detail_for = ttk.Frame(nb); nb.add(f_detail_for, text="📦 外贸明细清单") self._create_treeview(f_detail_for, self.cols_detail, f"{prefix}_Detail_Foreign") def _create_treeview(self, parent, cols, key): sy = ttk.Scrollbar(parent, orient=VERTICAL) sx = ttk.Scrollbar(parent, orient=HORIZONTAL) tv = ttk.Treeview(parent, columns=cols, show="headings", selectmode="browse", yscrollcommand=sy.set, xscrollcommand=sx.set) sy.config(command=tv.yview); sy.pack(side=RIGHT, fill=Y) sx.config(command=tv.xview); sx.pack(side=BOTTOM, fill=X) tv.pack(side=LEFT, fill=BOTH, expand=True) for c in cols: # === 全居中设置 === tv.heading(c, text=c, anchor="center") w = 100 if "描述" in c or "标的" in c or "公司" in c or "单位" in c: w = 200 elif "编号" in c: w = 120 elif "系统ID" in c: w = 0 elif "价" in c or "额" in c or "外购" in c: w = 80 tv.column(c, width=w, minwidth=50, anchor="center") # 移除双击编辑,保留右键菜单(仅用于浏览器打开) tv.bind("", lambda e: self.on_right_click(e, tv, key)) self.treeviews[key] = tv return tv def switch_view(self, view_name): self.curr_view.set(view_name) if view_name == "ASD": self.frame_non.pack_forget(); self.frame_asd.pack(fill=BOTH, expand=True) self.btn_view_asd.configure(bootstyle="primary") self.btn_view_non.configure(bootstyle="secondary-outline") else: self.frame_asd.pack_forget(); self.frame_non.pack(fill=BOTH, expand=True) self.btn_view_asd.configure(bootstyle="secondary-outline") self.btn_view_non.configure(bootstyle="primary") def start_thread(self): if self.is_running: return self.stored_data = {'ASD': {'Domestic': [], 'Foreign': [], 'Other': []}, 'NON_ASD': {'Domestic': [], 'Foreign': [], 'Other': []}} for tv in self.treeviews.values(): for item in tv.get_children(): tv.delete(item) self.is_running = True self.crawler.stop_flag = False self.btn_run.config(state=DISABLED); self.btn_stop.config(state=NORMAL) t = threading.Thread(target=self._worker); t.daemon = True; t.start() def stop_task(self): self.crawler.stop_flag = True self.log_msg("🛑 正在停止...") def _worker(self): user = self.user_ent.get(); pwd = self.pass_ent.get() if not self.crawler.login(user, pwd): self._reset_ui(); return curr_idx = self.nb_mode.index(self.nb_mode.select()) mode = "date"; kwargs = {} if curr_idx == 0: mode = "date" kwargs = {'start': self.ent_start.get(), 'end': self.ent_end.get()} elif curr_idx == 1: mode = "search" kwargs = {'query': self.ent_query.get()} try: self.crawler.run_task(mode, **kwargs); self.log_msg("🎉 完成!") except Exception as e: self.log_msg(f"❌ 错误: {e}") finally: self._reset_ui() def _reset_ui(self): self.is_running = False self.after(0, lambda: self.btn_run.config(state=NORMAL)) self.after(0, lambda: self.btn_stop.config(state=DISABLED)) def log_msg(self, msg): self.after(0, lambda: self._append_log(msg)) def _append_log(self, msg): self.txt_log.text.configure(state=NORMAL) self.txt_log.text.insert(END, f"[{datetime.now().strftime('%H:%M:%S')}] {msg}\n") self.txt_log.text.see(END); self.txt_log.text.configure(state=DISABLED) def add_record_to_table(self, record): def _update(): main_key = 'ASD' if record['IS_ASD'] else 'NON_ASD' c_no = str(record.get("合同编号", "")).strip().upper() sub_key = "Other" if c_no.startswith('N'): sub_key = "Domestic" elif c_no.startswith('W'): sub_key = "Foreign" self.stored_data[main_key][sub_key].append(record) record_idx = len(self.stored_data[main_key][sub_key]) - 1 # 主表 tv_key = f"{main_key}_{sub_key}" tv = self.treeviews.get(tv_key) if tv: cols = list(tv['columns']) vals = [record.get(c, "") for c in cols] tv.insert("", END, iid=f"main_{main_key}_{sub_key}_{record_idx}", values=vals) # 明细表 detail_key_suffix = "" if sub_key == "Domestic": detail_key_suffix = "Domestic" elif sub_key == "Foreign": detail_key_suffix = "Foreign" if detail_key_suffix: tv_detail_key = f"{main_key}_Detail_{detail_key_suffix}" tv_detail = self.treeviews.get(tv_detail_key) if tv_detail and record.get('product_list'): detail_cols = list(tv_detail['columns']) for p_idx, prod_row in enumerate(record['product_list']): d_vals = [prod_row.get(c, "") for c in detail_cols] unique_id = f"detail_{main_key}_{sub_key}_{record_idx}_{p_idx}" tv_detail.insert("", END, iid=unique_id, values=d_vals) self.after(0, _update) def on_right_click(self, event, tv, key): item_id = tv.identify_row(event.y) if not item_id: return tv.selection_set(item_id) # 仅在主表行点击时提供浏览器打开功能 if item_id.startswith("main_"): parts = item_id.split('_') main_key, sub_key, idx = parts[1], parts[2], int(parts[3]) record = self.stored_data[main_key][sub_key][idx] crm_id = record.get("系统ID", "") menu = tk.Menu(self, tearoff=0) menu.add_command(label="🌐 在浏览器查看", command=lambda: self.open_browser(crm_id)) menu.post(event.x_root, event.y_root) def open_browser(self, crm_id): if crm_id: url = f"http://111.198.24.44:88/index.php?module=SalesOrder&action=DetailView&record={crm_id}" webbrowser.open(url) # --- 导出功能 --- def export_data(self): folder = filedialog.askdirectory() if not folder: return self.log_msg(f"💾 正在导出...") ts = time.strftime("%Y%m%d_%H%M%S") export_cols = [ "合同编号", "签署公司", "收款情况", "签订日期", "销售员", "厂家", "最终用户单位", "最终用户信息联系人", "最终用户信息电话", "最终用户信息邮箱", "最终用户所在地", "买方单位", "买方信息联系人", "买方信息电话", "买方信息邮箱", "厂家型号", "合同标的", "数量", "单位", "折扣率(%)", "合同额", "合同总额", "外购付款方式", "最晚发货期", "已收款", "未收款", "收款日期" ] detail_cols_order = self.cols_detail for main_key, prefix in [('ASD', 'ASD_产品表'), ('NON_ASD', 'Non_ASD_产品表')]: data_map = self.stored_data[main_key] total = sum(len(v) for v in data_map.values()) if total == 0: continue detail_domestic_rows = [] detail_foreign_rows = [] for sub_key in data_map: for rec in data_map[sub_key]: products = rec.get('product_list', []) contract_no = rec.get('合同编号', '').upper() if contract_no.startswith('W'): detail_foreign_rows.extend(products) else: detail_domestic_rows.extend(products) path = os.path.join(folder, f"{prefix}_{ts}.xlsx") try: with pd.ExcelWriter(path, engine='openpyxl') as writer: if data_map['Domestic']: df = pd.DataFrame(data_map['Domestic']) for c in export_cols: if c not in df.columns: df[c] = "" cols = export_cols[:2] + ["内贸合同号"] + export_cols[2:] df = df.reindex(columns=cols) df.to_excel(writer, sheet_name='内贸汇总', index=False) if data_map['Foreign']: df = pd.DataFrame(data_map['Foreign']) for c in export_cols: if c not in df.columns: df[c] = "" cols = export_cols[:2] + ["外贸合同号"] + export_cols[2:] df = df.reindex(columns=cols) df.to_excel(writer, sheet_name='外贸汇总', index=False) if data_map['Other']: df = pd.DataFrame(data_map['Other']) for c in export_cols: if c not in df.columns: df[c] = "" cols = export_cols[:2] + ["内贸合同号"] + export_cols[2:] df = df.reindex(columns=cols) df.to_excel(writer, sheet_name='其他汇总', index=False) if detail_domestic_rows: df_d = pd.DataFrame(detail_domestic_rows) df_d = df_d.reindex(columns=detail_cols_order) df_d.to_excel(writer, sheet_name='内贸明细', index=False) if detail_foreign_rows: df_f = pd.DataFrame(detail_foreign_rows) df_f = df_f.reindex(columns=detail_cols_order) df_f.to_excel(writer, sheet_name='外贸明细', index=False) self.log_msg(f" ✅ 导出成功: {os.path.basename(path)}") except Exception as e: self.log_msg(f" ❌ 导出失败: {e}") Messagebox.show_info("导出完成", "Excel文件已生成") if __name__ == "__main__": app = CRMGUI() app.mainloop()