diff --git a/README.md b/README.md index 76392b8..0d3c74e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 中国邮政揽收系统 - 自动填单工具 +# 中国邮政揽收系统 - 自动填单工具 v2.0 通过 HTTP API + Node.js token bridge 实现,**不依赖浏览器自动化**。读取 Excel 运单数据,逐条自动填写产品、客户、运单信息并提交。 @@ -53,11 +53,11 @@ bash build.sh # Linux ``` Excel 行数据 - → 产品搜索 (autocomplete API) → 用户选择 - → 客户搜索 (autocomplete API) → 用户选择 + → 产品选择 (启动前搜索 + 缓存) → 用户确认 + → 客户选择 (启动前搜索 + 缓存) → 用户确认 → 运单查询 → 获取收寄件人/地址/电话 → 地址匹配 → 获取区划代码 - → 组装表单 (5层优先级) → 确认弹窗 → 提交 + → 组装表单 (5层优先级) → 确认弹窗(首行) → 提交 ``` ### 表单字段优先级 @@ -80,19 +80,32 @@ Excel 行数据 点击 `🔑 登录` → 弹出对话框 → 用浏览器 cookies 导出工具导出 `10.4.188.1` 的所有 cookies 为 JSON 数组 → 粘贴到对话框 → 保存。 -cookies 保存到 `session.json`(与 exe 同目录)。 +cookies 保存到 `session.json`(与 exe 同目录)。登录成功后自动初始化 token bridge 和搜索 API,供后续产品/客户搜索使用。 -### 2. 选择 Excel +### 2. 选择产品/客户 + +登录后,在"固定值设置"区域的产品和客户输入框中输入关键词,自动触发搜索并弹出下拉选择框。选中后自动获取产品详情和客户详情并缓存,缓存有效期为一个 batch(处理完一批 Excel 数据后自动清空)。 + +### 3. 选择 Excel 点击 `选择文件`,选择要处理的 Excel 文件。 -### 3. 配置固定值(可选) +### 4. 配置固定值(可选) -在 GUI 固定值表格中设置常用字段,勾选 "强制覆盖" 可覆盖 API 获取的值。 +在固定值区域填写: -### 4. 开始处理 +| 字段 | 说明 | +|------|------| +| 产品 | 用于 autocomplete 搜索,从下拉框选择 | +| 客户 | 用于 autocomplete 搜索,从下拉框选择 | +| 寄达地 | 强制覆盖运单返回的寄达地(勾选强制覆盖后生效) | +| 寄达地区划代码 | 直接决定寄达地,勾选强制覆盖后优先级最高 | +| 寄件人姓名 | 强制覆盖客户资料中的寄件人 | +| 寄件地址 | 强制覆盖客户资料中的寄件地址 | -点击 `▶ 开始处理` → 弹出产品/客户选择框 → 确认提交信息 → 自动批量处理后续行。 +### 5. 开始处理 + +点击 `▶ 开始处理` → 首行弹出确认弹窗 → 确认后自动批量处理后续行。提交结果实时显示在下方表格中。 ## Excel 格式 @@ -116,13 +129,13 @@ cookies 保存到 `session.json`(与 exe 同目录)。 - `url`: 揽收系统地址 ### timing -- `delay_between_rows_ms`: 行间延时(毫秒) +- `delay_between_rows_ms`: 行间延时(毫秒),GUI 中间隔输入框单位也是毫秒 ### fixed_values ``` "字段名": {"value": "值", "force": false} ``` -`force: true` 时覆盖 API 获取值。 +`force: true` 时覆盖 API 获取值。GUI 中通过复选框控制,点击"开始处理"时自动保存到配置文件。 ### defaults 字段的默认值(中文名,自动翻译为系统编码)。 @@ -130,6 +143,18 @@ cookies 保存到 `session.json`(与 exe 同目录)。 ### field_mapping Excel 列名 → 表单参数名的自定义映射。内置映射覆盖常见字段,仅需在此配置自定义列名。 +## 开发 + +### 日志等级 + +`log.py` 中修改 `LEVEL`: +- `-1` = 关闭所有日志(默认) +- `0` = 仅用户级消息 +- `1` = 调试级 +- `2` = 追踪级(API 请求/响应) + +修改后重新打包生效。 + ## 文档 项目级文档见 `../docs/`: @@ -142,14 +167,3 @@ Excel 列名 → 表单参数名的自定义映射。内置映射覆盖常见字 | `portal-to-pickup-full-flow.har` | 门户→揽收页面完整网络流 | | `product-customer-selection-flow.har` | 产品/客户选择 API 流 | | `successful-submit-with-address-change.har` | 改寄达地后成功提交 | - -## 开发 - -### 日志等级 - -`log.py` 中修改 `LEVEL`: -- `0` = 仅用户级消息 -- `1` = 调试级 -- `2` = 追踪级(API 请求/响应) - -修改后重新打包生效。 diff --git a/build.bat b/build.bat index 51ceb4f..c701b08 100644 --- a/build.bat +++ b/build.bat @@ -62,7 +62,7 @@ if exist build rmdir /s /q build echo [4/4] Building exe with PyInstaller... uv run pyinstaller ^ --onefile ^ - --windowed ^ + --console ^ --name "PickupAuto" ^ --icon="pickup.ico" ^ --add-data "config.json;." ^ diff --git a/build.sh b/build.sh index c9d3467..10e834f 100755 --- a/build.sh +++ b/build.sh @@ -31,7 +31,7 @@ rm -rf dist build echo "[4/4] Building with PyInstaller..." uv run pyinstaller \ --onefile \ - --windowed \ + --console \ --name "PickupAuto" \ --add-data "config.json:." \ --add-data "pickup.ico:." \ diff --git a/engine.py b/engine.py index 9c9f6cd..b2476ce 100644 --- a/engine.py +++ b/engine.py @@ -306,6 +306,7 @@ class PickupEngine: self._stop_requested = False self._pause_requested = False + self._infra_injected = False # 基础设施从外部注入时跳过 start/begin_processing # 跨行复用的产品/客户详情(从 API 获取) self._product_detail: dict = {} @@ -333,10 +334,24 @@ class PickupEngine: return translate_value(excel_col, value) return translate_value(excel_col, self.defaults.get(excel_col, "")) + # ─── 基础设施注入(由外部预先初始化)────────────────────── + + def inject_infrastructure(self, sess: PickupSession, bridge: TokenBridge, api: PickupAPI): + """注入已初始化的 session / bridge / api,跳过 start/begin_processing。""" + self._sess = sess + self._bridge = bridge + self._api = api + self._infra_injected = True + self._dlog("基础设施已从外部注入") + # ─── 生命周期 ──────────────────────────────────────────── async def start(self) -> None: """启动 token bridge 并初始化 HTTP 会话。""" + if self._infra_injected: + self._dlog("start 跳过(基础设施已注入)") + return + self._ulog("启动 HTTP API 引擎...") # 启动 token bridge @@ -364,6 +379,11 @@ class PickupEngine: if not self._sess: raise RuntimeError("未启动") + if self._infra_injected: + self._dlog("begin_processing 跳过(基础设施已注入)") + self._ulog("后台自动化已就绪") + return + self._ulog("加载页面资源...") resources = self._sess.fetch_page_resources() @@ -414,6 +434,12 @@ class PickupEngine: except Exception: pass + # 外部注入的基础设施不销毁,由外部管理生命周期 + if self._infra_injected: + self._dlog("跳过 bridge 销毁(基础设施由外部管理)") + self._clear_batch_cache() + return + # 停止 token bridge if self._bridge: try: diff --git a/log.py b/log.py index c77afca..6d11a07 100644 --- a/log.py +++ b/log.py @@ -9,7 +9,7 @@ import sys from datetime import datetime -LEVEL = -1 # 编译时修改此值 +LEVEL = 2 # 编译时修改此值 _gui_callback = None # GUI 注册的回调: (msg: str) -> None diff --git a/main.py b/main.py index ded602a..c5195b5 100644 --- a/main.py +++ b/main.py @@ -128,8 +128,26 @@ class App: self.fv_sender_addr = StringVar() self.fv_force_sender_addr = BooleanVar() + # 搜索基础设施(懒加载) + self._search_bridge = None + self._search_sess = None + self._search_api = None + self._loading_config = False + self._search_debounce = None + + # 产品/客户预缓存(batch 期间复用) + self._product_cached_choice = None + self._product_cached_detail = None + self._customer_cached_choice = None + self._customer_cached_detail = None + self._sender_realname_cached = None + self._build_ui() + # 监听固定值输入框变化,触发产品/客户搜索 + self.fv_product.trace_add("write", self._on_product_change) + self.fv_customer.trace_add("write", self._on_customer_change) + # 关闭窗口时清理 self.root.protocol("WM_DELETE_WINDOW", self._on_close) @@ -268,6 +286,7 @@ class App: def _load_config(self): """加载配置文件。首次运行时从 _MEIPASS 复制到 exe 目录。""" try: + self._loading_config = True config_path = _get_config_path() # 打包模式首次运行:从 _MEIPASS 复制 config.json 到可写目录 if getattr(sys, 'frozen', False) and not os.path.exists(config_path): @@ -292,6 +311,7 @@ class App: self.fv_force_sender_name.set(fv.get("寄件人", {}).get("force", False)) self.fv_sender_addr.set(fv.get("寄件地址", {}).get("value", "")) self.fv_force_sender_addr.set(fv.get("寄件地址", {}).get("force", False)) + self._loading_config = False # 检查已保存的登录会话 self._check_session_status() @@ -349,6 +369,189 @@ class App: ) self.result_tree.insert("", "end", values=values) + # ─── 产品/客户搜索 ────────────────────────────────────── + + def _init_search_infra(self): + """在登录后(或重新登录后)初始化搜索基础设施(token bridge + session + api)。""" + # 销毁旧的基础设施(可能是过期 cookies 的 session) + if self._search_bridge: + self._search_bridge.stop() + self._search_bridge = None + self._search_sess = None + self._search_api = None + + try: + from pickup_api import TokenBridge, PickupSession, PickupAPI + config = load_config(_get_config_path()) + url = config.get("system", {}).get("url", "https://10.4.188.1/pickup-web/a/postwaybill/main") + + self._search_bridge = TokenBridge() + self._search_bridge.start() + self._search_sess = PickupSession(url) + session_path = _get_session_path() + if not self._search_sess.load_session(session_path): + self._search_bridge.stop() + self._search_bridge = None + self._search_sess = None + self._log("[error] 搜索初始化失败:未找到登录会话") + return + resources = self._search_sess.fetch_page_resources() + minimal_html = ( + f"" + ) + self._search_bridge.initialize(minimal_html, resources["scripts"], resources["base_url"]) + self._search_api = PickupAPI(self._search_sess, self._search_bridge) + try: + self._search_api.query_ecommerce("B") + self._search_api.query_deliver_date() + self._search_api.check_sheet_warning() + except Exception: + pass + except Exception as e: + self._log(f"[error] 搜索初始化失败: {e}") + if self._search_bridge: + self._search_bridge.stop() + self._search_bridge = None + self._search_sess = None + + def _on_product_change(self, *args): + if self._loading_config: + return + if self._search_debounce: + self.root.after_cancel(self._search_debounce) + keyword = self.fv_product.get().strip() + if not keyword or len(keyword) < 1: + return + self._search_debounce = self.root.after(300, lambda: self._do_product_search(keyword)) + + def _on_customer_change(self, *args): + if self._loading_config: + return + if self._search_debounce: + self.root.after_cancel(self._search_debounce) + keyword = self.fv_customer.get().strip() + if not keyword or len(keyword) < 1: + return + self._search_debounce = self.root.after(300, lambda: self._do_customer_search(keyword)) + + def _do_product_search(self, keyword: str): + if self._search_api is None: + return + try: + candidates = self._search_api.query_product(keyword) + if not candidates: + return + names = [c.get("productName", "") for c in candidates] + self._show_search_dropdown( + "选择产品", names, + lambda idx: self._on_product_selected(candidates[idx]), + ) + except Exception as e: + self._log(f"[error] 产品搜索失败: {e}") + + def _do_customer_search(self, keyword: str): + if self._search_api is None: + return + try: + candidates = self._search_api.query_customer(keyword) + if not candidates: + return + names = [ + c.get("customerFullName", c.get("customerName", "")) + for c in candidates + ] + self._show_search_dropdown( + "选择客户", names, + lambda idx: self._on_customer_selected(candidates[idx]), + ) + except Exception as e: + self._log(f"[error] 客户搜索失败: {e}") + + def _show_search_dropdown(self, title: str, options: list[str], on_select): + """在屏幕中央弹出下拉选择窗口。""" + popup = Toplevel(self.root) + popup.title(title) + popup.geometry("360x260") + popup.transient(self.root) + popup.grab_set() + popup.attributes("-topmost", True) + + Label(popup, text=title, font=("", 10, "bold"), pady=6).pack() + + frame = Frame(popup) + frame.pack(fill="both", expand=True, padx=10, pady=5) + listbox = Listbox(frame, font=("", 10)) + scroll = Scrollbar(frame, orient=VERTICAL, command=listbox.yview) + listbox.configure(yscrollcommand=scroll.set) + scroll.pack(side=RIGHT, fill=Y) + listbox.pack(side=LEFT, fill=BOTH, expand=True) + for opt in options: + listbox.insert(END, opt) + + chosen = [False] + + def select(): + sel = listbox.curselection() + if sel: + chosen[0] = True + popup.destroy() + on_select(sel[0]) + + def cancel(): + popup.destroy() + + listbox.bind("", lambda e: select()) + listbox.bind("", lambda e: select()) + listbox.bind("", lambda e: cancel()) + listbox.focus_set() + if listbox.size() > 0: + listbox.selection_set(0) + + btn_f = Frame(popup) + btn_f.pack(pady=8) + Button(btn_f, text="选择", command=select, bg="#4CAF50", fg="white", width=8).pack(side=LEFT, padx=5) + Button(btn_f, text="取消", command=cancel, width=8).pack(side=LEFT, padx=5) + + popup.protocol("WM_DELETE_WINDOW", cancel) + popup.wait_window() + + def _on_product_selected(self, choice: dict): + """产品选中:缓存选择并获取详情。""" + self._product_cached_choice = choice + self.fv_product.set(choice.get("productName", "")) + # 获取产品详情 + try: + biz_id = choice.get("productId", "") + biz_no = choice.get("bizProductNo", "") + if biz_id and biz_no and self._search_api: + self._product_cached_detail = self._search_api.select_product(biz_id, biz_no) + except Exception as e: + self._log(f"[error] 获取产品详情失败: {e}") + self._product_cached_detail = {} + + def _on_customer_selected(self, choice: dict): + """客户选中:缓存选择并获取详情和寄件人实名。""" + self._customer_cached_choice = choice + self.fv_customer.set(choice.get("customerFullName", choice.get("customerName", ""))) + # 获取客户详情 + try: + cust_id = choice.get("customerId", "") + if cust_id and self._search_api: + detail = self._search_api.select_customer(str(cust_id)) + if isinstance(detail, dict) and "_text" not in detail: + self._customer_cached_detail = detail + base = detail.get("baseCustome", {}) if isinstance(detail.get("baseCustome"), dict) else {} + sender_no = base.get("businessCode") or base.get("customerSubCode") or "" + if sender_no: + self._sender_realname_cached = self._search_api.find_sender_history(str(sender_no)) + except Exception as e: + self._log(f"[error] 获取客户详情失败: {e}") + self._customer_cached_detail = {} + # ─── 文件选择 ─────────────────────────────────────────── def _pick_file(self): @@ -365,6 +568,8 @@ class App: global _engine if _engine: _engine.request_stop() + if self._search_bridge: + self._search_bridge.stop() self.root.destroy() # ─── 控制按钮 ─────────────────────────────────────────── @@ -389,10 +594,10 @@ class App: def do_save(): try: n = parse_json_and_save(json_text.get("1.0", "end-1c")) - self._log(f"已保存 {n} 个 cookies 到 session.json") self.engine_status_var.set("已登录") self.status_label.configure(fg="green") self._check_session_status() + self._init_search_infra() win.destroy() except Exception as e: messagebox.showerror("解析失败", f"JSON 解析失败:\n{e}", parent=win) @@ -489,6 +694,20 @@ class App: confirm_callback=self._confirm_callback, ) + # 注入已缓存的搜索基础设施和产品/客户选择 + if self._search_api is not None: + _engine.inject_infrastructure(self._search_sess, self._search_bridge, self._search_api) + if self._product_cached_choice is not None: + _engine._product_choice = self._product_cached_choice + if self._product_cached_detail is not None: + _engine._product_detail = self._product_cached_detail + if self._customer_cached_choice is not None: + _engine._customer_choice = self._customer_cached_choice + if self._customer_cached_detail is not None: + _engine._customer_detail = self._customer_cached_detail + if self._sender_realname_cached is not None: + _engine._sender_realname = self._sender_realname_cached + self._update_status("启动中...") try: await _engine.start() @@ -498,7 +717,7 @@ class App: await _engine.stop() return - # 验证 engine.start() 中已加载了 session,且包含关键 cookie + # 验证已加载了 session,且包含关键 cookie if not _engine._sess or not _engine._sess._http.cookies: self._log_async("[提示] 未找到登录会话,请点击'登录'按钮完成登录后再开始处理") self._update_status("未登录") @@ -564,7 +783,15 @@ class App: _engine = None self._set_controls_state("stopped") self.status_var.set("就绪") - self._log("处理结束") + # 清空产品/客户输入框(batch 结束后重置) + self.fv_product.set("") + self.fv_customer.set("") + # 清空缓存的选择 + self._product_cached_choice = None + self._product_cached_detail = None + self._customer_cached_choice = None + self._customer_cached_detail = None + self._sender_realname_cached = None def _pause(self): global _engine