产品/客户搜索提前到启动前:输入框防抖搜索+下拉选择+缓存,登录时初始化搜索API,batch结束自动清空

This commit is contained in:
2026-05-15 15:49:33 +08:00
parent ae43899dbd
commit b925a89b6c
6 changed files with 296 additions and 29 deletions
+37 -23
View File
@@ -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 请求/响应)
修改后重新打包生效。
+1 -1
View File
@@ -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;." ^
+1 -1
View File
@@ -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:." \
+26
View File
@@ -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:
+1 -1
View File
@@ -9,7 +9,7 @@
import sys
from datetime import datetime
LEVEL = -1 # 编译时修改此值
LEVEL = 2 # 编译时修改此值
_gui_callback = None # GUI 注册的回调: (msg: str) -> None
+230 -3
View File
@@ -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"<html><head><script>"
f"$_ts=window['$_ts']||{{}};"
f"$_ts.nsd={resources['ts_nsd']};"
f'$_ts.cd="{resources["ts_cd"]}";'
f"</script></head><body></body></html>"
)
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("<Double-1>", lambda e: select())
listbox.bind("<Return>", lambda e: select())
listbox.bind("<Escape>", 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