产品/客户搜索提前到启动前:输入框防抖搜索+下拉选择+缓存,登录时初始化搜索API,batch结束自动清空
This commit is contained in:
@@ -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 请求/响应)
|
||||
|
||||
修改后重新打包生效。
|
||||
|
||||
@@ -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;." ^
|
||||
|
||||
@@ -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:." \
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
LEVEL = -1 # 编译时修改此值
|
||||
LEVEL = 2 # 编译时修改此值
|
||||
|
||||
_gui_callback = None # GUI 注册的回调: (msg: str) -> None
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user