Files
auto-pickup-v2/engine.py
T
SkyLiCoder 2fbb7fc8a5 feat: filter cookies to only store required 10 based on HAR analysis
- Define REQUIRED_COOKIE_NAMES in login_helper.py (10 cookies from HAR)
- login_helper.py: import-time filtering + strict all-or-nothing validation
- pickup_api.py: save_session() filters to whitelist only (prevents accumulation)
- engine.py: unified strict check, all 10 cookies required

Based on portal-to-pickup-full-flow.har analysis:
  - All /pickup-web/a/* APIs use exactly 9 cookies
  - JSESSIONID additionally needed for portal page fetch (Referer)
  - enable_HFZrs6pXKulz is portal UI only, excluded
2026-06-12 11:22:32 +08:00

1080 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HTTP 自动化引擎 — 通过直接调用后端 API 完成表单填写和提交。
不依赖浏览器,使用 Node.js token bridge 计算 $_ts 安全 token。
"""
import asyncio
import copy
from datetime import datetime
from typing import Any, Callable
from log import debug as _ld, trace as _lt, user as _lu
from pickup_api import PickupAPI, PickupSession, TokenBridge
from field_mapping import get_fixed_value_str, translate_value
from login_helper import REQUIRED_COOKIE_NAMES
# ─── Submit 表单构建器 ─────────────────────────────────────────────────
# HAR 成功提交的完整字段模板(所有字段初始化为空,逐层覆盖)
_FORM_TEMPLATE: dict[str, str] = {
"QRcodeTransId": "",
"allCargoFileNames": "",
"baoLiuTiHuoDan": "",
"baoLiuTiHuoDanInsuranceAmount": "",
"batteryFlag": "",
"bizProductId": "",
"bizProductName": "",
"bizProductNo": "",
"cargoOptionName": "",
"cargoOptionNo": "",
"cargoPriceNumber": "",
"cargoTypeNameEn": "",
"caseNo": "",
"cha": "",
"chang": "",
"changeMinGanFlag": "",
"changeNoFlag": "",
"codAmount": "",
"codFlag": "",
"consignorIdName": "",
"consignorIdNo": "",
"consignorIdType": "",
"consignorMobile": "",
"consignorOtherFlag": "",
"contentsAttribute": "",
"contentsQuantity": "",
"contentsWeight": "",
"custRecvPhoneIsEmpty": "",
"customerManagerId": "",
"customerManagerName": "",
"customerManagerNo": "",
"deliverDate": "",
"deliverPreDate": "",
"deliverSign": "",
"deliverType": "",
"deliverTypeErjiText": "",
"deliverTypeSource": "",
"discountRate": "",
"ecommerceNo": "",
"fayuanName": "",
"fayuanNo": "",
"fffs": "",
"gao": "",
"goodsDesc": "",
"height": "",
"identifyFlag": "",
"ifInspection": "",
"imClient": "",
"importantAddress": "",
"innerChannel": "",
"insuranceAmount": "",
"insuranceAmountTempHide": "",
"insuranceFlag": "",
"isBreakable": "",
"isForceFillRecevier": "",
"isSpecialMarketing": "",
"isStoredCard": "",
"jdds": "",
"jsxbsx": "",
"ksms": "",
"kuan": "",
"length": "",
"lockCustomer": "",
"logisticsOrderNo": "",
"mailBoxKeyDown": "",
"mdjmbs": "",
"more3": "",
"more4": "",
"njxzs": "",
"noChangeProductXunShi": "",
"oldBizOccurDate": "",
"oldConsignorIdName": "",
"oldConsignorIdNo": "",
"oldConsignorMobile": "",
"omsCodFlag": "",
"omsContentsAttribute": "",
"omsInfoId": "",
"omsInfoReserved35": "",
"omsMinGanFlag": "",
"omsPackageMaterialTypeNo": "",
"omsPaymentMode": "",
"omsReceiverDistrictNo": "",
"omsReceiverMobile": "",
"omsSenderMobile": "",
"omsXunshiFlag": "",
"oneBillFlag": "",
"orderId": "",
"orderWeight": "",
"orgCityCode": "",
"orgProvinceCode": "",
"packageMaterial": "",
"packagehj": "",
"packaging": "",
"patrolProductFlag": "",
"paymentMode": "",
"paymentType": "",
"pickupOrgIntegrate": "",
"pickupPersonName": "",
"pickupPersonNo": "",
"pickupType": "",
"pkpGoodsFlag": "",
"pkpWaybillId": "",
"postBlend": "",
"preWaybillNo": "",
"printFlag": "",
"printtype": "",
"productType": "",
"projectId": "",
"qinQingFlag": "",
"quantity": "",
"readRemotePayMobileFlag": "",
"realNameCheckFlag": "",
"realNameFlag": "",
"realNameNotAdjust": "",
"realNameNotCheck": "",
"realWeight": "",
"reasonForTemporaryStorage": "",
"receiptFeeAmount": "",
"receiptFlag": "",
"receiptWaybillNo": "",
"receiver": "",
"receiverAddr": "",
"receiverAddrAdditional": "",
"receiverArriveOrgName": "",
"receiverDistrictNo": "",
"receiverDuty": "",
"receiverFixtel": "",
"receiverGis": "",
"receiverId": "",
"receiverImId": "",
"receiverImType": "",
"receiverLinker": "",
"receiverMobile": "",
"receiverNo": "",
"receiverNotes": "",
"receiverOrg": "",
"receiverPostcode": "",
"receiverSafetyCode": "",
"receiverType": "",
"receiverWarehouseId": "",
"receiverWarehouseName": "",
"registeredCustomerNo": "",
"remotePayMobile": "",
"reserved10": "",
"reserved12": "",
"reserved19": "",
"reserved29": "",
"reserved31": "",
"reserved4": "",
"reserved45": "",
"reserved5": "",
"reserved9": "",
"reusablePackagingCode": "",
"secretType": "",
"securityProtocol": "",
"securityProtocolTitle": "",
"sender": "",
"senderAddr": "",
"senderAddrAdditional": "",
"senderAgentIdNo": "",
"senderAgentIdType": "",
"senderClientType": "",
"senderDistrictNo": "",
"senderFixtel": "",
"senderGis": "",
"senderId": "",
"senderIdEncryptedCode": "",
"senderIdNo": "",
"senderIdType": "",
"senderImId": "",
"senderImType": "",
"senderIsCheckAddress": "",
"senderLinker": "",
"senderMobile": "",
"senderNo": "",
"senderNotes": "",
"senderOrg": "",
"senderPostcode": "",
"senderRemotePayMobile": "",
"senderSafetyCode": "",
"senderSignType": "",
"senderType": "",
"senders": "",
"settlementMode": "",
"signatureURL": "",
"signingTime": "",
"singleSealFlag": "",
"storedValueCardCheckFlag": "",
"subBillDelFlag": "",
"templateCode": "",
"tiHuoDanHaoTemp": "",
"transId": "",
"transferType": "",
"valuableFlag": "",
"waybillNo": "",
"width": "",
"yangGuangBaoXianOmsQuantity": "",
"yangGuangBaoXianQuantity": "",
"zhudanhao": "",
}
# 系统固定标志(每次提交都需要,值固定)
_SYSTEM_FLAGS: dict[str, str] = {
"ksms": "0",
"fffs": "0",
"jdds": "0",
"njxzs": "0",
"insuranceFlag": "1",
"receiptFlag": "1",
"codFlag": "9",
"deliverSign": "01",
"quantity": "1",
"reserved29": "0",
"imClient": "0",
"yangGuangBaoXianQuantity": "0",
"yangGuangBaoXianOmsQuantity": "0",
"omsXunshiFlag": "0",
"senderClientType": "2",
"noChangeProductXunShi": "0",
"isForceFillRecevier": "1",
"reserved10": '{"receiverAdderCode":"0","secretType":"1"}',
"realNameCheckFlag": "1",
"pkpGoodsFlag": "1",
"consignorOtherFlag": "0",
"pickupOrgIntegrate": "0",
"secretType": "1",
"senderSignType": "2",
"mdjmbs": "0",
"reserved31": "0",
"innerChannel": "0",
"settlementMode": "3",
"senderType": "1",
"printFlag": "0",
"printtype": "0",
"isSpecialMarketing": "2",
"packagehj": "0",
"more3": "0",
"more4": "0",
"cargoPriceNumber": "0",
"paymentType": "1",
"cha": "1",
"deliverType": "3",
"pkpWaybillId": "",
"reserved4": "0000",
"orderWeight": "1000",
}
class PickupEngine:
"""揽收系统自动化引擎(HTTP API 模式)。
工作模式:
1. 启动 token bridge(Node.js 子进程)
2. Python 管理 HTTP 会话和 cookies
3. 所有业务操作通过后端 API 直接完成
4. 用户通过 choose/confirm 回调参与首批决策
"""
def __init__(
self,
url: str,
field_mapping: dict[str, str],
defaults: dict[str, str],
timing: dict[str, Any],
log_callback: Callable[[str], None] = None,
fixed_values: dict[str, str] = None,
choose_callback: Callable = None,
confirm_callback: Callable = None,
):
self.url = url
self.field_mapping = field_mapping
self.defaults = defaults
self.timing = timing
self._log_cb = log_callback or print
self.fixed_values = fixed_values or {}
self._choose = choose_callback
self._confirm = confirm_callback
# 单次 batch 缓存(batch 结束后清除)
self._product_choice: dict | None = None
self._customer_choice: dict | None = None
self._first_row_confirmed: bool = False
# 基础设施
self._sess: PickupSession | None = None
self._api: PickupAPI | None = None
self._bridge: TokenBridge | None = None
self._stop_requested = False
self._pause_requested = False
self._infra_injected = False # 基础设施从外部注入时跳过 start/begin_processing
# 跨行复用的产品/客户详情(从 API 获取)
self._product_detail: dict = {}
self._customer_detail: dict = {}
# 经办人实名信息(证件类型/姓名/证件号/手机,来自 findrealnamehistorybysender API,与客户绑定)
self._sender_realname: dict = {}
# 地址匹配结果
self._address_match: dict = {}
# 运输类型(由 UI 下拉框选择,来自 transmode 数组的 propertyCode)
self._transport_type: str = ""
def _ulog(self, msg: str) -> None:
_lu(msg)
def _dlog(self, msg: str) -> None:
_ld(msg)
# ─── 字段值解析 ──────────────────────────────────────────
def _get_value(self, row: dict[str, str], excel_col: str) -> str:
"""优先级:固定值 > Excel 表格值 > 默认值。中文值自动翻译为系统编码。"""
fixed = get_fixed_value_str(self.fixed_values, excel_col)
if fixed:
return translate_value(excel_col, fixed)
value = row.get(excel_col, "").strip()
if value:
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
self._bridge = TokenBridge()
self._bridge.start()
self._dlog("Token bridge 已启动")
# 创建 HTTP 会话
self._sess = PickupSession(self.url)
# 尝试加载已保存的 cookies
session_path = self._get_session_path()
if self._sess.load_session(session_path):
cookie_names = {c.name for c in self._sess._http.cookies}
missing = [n for n in REQUIRED_COOKIE_NAMES if n not in cookie_names]
if missing:
self._ulog(f"缺少必需 cookie ({len(missing)}/{len(REQUIRED_COOKIE_NAMES)}): "
f"{', '.join(missing)},请重新登录并导入 cookies")
else:
self._dlog(f"已加载登录会话 ({len(REQUIRED_COOKIE_NAMES)} 个 cookie 齐全)")
else:
self._ulog("未找到登录会话,请先登录")
async def begin_processing(self) -> None:
"""加载 pickup 页面,初始化 token bridge。"""
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()
# 构造最小 HTML:只设置 $_ts.cd/nsd,不包含任何会阻塞 jsdom 的内联脚本
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._dlog(f"初始化 token bridge (cd={resources['ts_cd'][:20]}..., "
f"security JS: {len(resources['scripts'])} files)")
self._bridge.initialize(
minimal_html,
resources["scripts"],
resources["base_url"],
)
self._dlog("Token bridge 已初始化,$_ts 安全框架就绪")
# 创建 API 客户端
self._api = PickupAPI(self._sess, self._bridge)
# 加载初始数据(电商列表、投递日期等)
try:
self._dlog("预加载: query_ecommerce...")
self._api.query_ecommerce("B")
self._dlog("预加载: query_deliver_date...")
self._api.query_deliver_date()
self._dlog("预加载: check_sheet_warning...")
self._api.check_sheet_warning()
self._dlog("预加载完成")
except Exception as e:
self._dlog(f" [warn] 预加载部分失败: {e}")
self._ulog("后台自动化已就绪")
async def stop(self) -> None:
if self._stop_requested:
return # 防止重复调用
self._stop_requested = True
# 保存 session cookies
if self._sess:
try:
self._sess.save_session(self._get_session_path())
self._dlog("Session 已保存")
except Exception:
pass
# 外部注入的基础设施不销毁,由外部管理生命周期
if self._infra_injected:
self._dlog("跳过 bridge 销毁(基础设施由外部管理)")
self._clear_batch_cache()
return
# 停止 token bridge
if self._bridge:
try:
self._bridge.stop()
except Exception:
pass
self._bridge = None
self._api = None
self._ulog("引擎已停止")
# 清除 batch 缓存
self._clear_batch_cache()
def _get_session_path(self) -> str:
import os as _os, sys as _sys
if getattr(_sys, "frozen", False):
base = _os.path.dirname(_sys.executable)
else:
base = _os.path.dirname(_os.path.abspath(__file__))
return _os.path.join(base, "session.json")
def _clear_batch_cache(self):
self._product_choice = None
self._customer_choice = None
self._product_detail = {}
self._customer_detail = {}
self._sender_realname = {}
self._first_row_confirmed = False
# ─── 暂停控制 ────────────────────────────────────────────
def request_stop(self) -> None:
self._stop_requested = True
def request_pause(self) -> None:
self._pause_requested = True
def resume(self) -> None:
self._pause_requested = False
async def _check_pause(self) -> None:
while self._pause_requested and not self._stop_requested:
await asyncio.sleep(0.5)
# ─── 单行处理(核心流程)─────────────────────────────────
async def process_single_row(self, row: dict[str, str]) -> dict:
start_time = datetime.now()
result = {"status": "失败", "message": "", "postage": "", "time": "", "寄达地": "",
"单号": row.get("单号", "").strip(), "重量": row.get("重量", "").strip(),
"产品": "", "客户": "", "运输类型": "", "寄达地区划代码": "", "寄件人姓名": "", "寄件地址": ""}
try:
await self._check_pause()
# 1. 产品选择
self._dlog(" 选择产品...")
await self._handle_product(self._get_value(row, "产品名称"))
if self._stop_requested:
return result
await self._check_pause()
# 2. 客户选择
self._dlog(" 选择客户...")
await self._handle_customer(self._get_value(row, "客户名称"))
if self._stop_requested:
return result
await self._check_pause()
# 3. 查询运单
self._dlog(" 查询运单...")
await self._handle_waybill(row)
if self._stop_requested:
return result
await self._check_pause()
# 4. 收件地址
self._dlog(" 匹配收件地址...")
await self._handle_address(row)
await self._check_pause()
# 5. 货物信息
self._dlog(" 校验货物...")
await self._handle_cargo_checks(row)
# 6. 组装表单
form_data = self._assemble_form(row)
# 7. 首行确认
if not self._first_row_confirmed and self._confirm:
if not await self._confirm(form_data):
self._stop_requested = True
result.update(status="取消", message="用户取消确认,已停止",
time=datetime.now().strftime("%H:%M:%S"))
self._ulog(" STOP: 用户取消确认")
return result
self._first_row_confirmed = True
await self._check_pause()
# 8. 提交
self._dlog(" 提交中...")
submit_result = await self._submit_form(form_data)
result["寄达地"] = form_data.get("receiverArriveOrgName", "")
result["单号"] = row.get("单号", "").strip()
result["重量"] = form_data.get("realWeight", "")
result["产品"] = self._product_choice.get("productName", "") if self._product_choice else ""
result["客户"] = self._customer_choice.get("customerName", "") if self._customer_choice else ""
result["寄达地区划代码"] = form_data.get("receiverDistrictNo", "")
result["寄件人姓名"] = form_data.get("senderLinker", "")
result["寄件地址"] = form_data.get("senderAddr", "")
result["运输类型"] = form_data.get("transferType", "")
elapsed = (datetime.now() - start_time).total_seconds()
if submit_result.get("success"):
result.update(status="成功", message=submit_result.get("message", ""),
postage=str(submit_result.get("postage", "")),
time=datetime.now().strftime("%H:%M:%S"))
self._ulog(f" OK ({elapsed:.1f}s): {result['message']}")
else:
result.update(status="失败", message=submit_result.get("message", "提交失败"),
time=datetime.now().strftime("%H:%M:%S"))
self._ulog(f" FAIL ({elapsed:.1f}s): {result['message']}")
except Exception as e:
elapsed = (datetime.now() - start_time).total_seconds()
result.update(status="异常", message=str(e),
time=datetime.now().strftime("%H:%M:%S"))
self._ulog(f" ERROR ({elapsed:.1f}s): {e}")
return result
# ─── 产品处理 ────────────────────────────────────────────
async def _handle_product(self, product_name: str) -> None:
if not product_name:
return
if self._product_choice is not None:
if self._product_detail:
return
biz_id = self._product_choice.get("productId", "")
biz_no = self._product_choice.get("bizProductNo", "")
if biz_id and biz_no:
self._product_detail = self._api.select_product(biz_id, biz_no)
return
# 首次选择:搜索 → 让用户选 → 缓存
candidates = self._api.query_product(product_name)
self._dlog(f" query_product('{product_name}') → {len(candidates)} candidates: "
f"{[c.get('productName','')[:20] for c in candidates[:5]]}")
if not candidates:
self._ulog(f" [warn] 产品 '{product_name}' 无候选结果,请在固定值中设置正确的产品名称")
self.request_stop()
return
if self._choose:
choice = await self._choose(
"选择产品",
[c.get("productName", c.get("name", str(c))) for c in candidates],
)
if choice is None:
self._ulog("用户取消产品选择,停止 batch")
self.request_stop()
return
if 0 <= choice < len(candidates):
self._product_choice = candidates[choice]
if not self._product_choice and candidates:
self._product_choice = candidates[0]
chosen = self._product_choice
if chosen:
biz_id = chosen.get("productId", "")
biz_no = chosen.get("bizProductNo", "")
if biz_no and biz_id:
self._product_detail = self._api.select_product(biz_id, biz_no)
# ─── 客户处理 ────────────────────────────────────────────
async def _handle_customer(self, customer_name: str) -> None:
if not customer_name:
return
if self._customer_choice is not None:
if self._customer_detail:
return
cust_id = self._customer_choice.get("customerId", "")
if cust_id:
self._customer_detail = self._api.select_customer(str(cust_id))
if not isinstance(self._customer_detail, dict) or "_text" in self._customer_detail:
self._dlog(f" [warn] customerins 返回异常: {str(self._customer_detail)[:200]}")
return
base = self._customer_detail.get("baseCustome", {}) if isinstance(self._customer_detail.get("baseCustome"), dict) else {}
sender_no = base.get("businessCode") or base.get("customerSubCode") or ""
if sender_no:
try:
self._sender_realname = self._api.find_sender_history(
str(sender_no)
)
except Exception:
pass
return
# 首次选择
candidates = self._api.query_customer(customer_name)
self._dlog(f" query_customer('{customer_name}') → {len(candidates)} candidates: "
f"{[c.get('customerName','')[:20] for c in candidates[:5]]}")
if not candidates:
self._ulog(f" [warn] 客户 '{customer_name}' 无候选结果,请在固定值中设置正确的客户名称")
self.request_stop()
return
if self._choose:
choice = await self._choose(
"选择客户",
[c.get("customerName", c.get("name", str(c))) for c in candidates],
)
if choice is None:
self._ulog("用户取消客户选择,停止 batch")
self.request_stop()
return
if 0 <= choice < len(candidates):
self._customer_choice = candidates[choice]
if not self._customer_choice and candidates:
self._customer_choice = candidates[0]
chosen = self._customer_choice
if chosen:
cust_id = chosen.get("customerId", "")
if cust_id:
self._customer_detail = self._api.select_customer(str(cust_id))
if not isinstance(self._customer_detail, dict) or "_text" in self._customer_detail:
self._dlog(f" [warn] customerins 返回异常: {str(self._customer_detail)[:200]}")
return
base = self._customer_detail.get("baseCustome", {}) if isinstance(self._customer_detail.get("baseCustome"), dict) else {}
sender_no = base.get("businessCode") or base.get("customerSubCode") or ""
if sender_no:
try:
self._sender_realname = self._api.find_sender_history(
str(sender_no)
)
except Exception:
pass
# ─── 运单查询 ────────────────────────────────────────────
async def _handle_waybill(self, row: dict[str, str]) -> None:
waybill_no = self._get_value(row, "单号")
ecommerce = self._get_value(row, "电商平台")
payment = self._get_value(row, "付款方式")
biz_product_no = (
self._product_choice.get("bizProductNo", "")
if self._product_choice else ""
)
biz_product_id = self._product_detail.get("bizProductId", "")
result = self._api.query_waybill(
waybill_no=waybill_no,
ecommerce_no=ecommerce,
biz_product_no=biz_product_no,
payment_mode=payment,
biz_product_id=str(biz_product_id),
)
self._waybill_result = result
if isinstance(result.get("data"), dict):
wd = result["data"]
key_vals = {k: wd[k] for k in ["senderLinker","senderMobile","receiverLinker","receiverMobile","receiverAddr","senderAddr","senderNo","internalInfoId","bizProductNo","bizProductName"] if wd.get(k)}
self._dlog(f" waybill key_vals: {key_vals}")
else:
self._dlog(f" waybill result top: {list(result.keys())[:5]}, data type: {type(result.get('data'))}")
# ─── 地址处理 ────────────────────────────────────────────
async def _handle_address(self, row: dict[str, str]) -> None:
receiver_addr = self._get_value(row, "收件地址")
if not receiver_addr:
return
# 1. 用实际地址查询区划代码(addressMatch API)
dist_code = ""
try:
match = self._api.address_match_raw(receiver_addr)
if isinstance(match, dict):
dist_code = match.get("districtCode", "") or match.get("contyCode", "")
if not dist_code:
dist_code = match.get("provCode", "")
self._address_match = match
self._dlog(f" addressMatch → dist_code={dist_code}")
except Exception:
self._address_match = {}
self._dlog(" [warn] addressMatch 失败")
# 如果 addressMatch 没结果,尝试用运单数据中的区划代码
if not dist_code:
if isinstance(self._waybill_result, dict):
wr_data = self._waybill_result.get("data", {})
if isinstance(wr_data, str):
try:
import json as _json
wr_data = _json.loads(wr_data)
except Exception:
wr_data = {}
if isinstance(wr_data, dict):
dist_code = wr_data.get("receiverDistrictNo", "")
if dist_code:
try:
self._api.query_district(dist_code)
except Exception:
self._dlog(" [warn] 区划查询失败")
# 2. 地址网格匹配
biz_product_no = (
self._product_choice.get("bizProductNo", "")
if self._product_choice else ""
)
waybill_no = self._get_value(row, "单号")
sender_no = ""
if self._customer_detail:
sender_no = (self._customer_detail.get("baseCustome", {})).get("businessCode") or ""
transfer_type = self._get_value(row, "运输类型")
contents_attr = self._get_value(row, "内件属性")
try:
grid_result = self._api.match_address(
receiver_arrive_org_no=dist_code,
base_product_no=biz_product_no,
waybill_no=waybill_no,
receiver_addr=receiver_addr,
sender_no=sender_no,
transfer_type=transfer_type,
contents_attribute=contents_attr,
)
self._grid_result = grid_result
except Exception as e:
self._dlog(f" [warn] 地址匹配失败: {e}")
self._grid_result = {}
# ─── 货物校验 ────────────────────────────────────────────
async def _handle_cargo_checks(self, row: dict[str, str]) -> None:
cargo_names = self._get_value(row, "内件品名")
waybill_no = self._get_value(row, "单号")
weight = self._get_value(row, "重量")
biz_product_no = (
self._product_choice.get("bizProductNo", "")
if self._product_choice else ""
)
# 寄达区划代码
dist_code = ""
if hasattr(self, '_grid_result') and isinstance(self._grid_result, dict):
dist_code = self._grid_result.get("receiverDistrictNo", dist_code)
# 客户代码
cust_code = ""
if self._customer_detail:
cust_code = ""
if isinstance(self._customer_detail, dict):
base = self._customer_detail.get("baseCustome", {})
if isinstance(base, dict):
cust_code = base.get("businessCode") or ""
# 校验产品物料
try:
self._api.check_product_material(cargo_names, waybill_no, biz_product_no, dist_code)
except Exception:
pass
# 校验可达区域
try:
self._api.check_reach_area(biz_product_no, dist_code, cust_code, weight, waybill_no)
except Exception:
pass
# ─── 表单提交 ────────────────────────────────────────────
def _assemble_form(self, row: dict[str, str]) -> dict[str, str]:
"""组装完整表单数据。优先级:强制固定值 > API > 固定值 > 表格 > 默认值。"""
form = dict(_FORM_TEMPLATE)
form.update(_SYSTEM_FLAGS)
# ── 第一层:默认值 ──
for col, param in self.field_mapping.items():
val = self.defaults.get(col, "")
if val:
form[param] = translate_value(col, val)
# ── 第二层:表格值 ──
for col, param in self.field_mapping.items():
val = row.get(col, "").strip()
if val:
form[param] = translate_value(col, val)
waybill_no = row.get("单号", "").strip()
if waybill_no:
form["logisticsOrderNo"] = waybill_no
# ── 第三层:固定值(非强制)──
for col_name, cfg in self.fixed_values.items():
if not cfg.get("force") and cfg.get("value"):
param = self.field_mapping.get(col_name, col_name)
form[param] = translate_value(col_name, cfg["value"])
# ── 第四层:API 获取值 ──
if self._product_choice:
form["bizProductNo"] = self._product_choice.get("bizProductNo", "")
form["bizProductName"] = self._product_choice.get("productName", "")
if self._product_detail:
for k in ("bizProductId", "templateCode"):
if self._product_detail.get(k):
form[k] = str(self._product_detail[k])
# 产品的内件性质(queryproductbymanager 返回 innerpartsType)
ipt = self._product_detail.get("innerpartsType", "")
if ipt:
form["contentsAttribute"] = str(ipt)
# 运输类型(UI 下拉框选择,来自 transmode 数组)
if self._transport_type:
form["transferType"] = self._transport_type
if self._customer_choice:
# senders 用全名(带前缀如 qxsm-穹诩商贸),sender 用短名
full_name = self._customer_choice.get("customerFullName", "")
short_name = self._customer_choice.get("customerName", "")
if full_name:
form["senders"] = full_name
if short_name:
form["sender"] = short_name
if isinstance(self._customer_detail, dict):
base = self._customer_detail.get("baseCustome", {}) if isinstance(self._customer_detail.get("baseCustome"), dict) else {}
cn = base.get("customerNo", "")
if cn:
form["senderNo"] = str(cn)
cid = base.get("id") or self._customer_detail.get("customerId", "")
if cid:
form["senderId"] = str(cid)
wr = self._waybill_result if hasattr(self, '_waybill_result') and isinstance(self._waybill_result, dict) else {}
data_raw = wr.get("data", {})
if isinstance(data_raw, str):
try:
import json as _json
wr_data = _json.loads(data_raw)
self._dlog(f" waybill data parsed from JSON string, keys={len(wr_data)}")
except Exception as e:
self._dlog(f" waybill data JSON parse failed: {e}, raw={data_raw[:100]}")
wr_data = {}
elif isinstance(data_raw, dict):
wr_data = data_raw
else:
wr_data = {}
if not wr_data:
self._ulog(f" [warn] 运单 data 为空,可能 ecommerceNo 不正确,当前值: {self._get_value(row, '电商平台')}")
# 运单中有值的字段填入(白名单:HAR 确认需要的字段)
wr_whitelist = {
"ecommerceNo",
"senderLinker", "senderMobile", "senderPostcode",
"senderDistrictNo", "senderFixtel",
"receiverLinker", "receiverMobile", "receiverPostcode",
"receiverDistrictNo", "receiverArriveOrgNo", "receiverArriveOrgName",
"receiverSafetyCode",
"internalInfoId",
"contentsAttribute", "transferType",
"quantity", "deliverType", "reserved10",
"senderProvinceName", "senderCityName", "senderCountyName",
"receiverProvinceName", "receiverCityName", "receiverCountyName",
}
for key in wr_whitelist:
val = wr_data.get(key)
if val is not None and val != "":
form[key] = str(val)
# 拼接完整地址:省+市+县+地址
sp = wr_data.get("senderProvinceName", "")
sc = wr_data.get("senderCityName", "")
sn = wr_data.get("senderCountyName", "")
sa = wr_data.get("senderAddr", "")
full_sender = str(sp or "") + str(sc or "") + str(sn or "") + str(sa or "")
if full_sender.strip():
form["senderAddr"] = full_sender.strip()
rp = wr_data.get("receiverProvinceName", "")
rc = wr_data.get("receiverCityName", "")
rn = wr_data.get("receiverCountyName", "")
ra = wr_data.get("receiverAddr", "")
full_receiver = str(rp or "") + str(rc or "") + str(rn or "") + str(ra or "")
if full_receiver.strip():
form["receiverAddr"] = full_receiver.strip()
# receiverAddrAdditional 也拼接省市区
if wr_data.get("receiverAddrAdditional"):
full_radd = str(rp or "") + str(rc or "") + str(rn or "") + str(wr_data["receiverAddrAdditional"])
form["receiverAddrAdditional"] = full_radd.strip()
for key in wr_whitelist:
val = wr_data.get(key)
if val is not None and val != "":
form[key] = str(val)
if wr_data.get("internalInfoId"):
form["pkpWaybillId"] = str(wr_data["internalInfoId"])
# 寄达地:用 city+county 拼接
city = wr_data.get("receiverCityName", "")
county = wr_data.get("receiverCountyName", "")
if city or county:
form["receiverArriveOrgName"] = str(city) + str(county)
# 省份/城市代码
sp = wr_data.get("senderProvinceNo", "")
sc = wr_data.get("senderCityNo", "")
if sp:
form["orgProvinceCode"] = sp[:2]
if sc:
form["orgCityCode"] = sc[:4]
# 地址匹配结果(addressMatch API)的区划代码
if hasattr(self, '_address_match') and isinstance(self._address_match, dict):
dc = self._address_match.get("districtCode") or self._address_match.get("contyCode") or ""
if dc:
form["receiverDistrictNo"] = str(dc)
# receiverArriveOrgName 用 prov+city+county 拼接
parts = [self._address_match.get(k, "") for k in ("provName", "cityName", "contyName")]
org_name = "".join(p for p in parts if p)
if org_name:
form["receiverArriveOrgName"] = org_name
if hasattr(self, '_grid_result') and isinstance(self._grid_result, dict):
for k in ("receiverArriveOrgName", "receiverDistrictNo"):
if self._grid_result.get(k):
form[k] = str(self._grid_result[k])
# senderNo 使用 businessCode/customerSubCode(不是 customerNo)
if self._customer_detail and isinstance(self._customer_detail, dict):
base = self._customer_detail.get("baseCustome", {})
if isinstance(base, dict):
sn = base.get("businessCode") or base.get("customerSubCode") or ""
if sn:
form["senderNo"] = str(sn)
# customerManager
for data_k, form_k in [
("accountMgrName", "customerManagerName"),
("accountMgrCode", "customerManagerNo"),
("id", "customerManagerId"),
]:
v = base.get(data_k)
if v:
form[form_k] = str(v)
# 寄件人地址(客户资料中的完整地址)
cust_addr = base.get("addressStreet", "")
if cust_addr:
form["senderAddr"] = str(cust_addr)
# 寄件人联系方式
if base.get("contactName"):
form.setdefault("senderLinker", str(base["contactName"]))
if base.get("contactPhone"):
form.setdefault("senderMobile", str(base["contactPhone"]))
# 实名信息(在 detail[0] 下)
if isinstance(self._sender_realname, dict):
detail0 = self._sender_realname.get("detail", [{}])
sr = detail0[0] if isinstance(detail0, list) and detail0 else {}
for sr_key, form_key in [
("consignorIdType", "consignorIdType"),
("consignorIdName", "consignorIdName"),
("consignorIdNo", "consignorIdNo"),
("consignorMobile", "consignorMobile"),
("consignorIdName", "oldConsignorIdName"),
("consignorIdNo", "oldConsignorIdNo"),
("consignorMobile", "oldConsignorMobile"),
]:
if sr.get(sr_key):
form[form_key] = str(sr[sr_key])
# ── 第五层(最高):强制覆盖固定值 ──
for col_name, cfg in self.fixed_values.items():
if cfg.get("force") and cfg.get("value"):
param = self.field_mapping.get(col_name, col_name)
form[param] = translate_value(col_name, cfg["value"])
# ── 衍生字段(setdefault 不覆盖 API 值)──
form.setdefault("receiverAddrAdditional", form.get("receiverAddr", ""))
if form.get("receiverMobile"):
form.setdefault("omsReceiverMobile", form["receiverMobile"])
if form.get("senderMobile"):
form.setdefault("senderFixtel", form["senderMobile"])
form.setdefault("senderSafetyCode", form["senderMobile"])
form.setdefault("omsSenderMobile", form["senderMobile"])
if form.get("senderNotes", ""):
pass # already set above
elif row.get("备注", "").strip():
form["senderNotes"] = row["备注"].strip()
return form
async def _submit_form(self, form_data: dict[str, str]) -> dict:
"""提交已组装好的表单数据。"""
if not self._api:
return {"success": False, "message": "API 未初始化"}
# 输出有值的表单字段用于对比 HAR
non_empty = {k: v for k, v in sorted(form_data.items()) if v}
self._dlog(f" submit fields ({len(non_empty)}): {non_empty}")
response = self._api.submit(form_data)
res_mess = response.get("resMess", "")
comment = response.get("comment", "")
return {
"success": "收寄成功" in res_mess,
"message": res_mess,
"postage": str(comment) if comment else "",
}
# ─── 批量处理 ────────────────────────────────────────────
async def process_all(
self,
rows: list[dict[str, str]],
progress_callback: Callable[[int, int], None] = None,
) -> list[dict]:
results = []
total = len(rows)
delay = self.timing.get("delay_between_rows_ms", 3000) / 1000
try:
for i, row in enumerate(rows):
if self._stop_requested:
self._ulog("用户停止")
break
self._ulog(f"\n[{i+1}/{total}] 运单号: {row.get('运单号', 'N/A')}")
results.append(await self.process_single_row(row))
if progress_callback:
progress_callback(i + 1, total)
if i < total - 1 and not self._stop_requested:
await asyncio.sleep(delay)
finally:
# batch 结束后清除缓存,下次重新选择
self._clear_batch_cache()
return results