e99dfaef4d
基于 HTTP API + Node.js token bridge 方案,不依赖浏览器自动化。
1033 lines
39 KiB
Python
1033 lines
39 KiB
Python
"""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
|
||
|
||
# ─── 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
|
||
|
||
# 跨行复用的产品/客户详情(从 API 获取)
|
||
self._product_detail: dict = {}
|
||
self._customer_detail: dict = {}
|
||
# 经办人实名信息(证件类型/姓名/证件号/手机,来自 findrealnamehistorybysender API,与客户绑定)
|
||
self._sender_realname: dict = {}
|
||
# 地址匹配结果
|
||
self._address_match: dict = {}
|
||
|
||
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, ""))
|
||
|
||
# ─── 生命周期 ────────────────────────────────────────────
|
||
|
||
async def start(self) -> None:
|
||
"""启动 token bridge 并初始化 HTTP 会话。"""
|
||
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 ("JSESSIONID", "jdpt.session.id") if n not in cookie_names]
|
||
if missing:
|
||
self._ulog(f"缺少关键 cookie: {', '.join(missing)},请重新登录并手动填写")
|
||
else:
|
||
self._dlog("已加载登录会话")
|
||
else:
|
||
self._ulog("未找到登录会话,请先登录")
|
||
|
||
async def begin_processing(self) -> None:
|
||
"""加载 pickup 页面,初始化 token bridge。"""
|
||
if not self._sess:
|
||
raise RuntimeError("未启动")
|
||
|
||
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
|
||
|
||
# 停止 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": "", "寄达地": ""}
|
||
|
||
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", "")
|
||
|
||
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:
|
||
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:
|
||
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)
|
||
|
||
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
|